From ad60167a31d9244992a895f7386b61487f750b9e Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 17:31:17 +0200 Subject: [PATCH] feat(classes): weekly recurring schedules with lazy session materialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- apps/backend/internal/api/server.go | 45 ++++++ apps/backend/internal/bookings/service.go | 5 + apps/backend/internal/catalog/service.go | 150 +++++++++++++++++ apps/backend/internal/catalog/service_test.go | 89 ++++++++++ apps/backend/internal/db/class_schedules.go | 101 ++++++++++++ apps/backend/internal/db/classes.go | 9 +- apps/backend/internal/db/repository.go | 151 +++++++++++++++++ apps/backend/internal/db/services.go | 3 +- apps/backend/internal/domain/models.go | 25 +++ .../migrations/00018_class_schedules.sql | 27 ++++ apps/backend/openapi/bookra.openapi.yaml | 114 +++++++++++++ apps/frontend/src/providers/i18n-provider.tsx | 26 +++ .../src/routes/dashboard/zones-page.tsx | 127 ++++++++++++++- packages/api-client/src/generated/types.ts | 153 ++++++++++++++++++ project.md | 11 +- 15 files changed, 1029 insertions(+), 7 deletions(-) create mode 100644 apps/backend/internal/db/class_schedules.go create mode 100644 apps/backend/migrations/00018_class_schedules.sql diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index c532064..4a1a9f0 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -1115,6 +1115,51 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { } c.JSON(http.StatusOK, gin.H{"status": "deleted"}) }) + protected.GET("/catalog/class-schedules", func(c *gin.Context) { + response, err := catalogService.ListClassSchedules(c.Request.Context(), auth.PrincipalFromContext(c)) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, catalog.ErrTenantMembership) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) + protected.POST("/catalog/class-schedules", func(c *gin.Context) { + var request domain.CreateClassScheduleRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := catalogService.CreateClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound), errors.Is(err, catalog.ErrLocationNotFound): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidInput): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, response) + }) + protected.DELETE("/catalog/class-schedules/:scheduleID", func(c *gin.Context) { + err := catalogService.DeleteClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("scheduleID")) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound): + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) + }) // ============================================ // CUSTOMER BOOKING MANAGEMENT API (Public) diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 268d22d..ea51ae8 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -160,6 +160,11 @@ func (s *Service) Availability(ctx context.Context, tenantSlug string, filter Av }, nil } + // Recurring schedules write their concrete sessions here so the public + // booking page sees them without a background job. Idempotent. + if err := s.repo.MaterializeScheduleSessions(ctx, tenant.ID, now, 28); err != nil { + return domain.PublicAvailability{}, err + } classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8) if err != nil { return domain.PublicAvailability{}, err diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 533c12f..312598f 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -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 { diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go index c07cd04..d4c61ae 100644 --- a/apps/backend/internal/catalog/service_test.go +++ b/apps/backend/internal/catalog/service_test.go @@ -797,3 +797,92 @@ func TestUpdateClassTemplate(t *testing.T) { t.Fatalf("expected ErrClassTemplateNotFound, got %v", err) } } + +func TestClassScheduleMaterializesSessions(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + schedules, err := svc.ListClassSchedules(ctx, owner) + if err != nil { + t.Fatalf("list schedules: %v", err) + } + if len(schedules) != 0 { + t.Fatalf("expected 0 schedules in fresh repo, got %d", len(schedules)) + } + + locID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf" + created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{ + TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", + LocationID: &locID, + DayOfWeek: 1, + StartsLocal: "15:30", + }) + if err != nil { + t.Fatalf("create schedule: %v", err) + } + if !created.Active || created.StartsLocal != "15:30:00" { + t.Fatalf("unexpected created schedule: %+v", created) + } + + sessions, err := svc.ListClassSessions(ctx, owner) + if err != nil { + t.Fatalf("list sessions: %v", err) + } + var generated int + for _, session := range sessions { + if session.ScheduleID != nil && *session.ScheduleID == created.ID { + generated++ + if session.StartsAt.Weekday() != time.Monday { + t.Fatalf("generated session on wrong weekday: %v", session.StartsAt) + } + } + } + if generated < 3 { + t.Fatalf("expected >=3 materialized sessions over the horizon, got %d", generated) + } + + sessions2, err := svc.ListClassSessions(ctx, owner) + if err != nil { + t.Fatalf("list sessions 2: %v", err) + } + var generated2 int + for _, session := range sessions2 { + if session.ScheduleID != nil && *session.ScheduleID == created.ID { + generated2++ + } + } + if generated2 != generated { + t.Fatalf("materialize not idempotent: %d then %d", generated, generated2) + } + + if err := svc.DeleteClassSchedule(ctx, owner, created.ID); err != nil { + t.Fatalf("delete schedule: %v", err) + } + schedules, _ = svc.ListClassSchedules(ctx, owner) + if len(schedules) != 0 { + t.Fatalf("expected 0 schedules after delete, got %d", len(schedules)) + } +} + +func TestCreateClassScheduleValidation(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{ + TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 7, StartsLocal: "10:00", + }); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput for bad weekday, got %v", err) + } + if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{ + TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 2, StartsLocal: "25:00", + }); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("expected ErrInvalidInput for bad time, got %v", err) + } + if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{ + TemplateID: "00000000-0000-0000-0000-000000000099", DayOfWeek: 2, StartsLocal: "10:00", + }); !errors.Is(err, ErrClassTemplateNotFound) { + t.Fatalf("expected ErrClassTemplateNotFound, got %v", err) + } +} diff --git a/apps/backend/internal/db/class_schedules.go b/apps/backend/internal/db/class_schedules.go new file mode 100644 index 0000000..539f4b3 --- /dev/null +++ b/apps/backend/internal/db/class_schedules.go @@ -0,0 +1,101 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +const classScheduleColumns = "id, tenant_id, template_id, location_id, day_of_week, starts_local::text, active, created_at" + +func (r *PGRepository) ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error) { + rows, err := r.pool.Query(ctx, ` + SELECT `+classScheduleColumns+` + FROM class_schedules + WHERE tenant_id = $1 + ORDER BY day_of_week ASC, starts_local ASC + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []ClassScheduleRecord + for rows.Next() { + var record ClassScheduleRecord + if err := rows.Scan( + &record.ID, + &record.TenantID, + &record.TemplateID, + &record.LocationID, + &record.DayOfWeek, + &record.StartsLocal, + &record.Active, + &record.CreatedAt, + ); err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +func (r *PGRepository) CreateClassSchedule(ctx context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error) { + var record ClassScheduleRecord + err := r.pool.QueryRow(ctx, ` + INSERT INTO class_schedules (tenant_id, template_id, location_id, day_of_week, starts_local) + VALUES ($1, $2, $3, $4, $5::time) + RETURNING `+classScheduleColumns+` + `, params.TenantID, params.TemplateID, params.LocationID, params.DayOfWeek, params.StartsLocal).Scan( + &record.ID, + &record.TenantID, + &record.TemplateID, + &record.LocationID, + &record.DayOfWeek, + &record.StartsLocal, + &record.Active, + &record.CreatedAt, + ) + return record, err +} + +func (r *PGRepository) DeleteClassSchedule(ctx context.Context, tenantID string, scheduleID string) error { + tag, err := r.pool.Exec(ctx, `DELETE FROM class_schedules WHERE id = $1 AND tenant_id = $2`, scheduleID, tenantID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("class schedule not found") + } + return nil +} + +// MaterializeScheduleSessions writes concrete session rows for each active +// rule over the next `days` days in the tenant's timezone. The partial unique +// index on (schedule_id, starts_at) makes this idempotent: sessions already +// materialized are skipped, and sessions the staff edited or cancelled stay +// untouched. +func (r *PGRepository) MaterializeScheduleSessions(ctx context.Context, tenantID string, now time.Time, days int) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO class_sessions (tenant_id, template_id, location_id, starts_at, ends_at, capacity, schedule_id) + SELECT + s.tenant_id, + s.template_id, + s.location_id, + (d.day + s.starts_local) AT TIME ZONE t.timezone, + (d.day + s.starts_local) AT TIME ZONE t.timezone + make_interval(mins => tpl.duration_minutes), + tpl.capacity, + s.id + FROM class_schedules s + INNER JOIN tenants t ON t.id = s.tenant_id + INNER JOIN class_templates tpl ON tpl.id = s.template_id + CROSS JOIN generate_series(0, $3::int - 1) AS g(i) + CROSS JOIN LATERAL (SELECT ($2::timestamptz AT TIME ZONE t.timezone)::date + g.i AS day) d + WHERE s.tenant_id = $1 + AND s.active + AND EXTRACT(ISODOW FROM d.day)::int = ((s.day_of_week + 6) % 7) + 1 + AND (d.day + s.starts_local) AT TIME ZONE t.timezone > $2 + ON CONFLICT (schedule_id, starts_at) WHERE schedule_id IS NOT NULL DO NOTHING + `, tenantID, now, days) + return err +} diff --git a/apps/backend/internal/db/classes.go b/apps/backend/internal/db/classes.go index d3e76f0..cdb4c3c 100644 --- a/apps/backend/internal/db/classes.go +++ b/apps/backend/internal/db/classes.go @@ -5,7 +5,7 @@ import ( ) const classTemplateColumns = "id, tenant_id, title, duration_minutes, capacity, created_at" -const classSessionColumns = "cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity" +const classSessionColumns = "cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity, cs.schedule_id" func (r *PGRepository) ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error) { rows, err := r.pool.Query(ctx, ` @@ -110,6 +110,7 @@ func (r *PGRepository) GetClassSessionByID(ctx context.Context, sessionID string &record.StartsAt, &record.EndsAt, &record.Capacity, + &record.ScheduleID, ) return record, err } @@ -117,10 +118,10 @@ func (r *PGRepository) GetClassSessionByID(ctx context.Context, sessionID string func (r *PGRepository) CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) { var record ClassSessionRecord err := r.pool.QueryRow(ctx, ` - INSERT INTO class_sessions (tenant_id, template_id, location_id, starts_at, ends_at, capacity) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO class_sessions (tenant_id, template_id, location_id, starts_at, ends_at, capacity, schedule_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id - `, params.TenantID, params.TemplateID, params.LocationID, params.StartsAt, params.EndsAt, params.Capacity).Scan(&record.ID) + `, params.TenantID, params.TemplateID, params.LocationID, params.StartsAt, params.EndsAt, params.Capacity, params.ScheduleID).Scan(&record.ID) if err != nil { return record, err } diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 50decbc..4685c80 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "sort" + "strconv" "strings" "time" @@ -34,6 +35,10 @@ type Repository interface { CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) DeleteClassTemplate(ctx context.Context, templateID string) error + ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error) + CreateClassSchedule(ctx context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error) + DeleteClassSchedule(ctx context.Context, tenantID string, scheduleID string) error + MaterializeScheduleSessions(ctx context.Context, tenantID string, now time.Time, days int) error ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) @@ -392,6 +397,7 @@ type ClassSessionRecord struct { StartsAt time.Time EndsAt time.Time Capacity int32 + ScheduleID *string } type CreateClassSessionParams struct { @@ -401,6 +407,28 @@ type CreateClassSessionParams struct { StartsAt time.Time EndsAt time.Time Capacity int32 + ScheduleID *string +} + +// ClassScheduleRecord is a weekly recurring rule that materializes concrete +// class_sessions rows. DayOfWeek follows Go's time.Weekday (Sunday=0). +type ClassScheduleRecord struct { + ID string + TenantID string + TemplateID string + LocationID *string + DayOfWeek int + StartsLocal string + Active bool + CreatedAt time.Time +} + +type CreateClassScheduleParams struct { + TenantID string + TemplateID string + LocationID *string + DayOfWeek int + StartsLocal string } type BookingRecord struct { @@ -849,6 +877,7 @@ type MemoryRepository struct { rules []AvailabilityRuleRecord classTemplates []ClassTemplateRecord classSessions []ClassSessionRecord + classSchedules []ClassScheduleRecord bookings []BookingRecord waitlistSize int reminderJobs []ReminderJobRecord @@ -1470,6 +1499,7 @@ func (r *MemoryRepository) CreateClassSession(_ context.Context, params CreateCl StartsAt: params.StartsAt, EndsAt: params.EndsAt, Capacity: params.Capacity, + ScheduleID: params.ScheduleID, } for _, template := range r.classTemplates { if template.ID == params.TemplateID { @@ -1496,6 +1526,122 @@ func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID strin return pgx.ErrNoRows } +func (r *MemoryRepository) ListClassSchedulesByTenant(_ context.Context, tenantID string) ([]ClassScheduleRecord, error) { + if tenantID != r.tenant.ID { + return nil, nil + } + var out []ClassScheduleRecord + for _, schedule := range r.classSchedules { + if schedule.TenantID == tenantID { + out = append(out, schedule) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].DayOfWeek != out[j].DayOfWeek { + return out[i].DayOfWeek < out[j].DayOfWeek + } + return out[i].StartsLocal < out[j].StartsLocal + }) + return out, nil +} + +func (r *MemoryRepository) CreateClassSchedule(_ context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error) { + record := ClassScheduleRecord{ + ID: uuid.NewString(), + TenantID: params.TenantID, + TemplateID: params.TemplateID, + LocationID: params.LocationID, + DayOfWeek: params.DayOfWeek, + StartsLocal: params.StartsLocal, + Active: true, + CreatedAt: time.Now().UTC(), + } + r.classSchedules = append(r.classSchedules, record) + return record, nil +} + +func (r *MemoryRepository) DeleteClassSchedule(_ context.Context, tenantID string, scheduleID string) error { + for i, schedule := range r.classSchedules { + if schedule.ID == scheduleID && schedule.TenantID == tenantID { + r.classSchedules = append(r.classSchedules[:i], r.classSchedules[i+1:]...) + return nil + } + } + return fmt.Errorf("class schedule not found") +} + +// MaterializeScheduleSessions mirrors the PG implementation: for each active +// rule, concrete sessions are added for matching weekdays within the next +// `days` days. Sessions already materialized for a rule+start are skipped. +func (r *MemoryRepository) MaterializeScheduleSessions(_ context.Context, tenantID string, now time.Time, days int) error { + if tenantID != r.tenant.ID { + return nil + } + loc, err := time.LoadLocation(r.tenant.Timezone) + if err != nil { + loc = time.UTC + } + localNow := now.In(loc) + startDate := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc) + + existing := make(map[string]bool, len(r.classSessions)) + for _, session := range r.classSessions { + if session.ScheduleID != nil { + existing[*session.ScheduleID+session.StartsAt.UTC().Format(time.RFC3339)] = true + } + } + + for _, schedule := range r.classSchedules { + if !schedule.Active { + continue + } + var template ClassTemplateRecord + for _, tpl := range r.classTemplates { + if tpl.ID == schedule.TemplateID { + template = tpl + break + } + } + if template.ID == "" { + continue + } + for i := 0; i < days; i++ { + day := startDate.AddDate(0, 0, i) + if int(day.Weekday()) != schedule.DayOfWeek { + continue + } + parts := strings.Split(schedule.StartsLocal, ":") + if len(parts) < 2 { + continue + } + hour, _ := strconv.Atoi(parts[0]) + minute, _ := strconv.Atoi(parts[1]) + startsAt := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc) + if !startsAt.After(now) { + continue + } + key := schedule.ID + startsAt.UTC().Format(time.RFC3339) + if existing[key] { + continue + } + existing[key] = true + scheduleID := schedule.ID + r.classSessions = append(r.classSessions, ClassSessionRecord{ + ID: uuid.NewString(), + TenantID: tenantID, + TemplateID: schedule.TemplateID, + LocationID: schedule.LocationID, + Title: template.Title, + StartsAt: startsAt, + EndsAt: startsAt.Add(time.Duration(template.DurationMinutes) * time.Minute), + Capacity: int32(template.Capacity), + ScheduleID: &scheduleID, + }) + } + } + return nil +} + func (r *MemoryRepository) ListClassTemplatesByTenant(_ context.Context, tenantID string) ([]ClassTemplateRecord, error) { if tenantID != r.tenant.ID { return nil, nil @@ -1613,6 +1759,11 @@ func (r *MemoryRepository) seedDemoBookings() { serviceID := r.services[0].ID staffID := r.staff[0].ID locationID := r.locations[0].ID + templateID := r.classTemplates[0].ID + r.classSchedules = append(r.classSchedules, + ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 3, StartsLocal: "10:00:00", Active: true, CreatedAt: now}, + ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 6, StartsLocal: "09:30:00", Active: true, CreatedAt: now}, + ) seed := func(name, email, status string, startsAt time.Time) { r.bookings = append(r.bookings, BookingRecord{ ID: uuid.NewString(), diff --git a/apps/backend/internal/db/services.go b/apps/backend/internal/db/services.go index f9bc31b..e395489 100644 --- a/apps/backend/internal/db/services.go +++ b/apps/backend/internal/db/services.go @@ -138,7 +138,7 @@ func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenant func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) { rows, err := r.pool.Query(ctx, ` - SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity + SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity, cs.schedule_id FROM class_sessions cs INNER JOIN class_templates ct ON ct.id = cs.template_id WHERE cs.tenant_id = $1 AND cs.starts_at >= $2 @@ -162,6 +162,7 @@ func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID s &record.StartsAt, &record.EndsAt, &record.Capacity, + &record.ScheduleID, ); err != nil { return nil, err } diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 22e1728..fe18f0a 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -405,6 +405,9 @@ type ClassSession struct { BookedCount int `json:"bookedCount"` // WaitlistCount is the number of customers queued for a free seat. WaitlistCount int `json:"waitlistCount"` + // ScheduleID links the session back to the recurring rule that generated + // it; nil for manually created sessions. + ScheduleID *string `json:"scheduleId,omitempty"` } // SessionRosterEntry is one booking on a class session roster. WaitlistPosition @@ -447,6 +450,28 @@ type CreateClassSessionRequest struct { Capacity *int `json:"capacity,omitempty"` } +// ClassSchedule is a weekly recurring rule for a class template. Concrete +// sessions are materialized for a rolling horizon; DayOfWeek follows Go's +// time.Weekday (Sunday=0) and StartsLocal is an "HH:MM" tenant-local time. +type ClassSchedule struct { + ID string `json:"id"` + TenantID string `json:"tenantId"` + TemplateID string `json:"templateId"` + TemplateTitle string `json:"templateTitle"` + LocationID *string `json:"locationId,omitempty"` + LocationName string `json:"locationName,omitempty"` + DayOfWeek int `json:"dayOfWeek"` + StartsLocal string `json:"startsLocal"` + Active bool `json:"active"` +} + +type CreateClassScheduleRequest struct { + TemplateID string `json:"templateId" binding:"required"` + LocationID *string `json:"locationId,omitempty"` + DayOfWeek int `json:"dayOfWeek" binding:"min=0,max=6"` + StartsLocal string `json:"startsLocal" binding:"required"` // "HH:MM" +} + // ============================================ // CUSTOMER MODELS // ============================================ diff --git a/apps/backend/migrations/00018_class_schedules.sql b/apps/backend/migrations/00018_class_schedules.sql new file mode 100644 index 0000000..6524793 --- /dev/null +++ b/apps/backend/migrations/00018_class_schedules.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- Weekly recurring rules for class sessions. Rules materialize concrete +-- session rows for a rolling horizon (the service writes them); sessions +-- keep a nullable schedule_id link so regeneration is idempotent via the +-- partial unique index and deleting a rule stops future sessions without +-- touching existing ones. +CREATE TABLE class_schedules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + template_id uuid NOT NULL REFERENCES class_templates(id) ON DELETE CASCADE, + location_id uuid REFERENCES locations(id) ON DELETE SET NULL, + day_of_week integer NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), + starts_local time NOT NULL, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX class_schedules_tenant_idx ON class_schedules (tenant_id); + +ALTER TABLE class_sessions + ADD COLUMN schedule_id uuid REFERENCES class_schedules(id) ON DELETE SET NULL; +CREATE UNIQUE INDEX class_sessions_schedule_start_idx + ON class_sessions (schedule_id, starts_at) + WHERE schedule_id IS NOT NULL; + +-- +goose Down +ALTER TABLE class_sessions DROP COLUMN IF EXISTS schedule_id; +DROP TABLE IF EXISTS class_schedules; diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 6322a90..6a6e8cd 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -1350,6 +1350,68 @@ paths: $ref: "#/components/schemas/SessionRoster" "404": description: Class session not found + /v1/catalog/class-schedules: + get: + tags: [Catalog] + operationId: listClassSchedules + security: + - bearerAuth: [] + summary: Weekly recurring rules for class sessions + description: > + Each rule materializes concrete class_sessions for a rolling 28-day + horizon in the tenant timezone. Sessions are written idempotently on + read (list class sessions or public availability), so no background + job is needed. Deleting a rule stops future materialization; sessions + already created stay in place and keep their bookings. + responses: + "200": + description: Recurring class schedules + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ClassSchedule" + post: + tags: [Catalog] + operationId: createClassSchedule + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateClassScheduleRequest" + responses: + "201": + description: Class schedule created (sessions materialized immediately) + content: + application/json: + schema: + $ref: "#/components/schemas/ClassSchedule" + "400": + description: Invalid request + "404": + description: Template or location not found + /v1/catalog/class-schedules/{scheduleID}: + delete: + tags: [Catalog] + operationId: deleteClassSchedule + security: + - bearerAuth: [] + parameters: + - in: path + name: scheduleID + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Class schedule deleted + "404": + description: Class schedule not found # ---------------- Calendar ---------------- /v1/calendar/feed: get: @@ -2755,6 +2817,10 @@ components: type: integer waitlistCount: type: integer + scheduleId: + type: string + format: uuid + description: Recurring rule that generated this session; absent for manual sessions SessionRosterEntry: type: object required: [bookingId, reference, customerName, customerEmail, status, createdAt] @@ -2825,6 +2891,54 @@ components: format: date-time capacity: type: integer + ClassSchedule: + type: object + required: [id, tenantId, templateId, templateTitle, dayOfWeek, startsLocal, active] + properties: + id: + type: string + format: uuid + tenantId: + type: string + format: uuid + templateId: + type: string + format: uuid + templateTitle: + type: string + locationId: + type: string + format: uuid + locationName: + type: string + dayOfWeek: + type: integer + minimum: 0 + maximum: 6 + description: 0=Sunday ... 6=Saturday + startsLocal: + type: string + description: Tenant-local start time, HH:MM:SS + active: + type: boolean + CreateClassScheduleRequest: + type: object + required: [templateId, dayOfWeek, startsLocal] + properties: + templateId: + type: string + format: uuid + locationId: + type: string + format: uuid + dayOfWeek: + type: integer + minimum: 0 + maximum: 6 + description: 0=Sunday ... 6=Saturday + startsLocal: + type: string + description: Tenant-local start time, HH:MM PlanEntitlements: type: object required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index efb479a..3b29cb6 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -446,6 +446,19 @@ const dictionaries = { "dashboard.class.queuePosition": "v pořadí", "dashboard.class.showRoster": "Účastníci", "dashboard.class.hideRoster": "Skrýt", + "dashboard.class.recurring": "Opakující se lekce", + "dashboard.class.recurringHint": "Týdenní pravidla automaticky vytvářejí lekce na příštích 28 dní. Smazání pravidla nechává již vytvořené lekce nedotčené.", + "dashboard.class.weekday": "Den v týdnu", + "dashboard.class.startsLocal": "Čas", + "dashboard.class.addSchedule": "Přidat pravidlo", + "dashboard.class.noSchedules": "Zatím žádná opakující se pravidla.", + "dashboard.class.dow.0": "Neděle", + "dashboard.class.dow.1": "Pondělí", + "dashboard.class.dow.2": "Úterý", + "dashboard.class.dow.3": "Středa", + "dashboard.class.dow.4": "Čtvrtek", + "dashboard.class.dow.5": "Pátek", + "dashboard.class.dow.6": "Sobota", "dashboard.waitlistPosition": "Pořadí v čekací listině", "dashboard.booking.promote": "Potvrdit z čekací listiny", "dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?", @@ -1052,6 +1065,19 @@ const dictionaries = { "dashboard.class.queuePosition": "in queue", "dashboard.class.showRoster": "Roster", "dashboard.class.hideRoster": "Hide", + "dashboard.class.recurring": "Recurring classes", + "dashboard.class.recurringHint": "Weekly rules automatically create sessions for the next 28 days. Deleting a rule leaves existing sessions untouched.", + "dashboard.class.weekday": "Weekday", + "dashboard.class.startsLocal": "Time", + "dashboard.class.addSchedule": "Add rule", + "dashboard.class.noSchedules": "No recurring rules yet.", + "dashboard.class.dow.0": "Sunday", + "dashboard.class.dow.1": "Monday", + "dashboard.class.dow.2": "Tuesday", + "dashboard.class.dow.3": "Wednesday", + "dashboard.class.dow.4": "Thursday", + "dashboard.class.dow.5": "Friday", + "dashboard.class.dow.6": "Saturday", "dashboard.waitlistPosition": "Waitlist position", "dashboard.booking.promote": "Promote from waitlist", "dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?", diff --git a/apps/frontend/src/routes/dashboard/zones-page.tsx b/apps/frontend/src/routes/dashboard/zones-page.tsx index fdb485d..c12a884 100644 --- a/apps/frontend/src/routes/dashboard/zones-page.tsx +++ b/apps/frontend/src/routes/dashboard/zones-page.tsx @@ -3,7 +3,7 @@ import { apiClient } from "../../lib/api-client"; import { Input } from "../../components/ui/input"; import { Select } from "../../components/ui/select"; import { getInitials } from "../../components/dashboard/types"; -import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon } from "../../components/dashboard/icons"; +import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon } from "../../components/dashboard/icons"; import { DashboardLayout, useDashboardData } from "./layout"; function ZonesPage() { @@ -30,6 +30,11 @@ function ZonesPage() { const [newSessionLocationId, setNewSessionLocationId] = createSignal(""); const [newSessionStartsAt, setNewSessionStartsAt] = createSignal(""); const [sessionSaving, setSessionSaving] = createSignal(false); + const [newSchedTemplateId, setNewSchedTemplateId] = createSignal(""); + const [newSchedDayOfWeek, setNewSchedDayOfWeek] = createSignal("3"); + const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal(""); + const [newSchedLocationId, setNewSchedLocationId] = createSignal(""); + const [schedSaving, setSchedSaving] = createSignal(false); const [expandedSessionId, setExpandedSessionId] = createSignal(null); const [promoteBusy, setPromoteBusy] = createSignal(null); // Booking IDs whose promote attempt hit a full session — awaiting the @@ -131,6 +136,24 @@ function ZonesPage() { }, ); + const [classSchedules, { refetch: refetchSchedules }] = createResource( + () => [data.token(), activeTab()] as const, + async ([bearer, tab]) => { + if (!bearer || tab !== "classes") return []; + if (bearer.startsWith("demo.")) { + return [ + { id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true }, + { id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true }, + ]; + } + const response = await (apiClient as any).GET("/v1/catalog/class-schedules", authHeaders()); + return (response.data as any[]) ?? []; + }, + ); + + const weekdayName = (dow: number) => + data.i18n.t(`dashboard.class.dow.${dow}`) as string; + const [roster, { refetch: refetchRoster }] = createResource( () => [data.token(), expandedSessionId()] as const, async ([bearer, sessionId]) => { @@ -357,6 +380,42 @@ function ZonesPage() { } }; + const handleAddSchedule = async () => { + const bearer = data.token(); + if (!newSchedTemplateId() || !newSchedStartsLocal()) return; + if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; } + setSchedSaving(true); + try { + await (apiClient as any).POST("/v1/catalog/class-schedules", { + ...authHeaders(), + body: { + templateId: newSchedTemplateId(), + dayOfWeek: parseInt(newSchedDayOfWeek(), 10), + startsLocal: newSchedStartsLocal(), + ...(newSchedLocationId() ? { locationId: newSchedLocationId() } : {}), + }, + }); + setNewSchedTemplateId(""); setNewSchedStartsLocal(""); setNewSchedLocationId(""); + void refetchSchedules(); + void refetchSessions(); + } catch { + data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed."); + } finally { + setSchedSaving(false); + } + }; + + const handleDeleteSchedule = async (id: string) => { + const bearer = data.token(); + if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; } + try { + await (apiClient as any).DELETE(`/v1/catalog/class-schedules/${id}`, authHeaders()); + void refetchSchedules(); + } catch { + data.setDemoNotice(cs() ? "Smazání selhalo." : "Delete failed."); + } + }; + const typeLabel = (type: string) => { const map: Record = { room: data.i18n.t("dashboard.zone.rooms"), private: data.i18n.t("dashboard.zone.private"), hall: data.i18n.t("dashboard.zone.hall") }; return map[type] || type; @@ -607,6 +666,72 @@ function ZonesPage() { +
+
+

{data.i18n.t("dashboard.class.recurring")}

+

{data.i18n.t("dashboard.class.recurringHint")}

+
+
+
+ setNewSchedDayOfWeek(v)} + options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: weekdayName(d) }))} + /> + setNewSchedStartsLocal(e.currentTarget.value)} /> +