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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user