diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index 506b219..6679193 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -108,17 +108,23 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s serviceName, locationName := s.descriptors(ctx, booking) + position := 0 + if booking.Status == "waitlisted" && booking.ClassSessionID != nil { + position, _ = s.repo.GetWaitlistPosition(ctx, *booking.ClassSessionID, booking.ID) + } + return domain.CustomerBookingView{ - Reference: booking.Reference, - CustomerName: booking.CustomerName, - CustomerEmail: booking.CustomerEmail, - Service: serviceName, - BusinessName: tenant.Name, - StartsAt: booking.StartsAt, - EndsAt: booking.EndsAt, - Location: locationName, - Status: booking.Status, - Notes: booking.Notes, + Reference: booking.Reference, + CustomerName: booking.CustomerName, + CustomerEmail: booking.CustomerEmail, + Service: serviceName, + BusinessName: tenant.Name, + StartsAt: booking.StartsAt, + EndsAt: booking.EndsAt, + Location: locationName, + Status: booking.Status, + Notes: booking.Notes, + WaitlistPosition: position, }, nil } diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index b819333..24488cf 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -703,3 +703,66 @@ func TestCreateArmsSmsReminderWhenEnabled(t *testing.T) { t.Fatalf("expected email+sms channels, got %v", channels) } } + +func TestWaitlistPositionReflectsQueueOrder(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + customerService := NewCustomerService(repo, nil, "http://localhost:3000") + ctx := context.Background() + + session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435") + if err != nil { + t.Fatalf("get session: %v", err) + } + sessionID := session.ID + book := func(email string) domain.CreateBookingResponse { + resp, err := service.Create(ctx, domain.CreateBookingRequest{ + TenantSlug: "studio-atelier", + BookingMode: "class", + ClassSessionID: &sessionID, + CustomerName: "Queue Test", + CustomerEmail: email, + StartsAt: session.StartsAt.UTC().Format(time.RFC3339), + EndsAt: session.EndsAt.UTC().Format(time.RFC3339), + }) + if err != nil { + t.Fatalf("book %s: %v", email, err) + } + return resp + } + + var seats []domain.CreateBookingResponse + for i := 0; i < 4; i++ { + seats = append(seats, book(fmt.Sprintf("qseat%d@example.com", i))) + } + first := book("qfirst@example.com") + second := book("qsecond@example.com") + + positionOf := func(resp domain.CreateBookingResponse) int { + manageURL, _ := url.Parse(resp.ManageURL) + view, err := customerService.GetBookingByReference(ctx, resp.Reference, manageURL.Query().Get("token")) + if err != nil { + t.Fatalf("get booking view: %v", err) + } + return view.WaitlistPosition + } + + if got := positionOf(first); got != 1 { + t.Fatalf("expected first waiter at position 1, got %d", got) + } + if got := positionOf(second); got != 2 { + t.Fatalf("expected second waiter at position 2, got %d", got) + } + + // Free a seat: first waiter is promoted, second moves to the front. + manageURL, _ := url.Parse(seats[0].ManageURL) + if err := customerService.CancelBooking(ctx, seats[0].Reference, manageURL.Query().Get("token")); err != nil { + t.Fatalf("cancel: %v", err) + } + if got := positionOf(second); got != 1 { + t.Fatalf("expected second waiter at position 1 after promotion, got %d", got) + } + if got := positionOf(first); got != 0 { + t.Fatalf("expected promoted booking to report no position, got %d", got) + } +} diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 4c0df3d..c66105f 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -204,6 +204,18 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string, return metrics, err } +func (r *PGRepository) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) { + var position int + err := r.pool.QueryRow(ctx, ` + SELECT COUNT(*) + 1 + FROM bookings + WHERE class_session_id = $1 + AND status = 'waitlisted' + AND created_at < (SELECT created_at FROM bookings WHERE id = $2) + `, sessionID, bookingID).Scan(&position) + return position, err +} + func (r *PGRepository) CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error { _, err := r.pool.Exec(ctx, ` UPDATE reminder_jobs diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 33daead..db62e96 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -107,6 +107,7 @@ type Repository interface { // Customer Booking Management GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error) GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) + GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) UpdateBookingStatus(ctx context.Context, bookingID string, status string) error RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error @@ -1844,6 +1845,27 @@ func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) ( return BookingRecord{}, pgx.ErrNoRows } +func (r *MemoryRepository) GetWaitlistPosition(_ context.Context, sessionID, bookingID string) (int, error) { + var self *BookingRecord + for index := range r.bookings { + if r.bookings[index].ID == bookingID { + self = &r.bookings[index] + break + } + } + if self == nil { + return 0, pgx.ErrNoRows + } + position := 1 + for _, booking := range r.bookings { + if booking.ClassSessionID != nil && *booking.ClassSessionID == sessionID && + booking.Status == "waitlisted" && booking.CreatedAt.Before(self.CreatedAt) { + position++ + } + } + return position, nil +} + func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error { for i, b := range r.bookings { if b.ID == bookingID { diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 7947332..91a9b5d 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -458,6 +458,9 @@ type CustomerBookingView struct { Location string `json:"location"` Status string `json:"status"` Notes string `json:"notes,omitempty"` + // WaitlistPosition is the 1-based queue spot for waitlisted class + // bookings; zero for any other status. + WaitlistPosition int `json:"waitlistPosition,omitempty"` } type RescheduleBookingRequest struct { diff --git a/apps/frontend/src/routes/booking-manage-route.tsx b/apps/frontend/src/routes/booking-manage-route.tsx index 344be83..908bc29 100644 --- a/apps/frontend/src/routes/booking-manage-route.tsx +++ b/apps/frontend/src/routes/booking-manage-route.tsx @@ -195,18 +195,48 @@ export function BookingManageRoute() { {b().businessName} - {b().status === 'confirmed' + {b().status === 'confirmed' ? (i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed') + : b().status === 'waitlisted' + ? (i18n.locale() === 'cs' ? 'Čekací listina' : 'Waitlisted') + : b().status === 'cancelled' + ? (i18n.locale() === 'cs' ? 'Zrušeno' : 'Cancelled') : (i18n.locale() === 'cs' ? 'Čeká se' : 'Pending') } + {/* Waitlist position */} + +
+
+ + + + +
+
+

+ {b().waitlistPosition > 0 + ? (i18n.locale() === 'cs' ? `Jste ${b().waitlistPosition}. v pořadí` : `You are #${b().waitlistPosition} in the queue`) + : (i18n.locale() === 'cs' ? 'Jste na čekací listině' : 'You are on the waitlist')} +

+

+ {i18n.locale() === 'cs' + ? 'Po uvolnění místa vám rezervaci automaticky potvrdíme e-mailem.' + : 'When a seat frees up we will confirm your booking by email automatically.'} +

+
+
+
+ {/* Reference */}
diff --git a/project.md b/project.md index e90a6f1..1cb8cd8 100644 --- a/project.md +++ b/project.md @@ -107,6 +107,11 @@ - `sms.Service.SendMessage` still enforces enabled/plan/monthly-limit and logs usage, so SMS reminders inherit metering and billing automatically; missing provider config or phone marks the job `failed` with a delivery-log reason - reminder lifecycle work from milestone 13 applies per-booking, so cancel/reschedule clears and re-arms both channels - remaining: live staging validation against a real SMS Manager key +- `[done/partial]` Milestone 16: Waitlist position display + - `GetWaitlistPosition` repository method (pgx + memory) returns the 1-based queue spot: count of earlier-created waitlisted bookings on the same session + 1 + - `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN + - fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do) + - remaining: live staging validation; manual waitlist promotion controls are still not exposed ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.