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

@@ -518,13 +518,10 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st
if err != nil {
return domain.MoveBookingResponse{}, err
}
confirmed, waitlisted := 0, 0
confirmed := 0
for _, other := range targetBookings {
switch other.Status {
case "confirmed":
if other.Status == "confirmed" {
confirmed++
case "waitlisted":
waitlisted++
}
}
@@ -534,30 +531,15 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st
newStatus = "waitlisted"
}
// Release the old spot first so the seat/queue position is free before
// the retarget lands.
if booking.Status == "waitlisted" {
_ = s.repo.ResolveWaitlistEntry(ctx, booking.TenantID, currentSession.ID, booking.CustomerEmail)
}
if err := s.repo.UpdateBookingSession(ctx, booking.ID, target.ID, target.StartsAt, target.EndsAt, newStatus); err != nil {
return domain.MoveBookingResponse{}, err
}
if newStatus == "waitlisted" {
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
TenantID: booking.TenantID,
ClassSessionID: target.ID,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Position: waitlisted + 1,
}); err != nil {
return domain.MoveBookingResponse{}, err
}
}
// Re-arm reminders for the new window (confirmed only; a queue spot
// re-arms on promotion through the shared confirm path).
if err := s.repo.DeletePendingReminderJobsForBooking(ctx, booking.ID); err == nil && newStatus == "confirmed" {
_ = armReminderJobs(ctx, s.repo, booking.TenantID, booking.ID, target.StartsAt, booking.CustomerPhone)
if newStatus == "confirmed" {
booking.StartsAt, booking.EndsAt = target.StartsAt, target.EndsAt
RearmReminderJobs(ctx, s.repo, booking.TenantID, booking)
}
// A confirmed move frees a seat on the old session — drain its queue.
@@ -164,17 +164,6 @@ func seedMoveBooking(t *testing.T, repo *db.MemoryRepository, sessionID, referen
if err != nil {
t.Fatalf("get session: %v", err)
}
if status == "waitlisted" {
if err := repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
TenantID: moveTenantID,
ClassSessionID: sessionID,
CustomerName: "Move Customer",
CustomerEmail: "[email protected]",
Position: 1,
}); err != nil {
t.Fatalf("seed waitlist entry: %v", err)
}
}
created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: moveTenantID,
ClassSessionID: &sessionID,
+14 -8
View File
@@ -50,7 +50,7 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
break
}
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
if err := confirmWaitlistedBooking(ctx, repo, tenantID, booking); err != nil {
return promoted, err
}
confirmed++
@@ -104,24 +104,30 @@ func PromoteBooking(ctx context.Context, repo db.Repository, tenantID, bookingID
}
}
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
if err := confirmWaitlistedBooking(ctx, repo, tenantID, booking); err != nil {
return db.BookingRecord{}, err
}
booking.Status = "confirmed"
return booking, nil
}
// confirmWaitlistedBooking flips a waitlisted booking to confirmed, resolves
// the ledger entry, and re-arms reminder jobs — a booking promoted after its
// reminder already dispatched-as-skipped would otherwise go unreminded.
func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID, sessionID string, booking db.BookingRecord) error {
// confirmWaitlistedBooking flips a waitlisted booking to confirmed and
// re-arms reminder jobs — a booking promoted after its reminder already
// dispatched-as-skipped would otherwise go unreminded.
func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID string, booking db.BookingRecord) error {
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
return err
}
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
RearmReminderJobs(ctx, repo, tenantID, booking)
return nil
}
// RearmReminderJobs clears pending reminders for a booking and re-arms them
// for its (possibly new) start time. Used after promotion, session moves, and
// schedule retimes so reminders always match the booking's live window.
func RearmReminderJobs(ctx context.Context, repo db.Repository, tenantID string, booking db.BookingRecord) {
_ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID)
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
return nil
}
// armReminderJobs schedules the email reminder — and the SMS reminder when the
-13
View File
@@ -365,19 +365,6 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques
return domain.CreateBookingResponse{}, err
}
if status == "waitlisted" && request.ClassSessionID != nil {
waitlistPosition := int(countClassBookings(existing, *request.ClassSessionID)) + 1
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
TenantID: tenant.ID,
ClassSessionID: *request.ClassSessionID,
CustomerName: customerName,
CustomerEmail: customerEmail,
Position: waitlistPosition,
}); err != nil {
return domain.CreateBookingResponse{}, err
}
}
if err := armReminderJobs(ctx, s.repo, tenant.ID, created.ID, startsAt, request.CustomerPhone); err != nil {
return domain.CreateBookingResponse{}, err
}