mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
1 parent
6c3b2bf6bf
commit
e5cb9bd543
10 files changed
+927
-10
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -11,9 +11,12 @@ export function BookingManageRoute() {
|
||||
const i18n = useI18n();
|
||||
const [isRescheduling, setIsRescheduling] = createSignal(false);
|
||||
const [isCancelling, setIsCancelling] = createSignal(false);
|
||||
const [isMoving, setIsMoving] = createSignal(false);
|
||||
const [newDate, setNewDate] = createSignal("");
|
||||
const [selectedSlot, setSelectedSlot] = createSignal<{ startsAt: string; endsAt: string } | null>(null);
|
||||
const [selectedSession, setSelectedSession] = createSignal<string | null>(null);
|
||||
const [rescheduleBusy, setRescheduleBusy] = createSignal(false);
|
||||
const [moveBusy, setMoveBusy] = createSignal(false);
|
||||
const [message, setMessage] = createSignal("");
|
||||
const [actionSuccess, setActionSuccess] = 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 () => {
|
||||
setActionSuccess(null);
|
||||
setActionError(null);
|
||||
@@ -353,6 +411,21 @@ export function BookingManageRoute() {
|
||||
{i18n.locale() === 'cs' ? 'Změnit termín' : 'Reschedule'}
|
||||
</Button>
|
||||
</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
|
||||
variant="secondary"
|
||||
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'}
|
||||
</Button>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -500,6 +566,82 @@ export function BookingManageRoute() {
|
||||
</DialogContent>
|
||||
</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 */}
|
||||
<Dialog open={isCancelling()} onClose={() => setIsCancelling(false)}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -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"}
|
||||
Reference in new issue
Block a user