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