mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(classes): session roster + manual waitlist promotion
Adds the roster and manual promotion controls deferred from the booking
lifecycle milestones. Businesses can now inspect who holds a seat and who
is queueing on a class session, and promote waitlisted customers by hand
— including a deliberate over-capacity override when the session is full.
Backend:
- GET /v1/catalog/class-sessions/{id}/roster — session metadata plus
confirmed and waitlist entry lists with queue positions
- POST /v1/catalog/bookings/{id}/promote — 409 on a full session unless
allowOverCapacity is set; 400 for non-waitlisted, 404 for missing or
foreign bookings; reuses the shared confirm path so waitlist ledger
rows resolve and reminder jobs re-arm
- ListBookingsBySession repository method (pgx + memory); ClassSession
carries waitlistCount; UpcomingBooking carries classSessionId and
waitlistPosition
- OpenAPI paths + schemas, regenerated client, catalog service tests
Frontend:
- classes tab session rows expand into a two-column roster panel with
per-entry promote buttons and an explicit confirm-anyway step on 409
- booking detail modal shows waitlist position and offers promote for
waitlisted bookings; list badges show #N queue positions
- CS/EN i18n keys and seeded demo roster data; demo mutations surface
the demo notice
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
a82a48e1c8
commit
1cd5b1ba91
18 files changed
+1022
-59
No files matched your search
@@ -740,10 +740,28 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// CATALOG API - Blocked Days
|
||||
// ============================================
|
||||
protected.POST("/catalog/bookings/:bookingID/promote", func(c *gin.Context) {
|
||||
var request domain.PromoteBookingRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
err := catalogService.PromoteBooking(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("bookingID"), request)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrBookingNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, bookings.ErrNotWaitlisted):
|
||||
status = http.StatusBadRequest
|
||||
case errors.Is(err, bookings.ErrSessionFull):
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "confirmed"})
|
||||
})
|
||||
protected.GET("/catalog/blocked-days", func(c *gin.Context) {
|
||||
from := time.Now()
|
||||
to := from.AddDate(0, 3, 0) // 3 months ahead
|
||||
@@ -1063,6 +1081,19 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
}
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
protected.GET("/catalog/class-sessions/:sessionID/roster", func(c *gin.Context) {
|
||||
response, err := catalogService.ListSessionRoster(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound):
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
|
||||
err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
|
||||
if err != nil {
|
||||
|
||||
@@ -23,7 +23,7 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bookings, err := repo.ListBookingsByTenantBetween(ctx, tenantID, session.StartsAt.Add(-time.Second), session.EndsAt.Add(time.Second))
|
||||
bookings, err := repo.ListBookingsBySession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -31,9 +31,6 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
|
||||
confirmed := 0
|
||||
var waitlisted []db.BookingRecord
|
||||
for _, booking := range bookings {
|
||||
if booking.ClassSessionID == nil || *booking.ClassSessionID != sessionID {
|
||||
continue
|
||||
}
|
||||
switch booking.Status {
|
||||
case "confirmed":
|
||||
confirmed++
|
||||
@@ -53,20 +50,72 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
|
||||
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
||||
break
|
||||
}
|
||||
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
|
||||
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
|
||||
return promoted, err
|
||||
}
|
||||
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
|
||||
// A booking promoted after its reminder already dispatched-as-skipped
|
||||
// would otherwise go unreminded; re-arm whatever the horizon allows.
|
||||
_ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID)
|
||||
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
|
||||
confirmed++
|
||||
promoted = append(promoted, booking)
|
||||
}
|
||||
return promoted, nil
|
||||
}
|
||||
|
||||
// PromoteBooking manually confirms one waitlisted class booking. Unlike the
|
||||
// automatic FIFO promotion it targets a specific booking; when the session is
|
||||
// full it fails with ErrSessionFull unless allowOverCapacity is set — a
|
||||
// deliberate business override, not a silent overbooking.
|
||||
func PromoteBooking(ctx context.Context, repo db.Repository, tenantID, bookingID string, allowOverCapacity bool) (db.BookingRecord, error) {
|
||||
booking, err := repo.GetBookingByID(ctx, bookingID)
|
||||
if err != nil {
|
||||
return db.BookingRecord{}, err
|
||||
}
|
||||
if booking.TenantID != tenantID {
|
||||
return db.BookingRecord{}, pgx.ErrNoRows
|
||||
}
|
||||
if booking.Status != "waitlisted" || booking.ClassSessionID == nil {
|
||||
return db.BookingRecord{}, ErrNotWaitlisted
|
||||
}
|
||||
sessionID := *booking.ClassSessionID
|
||||
|
||||
if !allowOverCapacity {
|
||||
session, err := repo.GetClassSessionByID(ctx, sessionID)
|
||||
if err != nil {
|
||||
return db.BookingRecord{}, err
|
||||
}
|
||||
sessionBookings, err := repo.ListBookingsBySession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return db.BookingRecord{}, err
|
||||
}
|
||||
confirmed := 0
|
||||
for _, other := range sessionBookings {
|
||||
if other.Status == "confirmed" {
|
||||
confirmed++
|
||||
}
|
||||
}
|
||||
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
||||
return db.BookingRecord{}, ErrSessionFull
|
||||
}
|
||||
}
|
||||
|
||||
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, 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 {
|
||||
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
|
||||
_ = 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
|
||||
// tenant opted into SMS and a phone number is on file — for a booking whose
|
||||
// start still falls inside a reminder horizon. A start too close or too far
|
||||
|
||||
@@ -25,6 +25,8 @@ var (
|
||||
ErrBookingConflict = errors.New("booking conflict")
|
||||
ErrTenantMembership = errors.New("tenant membership not found")
|
||||
ErrBookingNotFound = errors.New("booking not found")
|
||||
ErrNotWaitlisted = errors.New("booking is not on a waitlist")
|
||||
ErrSessionFull = errors.New("class session is at capacity")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -538,14 +540,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
}
|
||||
toDomain := func(booking db.BookingRecord) domain.UpcomingBooking {
|
||||
out := domain.UpcomingBooking{
|
||||
ID: booking.ID,
|
||||
Reference: booking.Reference,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Notes: booking.Notes,
|
||||
StartsAt: booking.StartsAt,
|
||||
EndsAt: booking.EndsAt,
|
||||
Status: booking.Status,
|
||||
ID: booking.ID,
|
||||
Reference: booking.Reference,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
Notes: booking.Notes,
|
||||
StartsAt: booking.StartsAt,
|
||||
EndsAt: booking.EndsAt,
|
||||
Status: booking.Status,
|
||||
ClassSessionID: booking.ClassSessionID,
|
||||
}
|
||||
if booking.ServiceID != nil {
|
||||
out.Service = serviceNames[*booking.ServiceID]
|
||||
@@ -559,6 +562,24 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
return out
|
||||
}
|
||||
|
||||
// Waitlist positions are FIFO per session, ordered by booking creation —
|
||||
// computed in memory to avoid one position query per queued booking.
|
||||
waitlistPositions := func(records []db.BookingRecord) map[string]int {
|
||||
positions := map[string]int{}
|
||||
perSession := map[string]int{}
|
||||
sorted := make([]db.BookingRecord, len(records))
|
||||
copy(sorted, records)
|
||||
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].CreatedAt.Before(sorted[j].CreatedAt) })
|
||||
for _, booking := range sorted {
|
||||
if booking.Status != "waitlisted" || booking.ClassSessionID == nil {
|
||||
continue
|
||||
}
|
||||
perSession[*booking.ClassSessionID]++
|
||||
positions[booking.ID] = perSession[*booking.ClassSessionID]
|
||||
}
|
||||
return positions
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
weekEnd := now.AddDate(0, 0, 7)
|
||||
metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd)
|
||||
@@ -577,12 +598,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
if err != nil {
|
||||
return domain.DashboardSummary{}, err
|
||||
}
|
||||
upcomingPositions := waitlistPositions(upcomingRecords)
|
||||
upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
|
||||
for _, booking := range upcomingRecords {
|
||||
if booking.Status == "cancelled" {
|
||||
continue
|
||||
}
|
||||
upcoming = append(upcoming, toDomain(booking))
|
||||
entry := toDomain(booking)
|
||||
entry.WaitlistPosition = upcomingPositions[booking.ID]
|
||||
upcoming = append(upcoming, entry)
|
||||
}
|
||||
if len(upcoming) > 5 {
|
||||
upcoming = upcoming[:5]
|
||||
@@ -595,9 +619,12 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
if err != nil {
|
||||
return domain.DashboardSummary{}, err
|
||||
}
|
||||
allPositions := waitlistPositions(allRecords)
|
||||
allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
|
||||
for _, booking := range allRecords {
|
||||
allBookings = append(allBookings, toDomain(booking))
|
||||
entry := toDomain(booking)
|
||||
entry.WaitlistPosition = allPositions[booking.ID]
|
||||
allBookings = append(allBookings, entry)
|
||||
}
|
||||
|
||||
brandName := membership.Tenant.Name
|
||||
|
||||
@@ -1128,9 +1128,16 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
|
||||
out := make([]domain.ClassSession, len(sessions))
|
||||
for i, session := range sessions {
|
||||
booked := 0
|
||||
waitlisted := 0
|
||||
for _, booking := range bookings {
|
||||
if booking.ClassSessionID != nil && *booking.ClassSessionID == session.ID &&
|
||||
booking.Status != "cancelled" && booking.Status != "waitlisted" {
|
||||
if booking.ClassSessionID == nil || *booking.ClassSessionID != session.ID {
|
||||
continue
|
||||
}
|
||||
switch booking.Status {
|
||||
case "waitlisted":
|
||||
waitlisted++
|
||||
case "cancelled":
|
||||
default:
|
||||
booked++
|
||||
}
|
||||
}
|
||||
@@ -1145,11 +1152,73 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
|
||||
EndsAt: session.EndsAt,
|
||||
Capacity: int(session.Capacity),
|
||||
BookedCount: booked,
|
||||
WaitlistCount: waitlisted,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListSessionRoster returns the confirmed attendees and the FIFO waitlist of a
|
||||
// tenant-owned class session for the dashboard roster view.
|
||||
func (s *Service) ListSessionRoster(ctx context.Context, principal domain.Principal, sessionID string) (domain.SessionRoster, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return domain.SessionRoster{}, ErrTenantMembership
|
||||
}
|
||||
session, err := s.repo.GetClassSessionByID(ctx, sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.SessionRoster{}, ErrClassSessionNotFound
|
||||
}
|
||||
return domain.SessionRoster{}, err
|
||||
}
|
||||
if session.TenantID != membership.Tenant.ID {
|
||||
return domain.SessionRoster{}, ErrClassSessionNotFound
|
||||
}
|
||||
|
||||
sessionBookings, err := s.repo.ListBookingsBySession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return domain.SessionRoster{}, err
|
||||
}
|
||||
|
||||
toEntry := func(booking db.BookingRecord) domain.SessionRosterEntry {
|
||||
return domain.SessionRosterEntry{
|
||||
BookingID: booking.ID,
|
||||
Reference: booking.Reference,
|
||||
CustomerName: booking.CustomerName,
|
||||
CustomerEmail: booking.CustomerEmail,
|
||||
CustomerPhone: booking.CustomerPhone,
|
||||
Status: booking.Status,
|
||||
CreatedAt: booking.CreatedAt,
|
||||
}
|
||||
}
|
||||
roster := domain.SessionRoster{
|
||||
SessionID: session.ID,
|
||||
TemplateTitle: session.Title,
|
||||
StartsAt: session.StartsAt,
|
||||
EndsAt: session.EndsAt,
|
||||
Capacity: int(session.Capacity),
|
||||
Confirmed: []domain.SessionRosterEntry{},
|
||||
Waitlist: []domain.SessionRosterEntry{},
|
||||
}
|
||||
if session.LocationID != nil {
|
||||
if location, err := s.repo.GetLocationByID(ctx, *session.LocationID); err == nil {
|
||||
roster.LocationName = location.Name
|
||||
}
|
||||
}
|
||||
for _, booking := range sessionBookings {
|
||||
switch booking.Status {
|
||||
case "confirmed":
|
||||
roster.Confirmed = append(roster.Confirmed, toEntry(booking))
|
||||
case "waitlisted":
|
||||
entry := toEntry(booking)
|
||||
entry.WaitlistPosition = len(roster.Waitlist) + 1
|
||||
roster.Waitlist = append(roster.Waitlist, entry)
|
||||
}
|
||||
}
|
||||
return roster, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateClassSession(ctx context.Context, principal domain.Principal, req domain.CreateClassSessionRequest) (domain.ClassSession, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
@@ -1311,6 +1380,25 @@ func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal,
|
||||
return nil
|
||||
}
|
||||
|
||||
// PromoteBooking confirms one waitlisted class booking on behalf of the
|
||||
// business. Sessions at capacity require the explicit allowOverCapacity flag;
|
||||
// the promoted customer receives the standard confirmation email.
|
||||
func (s *Service) PromoteBooking(ctx context.Context, principal domain.Principal, bookingID string, req domain.PromoteBookingRequest) error {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
promoted, err := bookings.PromoteBooking(ctx, s.repo, membership.Tenant.ID, bookingID, req.AllowOverCapacity)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrBookingNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
go s.sendBookingEmail(promoted, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendBookingEmail builds the shared payload and fires a confirmation
|
||||
// (promoted=true) or cancellation notification. Failures are logged-and-done:
|
||||
// email delivery must not fail the cancellation itself.
|
||||
|
||||
@@ -641,6 +641,123 @@ func TestCancelBookingPromotesWaitlist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSessionRoster(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := newCatalogService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i))
|
||||
}
|
||||
bookClass(t, repo, "[email protected]")
|
||||
bookClass(t, repo, "[email protected]")
|
||||
|
||||
roster, err := svc.ListSessionRoster(ctx, owner, demoSessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("roster: %v", err)
|
||||
}
|
||||
if roster.Capacity != 4 || len(roster.Confirmed) != 4 || len(roster.Waitlist) != 2 {
|
||||
t.Fatalf("unexpected roster %+v", roster)
|
||||
}
|
||||
if roster.Waitlist[0].CustomerEmail != "[email protected]" || roster.Waitlist[0].WaitlistPosition != 1 {
|
||||
t.Fatalf("expected first waiter at position 1, got %+v", roster.Waitlist[0])
|
||||
}
|
||||
if roster.Waitlist[1].WaitlistPosition != 2 {
|
||||
t.Fatalf("expected second waiter at position 2, got %+v", roster.Waitlist[1])
|
||||
}
|
||||
if roster.TemplateTitle != "Small group mobility class" || roster.LocationName != "Main Studio" {
|
||||
t.Fatalf("unexpected roster header %+v", roster)
|
||||
}
|
||||
|
||||
sessions, _ := svc.ListClassSessions(ctx, owner)
|
||||
if sessions[0].WaitlistCount != 2 {
|
||||
t.Fatalf("expected waitlistCount 2, got %d", sessions[0].WaitlistCount)
|
||||
}
|
||||
|
||||
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
|
||||
if _, err := svc.ListSessionRoster(ctx, stranger, demoSessionID); !errors.Is(err, ErrTenantMembership) {
|
||||
t.Fatalf("expected ErrTenantMembership, got %v", err)
|
||||
}
|
||||
if _, err := svc.ListSessionRoster(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrClassSessionNotFound) {
|
||||
t.Fatalf("expected ErrClassSessionNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteBooking(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := newCatalogService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i))
|
||||
}
|
||||
first := bookClass(t, repo, "[email protected]")
|
||||
second := bookClass(t, repo, "[email protected]")
|
||||
|
||||
// Full session: strict promote is rejected with ErrSessionFull.
|
||||
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, bookings.ErrSessionFull) {
|
||||
t.Fatalf("expected ErrSessionFull, got %v", err)
|
||||
}
|
||||
|
||||
// The explicit override confirms anyway — deliberate out-of-order
|
||||
// overbooking, skipping the first waiter.
|
||||
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{AllowOverCapacity: true}); err != nil {
|
||||
t.Fatalf("over-capacity promote: %v", err)
|
||||
}
|
||||
booking, _ := repo.GetBookingByID(ctx, second.BookingID)
|
||||
if booking.Status != "confirmed" {
|
||||
t.Fatalf("expected confirmed after override, got %q", booking.Status)
|
||||
}
|
||||
|
||||
// Confirmed bookings, unknown IDs, and foreign principals are rejected.
|
||||
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, bookings.ErrNotWaitlisted) {
|
||||
t.Fatalf("expected ErrNotWaitlisted for confirmed booking, got %v", err)
|
||||
}
|
||||
if err := svc.PromoteBooking(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.PromoteBookingRequest{}); !errors.Is(err, ErrBookingNotFound) {
|
||||
t.Fatalf("expected ErrBookingNotFound, got %v", err)
|
||||
}
|
||||
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
|
||||
if err := svc.PromoteBooking(ctx, stranger, first.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, ErrTenantMembership) {
|
||||
t.Fatalf("expected ErrTenantMembership, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteBookingWithFreeSeat(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := newCatalogService(repo)
|
||||
ctx := context.Background()
|
||||
|
||||
// A waitlisted booking on a session with free capacity — an edge state the
|
||||
// auto-promoter cannot reach, e.g. a queue entry left after manual edits.
|
||||
session, err := repo.GetClassSessionByID(ctx, demoSessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("get session: %v", err)
|
||||
}
|
||||
created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
|
||||
ClassSessionID: &[]string{demoSessionID}[0],
|
||||
BookingMode: "class",
|
||||
CustomerName: "Queued Customer",
|
||||
CustomerEmail: "[email protected]",
|
||||
StartsAt: session.StartsAt,
|
||||
EndsAt: session.EndsAt,
|
||||
Status: "waitlisted",
|
||||
Reference: "BOK-QUEUED",
|
||||
ManageToken: "queued-token",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed waitlisted booking: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.PromoteBooking(ctx, owner, created.ID, domain.PromoteBookingRequest{}); err != nil {
|
||||
t.Fatalf("promote with free seat: %v", err)
|
||||
}
|
||||
booking, _ := repo.GetBookingByID(ctx, created.ID)
|
||||
if booking.Status != "confirmed" {
|
||||
t.Fatalf("expected confirmed, got %q", booking.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClassTemplate(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
svc := newCatalogService(repo)
|
||||
|
||||
@@ -50,6 +50,50 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
// ListBookingsBySession returns every booking on a class session in
|
||||
// creation order — the ordering that defines the waitlist queue.
|
||||
func (r *PGRepository) ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error) {
|
||||
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, ''), created_at
|
||||
FROM bookings
|
||||
WHERE class_session_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []BookingRecord
|
||||
for rows.Next() {
|
||||
var record BookingRecord
|
||||
if err := rows.Scan(
|
||||
&record.ID,
|
||||
&record.TenantID,
|
||||
&record.ServiceID,
|
||||
&record.ClassSessionID,
|
||||
&record.StaffID,
|
||||
&record.LocationID,
|
||||
&record.CustomerName,
|
||||
&record.CustomerEmail,
|
||||
&record.CustomerPhone,
|
||||
&record.StartsAt,
|
||||
&record.EndsAt,
|
||||
&record.Status,
|
||||
&record.Reference,
|
||||
&record.ManageToken,
|
||||
&record.Notes,
|
||||
&record.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) {
|
||||
var created CreatedBooking
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
|
||||
@@ -35,6 +35,7 @@ type Repository interface {
|
||||
UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
||||
DeleteClassTemplate(ctx context.Context, templateID string) 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
|
||||
@@ -1356,6 +1357,17 @@ func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenant
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListBookingsBySession(_ context.Context, sessionID string) ([]BookingRecord, error) {
|
||||
var out []BookingRecord
|
||||
for _, booking := range r.bookings {
|
||||
if booking.ClassSessionID != nil && *booking.ClassSessionID == sessionID {
|
||||
out = append(out, booking)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) {
|
||||
created := CreatedBooking{
|
||||
ID: uuid.NewString(),
|
||||
|
||||
@@ -18,18 +18,20 @@ type DashboardKPI struct {
|
||||
}
|
||||
|
||||
type UpcomingBooking struct {
|
||||
ID string `json:"id"`
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
StaffName string `json:"staffName,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Status string `json:"status"`
|
||||
Label string `json:"label,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
StaffName string `json:"staffName,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Status string `json:"status"`
|
||||
Label string `json:"label,omitempty"`
|
||||
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||
}
|
||||
|
||||
type WidgetSnippet struct {
|
||||
@@ -401,6 +403,41 @@ type ClassSession struct {
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Capacity int `json:"capacity"`
|
||||
BookedCount int `json:"bookedCount"`
|
||||
// WaitlistCount is the number of customers queued for a free seat.
|
||||
WaitlistCount int `json:"waitlistCount"`
|
||||
}
|
||||
|
||||
// SessionRosterEntry is one booking on a class session roster. WaitlistPosition
|
||||
// is the 1-based queue spot for waitlisted entries; zero for confirmed ones.
|
||||
type SessionRosterEntry struct {
|
||||
BookingID string `json:"bookingId"`
|
||||
Reference string `json:"reference"`
|
||||
CustomerName string `json:"customerName"`
|
||||
CustomerEmail string `json:"customerEmail"`
|
||||
CustomerPhone string `json:"customerPhone,omitempty"`
|
||||
Status string `json:"status"`
|
||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// SessionRoster is the dashboard attendee view of a class session: confirmed
|
||||
// seats plus the waitlist queue in FIFO order.
|
||||
type SessionRoster struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
TemplateTitle string `json:"templateTitle"`
|
||||
LocationName string `json:"locationName,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Capacity int `json:"capacity"`
|
||||
Confirmed []SessionRosterEntry `json:"confirmed"`
|
||||
Waitlist []SessionRosterEntry `json:"waitlist"`
|
||||
}
|
||||
|
||||
// PromoteBookingRequest controls manual waitlist promotion. AllowOverCapacity
|
||||
// lets a business deliberately overbook a session; without it the request fails
|
||||
// when no free seat remains.
|
||||
type PromoteBookingRequest struct {
|
||||
AllowOverCapacity bool `json:"allowOverCapacity,omitempty"`
|
||||
}
|
||||
|
||||
type CreateClassSessionRequest struct {
|
||||
|
||||
@@ -820,6 +820,34 @@ paths:
|
||||
description: Booking already cancelled
|
||||
"404":
|
||||
description: Booking not found
|
||||
/v1/catalog/bookings/{bookingID}/promote:
|
||||
post:
|
||||
tags: [Catalog]
|
||||
operationId: promoteDashboardBooking
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: bookingID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PromoteBookingRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Waitlisted booking confirmed; customer notified
|
||||
"400":
|
||||
description: Booking is not on a waitlist
|
||||
"404":
|
||||
description: Booking not found
|
||||
"409":
|
||||
description: Session at capacity; retry with allowOverCapacity to override
|
||||
/v1/catalog/blocked-days:
|
||||
get:
|
||||
tags: [Catalog]
|
||||
@@ -1196,6 +1224,28 @@ paths:
|
||||
description: Class session deleted
|
||||
"404":
|
||||
description: Class session not found
|
||||
/v1/catalog/class-sessions/{sessionID}/roster:
|
||||
get:
|
||||
tags: [Catalog]
|
||||
operationId: getSessionRoster
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: sessionID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"200":
|
||||
description: Confirmed attendees and FIFO waitlist for the session
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SessionRoster"
|
||||
"404":
|
||||
description: Class session not found
|
||||
# ---------------- Billing ----------------
|
||||
/v1/billing/subscription:
|
||||
get:
|
||||
@@ -1714,6 +1764,12 @@ components:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
classSessionId:
|
||||
type: string
|
||||
format: uuid
|
||||
waitlistPosition:
|
||||
type: integer
|
||||
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
||||
DashboardBookingRequest:
|
||||
type: object
|
||||
required: [customerName, customerEmail, startsAt]
|
||||
@@ -2126,7 +2182,7 @@ components:
|
||||
type: integer
|
||||
ClassSession:
|
||||
type: object
|
||||
required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount]
|
||||
required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount, waitlistCount]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -2154,6 +2210,63 @@ components:
|
||||
type: integer
|
||||
bookedCount:
|
||||
type: integer
|
||||
waitlistCount:
|
||||
type: integer
|
||||
SessionRosterEntry:
|
||||
type: object
|
||||
required: [bookingId, reference, customerName, customerEmail, status, createdAt]
|
||||
properties:
|
||||
bookingId:
|
||||
type: string
|
||||
format: uuid
|
||||
reference:
|
||||
type: string
|
||||
customerName:
|
||||
type: string
|
||||
customerEmail:
|
||||
type: string
|
||||
customerPhone:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
waitlistPosition:
|
||||
type: integer
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
SessionRoster:
|
||||
type: object
|
||||
required: [sessionId, templateTitle, startsAt, endsAt, capacity, confirmed, waitlist]
|
||||
properties:
|
||||
sessionId:
|
||||
type: string
|
||||
format: uuid
|
||||
templateTitle:
|
||||
type: string
|
||||
locationName:
|
||||
type: string
|
||||
startsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
endsAt:
|
||||
type: string
|
||||
format: date-time
|
||||
capacity:
|
||||
type: integer
|
||||
confirmed:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SessionRosterEntry"
|
||||
waitlist:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SessionRosterEntry"
|
||||
PromoteBookingRequest:
|
||||
type: object
|
||||
properties:
|
||||
allowOverCapacity:
|
||||
type: boolean
|
||||
description: Confirm even when the session is full (deliberate overbooking)
|
||||
CreateClassSessionRequest:
|
||||
type: object
|
||||
required: [templateId, startsAt]
|
||||
|
||||
Reference in new issue
Block a user