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

+45
View File
@@ -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)
@@ -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
+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 {
@@ -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)
}
}
+101
View File
@@ -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
}
+5 -4
View File
@@ -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
}
+151
View File
@@ -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(),
+2 -1
View File
@@ -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
}
+25
View File
@@ -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
// ============================================
@@ -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;
+114
View File
@@ -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]