Merge pull request #6 from Dvorinka/feat/class-session-move

feat(bookings): session-to-session move for class bookings
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-18 20:24:49 +02:00
commit acd671fd82
10 files changed
+927 -10

No files matched your search

+48
View File
@@ -1176,6 +1176,54 @@ 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"})
}) })
// Class bookings move between sessions of the same template rather than
// rescheduling into arbitrary windows.
server.router.GET("/v1/public/bookings/:reference/sessions", func(c *gin.Context) {
token := c.Query("token")
sessions, err := customerBookingService.ListSessionMoveTargets(c.Request.Context(), c.Param("reference"), token)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict
case errors.Is(err, bookings.ErrInvalidReschedule):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"sessions": sessions})
})
server.router.POST("/v1/public/bookings/:reference/move", publicRateLimiter.Middleware(), func(c *gin.Context) {
token := c.Query("token")
var request domain.MoveBookingRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := customerBookingService.MoveBookingToSession(c.Request.Context(), c.Param("reference"), token, request.ClassSessionID)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict
case errors.Is(err, bookings.ErrInvalidReschedule):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// Read-only ICS feed. The path token is the only credential — same trust // Read-only ICS feed. The path token is the only credential — same trust
// model as private calendar URLs from Google/Outlook. // model as private calendar URLs from Google/Outlook.
server.router.GET("/v1/public/calendar/:token", publicRateLimiter.Middleware(), func(c *gin.Context) { server.router.GET("/v1/public/calendar/:token", publicRateLimiter.Middleware(), func(c *gin.Context) {
@@ -373,3 +373,168 @@ func (s *CustomerService) sendPromotionEmail(booking db.BookingRecord) {
ManagementURL: s.managementURL(booking), ManagementURL: s.managementURL(booking),
}) })
} }
// classBookingForMove returns the booking and its current session when the
// record is a live class booking, or an error explaining why it cannot move.
func (s *CustomerService) classBookingForMove(ctx context.Context, reference string, token string) (db.BookingRecord, db.ClassSessionRecord, error) {
booking, err := s.repo.GetBookingByReference(ctx, reference)
if err != nil {
return db.BookingRecord{}, db.ClassSessionRecord{}, ErrBookingNotFound
}
if err := authorize(booking, token); err != nil {
return db.BookingRecord{}, db.ClassSessionRecord{}, err
}
if booking.Status == "cancelled" {
return db.BookingRecord{}, db.ClassSessionRecord{}, ErrBookingCancelled
}
if booking.ClassSessionID == nil || (booking.Status != "confirmed" && booking.Status != "waitlisted") {
return db.BookingRecord{}, db.ClassSessionRecord{}, ErrInvalidReschedule
}
session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID)
if err != nil {
return db.BookingRecord{}, db.ClassSessionRecord{}, ErrInvalidReschedule
}
return booking, session, nil
}
// ListSessionMoveTargets returns upcoming sessions of the same class template
// the customer could move to, with live capacity info for the picker.
func (s *CustomerService) ListSessionMoveTargets(ctx context.Context, reference string, token string) ([]domain.SessionMoveTarget, error) {
booking, session, err := s.classBookingForMove(ctx, reference, token)
if err != nil {
return nil, err
}
now := time.Now().UTC()
sessions, err := s.repo.ListClassSessionsByTenant(ctx, booking.TenantID, now, 50)
if err != nil {
return nil, err
}
locations, _ := s.repo.ListLocationsByTenant(ctx, booking.TenantID)
locationNames := map[string]string{}
for _, location := range locations {
locationNames[location.ID] = location.Name
}
var targets []domain.SessionMoveTarget
for _, candidate := range sessions {
if candidate.ID == session.ID || candidate.TemplateID != session.TemplateID {
continue
}
if !candidate.StartsAt.After(now) {
continue
}
bookings, err := s.repo.ListBookingsBySession(ctx, candidate.ID)
if err != nil {
return nil, err
}
confirmed, waitlisted := 0, 0
for _, other := range bookings {
switch other.Status {
case "confirmed":
confirmed++
case "waitlisted":
waitlisted++
}
}
location := ""
if candidate.LocationID != nil {
location = locationNames[*candidate.LocationID]
}
targets = append(targets, domain.SessionMoveTarget{
SessionID: candidate.ID,
Title: candidate.Title,
StartsAt: candidate.StartsAt,
EndsAt: candidate.EndsAt,
Location: location,
Capacity: candidate.Capacity,
ConfirmedCount: confirmed,
WaitlistCount: waitlisted,
Remaining: int(candidate.Capacity) - confirmed,
})
}
return targets, nil
}
// MoveBookingToSession retargets a class booking to another session of the
// same template. The freed spot (confirmed or queue position) is released on
// the old session — a confirmed move also drains its waitlist — and the
// booking is re-seated or re-queued on the target.
func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference string, token string, targetSessionID string) (domain.MoveBookingResponse, error) {
booking, currentSession, err := s.classBookingForMove(ctx, reference, token)
if err != nil {
return domain.MoveBookingResponse{}, err
}
target, err := s.repo.GetClassSessionByID(ctx, targetSessionID)
if err != nil {
return domain.MoveBookingResponse{}, ErrInvalidReschedule
}
if target.TenantID != booking.TenantID || target.TemplateID != currentSession.TemplateID || target.ID == currentSession.ID {
return domain.MoveBookingResponse{}, ErrInvalidReschedule
}
if !target.StartsAt.After(time.Now().UTC()) {
return domain.MoveBookingResponse{}, ErrInvalidReschedule
}
targetBookings, err := s.repo.ListBookingsBySession(ctx, target.ID)
if err != nil {
return domain.MoveBookingResponse{}, err
}
confirmed, waitlisted := 0, 0
for _, other := range targetBookings {
switch other.Status {
case "confirmed":
confirmed++
case "waitlisted":
waitlisted++
}
}
wasConfirmed := booking.Status == "confirmed"
newStatus := "confirmed"
if target.Capacity > 0 && confirmed >= int(target.Capacity) {
newStatus = "waitlisted"
}
// Release the old spot first so the seat/queue position is free before
// the retarget lands.
if booking.Status == "waitlisted" {
_ = s.repo.ResolveWaitlistEntry(ctx, booking.TenantID, currentSession.ID, booking.CustomerEmail)
}
if err := s.repo.UpdateBookingSession(ctx, booking.ID, target.ID, target.StartsAt, target.EndsAt, newStatus); err != nil {
return domain.MoveBookingResponse{}, err
}
if newStatus == "waitlisted" {
if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
TenantID: booking.TenantID,
ClassSessionID: target.ID,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Position: waitlisted + 1,
}); err != nil {
return domain.MoveBookingResponse{}, err
}
}
// Re-arm reminders for the new window (confirmed only; a queue spot
// re-arms on promotion through the shared confirm path).
if err := s.repo.DeletePendingReminderJobsForBooking(ctx, booking.ID); err == nil && newStatus == "confirmed" {
_ = armReminderJobs(ctx, s.repo, booking.TenantID, booking.ID, target.StartsAt, booking.CustomerPhone)
}
// A confirmed move frees a seat on the old session — drain its queue.
if wasConfirmed {
if promoted, err := PromoteWaitlist(ctx, s.repo, booking.TenantID, currentSession.ID); err == nil {
for _, promotedBooking := range promoted {
go s.sendPromotionEmail(promotedBooking)
}
}
}
updated := booking
updated.ClassSessionID = &target.ID
go s.sendRescheduleEmail(updated, target.StartsAt, target.EndsAt)
return domain.MoveBookingResponse{Status: newStatus}, nil
}
@@ -149,3 +149,225 @@ func TestRescheduleRejectsClassWaitlistedAndCompleted(t *testing.T) {
} }
} }
} }
const (
moveTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
moveTemplateID = "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7"
moveSessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435"
moveLocationID = "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
)
func seedMoveBooking(t *testing.T, repo *db.MemoryRepository, sessionID, reference, status string) db.BookingRecord {
t.Helper()
ctx := context.Background()
session, err := repo.GetClassSessionByID(ctx, sessionID)
if err != nil {
t.Fatalf("get session: %v", err)
}
if status == "waitlisted" {
if err := repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{
TenantID: moveTenantID,
ClassSessionID: sessionID,
CustomerName: "Move Customer",
CustomerEmail: "[email protected]",
Position: 1,
}); err != nil {
t.Fatalf("seed waitlist entry: %v", err)
}
}
created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: moveTenantID,
ClassSessionID: &sessionID,
BookingMode: "class",
CustomerName: "Move Customer",
CustomerEmail: "[email protected]",
StartsAt: session.StartsAt,
EndsAt: session.EndsAt,
Status: status,
Reference: reference,
ManageToken: "move-token",
})
if err != nil {
t.Fatalf("create booking: %v", err)
}
booking, err := repo.GetBookingByReference(ctx, created.Reference)
if err != nil {
t.Fatalf("get booking: %v", err)
}
return booking
}
func newTargetSession(t *testing.T, repo *db.MemoryRepository, templateID string, capacity int32) db.ClassSessionRecord {
t.Helper()
session, err := repo.CreateClassSession(context.Background(), db.CreateClassSessionParams{
TenantID: moveTenantID,
TemplateID: templateID,
LocationID: &[]string{moveLocationID}[0],
StartsAt: time.Now().Add(96 * time.Hour),
EndsAt: time.Now().Add(97 * time.Hour),
Capacity: capacity,
})
if err != nil {
t.Fatalf("create target session: %v", err)
}
return session
}
func TestListSessionMoveTargets(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
booking := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-1", "confirmed")
target := newTargetSession(t, repo, moveTemplateID, 4)
// A session of a different template must not be offered.
otherTemplate, err := repo.CreateClassTemplate(ctx, db.CreateClassTemplateParams{TenantID: moveTenantID, Title: "Other class", DurationMinutes: 60, Capacity: 8})
if err != nil {
t.Fatalf("create other template: %v", err)
}
other := newTargetSession(t, repo, otherTemplate.ID, 8)
targets, err := svc.ListSessionMoveTargets(ctx, booking.Reference, "move-token")
if err != nil {
t.Fatalf("list targets: %v", err)
}
if len(targets) != 1 || targets[0].SessionID != target.ID {
t.Fatalf("expected only the same-template target, got %+v", targets)
}
if targets[0].Remaining != 4 || targets[0].Location != "Main Studio" {
t.Fatalf("unexpected target payload: %+v", targets[0])
}
if targets[0].SessionID == other.ID {
t.Fatalf("other-template session leaked into targets")
}
if _, err := svc.ListSessionMoveTargets(ctx, booking.Reference, "wrong"); err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got %v", err)
}
}
func TestMoveBookingToSessionConfirmed(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
booking := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-2", "confirmed")
target := newTargetSession(t, repo, moveTemplateID, 4)
response, err := svc.MoveBookingToSession(ctx, booking.Reference, "move-token", target.ID)
if err != nil {
t.Fatalf("move: %v", err)
}
if response.Status != "confirmed" {
t.Fatalf("expected confirmed, got %q", response.Status)
}
moved, _ := repo.GetBookingByID(ctx, booking.ID)
if moved.ClassSessionID == nil || *moved.ClassSessionID != target.ID {
t.Fatalf("booking not retargeted: %+v", moved.ClassSessionID)
}
if !moved.StartsAt.Equal(target.StartsAt) || !moved.EndsAt.Equal(target.EndsAt) {
t.Fatalf("booking times not aligned to target session")
}
}
func TestMoveBookingToFullSessionWaitlists(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
target := newTargetSession(t, repo, moveTemplateID, 1)
// Fill the single seat.
seedMoveBooking(t, repo, target.ID, "BK-TARGET-FULL", "confirmed")
booking := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-3", "confirmed")
response, err := svc.MoveBookingToSession(ctx, booking.Reference, "move-token", target.ID)
if err != nil {
t.Fatalf("move: %v", err)
}
if response.Status != "waitlisted" {
t.Fatalf("expected waitlisted, got %q", response.Status)
}
position, _ := repo.GetWaitlistPosition(ctx, target.ID, booking.ID)
if position != 1 {
t.Fatalf("expected waitlist position 1, got %d", position)
}
}
func TestMoveBookingWaitlistedResolvesOldQueue(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
booking := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-4", "waitlisted")
target := newTargetSession(t, repo, moveTemplateID, 4)
response, err := svc.MoveBookingToSession(ctx, booking.Reference, "move-token", target.ID)
if err != nil {
t.Fatalf("move: %v", err)
}
if response.Status != "confirmed" {
t.Fatalf("expected confirmed, got %q", response.Status)
}
// The booking must depart the old session's roster entirely — positions
// are derived from waitlisted bookings on the session.
roster, _ := repo.ListBookingsBySession(ctx, moveSessionID)
for _, entry := range roster {
if entry.ID == booking.ID {
t.Fatalf("booking still on the old session roster")
}
}
}
func TestMoveBookingFreesSeatAndPromotes(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
// Demo session has capacity 4: three fillers + the moving booking fill it,
// one queued booking waits. The move drops confirmed to 3 → queue head
// promotes.
for i := 0; i < 3; i++ {
seedMoveBooking(t, repo, moveSessionID, "BK-FILL-"+string(rune('A'+i)), "confirmed")
}
queued := seedMoveBooking(t, repo, moveSessionID, "BK-QUEUED", "waitlisted")
moving := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-5", "confirmed")
target := newTargetSession(t, repo, moveTemplateID, 4)
if _, err := svc.MoveBookingToSession(ctx, moving.Reference, "move-token", target.ID); err != nil {
t.Fatalf("move: %v", err)
}
promoted, _ := repo.GetBookingByID(ctx, queued.ID)
if promoted.Status != "confirmed" {
t.Fatalf("expected freed seat to promote queued booking, got %q", promoted.Status)
}
}
func TestMoveBookingRejections(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
// Appointment booking has no session to move between.
appointment := newBookingFixture(t, repo)
if _, err := svc.MoveBookingToSession(ctx, appointment.Reference, "secret-manage-token", moveSessionID); err != ErrInvalidReschedule {
t.Fatalf("appointment: expected ErrInvalidReschedule, got %v", err)
}
booking := seedMoveBooking(t, repo, moveSessionID, "BK-MOVE-6", "confirmed")
target := newTargetSession(t, repo, moveTemplateID, 4)
// Same session is a no-op request.
if _, err := svc.MoveBookingToSession(ctx, booking.Reference, "move-token", moveSessionID); err != ErrInvalidReschedule {
t.Fatalf("same session: expected ErrInvalidReschedule, got %v", err)
}
// Bad token.
if _, err := svc.MoveBookingToSession(ctx, booking.Reference, "wrong", target.ID); err != ErrUnauthorized {
t.Fatalf("bad token: expected ErrUnauthorized, got %v", err)
}
// Cancelled booking.
_ = repo.UpdateBookingStatus(ctx, booking.ID, "cancelled")
if _, err := svc.MoveBookingToSession(ctx, booking.Reference, "move-token", target.ID); err != ErrBookingCancelled {
t.Fatalf("cancelled: expected ErrBookingCancelled, got %v", err)
}
}
+23
View File
@@ -112,6 +112,7 @@ type Repository interface {
GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error)
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
UpdateBookingSession(ctx context.Context, bookingID string, sessionID string, startsAt time.Time, endsAt time.Time, status string) error
// Working Hours // Working Hours
ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error) ListWorkingHoursByTenant(ctx context.Context, tenantID string, staffID *string) ([]WorkingHoursRecord, error)
@@ -780,6 +781,15 @@ func (r *PGRepository) RescheduleBooking(ctx context.Context, bookingID string,
return err return err
} }
// UpdateBookingSession retargets a class booking to a different session and
// carries the session's own time window onto the booking.
func (r *PGRepository) UpdateBookingSession(ctx context.Context, bookingID string, sessionID string, startsAt time.Time, endsAt time.Time, status string) error {
_, err := r.pool.Exec(ctx, `
UPDATE bookings SET class_session_id = $2, starts_at = $3, ends_at = $4, status = $5, updated_at = now() WHERE id = $1
`, bookingID, sessionID, startsAt, endsAt, status)
return err
}
// ============================================ // ============================================
// WORKING HOURS METHODS - PG REPOSITORY (STUBS) // WORKING HOURS METHODS - PG REPOSITORY (STUBS)
// ============================================ // ============================================
@@ -2002,6 +2012,19 @@ func (r *MemoryRepository) RescheduleBooking(_ context.Context, bookingID string
return pgx.ErrNoRows return pgx.ErrNoRows
} }
func (r *MemoryRepository) UpdateBookingSession(_ context.Context, bookingID string, sessionID string, startsAt time.Time, endsAt time.Time, status string) error {
for i, b := range r.bookings {
if b.ID == bookingID {
r.bookings[i].ClassSessionID = &sessionID
r.bookings[i].StartsAt = startsAt
r.bookings[i].EndsAt = endsAt
r.bookings[i].Status = status
return nil
}
}
return pgx.ErrNoRows
}
// ============================================ // ============================================
// EMAIL TEMPLATE METHODS - MEMORY REPOSITORY // EMAIL TEMPLATE METHODS - MEMORY REPOSITORY
// ============================================ // ============================================
+23
View File
@@ -512,6 +512,29 @@ type RescheduleBookingRequest struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
// SessionMoveTarget is an upcoming session of the same class template a
// customer can move their booking to. Remaining is the free confirmed seats;
// negative means a queue would form.
type SessionMoveTarget struct {
SessionID string `json:"sessionId"`
Title string `json:"title"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
Location string `json:"location,omitempty"`
Capacity int32 `json:"capacity"`
ConfirmedCount int `json:"confirmedCount"`
WaitlistCount int `json:"waitlistCount"`
Remaining int `json:"remaining"`
}
type MoveBookingRequest struct {
ClassSessionID string `json:"classSessionId" binding:"required"`
}
type MoveBookingResponse struct {
Status string `json:"status"` // confirmed | waitlisted
}
type CancelBookingRequest struct { type CancelBookingRequest struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
+116
View File
@@ -396,6 +396,97 @@ paths:
description: Invalid manage token description: Invalid manage token
"409": "409":
description: New slot conflicts or booking already cancelled description: New slot conflicts or booking already cancelled
/v1/public/bookings/{reference}/sessions:
get:
tags: [Public Booking]
operationId: listSessionMoveTargets
summary: Upcoming sessions a class booking can move to
description: >-
Class bookings cannot reschedule to arbitrary windows — they move
between sessions of the same template. Returns upcoming same-template
sessions with live capacity info.
parameters:
- in: path
name: reference
required: true
schema:
type: string
- in: query
name: token
required: true
schema:
type: string
responses:
"200":
description: Move targets with capacity info
content:
application/json:
schema:
type: object
properties:
sessions:
type: array
items:
$ref: "#/components/schemas/SessionMoveTarget"
"400":
description: Not a live class booking
"403":
description: Invalid manage token
"404":
description: Booking not found
"409":
description: Booking already cancelled
/v1/public/bookings/{reference}/move:
post:
tags: [Public Booking]
operationId: moveBookingToSession
summary: Move a class booking to another session of the same template
description: >-
Retargets the booking to the target session. Confirmed bookings free
their old seat (draining its waitlist); waitlisted bookings release
their queue position. The booking is confirmed when the target has a
free seat, otherwise it joins the target's waitlist.
parameters:
- in: path
name: reference
required: true
schema:
type: string
- in: query
name: token
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [classSessionId]
properties:
classSessionId:
type: string
format: uuid
responses:
"200":
description: Booking moved; status is the resulting booking state
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum: [confirmed, waitlisted]
"400":
description: Not a live class booking, or target is not an upcoming same-template session
"403":
description: Invalid manage token
"404":
description: Booking not found
"409":
description: Booking already cancelled
/v1/public/bookings/{reference}/cancel: /v1/public/bookings/{reference}/cancel:
post: post:
tags: [Public Booking] tags: [Public Booking]
@@ -1902,6 +1993,31 @@ components:
waitlistPosition: waitlistPosition:
type: integer type: integer
description: 1-based queue spot for waitlisted class bookings; absent otherwise description: 1-based queue spot for waitlisted class bookings; absent otherwise
SessionMoveTarget:
type: object
properties:
sessionId:
type: string
format: uuid
title:
type: string
startsAt:
type: string
format: date-time
endsAt:
type: string
format: date-time
location:
type: string
capacity:
type: integer
confirmedCount:
type: integer
waitlistCount:
type: integer
remaining:
type: integer
description: Free confirmed seats; 0 or negative means the move lands on the waitlist
CalendarFeedEntry: CalendarFeedEntry:
type: object type: object
properties: properties:
@@ -11,9 +11,12 @@ export function BookingManageRoute() {
const i18n = useI18n(); const i18n = useI18n();
const [isRescheduling, setIsRescheduling] = createSignal(false); const [isRescheduling, setIsRescheduling] = createSignal(false);
const [isCancelling, setIsCancelling] = createSignal(false); const [isCancelling, setIsCancelling] = createSignal(false);
const [isMoving, setIsMoving] = createSignal(false);
const [newDate, setNewDate] = createSignal(""); const [newDate, setNewDate] = createSignal("");
const [selectedSlot, setSelectedSlot] = createSignal<{ startsAt: string; endsAt: string } | null>(null); const [selectedSlot, setSelectedSlot] = createSignal<{ startsAt: string; endsAt: string } | null>(null);
const [selectedSession, setSelectedSession] = createSignal<string | null>(null);
const [rescheduleBusy, setRescheduleBusy] = createSignal(false); const [rescheduleBusy, setRescheduleBusy] = createSignal(false);
const [moveBusy, setMoveBusy] = createSignal(false);
const [message, setMessage] = createSignal(""); const [message, setMessage] = createSignal("");
const [actionSuccess, setActionSuccess] = createSignal<string | null>(null); const [actionSuccess, setActionSuccess] = createSignal<string | null>(null);
const [actionError, setActionError] = createSignal<string | null>(null); const [actionError, setActionError] = createSignal<string | null>(null);
@@ -67,6 +70,61 @@ export function BookingManageRoute() {
}, },
); );
const [moveTargets] = createResource(
() => [isMoving(), token(), reference()] as const,
async ([open, t, ref]) => {
if (!open || !t || !ref) return [];
try {
const response = await (apiClient as any).GET(`/v1/public/bookings/${ref}/sessions`, {
params: { query: { token: t } },
});
return (response.data?.sessions ?? []) as any[];
} catch {
return [];
}
},
);
const handleMove = async () => {
setActionSuccess(null);
setActionError(null);
const sessionId = selectedSession();
const ref = reference();
const t = token();
if (!sessionId || !ref || !t) {
setActionError(i18n.locale() === 'cs' ? 'Vyberte prosím jinou lekci' : 'Please pick another session');
return;
}
setMoveBusy(true);
try {
const response = await (apiClient as any).POST(`/v1/public/bookings/${ref}/move`, {
params: { query: { token: t } },
body: { classSessionId: sessionId },
});
if (response.error) {
setActionError(i18n.locale() === 'cs' ? 'Přesun na jinou lekci selhal' : 'Moving to another session failed');
return;
}
const waitlisted = (response.data as any)?.status === 'waitlisted';
setActionSuccess(
waitlisted
? (i18n.locale() === 'cs' ? 'Lekce je plná — byli jste zařazeni na čekací listinu' : 'Session is full — you were added to its waitlist')
: (i18n.locale() === 'cs' ? 'Rezervace byla přesunuta na jinou lekci' : 'Booking moved to another session')
);
setIsMoving(false);
setSelectedSession(null);
void refetchBooking();
} catch {
setActionError(i18n.locale() === 'cs' ? 'Přesun na jinou lekci selhal' : 'Moving to another session failed');
} finally {
setMoveBusy(false);
}
};
const handleReschedule = async () => { const handleReschedule = async () => {
setActionSuccess(null); setActionSuccess(null);
setActionError(null); setActionError(null);
@@ -353,6 +411,21 @@ export function BookingManageRoute() {
{i18n.locale() === 'cs' ? 'Změnit termín' : 'Reschedule'} {i18n.locale() === 'cs' ? 'Změnit termín' : 'Reschedule'}
</Button> </Button>
</Show> </Show>
<Show when={b().classSessionId}>
<Button
variant="secondary"
class="flex-1"
onClick={() => setIsMoving(true)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
<path d="M8 3 4 7l4 4"/>
<path d="M4 7h16"/>
<path d="m16 21 4-4-4-4"/>
<path d="M20 17H4"/>
</svg>
{i18n.locale() === 'cs' ? 'Změnit lekci' : 'Change session'}
</Button>
</Show>
<Button <Button
variant="secondary" variant="secondary"
class="flex-1 text-[hsl(var(--error))] hover:bg-[hsl(var(--error-soft))]" class="flex-1 text-[hsl(var(--error))] hover:bg-[hsl(var(--error-soft))]"
@@ -366,13 +439,6 @@ export function BookingManageRoute() {
{i18n.locale() === 'cs' ? 'Zrušit rezervaci' : 'Cancel Booking'} {i18n.locale() === 'cs' ? 'Zrušit rezervaci' : 'Cancel Booking'}
</Button> </Button>
</div> </div>
<Show when={b().classSessionId}>
<p class="text-sm text-ink-muted pt-1">
{i18n.locale() === 'cs'
? 'Termín lekce je pevně daný. Pro jiný termín rezervaci zrušte a objednejte jinou lekci.'
: 'Class times are fixed. To switch sessions, cancel this booking and book another class.'}
</p>
</Show>
</Show> </Show>
</CardContent> </CardContent>
</Card> </Card>
@@ -500,6 +566,82 @@ export function BookingManageRoute() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Move Session Dialog */}
<Dialog open={isMoving()} onClose={() => setIsMoving(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{i18n.locale() === 'cs' ? 'Přejít na jinou lekci' : 'Move to another session'}
</DialogTitle>
<DialogDescription>
{i18n.locale() === 'cs'
? 'Vyberte jiný termín téže lekce. Plná lekce vás zařadí na čekací listinu.'
: 'Pick another time for the same class. A full session places you on its waitlist.'}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 mt-4">
<Show when={!moveTargets.loading} fallback={
<div class="p-4 text-center text-sm text-ink-muted animate-pulse">…</div>
}>
<Show when={(moveTargets() ?? []).length > 0} fallback={
<p class="p-4 text-sm text-ink-muted bg-canvas-subtle rounded-xl text-center">
{i18n.locale() === 'cs' ? 'Žádné další nadcházející termíny této lekce.' : 'No other upcoming sessions for this class.'}
</p>
}>
<div class="space-y-2 max-h-80 overflow-y-auto pr-1">
<For each={moveTargets() ?? []}>
{(target: any) => {
const isSelected = () => selectedSession() === target.sessionId;
const full = () => target.remaining <= 0;
return (
<button
type="button"
onClick={() => setSelectedSession(target.sessionId)}
class={`w-full text-left p-3 rounded-xl border transition-colors ${
isSelected()
? 'border-accent bg-accent-subtle'
: 'border-border bg-canvas-subtle hover:border-accent'
}`}
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-ink text-sm">{formatDate(target.startsAt)}</p>
<p class="text-xs text-ink-muted mt-0.5">
{formatTime(target.startsAt)} – {formatTime(target.endsAt)}
{target.location ? ` · ${target.location}` : ''}
</p>
</div>
<span class={`shrink-0 text-xs font-medium px-2.5 py-1 rounded-full ${
full()
? 'bg-[hsl(var(--warning-subtle))] text-[hsl(var(--warning))]'
: 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]'
}`}>
{full()
? (i18n.locale() === 'cs' ? 'Čekací listina' : 'Waitlist')
: (i18n.locale() === 'cs' ? `${target.remaining} volných` : `${target.remaining} free`)}
</span>
</div>
</button>
);
}}
</For>
</div>
</Show>
</Show>
<div class="flex gap-3 pt-2">
<Button variant="secondary" class="flex-1" onClick={() => { setIsMoving(false); setSelectedSession(null); }}>
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
</Button>
<Button class="flex-1" onClick={handleMove} disabled={!selectedSession() || moveBusy()}>
{moveBusy()
? '…'
: (i18n.locale() === 'cs' ? 'Přejít na lekci' : 'Move to session')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Cancel Dialog */} {/* Cancel Dialog */}
<Dialog open={isCancelling()} onClose={() => setIsCancelling(false)}> <Dialog open={isCancelling()} onClose={() => setIsCancelling(false)}>
<DialogContent> <DialogContent>
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/services-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/staff-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/calendar-feed.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/services-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/staff-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
+171
View File
@@ -740,6 +740,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/public/bookings/{reference}/move": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Move a class booking to another session of the same template
* @description Retargets the booking to the target session. Confirmed bookings free their old seat (draining its waitlist); waitlisted bookings release their queue position. The booking is confirmed when the target has a free seat, otherwise it joins the target's waitlist.
*/
post: operations["moveBookingToSession"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/public/bookings/{reference}/reschedule": { "/v1/public/bookings/{reference}/reschedule": {
parameters: { parameters: {
query?: never; query?: never;
@@ -756,6 +776,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/public/bookings/{reference}/sessions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Upcoming sessions a class booking can move to
* @description Class bookings cannot reschedule to arbitrary windows — they move between sessions of the same template. Returns upcoming same-template sessions with live capacity info.
*/
get: operations["listSessionMoveTargets"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/public/calendar/{token}": { "/v1/public/calendar/{token}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1374,6 +1414,21 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
tenantId: string; tenantId: string;
}; };
SessionMoveTarget: {
capacity?: number;
confirmedCount?: number;
/** Format: date-time */
endsAt?: string;
location?: string;
/** @description Free confirmed seats; 0 or negative means the move lands on the waitlist */
remaining?: number;
/** Format: uuid */
sessionId?: string;
/** Format: date-time */
startsAt?: string;
title?: string;
waitlistCount?: number;
};
SessionRoster: { SessionRoster: {
capacity: number; capacity: number;
confirmed: components["schemas"]["SessionRosterEntry"][]; confirmed: components["schemas"]["SessionRosterEntry"][];
@@ -3412,6 +3467,68 @@ export interface operations {
}; };
}; };
}; };
moveBookingToSession: {
parameters: {
query: {
token: string;
};
header?: never;
path: {
reference: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** Format: uuid */
classSessionId: string;
};
};
};
responses: {
/** @description Booking moved; status is the resulting booking state */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @enum {string} */
status?: "confirmed" | "waitlisted";
};
};
};
/** @description Not a live class booking, or target is not an upcoming same-template session */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid manage token */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Booking not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Booking already cancelled */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
rescheduleBooking: { rescheduleBooking: {
parameters: { parameters: {
query: { query: {
@@ -3466,6 +3583,60 @@ export interface operations {
}; };
}; };
}; };
listSessionMoveTargets: {
parameters: {
query: {
token: string;
};
header?: never;
path: {
reference: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Move targets with capacity info */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
sessions?: components["schemas"]["SessionMoveTarget"][];
};
};
};
/** @description Not a live class booking */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid manage token */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Booking not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Booking already cancelled */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
getCalendarFeed: { getCalendarFeed: {
parameters: { parameters: {
query?: never; query?: never;
+9 -2
View File
@@ -140,7 +140,7 @@
- remaining: live staging validation on Neon + pgx repository path - remaining: live staging validation on Neon + pgx repository path
- `[done/partial]` Milestone 21: Customer self-service rebooking - `[done/partial]` Milestone 21: Customer self-service rebooking
- `CustomerBookingView` now carries `tenantSlug`, `serviceId`, `staffId`, `classSessionId` — everything the manage page needs to query real availability; class bookings resolve the session title as their service label instead of rendering blank - `CustomerBookingView` now carries `tenantSlug`, `serviceId`, `staffId`, `classSessionId` — everything the manage page needs to query real availability; class bookings resolve the session title as their service label instead of rendering blank
- `RescheduleBooking` hardened: rejects `waitlisted`/`completed` bookings and any class booking (session-bound times cannot move to arbitrary windows; switching sessions is cancel-and-rebook); the previously dead `ClassSessionID == nil` branch collapsed into a straight appointment validation - `RescheduleBooking` hardened: rejects `waitlisted`/`completed` bookings and any class booking (session-bound times cannot move to arbitrary windows; session-to-session moves landed in Milestone 23); the previously dead `ClassSessionID == nil` branch collapsed into a straight appointment validation
- manage page reschedule dialog replaced free-form date+time inputs with a real slot picker: pick a date → `GET /v1/public/tenants/{slug}/availability` (filtered by the booking's service/staff) → time buttons; the slot carries its own duration so the hardcoded 60-minute assumption is gone; 409 surfaces "slot just taken" instead of a generic failure - manage page reschedule dialog replaced free-form date+time inputs with a real slot picker: pick a date → `GET /v1/public/tenants/{slug}/availability` (filtered by the booking's service/staff) → time buttons; the slot carries its own duration so the hardcoded 60-minute assumption is gone; 409 surfaces "slot just taken" instead of a generic failure
- action buttons are now status-gated: reschedule shows only for confirmed/pending appointments, cancel for any live booking, and class bookings get an explanatory note instead of a broken button - action buttons are now status-gated: reschedule shows only for confirmed/pending appointments, cancel for any live booking, and class bookings get an explanatory note instead of a broken button
- `CustomerBookingView` schema added to OpenAPI and referenced from `GET /v1/public/bookings/{reference}`; the reschedule contract documents the class-booking 400 and slot-conflict 409 - `CustomerBookingView` schema added to OpenAPI and referenced from `GET /v1/public/bookings/{reference}`; the reschedule contract documents the class-booking 400 and slot-conflict 409
@@ -154,6 +154,14 @@
- settings page "Kalendář (ICS)" card: copyable feed URLs per business/staff member, two-step rotate with confirmation, demo-mode placeholders pointing at real seeded tokens - settings page "Kalendář (ICS)" card: copyable feed URLs per business/staff member, two-step rotate with confirmation, demo-mode placeholders pointing at real seeded tokens
- verified live: feed info returns tenant+staff tokens, tenant feed renders the seeded booking with staff name in the summary, staff feed omits it, bogus token 404s, rotation kills the old URL and the new one serves the same feed - verified live: feed info returns tenant+staff tokens, tenant feed renders the seeded booking with staff name in the summary, staff feed omits it, bogus token 404s, rotation kills the old URL and the new one serves the same feed
- remaining: live staging validation on Neon + pgx repository path; Google Calendar two-way sync stays deferred (the ICS read direction covers the common ask) - remaining: live staging validation on Neon + pgx repository path; Google Calendar two-way sync stays deferred (the ICS read direction covers the common ask)
- `[done/partial]` Milestone 23: Class session-to-session move
- class bookings are session-bound, so rebooking is a move between sessions of the same template rather than a free reschedule — `GET /v1/public/bookings/{reference}/sessions` lists future same-template targets with capacity/confirmed/waitlist counts, `POST /v1/public/bookings/{reference}/move` performs the move under the manage token
- move semantics: confirmed when the target has a free seat, waitlisted when full; the old waitlist entry resolves, reminders re-arm for the new time, and freeing a confirmed seat drains the old session's queue through the existing promotion path
- rejects appointment bookings (they use the slot picker), cancelled/completed bookings, same-session moves, foreign-template or past sessions
- manage page: "Change session" / "Změnit lekci" action opens a target picker — date/time/location rows with free-seat or waitlist badges, per-row selection, localized success/error states; the current session is excluded from targets
- `SessionMoveTarget` schema + both endpoints documented in OpenAPI; generated client regenerated
- verified live + browser: created a second session of the demo template, moved a confirmed booking over HTTP (200 + new `classSessionId`), then repeated through the real manage page in Czech and English — success banner, updated date/time, 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.
@@ -404,7 +412,6 @@ Key entities:
Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle: 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. - **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.
- **Session-to-session move for classes**: M21 covered appointment rebooking; class customers currently must cancel and book another session. A "move to another session of the same template" flow (target session picker + capacity/waitlist handling) would close the gap.
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request. - **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
- **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. - **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. - **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.