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

+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(),