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"`
}