feat(bookings): dashboard cancellation and waitlist promotion

- POST /v1/catalog/bookings/{id}/cancel lets businesses cancel
  tenant-owned bookings; two-step confirm in the detail modal
- Shared PromoteWaitlist helper promotes earliest waitlisted bookings
  when a class seat frees, on both dashboard and customer cancel paths;
  promoted customers get a confirmation email, waitlist ledger marked
  resolved
- waitlisted status surfaced in bookings UI: stats card, filter, warning
  badge, demo seed, CS+EN strings
- BookingRecord gains created_at for fair ordering; GetBookingByID and
  ResolveWaitlistEntry added to the repository surface

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 11:21:46 +02:00
1 parent 0405106587
commit 668a54ae1d
15 files changed
+563 -5

No files matched your search

+13 -1
View File
@@ -9,7 +9,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, '')
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
@@ -38,6 +38,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
&record.Reference,
&record.ManageToken,
&record.Notes,
&record.CreatedAt,
); err != nil {
return nil, err
}
@@ -84,6 +85,17 @@ func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistE
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)
+35 -2
View File
@@ -36,6 +36,7 @@ type Repository interface {
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]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
@@ -103,6 +104,7 @@ type Repository interface {
// Customer Booking Management
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error)
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
@@ -352,6 +354,7 @@ type BookingRecord struct {
Reference string
ManageToken string
Notes string
CreatedAt time.Time
}
type CreateBookingParams struct {
@@ -719,11 +722,24 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, '')
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE reference = $1
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes)
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) {
var rec BookingRecord
err := r.pool.QueryRow(ctx, `
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE id = $1
`, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
return rec, err
}
@@ -1343,6 +1359,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
Reference: params.Reference,
ManageToken: params.ManageToken,
Notes: params.Notes,
CreatedAt: time.Now().UTC(),
})
return created, nil
}
@@ -1352,6 +1369,13 @@ func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntr
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()
@@ -1756,6 +1780,15 @@ func (r *MemoryRepository) GetBookingByReference(_ context.Context, reference st
return BookingRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) (BookingRecord, error) {
for _, b := range r.bookings {
if b.ID == bookingID {
return b, nil
}
}
return BookingRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error {
for i, b := range r.bookings {
if b.ID == bookingID {