feat: series-aware schedule edits + waitlist ledger removal (M32)

PATCH /v1/catalog/class-schedules/:id patches a recurring rule and,
with applyToFuture, retimes materialized future sessions to the new
local start time on their existing dates — moving confirmed and
waitlisted bookings with them and re-arming reminders. Zones-page
schedule rows gain an inline editor (CS/EN).

Also drops the vestigial waitlist_entries table (migration 00020):
positions derive from bookings.created_at, so the ledger was written
but never read.

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-22 20:59:34 +02:00
1 parent 55fc0d06e3
commit 65a693cf38
17 files changed
+840 -230

No files matched your search

@@ -886,3 +886,110 @@ func TestCreateClassScheduleValidation(t *testing.T) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
}
}
func TestUpdateClassScheduleRetimesFutureSessions(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
templateID := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7" // 60min mobility class
created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: templateID, DayOfWeek: 1, StartsLocal: "10:00",
})
if err != nil {
t.Fatalf("create schedule: %v", err)
}
sessions, err := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
if err != nil || len(sessions) == 0 {
t.Fatalf("expected materialized sessions, got %d err=%v", len(sessions), err)
}
// Seed a confirmed booking on the first session.
target := sessions[0]
if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
ClassSessionID: &target.ID,
BookingMode: "class",
CustomerName: "Mover",
CustomerEmail: "[email protected]",
StartsAt: target.StartsAt,
EndsAt: target.EndsAt,
Status: "confirmed",
Reference: "BK-MOVE-1",
}); err != nil {
t.Fatalf("seed booking: %v", err)
}
originalLocalHour := target.StartsAt.In(time.FixedZone("x", 0)).UTC().In(time.Local).Hour()
_ = originalLocalHour
newTime := "15:30"
updated, err := svc.UpdateClassSchedule(ctx, owner, created.ID, domain.UpdateClassScheduleRequest{
StartsLocal: &newTime,
ApplyToFuture: true,
})
if err != nil {
t.Fatalf("update schedule: %v", err)
}
if updated.StartsLocal != "15:30:00" {
t.Fatalf("expected normalized 15:30:00, got %s", updated.StartsLocal)
}
loc, _ := time.LoadLocation("Europe/Prague")
moved, err := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
if err != nil || len(moved) == 0 {
t.Fatalf("expected moved sessions, got %d err=%v", len(moved), err)
}
for _, session := range moved {
local := session.StartsAt.In(loc)
if local.Hour() != 15 || local.Minute() != 30 {
t.Fatalf("session not retimed: %v local=%v", session.StartsAt, local)
}
if session.EndsAt.Sub(session.StartsAt) != 60*time.Minute {
t.Fatalf("expected 60min duration preserved, got %v", session.EndsAt.Sub(session.StartsAt))
}
// Same local date as before — only the time-of-day moved.
if local.Weekday() != time.Monday {
t.Fatalf("session changed weekday: %v", local.Weekday())
}
}
// The seeded booking must follow its session's new window.
booking, err := repo.GetBookingByReference(ctx, "BK-MOVE-1")
if err != nil {
t.Fatalf("fetch booking: %v", err)
}
if !booking.StartsAt.Equal(moved[0].StartsAt) || !booking.EndsAt.Equal(moved[0].EndsAt) {
t.Fatalf("booking not synced: %v–%v vs session %v–%v", booking.StartsAt, booking.EndsAt, moved[0].StartsAt, moved[0].EndsAt)
}
}
func TestUpdateClassScheduleWithoutCascade(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 1, StartsLocal: "10:00",
})
if err != nil {
t.Fatalf("create schedule: %v", err)
}
before, _ := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
newTime := "16:00"
if _, err := svc.UpdateClassSchedule(ctx, owner, created.ID, domain.UpdateClassScheduleRequest{
StartsLocal: &newTime,
}); err != nil {
t.Fatalf("update without cascade: %v", err)
}
after, _ := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC())
if len(after) != len(before) {
t.Fatalf("session count changed without cascade: %d -> %d", len(before), len(after))
}
for i := range after {
if !after[i].StartsAt.Equal(before[i].StartsAt) {
t.Fatal("session moved without applyToFuture")
}
}
}