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

-19
View File
@@ -127,25 +127,6 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa
return created, err
}
func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO waitlist_entries (tenant_id, class_session_id, customer_name, customer_email, position)
VALUES ($1,$2,$3,$4,$5)
`, params.TenantID, params.ClassSessionID, params.CustomerName, params.CustomerEmail, params.Position)
return err
}
// ResolveWaitlistEntry marks the pending ledger row for a promoted booking.
// Entries carry no booking_id, so the session + customer email pair is the
// practical key.
func (r *PGRepository) ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error {
_, err := r.pool.Exec(ctx, `
UPDATE waitlist_entries SET status = 'resolved'
WHERE tenant_id = $1 AND class_session_id = $2 AND customer_email = $3 AND status = 'pending'
`, tenantID, sessionID, customerEmail)
return err
}
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
@@ -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
}
+160 -68
View File
@@ -37,13 +37,14 @@ type Repository interface {
DeleteClassTemplate(ctx context.Context, templateID string) error
ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error)
CreateClassSchedule(ctx context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error)
UpdateClassSchedule(ctx context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error)
DeleteClassSchedule(ctx context.Context, tenantID string, scheduleID string) error
MaterializeScheduleSessions(ctx context.Context, tenantID string, now time.Time, days int) error
ListClassSessionsBySchedule(ctx context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error)
RetimeFutureScheduleSessions(ctx context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) 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)
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error
CreateReminderJob(ctx context.Context, params ReminderJobParams) error
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
@@ -278,8 +279,8 @@ type ServiceRecord struct {
PriceCents int
// IntakeFields is the raw JSON list of declared structured intake fields;
// the catalog service parses and validates it into domain.IntakeField.
IntakeFields string
CreatedAt time.Time
IntakeFields string
CreatedAt time.Time
}
type CreateServiceParams struct {
@@ -343,14 +344,14 @@ type ActivityEventRecord struct {
}
type ActivityEventParams struct {
TenantID string
ActorKind string
TenantID string
ActorKind string
ActorSubject string
ActorName string
Action string
EntityType string
EntityID *string
Summary string
ActorName string
Action string
EntityType string
EntityID *string
Summary string
}
type CreateStaffParams struct {
@@ -418,14 +419,14 @@ type CreateClassSessionParams struct {
// 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
ID string
TenantID string
TemplateID string
LocationID *string
DayOfWeek int
StartsLocal string
Active bool
CreatedAt time.Time
Active bool
CreatedAt time.Time
}
type CreateClassScheduleParams struct {
@@ -436,6 +437,15 @@ type CreateClassScheduleParams struct {
StartsLocal string
}
// UpdateClassScheduleParams carries optional rule fields; nil means keep.
// LocationID nil keeps the location, a pointer to "" clears it.
type UpdateClassScheduleParams struct {
DayOfWeek *int
StartsLocal *string
LocationID *string
Active *bool
}
type BookingRecord struct {
ID string
TenantID string
@@ -453,8 +463,8 @@ type BookingRecord struct {
ManageToken string
Notes string
// Intake is the raw JSON object of structured intake answers.
Intake string
CreatedAt time.Time
Intake string
CreatedAt time.Time
}
type CreateBookingParams struct {
@@ -483,14 +493,6 @@ type CreatedBooking struct {
ManageToken string
}
type WaitlistEntryParams struct {
TenantID string
ClassSessionID string
CustomerName string
CustomerEmail string
Position int
}
type DashboardMetrics struct {
BookingsCount int
CancellationsCount int
@@ -878,32 +880,31 @@ func (r *PGRepository) UpdateBookingSession(ctx context.Context, bookingID strin
// ============================================
type MemoryRepository struct {
tenant TenantRecord
membership TenantMembershipRecord
brand BrandProfileRecord
services []ServiceRecord
rules []AvailabilityRuleRecord
classTemplates []ClassTemplateRecord
classSessions []ClassSessionRecord
classSchedules []ClassScheduleRecord
bookings []BookingRecord
waitlistSize int
reminderJobs []ReminderJobRecord
deliveryLogs []NotificationDeliveryLogParams
billingSnapshot BillingSnapshotRecord
recordedEvents map[string]struct{}
locations []LocationRecord
staff []StaffRecord
blockedDays []BlockedDayRecord
customers []CustomerRecord
workingHours []WorkingHoursRecord
smsSettings TenantSMSSettingsRecord
smsLogs []SMSUsageLogRecord
smsReports []SMSMonthlyReportRecord
emailTemplates []EmailTemplateRecord
tenantFeedToken string
staffFeedTokens map[string]string
activityEvents []ActivityEventRecord
tenant TenantRecord
membership TenantMembershipRecord
brand BrandProfileRecord
services []ServiceRecord
rules []AvailabilityRuleRecord
classTemplates []ClassTemplateRecord
classSessions []ClassSessionRecord
classSchedules []ClassScheduleRecord
bookings []BookingRecord
reminderJobs []ReminderJobRecord
deliveryLogs []NotificationDeliveryLogParams
billingSnapshot BillingSnapshotRecord
recordedEvents map[string]struct{}
locations []LocationRecord
staff []StaffRecord
blockedDays []BlockedDayRecord
customers []CustomerRecord
workingHours []WorkingHoursRecord
smsSettings TenantSMSSettingsRecord
smsLogs []SMSUsageLogRecord
smsReports []SMSMonthlyReportRecord
emailTemplates []EmailTemplateRecord
tenantFeedToken string
staffFeedTokens map[string]string
activityEvents []ActivityEventRecord
notificationCursors map[string]time.Time
}
@@ -1582,6 +1583,109 @@ func (r *MemoryRepository) DeleteClassSchedule(_ context.Context, tenantID strin
return fmt.Errorf("class schedule not found")
}
// UpdateClassSchedule mirrors the PG implementation: nil fields are kept,
// a LocationID pointer to "" clears the location.
func (r *MemoryRepository) UpdateClassSchedule(_ context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error) {
for i, schedule := range r.classSchedules {
if schedule.ID != scheduleID || schedule.TenantID != tenantID {
continue
}
if params.DayOfWeek != nil {
r.classSchedules[i].DayOfWeek = *params.DayOfWeek
}
if params.StartsLocal != nil {
r.classSchedules[i].StartsLocal = normalizeLocalTime(*params.StartsLocal)
}
if params.LocationID != nil {
if *params.LocationID == "" {
r.classSchedules[i].LocationID = nil
} else {
loc := *params.LocationID
r.classSchedules[i].LocationID = &loc
}
}
if params.Active != nil {
r.classSchedules[i].Active = *params.Active
}
return r.classSchedules[i], nil
}
return ClassScheduleRecord{}, fmt.Errorf("class schedule not found")
}
// ListClassSessionsBySchedule mirrors the PG implementation.
func (r *MemoryRepository) ListClassSessionsBySchedule(_ context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
var out []ClassSessionRecord
for _, session := range r.classSessions {
if session.ScheduleID != nil && *session.ScheduleID == scheduleID && session.StartsAt.After(after) {
out = append(out, session)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) })
return out, nil
}
// RetimeFutureScheduleSessions mirrors the PG implementation: future sessions
// of the rule keep their local date but move to the new start time and
// template duration, and live bookings on them follow.
func (r *MemoryRepository) RetimeFutureScheduleSessions(_ context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) error {
if tenantID != r.tenant.ID {
return nil
}
loc, err := time.LoadLocation(r.tenant.Timezone)
if err != nil {
loc = time.UTC
}
var hour, minute int
if startsLocal != nil {
parts := strings.Split(normalizeLocalTime(*startsLocal), ":")
if len(parts) < 2 {
return nil
}
hour, _ = strconv.Atoi(parts[0])
minute, _ = strconv.Atoi(parts[1])
}
for i := range r.classSessions {
session := &r.classSessions[i]
if session.ScheduleID == nil || *session.ScheduleID != scheduleID || !session.StartsAt.After(now) {
continue
}
if startsLocal != nil {
day := session.StartsAt.In(loc)
newStart := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc)
var duration time.Duration
for _, tpl := range r.classTemplates {
if tpl.ID == session.TemplateID {
duration = time.Duration(tpl.DurationMinutes) * time.Minute
break
}
}
session.StartsAt = newStart
session.EndsAt = newStart.Add(duration)
}
if locationID != nil && *locationID != "" {
loc := *locationID
session.LocationID = &loc
}
}
for i := range r.bookings {
booking := &r.bookings[i]
if booking.ClassSessionID == nil || (booking.Status != "confirmed" && booking.Status != "waitlisted") {
continue
}
for _, session := range r.classSessions {
if session.ID == *booking.ClassSessionID && session.ScheduleID != nil && *session.ScheduleID == scheduleID {
booking.StartsAt = session.StartsAt
booking.EndsAt = session.EndsAt
break
}
}
}
return nil
}
// 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.
@@ -1802,18 +1906,6 @@ func (r *MemoryRepository) seedDemoBookings() {
r.bookings[0].Intake = `{"health_notes":"Sensitive skin","first_visit":"yes"}`
}
func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error {
r.waitlistSize++
return nil
}
func (r *MemoryRepository) ResolveWaitlistEntry(_ context.Context, _, _, _ string) error {
if r.waitlistSize > 0 {
r.waitlistSize--
}
return nil
}
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
reference := params.BookingID
startsAt := time.Now().UTC()
@@ -2489,8 +2581,8 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string
}
}
r.workingHours = append(r.workingHours, WorkingHoursRecord{
TenantID: tenantID,
DayOfWeek: dayOfWeek,
TenantID: tenantID,
DayOfWeek: dayOfWeek,
StartsLocal: *params.StartsLocal,
EndsLocal: *params.EndsLocal,
})