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

@@ -99,3 +99,89 @@ func (r *PGRepository) MaterializeScheduleSessions(ctx context.Context, tenantID
`, tenantID, now, days)
return err
}
// UpdateClassSchedule edits an existing recurring rule; nil fields are kept.
// The tenant guard makes the update a no-match error for foreign schedules.
func (r *PGRepository) UpdateClassSchedule(ctx context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error) {
var record ClassScheduleRecord
err := r.pool.QueryRow(ctx, `
UPDATE class_schedules SET
day_of_week = COALESCE($3, day_of_week),
starts_local = COALESCE($4::time, starts_local),
location_id = CASE WHEN $5::text IS NULL THEN location_id WHEN $5::text = '' THEN NULL ELSE $5::uuid END,
active = COALESCE($6, active)
WHERE id = $1 AND tenant_id = $2
RETURNING `+classScheduleColumns+`
`, scheduleID, tenantID, params.DayOfWeek, params.StartsLocal, params.LocationID, params.Active).Scan(
&record.ID,
&record.TenantID,
&record.TemplateID,
&record.LocationID,
&record.DayOfWeek,
&record.StartsLocal,
&record.Active,
&record.CreatedAt,
)
return record, err
}
// ListClassSessionsBySchedule returns materialized sessions of one rule,
// optionally only those starting after `after`.
func (r *PGRepository) ListClassSessionsBySchedule(ctx context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+classSessionColumns+`
FROM class_sessions cs
INNER JOIN class_templates ct ON ct.id = cs.template_id
WHERE cs.tenant_id = $1 AND cs.schedule_id = $2 AND cs.starts_at > $3
ORDER BY cs.starts_at ASC
`, tenantID, scheduleID, after)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ClassSessionRecord
for rows.Next() {
var record ClassSessionRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TemplateID,
&record.LocationID,
&record.Title,
&record.StartsAt,
&record.EndsAt,
&record.Capacity,
&record.ScheduleID,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
// RetimeFutureScheduleSessions shifts each future session of a rule to a new
// local start time (keeping its calendar date) and optionally a new location.
// Live bookings on those sessions are moved to the same window so reminders
// and calendars stay in sync. A nil startsLocal keeps session times.
func (r *PGRepository) RetimeFutureScheduleSessions(ctx context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) error {
_, err := r.pool.Exec(ctx, `
WITH moved AS (
UPDATE class_sessions cs SET
starts_at = CASE WHEN $4::time IS NULL THEN cs.starts_at
ELSE ((cs.starts_at AT TIME ZONE t.timezone)::date + $4::time) AT TIME ZONE t.timezone END,
ends_at = CASE WHEN $4::time IS NULL THEN cs.ends_at
ELSE ((cs.starts_at AT TIME ZONE t.timezone)::date + $4::time) AT TIME ZONE t.timezone + make_interval(mins => tpl.duration_minutes) END,
location_id = CASE WHEN $5::text IS NULL THEN cs.location_id WHEN $5::text = '' THEN NULL ELSE $5::uuid END
FROM tenants t, class_templates tpl
WHERE cs.tenant_id = $1 AND cs.schedule_id = $2 AND cs.starts_at > $3
AND t.id = cs.tenant_id AND tpl.id = cs.template_id
RETURNING cs.id, cs.starts_at, cs.ends_at
)
UPDATE bookings b SET starts_at = m.starts_at, ends_at = m.ends_at
FROM moved m
WHERE b.class_session_id = m.id AND b.status IN ('confirmed', 'waitlisted')
`, tenantID, scheduleID, now, startsLocal, locationID)
return err
}