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:
Tomas DvorakandDevin committed 2026-09-18 14:37:40 +02:00
1 parent a82a48e1c8
commit 1cd5b1ba91
18 files changed
+1022 -59

No files matched your search

+7
View File
@@ -39,3 +39,10 @@
- Demo bootstrap slug aligned with backend demo tenant (`studio-atelier`). - Demo bootstrap slug aligned with backend demo tenant (`studio-atelier`).
- Paddle mentions replaced by Stripe in legal + i18n copy. - Paddle mentions replaced by Stripe in legal + i18n copy.
- `@neondatabase/neon-js` bumped to 0.7.0-beta — `npm audit` now reports 0 vulnerabilities. - `@neondatabase/neon-js` bumped to 0.7.0-beta — `npm audit` now reports 0 vulnerabilities.
## Fixed This Session (class roster + waitlist promotion)
- Class sessions in the zones classes tab now show a `N waiting` badge and expand into a two-column roster panel — confirmed attendees vs waitlist with `#N` queue positions, names, emails, and booking references.
- Waitlisted roster entries have a **Confirm seat** action; when the session is full the backend returns 409 and the button re-arms into an explicit "confirm anyway" over-capacity step rather than silently overriding.
- The booking detail modal now shows the waitlist position for waitlisted bookings and offers the same promote action (accent button above cancel); the bookings list badges waitlisted rows with their `#N` position.
- Demo mode seeds a class session with a populated roster; mutation attempts surface the demo notice instead of failing. Verified in-browser in both Czech and English with a clean console.
+35 -4
View File
@@ -740,10 +740,28 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
} }
c.JSON(http.StatusOK, gin.H{"status": "cancelled"}) c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
}) })
protected.POST("/catalog/bookings/:bookingID/promote", func(c *gin.Context) {
// ============================================ var request domain.PromoteBookingRequest
// CATALOG API - Blocked Days 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) { protected.GET("/catalog/blocked-days", func(c *gin.Context) {
from := time.Now() from := time.Now()
to := from.AddDate(0, 3, 0) // 3 months ahead 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) 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) { protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID")) err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
if err != nil { if err != nil {
+59 -10
View File
@@ -23,7 +23,7 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -31,9 +31,6 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
confirmed := 0 confirmed := 0
var waitlisted []db.BookingRecord var waitlisted []db.BookingRecord
for _, booking := range bookings { for _, booking := range bookings {
if booking.ClassSessionID == nil || *booking.ClassSessionID != sessionID {
continue
}
switch booking.Status { switch booking.Status {
case "confirmed": case "confirmed":
confirmed++ confirmed++
@@ -53,20 +50,72 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
if session.Capacity > 0 && confirmed >= int(session.Capacity) { if session.Capacity > 0 && confirmed >= int(session.Capacity) {
break break
} }
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil { if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
return promoted, err 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++ confirmed++
promoted = append(promoted, booking) promoted = append(promoted, booking)
} }
return promoted, nil 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 // 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 // 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 // start still falls inside a reminder horizon. A start too close or too far
+37 -10
View File
@@ -25,6 +25,8 @@ var (
ErrBookingConflict = errors.New("booking conflict") ErrBookingConflict = errors.New("booking conflict")
ErrTenantMembership = errors.New("tenant membership not found") ErrTenantMembership = errors.New("tenant membership not found")
ErrBookingNotFound = errors.New("booking 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 { type Service struct {
@@ -538,14 +540,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
} }
toDomain := func(booking db.BookingRecord) domain.UpcomingBooking { toDomain := func(booking db.BookingRecord) domain.UpcomingBooking {
out := domain.UpcomingBooking{ out := domain.UpcomingBooking{
ID: booking.ID, ID: booking.ID,
Reference: booking.Reference, Reference: booking.Reference,
CustomerName: booking.CustomerName, CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail, CustomerEmail: booking.CustomerEmail,
Notes: booking.Notes, Notes: booking.Notes,
StartsAt: booking.StartsAt, StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt, EndsAt: booking.EndsAt,
Status: booking.Status, Status: booking.Status,
ClassSessionID: booking.ClassSessionID,
} }
if booking.ServiceID != nil { if booking.ServiceID != nil {
out.Service = serviceNames[*booking.ServiceID] out.Service = serviceNames[*booking.ServiceID]
@@ -559,6 +562,24 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
return out 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() now := time.Now().UTC()
weekEnd := now.AddDate(0, 0, 7) weekEnd := now.AddDate(0, 0, 7)
metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd) 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 { if err != nil {
return domain.DashboardSummary{}, err return domain.DashboardSummary{}, err
} }
upcomingPositions := waitlistPositions(upcomingRecords)
upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords)) upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
for _, booking := range upcomingRecords { for _, booking := range upcomingRecords {
if booking.Status == "cancelled" { if booking.Status == "cancelled" {
continue continue
} }
upcoming = append(upcoming, toDomain(booking)) entry := toDomain(booking)
entry.WaitlistPosition = upcomingPositions[booking.ID]
upcoming = append(upcoming, entry)
} }
if len(upcoming) > 5 { if len(upcoming) > 5 {
upcoming = upcoming[:5] upcoming = upcoming[:5]
@@ -595,9 +619,12 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
if err != nil { if err != nil {
return domain.DashboardSummary{}, err return domain.DashboardSummary{}, err
} }
allPositions := waitlistPositions(allRecords)
allBookings := make([]domain.UpcomingBooking, 0, len(allRecords)) allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
for _, booking := range 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 brandName := membership.Tenant.Name
+90 -2
View File
@@ -1128,9 +1128,16 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
out := make([]domain.ClassSession, len(sessions)) out := make([]domain.ClassSession, len(sessions))
for i, session := range sessions { for i, session := range sessions {
booked := 0 booked := 0
waitlisted := 0
for _, booking := range bookings { for _, booking := range bookings {
if booking.ClassSessionID != nil && *booking.ClassSessionID == session.ID && if booking.ClassSessionID == nil || *booking.ClassSessionID != session.ID {
booking.Status != "cancelled" && booking.Status != "waitlisted" { continue
}
switch booking.Status {
case "waitlisted":
waitlisted++
case "cancelled":
default:
booked++ booked++
} }
} }
@@ -1145,11 +1152,73 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
EndsAt: session.EndsAt, EndsAt: session.EndsAt,
Capacity: int(session.Capacity), Capacity: int(session.Capacity),
BookedCount: booked, BookedCount: booked,
WaitlistCount: waitlisted,
} }
} }
return out, nil 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) { func (s *Service) CreateClassSession(ctx context.Context, principal domain.Principal, req domain.CreateClassSessionRequest) (domain.ClassSession, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil { if err != nil {
@@ -1311,6 +1380,25 @@ func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal,
return nil 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 // sendBookingEmail builds the shared payload and fires a confirmation
// (promoted=true) or cancellation notification. Failures are logged-and-done: // (promoted=true) or cancellation notification. Failures are logged-and-done:
// email delivery must not fail the cancellation itself. // 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) { func TestUpdateClassTemplate(t *testing.T) {
repo := db.NewMemoryRepository() repo := db.NewMemoryRepository()
svc := newCatalogService(repo) svc := newCatalogService(repo)
+44
View File
@@ -50,6 +50,50 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
return records, rows.Err() 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) { func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) {
var created CreatedBooking var created CreatedBooking
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
+12
View File
@@ -35,6 +35,7 @@ type Repository interface {
UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error)
DeleteClassTemplate(ctx context.Context, templateID string) error DeleteClassTemplate(ctx context.Context, templateID string) error
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, 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) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error
@@ -1356,6 +1357,17 @@ func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenant
return out, nil 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) { func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) {
created := CreatedBooking{ created := CreatedBooking{
ID: uuid.NewString(), ID: uuid.NewString(),
+49 -12
View File
@@ -18,18 +18,20 @@ type DashboardKPI struct {
} }
type UpcomingBooking struct { type UpcomingBooking struct {
ID string `json:"id"` ID string `json:"id"`
Reference string `json:"reference"` Reference string `json:"reference"`
CustomerName string `json:"customerName"` CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"` CustomerEmail string `json:"customerEmail"`
Service string `json:"service,omitempty"` Service string `json:"service,omitempty"`
Location string `json:"location,omitempty"` Location string `json:"location,omitempty"`
StaffName string `json:"staffName,omitempty"` StaffName string `json:"staffName,omitempty"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
StartsAt time.Time `json:"startsAt"` StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"` EndsAt time.Time `json:"endsAt"`
Status string `json:"status"` Status string `json:"status"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
ClassSessionID *string `json:"classSessionId,omitempty"`
WaitlistPosition int `json:"waitlistPosition,omitempty"`
} }
type WidgetSnippet struct { type WidgetSnippet struct {
@@ -401,6 +403,41 @@ type ClassSession struct {
EndsAt time.Time `json:"endsAt"` EndsAt time.Time `json:"endsAt"`
Capacity int `json:"capacity"` Capacity int `json:"capacity"`
BookedCount int `json:"bookedCount"` 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 { type CreateClassSessionRequest struct {
+114 -1
View File
@@ -820,6 +820,34 @@ paths:
description: Booking already cancelled description: Booking already cancelled
"404": "404":
description: Booking not found 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: /v1/catalog/blocked-days:
get: get:
tags: [Catalog] tags: [Catalog]
@@ -1196,6 +1224,28 @@ paths:
description: Class session deleted description: Class session deleted
"404": "404":
description: Class session not found 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 ---------------- # ---------------- Billing ----------------
/v1/billing/subscription: /v1/billing/subscription:
get: get:
@@ -1714,6 +1764,12 @@ components:
type: string type: string
notes: notes:
type: string type: string
classSessionId:
type: string
format: uuid
waitlistPosition:
type: integer
description: 1-based queue spot for waitlisted class bookings; absent otherwise
DashboardBookingRequest: DashboardBookingRequest:
type: object type: object
required: [customerName, customerEmail, startsAt] required: [customerName, customerEmail, startsAt]
@@ -2126,7 +2182,7 @@ components:
type: integer type: integer
ClassSession: ClassSession:
type: object type: object
required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount] required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount, waitlistCount]
properties: properties:
id: id:
type: string type: string
@@ -2154,6 +2210,63 @@ components:
type: integer type: integer
bookedCount: bookedCount:
type: integer 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: CreateClassSessionRequest:
type: object type: object
required: [templateId, startsAt] required: [templateId, startsAt]
@@ -96,6 +96,12 @@ export const ChevronRightIcon = () => (
</svg> </svg>
); );
export const ChevronDownIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
);
export const SparklesIcon = () => ( export const SparklesIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/> <path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
@@ -424,6 +424,23 @@ const dictionaries = {
"dashboard.class.noTemplates": "Zatím žádné šablony lekcí.", "dashboard.class.noTemplates": "Zatím žádné šablony lekcí.",
"dashboard.class.noSessions": "Zatím žádné naplánované lekce.", "dashboard.class.noSessions": "Zatím žádné naplánované lekce.",
"dashboard.class.sessionHint": "Vytvořte šablonu a poté z ní naplánujte konkrétní lekce.", "dashboard.class.sessionHint": "Vytvořte šablonu a poté z ní naplánujte konkrétní lekce.",
"dashboard.class.roster": "Prezenční listina",
"dashboard.class.attendees": "Potvrzení účastníci",
"dashboard.class.waitlist": "Čekací listina",
"dashboard.class.waiting": "čeká",
"dashboard.class.noAttendees": "Zatím žádní účastníci.",
"dashboard.class.noWaitlist": "Čekací listina je prázdná.",
"dashboard.class.promote": "Potvrdit místo",
"dashboard.class.promoteFull": "Lekce je plná — přesto potvrdit?",
"dashboard.class.promoteConfirm": "Přesto potvrdit",
"dashboard.class.promoted": "Rezervace byla potvrzena.",
"dashboard.class.promoteFailed": "Potvrzení rezervace selhalo.",
"dashboard.class.queuePosition": "v pořadí",
"dashboard.class.showRoster": "Účastníci",
"dashboard.class.hideRoster": "Skrýt",
"dashboard.waitlistPosition": "Pořadí v čekací listině",
"dashboard.booking.promote": "Potvrdit z čekací listiny",
"dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?",
"dashboard.services": "Služby", "dashboard.services": "Služby",
"dashboard.services.subtitle": "služeb v nabídce", "dashboard.services.subtitle": "služeb v nabídce",
"dashboard.service.add": "Přidat službu", "dashboard.service.add": "Přidat službu",
@@ -1002,6 +1019,23 @@ const dictionaries = {
"dashboard.class.noTemplates": "No class templates yet.", "dashboard.class.noTemplates": "No class templates yet.",
"dashboard.class.noSessions": "No scheduled sessions yet.", "dashboard.class.noSessions": "No scheduled sessions yet.",
"dashboard.class.sessionHint": "Create a template, then schedule individual sessions from it.", "dashboard.class.sessionHint": "Create a template, then schedule individual sessions from it.",
"dashboard.class.roster": "Session roster",
"dashboard.class.attendees": "Confirmed attendees",
"dashboard.class.waitlist": "Waitlist",
"dashboard.class.waiting": "waiting",
"dashboard.class.noAttendees": "No attendees yet.",
"dashboard.class.noWaitlist": "The waitlist is empty.",
"dashboard.class.promote": "Confirm seat",
"dashboard.class.promoteFull": "Session is full — confirm anyway?",
"dashboard.class.promoteConfirm": "Confirm anyway",
"dashboard.class.promoted": "Booking confirmed.",
"dashboard.class.promoteFailed": "Failed to confirm the booking.",
"dashboard.class.queuePosition": "in queue",
"dashboard.class.showRoster": "Roster",
"dashboard.class.hideRoster": "Hide",
"dashboard.waitlistPosition": "Waitlist position",
"dashboard.booking.promote": "Promote from waitlist",
"dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?",
"dashboard.services": "Services", "dashboard.services": "Services",
"dashboard.services.subtitle": "services offered", "dashboard.services.subtitle": "services offered",
"dashboard.service.add": "Add service", "dashboard.service.add": "Add service",
@@ -226,6 +226,7 @@ function BookingsPage() {
: "bg-canvas-subtle text-ink-subtle" : "bg-canvas-subtle text-ink-subtle"
}`}> }`}>
{statusLabels[booking.status]} {statusLabels[booking.status]}
{booking.status === "waitlisted" && booking.waitlistPosition ? ` #${booking.waitlistPosition}` : ""}
</span> </span>
<button onClick={() => data.openBookingDetail(booking)} class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" title={data.i18n.t("dashboard.details")}> <button onClick={() => data.openBookingDetail(booking)} class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" title={data.i18n.t("dashboard.details")}>
<EyeIcon /> <EyeIcon />
@@ -48,7 +48,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" }, { id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" },
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" }, { id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" },
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" }, { id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" },
{ id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" }, { id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", waitlistPosition: 1, startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" },
]; ];
return { return {
+67 -2
View File
@@ -1,4 +1,4 @@
import { Show, createSignal, type JSX, type ParentComponent } from "solid-js"; import { Show, createEffect, createSignal, type JSX, type ParentComponent } from "solid-js";
import { A, useLocation } from "@solidjs/router"; import { A, useLocation } from "@solidjs/router";
import { useTheme } from "../../providers/theme-provider"; import { useTheme } from "../../providers/theme-provider";
import { BookraCharacter } from "../../components/bookra-character"; import { BookraCharacter } from "../../components/bookra-character";
@@ -32,6 +32,15 @@ function DashboardShell(props: { children: JSX.Element }) {
const location = useLocation(); const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
const [cancelConfirm, setCancelConfirm] = createSignal<"idle" | "armed" | "busy">("idle"); const [cancelConfirm, setCancelConfirm] = createSignal<"idle" | "armed" | "busy">("idle");
// "armed" means the session reported full and the next click confirms anyway.
const [promoteConfirm, setPromoteConfirm] = createSignal<"idle" | "armed" | "busy">("idle");
// Reset two-step confirmations whenever a different booking opens.
createEffect(() => {
data.selectedBooking()?.id;
setCancelConfirm("idle");
setPromoteConfirm("idle");
});
const handleCancelBooking = async () => { const handleCancelBooking = async () => {
if (cancelConfirm() === "busy") return; if (cancelConfirm() === "busy") return;
@@ -57,6 +66,39 @@ function DashboardShell(props: { children: JSX.Element }) {
} }
}; };
const handlePromoteBooking = async () => {
if (promoteConfirm() === "busy") return;
const booking = data.selectedBooking();
const bearer = data.token();
if (!booking || booking.status !== "waitlisted") return;
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze měnit rezervace." : "Cannot modify bookings in demo mode.");
setPromoteConfirm("idle");
return;
}
const allowOverCapacity = promoteConfirm() === "armed";
setPromoteConfirm("busy");
try {
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${booking.id}/promote`, {
headers: { Authorization: `Bearer ${bearer}` },
body: { allowOverCapacity },
});
if (response.error) {
if (response.response?.status === 409 && !allowOverCapacity) {
setPromoteConfirm("armed");
return;
}
throw new Error("promote failed");
}
data.setShowBookingDetail(false);
void data.refetchSummary();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.booking.promoteFailed"));
} finally {
if (promoteConfirm() === "busy") setPromoteConfirm("idle");
}
};
const activeSection = () => { const activeSection = () => {
const p = location.pathname; const p = location.pathname;
const exact = navItems.find(item => p === item.path); const exact = navItems.find(item => p === item.path);
@@ -275,6 +317,12 @@ function DashboardShell(props: { children: JSX.Element }) {
{data.i18n.t(`dashboard.${data.selectedBooking()?.status}`)} {data.i18n.t(`dashboard.${data.selectedBooking()?.status}`)}
</span> </span>
</div> </div>
<Show when={data.selectedBooking()?.status === "waitlisted" && data.selectedBooking()?.waitlistPosition}>
<div class="flex items-center justify-between p-4 bg-warning-subtle/50 rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider">{data.i18n.t("dashboard.waitlistPosition")}</p>
<p class="font-semibold text-warning">#{data.selectedBooking()?.waitlistPosition}</p>
</div>
</Show>
<Show when={data.selectedBooking()?.notes}> <Show when={data.selectedBooking()?.notes}>
<div class="p-4 bg-canvas-subtle rounded-xl"> <div class="p-4 bg-canvas-subtle rounded-xl">
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.notes")}</p> <p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.notes")}</p>
@@ -283,7 +331,24 @@ function DashboardShell(props: { children: JSX.Element }) {
</Show> </Show>
</div> </div>
<Show when={data.selectedBooking()?.status !== "cancelled"}> <Show when={data.selectedBooking()?.status !== "cancelled"}>
<div class="p-6 border-t border-border"> <div class="p-6 border-t border-border space-y-3">
<Show when={data.selectedBooking()?.status === "waitlisted"}>
<button
onClick={() => void handlePromoteBooking()}
disabled={promoteConfirm() === "busy"}
class={`w-full px-4 py-2.5 rounded-xl text-sm font-medium transition-colors disabled:opacity-60 ${
promoteConfirm() === "armed"
? "bg-warning text-canvas hover:opacity-90"
: "bg-accent text-canvas hover:bg-accent-hover"
}`}
>
{promoteConfirm() === "busy"
? "…"
: promoteConfirm() === "armed"
? data.i18n.t("dashboard.booking.promoteFull")
: data.i18n.t("dashboard.booking.promote")}
</button>
</Show>
<button <button
onClick={() => void handleCancelBooking()} onClick={() => void handleCancelBooking()}
disabled={cancelConfirm() === "busy"} disabled={cancelConfirm() === "busy"}
+182 -13
View File
@@ -2,7 +2,8 @@ import { For, Show, createResource, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client"; import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select"; import { Select } from "../../components/ui/select";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon } from "../../components/dashboard/icons"; import { getInitials } from "../../components/dashboard/types";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout"; import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() { function ZonesPage() {
@@ -29,6 +30,11 @@ function ZonesPage() {
const [newSessionLocationId, setNewSessionLocationId] = createSignal(""); const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal(""); const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
const [sessionSaving, setSessionSaving] = createSignal(false); const [sessionSaving, setSessionSaving] = createSignal(false);
const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
// Booking IDs whose promote attempt hit a full session — awaiting the
// explicit "confirm anyway" over-capacity resubmit.
const [promoteArmed, setPromoteArmed] = createSignal<Set<string>>(new Set());
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } }); const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
@@ -118,13 +124,74 @@ function ZonesPage() {
if (!bearer || tab !== "classes") return []; if (!bearer || tab !== "classes") return [];
if (bearer.startsWith("demo.")) { if (bearer.startsWith("demo.")) {
const start = new Date(Date.now() + 3 * 86400000); const start = new Date(Date.now() + 3 * 86400000);
return [{ id: "cs1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", startsAt: start.toISOString(), endsAt: new Date(start.getTime() + 3600000).toISOString(), capacity: 8, bookedCount: 3 }]; return [{ id: "cs1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", startsAt: start.toISOString(), endsAt: new Date(start.getTime() + 3600000).toISOString(), capacity: 8, bookedCount: 3, waitlistCount: 2 }];
} }
const response = await (apiClient as any).GET("/v1/catalog/class-sessions", authHeaders()); const response = await (apiClient as any).GET("/v1/catalog/class-sessions", authHeaders());
return (response.data as any[]) ?? []; return (response.data as any[]) ?? [];
}, },
); );
const [roster, { refetch: refetchRoster }] = createResource(
() => [data.token(), expandedSessionId()] as const,
async ([bearer, sessionId]) => {
if (!bearer || !sessionId) return null;
if (bearer.startsWith("demo.")) {
const start = new Date(Date.now() + 3 * 86400000);
const mk = (id: string, name: string, email: string, status: string, pos = 0) => ({
bookingId: id, reference: `BOK-${id.slice(-3).toUpperCase()}`, customerName: name, customerEmail: email,
status, waitlistPosition: pos, createdAt: start.toISOString(),
});
return {
sessionId,
templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga",
locationName: cs() ? "Hlavní sál" : "Main Hall",
startsAt: start.toISOString(),
endsAt: new Date(start.getTime() + 3600000).toISOString(),
capacity: 8,
confirmed: [
mk("roster-001", cs() ? "Martina Nováková" : "Martina Novakova", "[email protected]", "confirmed"),
mk("roster-002", "David Svoboda", "[email protected]", "confirmed"),
mk("roster-003", "Alice Johnson", "[email protected]", "confirmed"),
],
waitlist: [
mk("roster-004", cs() ? "Jana Kovářová" : "Jana Kovarova", "[email protected]", "waitlisted", 1),
mk("roster-005", "Bob Smith", "[email protected]", "waitlisted", 2),
],
};
}
const response = await (apiClient as any).GET(`/v1/catalog/class-sessions/${sessionId}/roster`, authHeaders());
return response.data ?? null;
},
);
const handlePromote = async (bookingId: string, allowOverCapacity: boolean) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze měnit rezervace." : "Cannot modify bookings in demo mode.");
return;
}
setPromoteBusy(bookingId);
try {
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${bookingId}/promote`, {
...authHeaders(),
body: { allowOverCapacity },
});
if (response.error) {
if (response.response?.status === 409 && !allowOverCapacity) {
setPromoteArmed((prev) => new Set(prev).add(bookingId));
return;
}
throw new Error("promote failed");
}
setPromoteArmed((prev) => { const next = new Set(prev); next.delete(bookingId); return next; });
void refetchRoster(); void refetchSessions(); void data.refetchSummary();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.class.promoteFailed"));
} finally {
setPromoteBusy(null);
}
};
const resolvedLocations = () => locations() ?? []; const resolvedLocations = () => locations() ?? [];
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3; const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit(); const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
@@ -579,19 +646,121 @@ function ZonesPage() {
}> }>
<div class="divide-y divide-border/60"> <div class="divide-y divide-border/60">
<For each={classSessions() ?? []}> <For each={classSessions() ?? []}>
{(session: any) => ( {(session: any) => {
<div class="flex items-center justify-between p-4 px-6"> const expanded = () => expandedSessionId() === session.id;
return (
<div> <div>
<p class="font-medium text-ink">{session.templateTitle}</p> <div class="flex items-center justify-between gap-3 p-4 px-6">
<p class="text-sm text-ink-muted"> <div class="min-w-0">
{new Date(session.startsAt).toLocaleString(cs() ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })} <div class="flex items-center gap-2 flex-wrap">
{session.locationName ? ` • ${session.locationName}` : ""} <p class="font-medium text-ink">{session.templateTitle}</p>
{` • ${session.bookedCount}/${session.capacity} ${data.i18n.t("dashboard.class.booked")}`} <Show when={session.waitlistCount > 0}>
</p> <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-warning-subtle text-warning">
{session.waitlistCount} {data.i18n.t("dashboard.class.waiting")}
</span>
</Show>
</div>
<p class="text-sm text-ink-muted">
{new Date(session.startsAt).toLocaleString(cs() ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
{session.locationName ? ` • ${session.locationName}` : ""}
{` • ${session.bookedCount}/${session.capacity} ${data.i18n.t("dashboard.class.booked")}`}
</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
onClick={() => setExpandedSessionId(expanded() ? null : session.id)}
class={`flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${expanded() ? "bg-accent-subtle text-accent" : "text-ink-muted hover:text-ink hover:bg-canvas-subtle"}`}
>
{expanded() ? data.i18n.t("dashboard.class.hideRoster") : data.i18n.t("dashboard.class.showRoster")}
<span class={`inline-flex transition-transform duration-200 ${expanded() ? "rotate-180" : ""}`}><ChevronDownIcon /></span>
</button>
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
</div>
</div>
<Show when={expanded()}>
<div class="px-6 pb-5 animate-fade-in">
<div class="rounded-xl border border-border/60 bg-canvas-subtle/30 overflow-hidden">
<Show when={!roster.loading} fallback={
<div class="p-8 text-center text-sm text-ink-muted animate-pulse">…</div>
}>
<div class="grid sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-border/60">
<div class="p-4">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-muted mb-3">
{data.i18n.t("dashboard.class.attendees")} ({(roster()?.confirmed ?? []).length}/{roster()?.capacity ?? session.capacity})
</p>
<Show when={(roster()?.confirmed ?? []).length > 0} fallback={
<p class="text-sm text-ink-subtle">{data.i18n.t("dashboard.class.noAttendees")}</p>
}>
<div class="space-y-2">
<For each={roster()?.confirmed ?? []}>
{(entry: any) => (
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-accent-subtle flex items-center justify-center text-[11px] font-bold text-accent shrink-0">
{getInitials(entry.customerName)}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-ink truncate">{entry.customerName}</p>
<p class="text-xs text-ink-muted truncate">{entry.customerEmail} • {entry.reference}</p>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
<div class="p-4">
<p class="text-xs font-semibold uppercase tracking-wider text-ink-muted mb-3">
{data.i18n.t("dashboard.class.waitlist")} ({(roster()?.waitlist ?? []).length})
</p>
<Show when={(roster()?.waitlist ?? []).length > 0} fallback={
<p class="text-sm text-ink-subtle">{data.i18n.t("dashboard.class.noWaitlist")}</p>
}>
<div class="space-y-2">
<For each={roster()?.waitlist ?? []}>
{(entry: any) => (
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-warning-subtle flex items-center justify-center text-[11px] font-bold text-warning shrink-0">
#{entry.waitlistPosition}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-ink truncate">{entry.customerName}</p>
<p class="text-xs text-ink-muted truncate">{entry.customerEmail} • {entry.reference}</p>
</div>
<Show
when={!promoteArmed().has(entry.bookingId)}
fallback={
<button
onClick={() => void handlePromote(entry.bookingId, true)}
disabled={promoteBusy() === entry.bookingId}
title={data.i18n.t("dashboard.class.promoteFull")}
class="shrink-0 px-2.5 py-1.5 text-xs font-medium rounded-lg bg-warning text-canvas hover:opacity-90 transition-opacity disabled:opacity-60"
>
{promoteBusy() === entry.bookingId ? "…" : data.i18n.t("dashboard.class.promoteConfirm")}
</button>
}
>
<button
onClick={() => void handlePromote(entry.bookingId, false)}
disabled={promoteBusy() === entry.bookingId}
class="shrink-0 px-2.5 py-1.5 text-xs font-medium rounded-lg bg-accent text-canvas hover:bg-accent-hover transition-colors disabled:opacity-60"
>
{promoteBusy() === entry.bookingId ? "…" : data.i18n.t("dashboard.class.promote")}
</button>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
</div>
</div>
</Show>
</div> </div>
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button> );
</div> }}
)}
</For> </For>
</div> </div>
</Show> </Show>
+140
View File
@@ -303,6 +303,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/catalog/bookings/{bookingID}/promote": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["promoteDashboardBooking"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/class-sessions": { "/v1/catalog/class-sessions": {
parameters: { parameters: {
query?: never; query?: never;
@@ -335,6 +351,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/catalog/class-sessions/{sessionID}/roster": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getSessionRoster"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/class-templates": { "/v1/catalog/class-templates": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1024,6 +1056,7 @@ export interface components {
templateTitle: string; templateTitle: string;
/** Format: uuid */ /** Format: uuid */
tenantId: string; tenantId: string;
waitlistCount: number;
}; };
ClassTemplate: { ClassTemplate: {
capacity: number; capacity: number;
@@ -1196,6 +1229,10 @@ export interface components {
/** Format: uri */ /** Format: uri */
url: string; url: string;
}; };
PromoteBookingRequest: {
/** @description Confirm even when the session is full (deliberate overbooking) */
allowOverCapacity?: boolean;
};
PublicAvailabilityResponse: { PublicAvailabilityResponse: {
/** @enum {string} */ /** @enum {string} */
locale: "cs" | "en"; locale: "cs" | "en";
@@ -1244,6 +1281,31 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
tenantId: string; tenantId: string;
}; };
SessionRoster: {
capacity: number;
confirmed: components["schemas"]["SessionRosterEntry"][];
/** Format: date-time */
endsAt: string;
locationName?: string;
/** Format: uuid */
sessionId: string;
/** Format: date-time */
startsAt: string;
templateTitle: string;
waitlist: components["schemas"]["SessionRosterEntry"][];
};
SessionRosterEntry: {
/** Format: uuid */
bookingId: string;
/** Format: date-time */
createdAt: string;
customerEmail: string;
customerName: string;
customerPhone?: string;
reference: string;
status: string;
waitlistPosition?: number;
};
StaffMember: { StaffMember: {
active: boolean; active: boolean;
/** Format: date-time */ /** Format: date-time */
@@ -1334,6 +1396,8 @@ export interface components {
siteId?: string; siteId?: string;
}; };
UpcomingBooking: { UpcomingBooking: {
/** Format: uuid */
classSessionId?: string;
customerEmail?: string; customerEmail?: string;
customerName?: string; customerName?: string;
/** Format: date-time */ /** Format: date-time */
@@ -1348,6 +1412,8 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
startsAt?: string; startsAt?: string;
status?: string; status?: string;
/** @description 1-based queue spot for waitlisted class bookings; absent otherwise */
waitlistPosition?: number;
}; };
UpdateBrandRequest: { UpdateBrandRequest: {
logoUrl?: string; logoUrl?: string;
@@ -2084,6 +2150,51 @@ export interface operations {
}; };
}; };
}; };
promoteDashboardBooking: {
parameters: {
query?: never;
header?: never;
path: {
bookingID: string;
};
cookie?: never;
};
requestBody?: {
content: {
"application/json": components["schemas"]["PromoteBookingRequest"];
};
};
responses: {
/** @description Waitlisted booking confirmed; customer notified */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Booking is not on a waitlist */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Booking not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Session at capacity; retry with allowOverCapacity to override */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listClassSessions: { listClassSessions: {
parameters: { parameters: {
query?: never; query?: never;
@@ -2169,6 +2280,35 @@ export interface operations {
}; };
}; };
}; };
getSessionRoster: {
parameters: {
query?: never;
header?: never;
path: {
sessionID: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Confirmed attendees and FIFO waitlist for the session */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SessionRoster"];
};
};
/** @description Class session not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listClassTemplates: { listClassTemplates: {
parameters: { parameters: {
query?: never; query?: never;
+27 -4
View File
@@ -81,7 +81,8 @@
- shared `bookings.PromoteWaitlist` helper drives both cancel paths; waitlist ledger entries are marked resolved on promotion - shared `bookings.PromoteWaitlist` helper drives both cancel paths; waitlist ledger entries are marked resolved on promotion
- bookings surface the `waitlisted` status: stats card, filter option, warning-tone badge, demo seed entry; `dashboard.{status}` interpolation resolves the new i18n key automatically - bookings surface the `waitlisted` status: stats card, filter option, warning-tone badge, demo seed entry; `dashboard.{status}` interpolation resolves the new i18n key automatically
- `BookingRecord` now carries `created_at` (needed for fair waitlist ordering); `GetBookingByID` added to the repository surface - `BookingRecord` now carries `created_at` (needed for fair waitlist ordering); `GetBookingByID` added to the repository surface
- remaining: live staging validation; waitlist position display and manual promotion controls are not yet exposed - waitlist position display landed in Milestone 16; manual promotion controls landed in Milestone 20
- remaining: live staging validation
- `[done/partial]` Milestone 12: Real dashboard business metrics - `[done/partial]` Milestone 12: Real dashboard business metrics
- `DashboardMetrics` gained `CompletedCount` (confirmed bookings already ended within the window) in both pgx and in-memory repositories - `DashboardMetrics` gained `CompletedCount` (confirmed bookings already ended within the window) in both pgx and in-memory repositories
- `DashboardSummary` now queries three trailing windows (next 7d, last 7d, 7-14d ago) and emits four KPIs with real trend deltas: `bookings_this_week`, `completed`, `cancellations`, `utilization` (delta in percentage points) - `DashboardSummary` now queries three trailing windows (next 7d, last 7d, 7-14d ago) and emits four KPIs with real trend deltas: `bookings_this_week`, `completed`, `cancellations`, `utilization` (delta in percentage points)
@@ -111,7 +112,8 @@
- `GetWaitlistPosition` repository method (pgx + memory) returns the 1-based queue spot: count of earlier-created waitlisted bookings on the same session + 1 - `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 - `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) - 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 - manual promotion controls landed in Milestone 20
- remaining: live staging validation
- `[done/partial]` Milestone 17: Class template editing - `[done/partial]` Milestone 17: Class template editing
- `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client - `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client
- classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance - classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance
@@ -127,6 +129,15 @@
- validated end-to-end against a local `postgres:17` container: SQL-level matrix (staff/location/unassigned overlap blocked, abutting allowed, cancel frees the slot), repo-level 23P01 detection, and a 10-request concurrent burst on one slot producing exactly one 201 + 409s for the rest; down/up migration roundtrip clean - validated end-to-end against a local `postgres:17` container: SQL-level matrix (staff/location/unassigned overlap blocked, abutting allowed, cancel frees the slot), repo-level 23P01 detection, and a 10-request concurrent burst on one slot producing exactly one 201 + 409s for the rest; down/up migration roundtrip clean
- local test container: `docker run -d --name bookra-pg-local -p 5439:5432 -e POSTGRES_PASSWORD=bookra-local postgres:17-alpine`, migrations via `npm run db:migrate:up` with `BOOKRA_DATABASE_DIRECT_URL` pointed at it - local test container: `docker run -d --name bookra-pg-local -p 5439:5432 -e POSTGRES_PASSWORD=bookra-local postgres:17-alpine`, migrations via `npm run db:migrate:up` with `BOOKRA_DATABASE_DIRECT_URL` pointed at it
- remaining: live staging validation on Neon - remaining: live staging validation on Neon
- `[done/partial]` Milestone 20: Class roster + manual waitlist promotion
- `GET /v1/catalog/class-sessions/{id}/roster` returns session metadata plus `confirmed` and `waitlist` entry lists (booking id, reference, customer name/email, status, `waitlistPosition`, `createdAt`); `ClassSession` now carries `waitlistCount` so session rows can badge queue length without an extra request
- `POST /v1/catalog/bookings/{id}/promote` manually confirms a waitlisted booking: `409` when the session is full unless the caller passes `allowOverCapacity`, `400` for non-waitlisted bookings, `404` for missing or foreign bookings; promotion reuses the shared confirm path so waitlist ledger rows resolve and reminders re-arm automatically
- `ListBookingsBySession` repository method (pgx + memory) backs the roster and simplifies `PromoteWaitlist`; `UpcomingBooking` carries `classSessionId` + `waitlistPosition`, computed per session in the dashboard summary
- classes tab session rows expand into a two-column roster panel (attendees vs waitlist with `#N` position badges) with per-entry promote buttons; a full session arms an explicit "confirm anyway" over-capacity step instead of silently overriding
- the booking detail modal shows the waitlist position and offers the same promote action for waitlisted bookings; the bookings list badges waitlisted rows with `#N`
- demo mode ships a seeded session with a populated roster; mutation attempts surface the demo notice instead of failing
- verified live in demo mode: seeded a full session via the public booking API, roster listed 4 confirmed + 1 waitlisted, promote rejected with 409, `allowOverCapacity` promoted and drained the queue; browser-checked CS/EN rendering with a clean console
- remaining: live staging validation on Neon + pgx repository path
## Overview ## 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. 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.
@@ -361,7 +372,7 @@ Key entities:
## Current Gaps ## Current Gaps
- Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment. - Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment.
- Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants. - Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants.
- Post-onboarding CRUD now covers services, staff, locations, blocked days, working hours, and customers; class/session template management and per-staff availability rules still have no management UI. - Post-onboarding CRUD now covers services, staff, locations, blocked days, working hours, customers, and class templates/sessions with rosters; per-staff availability rules still have no management UI.
- Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures. - Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures.
- Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops. - Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops.
- The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup. - The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup.
@@ -371,7 +382,19 @@ Key entities:
- sync Neon Auth identities into tenant membership records and onboarding bootstrap - sync Neon Auth identities into tenant membership records and onboarding bootstrap
- validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure - validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure
- replace noop notification providers with the first production email transport and a gated SMS transport - replace noop notification providers with the first production email transport and a gated SMS transport
- add management UI for class/session templates and per-staff availability rules so service duration, capacity, and staff schedules are editable after onboarding - add management UI for per-staff availability rules so staff schedules are editable after onboarding
## Future Phases
Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle:
- **Activity/audit log**: a per-tenant feed of who changed what (booking created/cancelled/promoted, settings edits). Natural fit for the notification dropdown and useful for multi-staff accountability.
- **Customer self-service rebooking**: let customers move to a different free slot from the manage page instead of cancel-and-rebook; the reschedule path already validates targets, so this is mostly UI plus a picker.
- **Calendar integrations**: ICS feed per tenant/staff and optional Google Calendar sync; high demand for salons, moderate complexity. Start with a read-only `.ics` subscription URL — zero third-party auth needed.
- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios.
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
- **Persistent notification center**: the dropdown currently derives notifications from summary data; a `notifications` table would enable read/unread state and delivery history in-app.
- **Staff performance view**: bookings/completion/utilization per staff member — the metrics primitives already exist; this is a filtered presentation layer.
- **CSV export**: bookings and customers export for accounting/backup; cheap to build, frequently requested by small businesses.
## Product Principles ## Product Principles
- calm, premium, structured UX - calm, premium, structured UX