feat(classes): weekly recurring schedules with lazy session materialization

class_schedules rules generate concrete session rows for a rolling 28-day
horizon — no cron; materialize runs idempotently on dashboard and public
reads via a partial unique index on (schedule_id, starts_at). Rule delete
stops future sessions; existing sessions keep bookings and overrides.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 17:31:17 +02:00
1 parent 7b9080eb1c
commit ad60167a31
15 files changed
+1029 -7

No files matched your search

+150
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/mail"
"strconv"
"strings"
"time"
@@ -1088,8 +1089,14 @@ func (s *Service) DeleteClassTemplate(ctx context.Context, principal domain.Prin
return s.repo.DeleteClassTemplate(ctx, templateID)
}
// scheduleHorizonDays is the rolling window that recurring class schedules
// materialize concrete sessions for. Sessions are written idempotently on
// read, so the horizon stays populated without a background job.
const scheduleHorizonDays = 28
// ListClassSessions returns upcoming sessions enriched with location names and
// live booked counts for the management UI.
func (s *Service) ListClassSessions(ctx context.Context, principal domain.Principal) ([]domain.ClassSession, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
@@ -1098,6 +1105,9 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
tenantID := membership.Tenant.ID
now := time.Now().UTC()
if err := s.repo.MaterializeScheduleSessions(ctx, tenantID, now, scheduleHorizonDays); err != nil {
return nil, err
}
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, now, 100)
if err != nil {
return nil, err
@@ -1153,6 +1163,7 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
Capacity: int(session.Capacity),
BookedCount: booked,
WaitlistCount: waitlisted,
ScheduleID: session.ScheduleID,
}
}
return out, nil
@@ -1287,6 +1298,145 @@ func (s *Service) CreateClassSession(ctx context.Context, principal domain.Princ
}, nil
}
// ListClassSchedules returns the tenant's weekly recurring rules enriched
// with template titles and location names.
func (s *Service) ListClassSchedules(ctx context.Context, principal domain.Principal) ([]domain.ClassSchedule, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
tenantID := membership.Tenant.ID
schedules, err := s.repo.ListClassSchedulesByTenant(ctx, tenantID)
if err != nil {
return nil, err
}
if len(schedules) == 0 {
return []domain.ClassSchedule{}, nil
}
templates, err := s.repo.ListClassTemplatesByTenant(ctx, tenantID)
if err != nil {
return nil, err
}
templateTitles := make(map[string]string, len(templates))
for _, tpl := range templates {
templateTitles[tpl.ID] = tpl.Title
}
locations, err := s.repo.ListLocationsByTenant(ctx, tenantID)
if err != nil {
return nil, err
}
locationNames := make(map[string]string, len(locations))
for _, loc := range locations {
locationNames[loc.ID] = loc.Name
}
out := make([]domain.ClassSchedule, len(schedules))
for i, sch := range schedules {
out[i] = domain.ClassSchedule{
ID: sch.ID,
TenantID: sch.TenantID,
TemplateID: sch.TemplateID,
TemplateTitle: templateTitles[sch.TemplateID],
LocationID: sch.LocationID,
LocationName: locationNames[deref(sch.LocationID)],
DayOfWeek: sch.DayOfWeek,
StartsLocal: sch.StartsLocal,
Active: sch.Active,
}
}
return out, nil
}
// CreateClassSchedule adds a weekly recurring rule for a class template, then
// immediately materializes the horizon so the new sessions appear at once.
func (s *Service) CreateClassSchedule(ctx context.Context, principal domain.Principal, req domain.CreateClassScheduleRequest) (domain.ClassSchedule, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ClassSchedule{}, ErrTenantMembership
}
tenantID := membership.Tenant.ID
if err := s.requireTenantClassTemplate(ctx, tenantID, req.TemplateID); err != nil {
return domain.ClassSchedule{}, err
}
if req.DayOfWeek < 0 || req.DayOfWeek > 6 {
return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput)
}
startsLocal := strings.TrimSpace(req.StartsLocal)
parts := strings.SplitN(startsLocal, ":", 2)
if len(parts) != 2 {
return domain.ClassSchedule{}, fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
}
hour, errH := strconv.Atoi(parts[0])
minute, errM := strconv.Atoi(parts[1])
if errH != nil || errM != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return domain.ClassSchedule{}, fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
}
startsLocal = fmt.Sprintf("%02d:%02d:00", hour, minute)
if req.LocationID != nil {
location, err := s.repo.GetLocationByID(ctx, *req.LocationID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ClassSchedule{}, ErrLocationNotFound
}
return domain.ClassSchedule{}, err
}
if location.TenantID != tenantID {
return domain.ClassSchedule{}, ErrLocationNotFound
}
}
rec, err := s.repo.CreateClassSchedule(ctx, db.CreateClassScheduleParams{
TenantID: tenantID,
TemplateID: req.TemplateID,
LocationID: req.LocationID,
DayOfWeek: req.DayOfWeek,
StartsLocal: startsLocal,
})
if err != nil {
return domain.ClassSchedule{}, err
}
if err := s.repo.MaterializeScheduleSessions(ctx, tenantID, time.Now().UTC(), scheduleHorizonDays); err != nil {
return domain.ClassSchedule{}, err
}
template, _ := s.repo.GetClassTemplateByID(ctx, req.TemplateID)
out := domain.ClassSchedule{
ID: rec.ID,
TenantID: rec.TenantID,
TemplateID: rec.TemplateID,
TemplateTitle: template.Title,
LocationID: rec.LocationID,
DayOfWeek: rec.DayOfWeek,
StartsLocal: rec.StartsLocal,
Active: rec.Active,
}
if rec.LocationID != nil {
if location, err := s.repo.GetLocationByID(ctx, *rec.LocationID); err == nil {
out.LocationName = location.Name
}
}
return out, nil
}
// DeleteClassSchedule removes a recurring rule. Sessions already materialized
// stay in place; no new ones are written.
func (s *Service) DeleteClassSchedule(ctx context.Context, principal domain.Principal, scheduleID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
if err := s.repo.DeleteClassSchedule(ctx, membership.Tenant.ID, scheduleID); err != nil {
if strings.Contains(err.Error(), "not found") {
return ErrClassSessionNotFound
}
return err
}
return nil
}
func (s *Service) DeleteClassSession(ctx context.Context, principal domain.Principal, sessionID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {