feat(bookings): session-to-session move for class bookings

Class customers can move a booking to another future session of the
same template from the manage page. Full target sessions place the
booking on the waitlist; freeing a confirmed seat drains the old
session's queue via the existing promotion path. Reminders re-arm
for the new time.

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 15:42:04 +02:00
1 parent 6c3b2bf6bf
commit e5cb9bd543
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"})
})
// 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
// model as private calendar URLs from Google/Outlook.
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),
})
}
// 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)
UpdateBookingStatus(ctx context.Context, bookingID string, status string) 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
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
}
// 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)
// ============================================
@@ -2002,6 +2012,19 @@ func (r *MemoryRepository) RescheduleBooking(_ context.Context, bookingID string
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
// ============================================
+23
View File
@@ -512,6 +512,29 @@ type RescheduleBookingRequest struct {
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 {
Reason string `json:"reason,omitempty"`
}
+116
View File
@@ -396,6 +396,97 @@ paths:
description: Invalid manage token
"409":
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:
post:
tags: [Public Booking]
@@ -1902,6 +1993,31 @@ components:
waitlistPosition:
type: integer
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:
type: object
properties: