From 65a693cf383d73e1989266d0e398b40204dce657 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Tue, 22 Sep 2026 20:59:34 +0200 Subject: [PATCH] feat: series-aware schedule edits + waitlist ledger removal (M32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATCH /v1/catalog/class-schedules/:id patches a recurring rule and, with applyToFuture, retimes materialized future sessions to the new local start time on their existing dates — moving confirmed and waitlisted bookings with them and re-arming reminders. Zones-page schedule rows gain an inline editor (CS/EN). Also drops the vestigial waitlist_entries table (migration 00020): positions derive from bookings.created_at, so the ledger was written but never read. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/backend/internal/api/server.go | 44 +++- .../internal/bookings/customer_service.go | 28 +-- .../bookings/customer_service_test.go | 11 - apps/backend/internal/bookings/lifecycle.go | 22 +- apps/backend/internal/bookings/service.go | 13 - apps/backend/internal/catalog/service.go | 136 ++++++++++- apps/backend/internal/catalog/service_test.go | 107 ++++++++ apps/backend/internal/db/bookings.go | 19 -- apps/backend/internal/db/class_schedules.go | 86 +++++++ apps/backend/internal/db/repository.go | 228 ++++++++++++------ apps/backend/internal/domain/models.go | 111 +++++---- .../00020_drop_waitlist_entries.sql | 18 ++ apps/backend/openapi/bookra.openapi.yaml | 53 ++++ apps/frontend/src/providers/i18n-provider.tsx | 10 + .../src/routes/dashboard/zones-page.tsx | 120 +++++++-- packages/api-client/src/generated/types.ts | 52 +++- project.md | 12 +- 17 files changed, 840 insertions(+), 230 deletions(-) create mode 100644 apps/backend/migrations/00020_drop_waitlist_entries.sql diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 037a4af..5327b5b 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -369,19 +369,19 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { server.router.POST("/v1/public/bookings", publicRateLimiter.Middleware(), func(c *gin.Context) { var request struct { - TenantSlug string `json:"tenantSlug" binding:"required"` - BookingMode string `json:"bookingMode" binding:"required"` - ServiceID *string `json:"serviceId"` - ClassSessionID *string `json:"classSessionId"` - StaffID *string `json:"staffId"` - LocationID *string `json:"locationId"` - CustomerName string `json:"customerName" binding:"required"` - CustomerEmail string `json:"customerEmail" binding:"required,email"` - CustomerPhone string `json:"customerPhone"` - Notes string `json:"notes"` + TenantSlug string `json:"tenantSlug" binding:"required"` + BookingMode string `json:"bookingMode" binding:"required"` + ServiceID *string `json:"serviceId"` + ClassSessionID *string `json:"classSessionId"` + StaffID *string `json:"staffId"` + LocationID *string `json:"locationId"` + CustomerName string `json:"customerName" binding:"required"` + CustomerEmail string `json:"customerEmail" binding:"required,email"` + CustomerPhone string `json:"customerPhone"` + Notes string `json:"notes"` Intake map[string]string `json:"intake,omitempty"` - StartsAt string `json:"startsAt" binding:"required"` - EndsAt string `json:"endsAt" binding:"required"` + StartsAt string `json:"startsAt" binding:"required"` + EndsAt string `json:"endsAt" binding:"required"` } if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) @@ -1148,6 +1148,26 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { } c.JSON(http.StatusCreated, response) }) + protected.PATCH("/catalog/class-schedules/:scheduleID", func(c *gin.Context) { + var request domain.UpdateClassScheduleRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + response, err := catalogService.UpdateClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("scheduleID"), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound), errors.Is(err, catalog.ErrLocationNotFound): + status = http.StatusNotFound + case errors.Is(err, catalog.ErrInvalidInput): + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) protected.DELETE("/catalog/class-schedules/:scheduleID", func(c *gin.Context) { err := catalogService.DeleteClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("scheduleID")) if err != nil { diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index 254a017..5c65e56 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -518,13 +518,10 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st if err != nil { return domain.MoveBookingResponse{}, err } - confirmed, waitlisted := 0, 0 + confirmed := 0 for _, other := range targetBookings { - switch other.Status { - case "confirmed": + if other.Status == "confirmed" { confirmed++ - case "waitlisted": - waitlisted++ } } @@ -534,30 +531,15 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st 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) + if newStatus == "confirmed" { + booking.StartsAt, booking.EndsAt = target.StartsAt, target.EndsAt + RearmReminderJobs(ctx, s.repo, booking.TenantID, booking) } // A confirmed move frees a seat on the old session — drain its queue. diff --git a/apps/backend/internal/bookings/customer_service_test.go b/apps/backend/internal/bookings/customer_service_test.go index a30c2c0..01d4762 100644 --- a/apps/backend/internal/bookings/customer_service_test.go +++ b/apps/backend/internal/bookings/customer_service_test.go @@ -164,17 +164,6 @@ func seedMoveBooking(t *testing.T, repo *db.MemoryRepository, sessionID, referen 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: "move@example.com", - Position: 1, - }); err != nil { - t.Fatalf("seed waitlist entry: %v", err) - } - } created, err := repo.CreateBooking(ctx, db.CreateBookingParams{ TenantID: moveTenantID, ClassSessionID: &sessionID, diff --git a/apps/backend/internal/bookings/lifecycle.go b/apps/backend/internal/bookings/lifecycle.go index 20f12bb..aba877c 100644 --- a/apps/backend/internal/bookings/lifecycle.go +++ b/apps/backend/internal/bookings/lifecycle.go @@ -50,7 +50,7 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI if session.Capacity > 0 && confirmed >= int(session.Capacity) { break } - if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil { + if err := confirmWaitlistedBooking(ctx, repo, tenantID, booking); err != nil { return promoted, err } confirmed++ @@ -104,24 +104,30 @@ func PromoteBooking(ctx context.Context, repo db.Repository, tenantID, bookingID } } - if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil { + if err := confirmWaitlistedBooking(ctx, repo, tenantID, booking); err != nil { return db.BookingRecord{}, err } booking.Status = "confirmed" return booking, nil } -// confirmWaitlistedBooking flips a waitlisted booking to confirmed, resolves -// the ledger entry, and re-arms reminder jobs — a booking promoted after its -// reminder already dispatched-as-skipped would otherwise go unreminded. -func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID, sessionID string, booking db.BookingRecord) error { +// confirmWaitlistedBooking flips a waitlisted booking to confirmed and +// re-arms reminder jobs — a booking promoted after its reminder already +// dispatched-as-skipped would otherwise go unreminded. +func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID string, booking db.BookingRecord) error { if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil { return err } - _ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail) + RearmReminderJobs(ctx, repo, tenantID, booking) + return nil +} + +// RearmReminderJobs clears pending reminders for a booking and re-arms them +// for its (possibly new) start time. Used after promotion, session moves, and +// schedule retimes so reminders always match the booking's live window. +func RearmReminderJobs(ctx context.Context, repo db.Repository, tenantID string, booking db.BookingRecord) { _ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID) _ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone) - return nil } // armReminderJobs schedules the email reminder — and the SMS reminder when the diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 2c77e03..7d943bd 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -365,19 +365,6 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques return domain.CreateBookingResponse{}, err } - if status == "waitlisted" && request.ClassSessionID != nil { - waitlistPosition := int(countClassBookings(existing, *request.ClassSessionID)) + 1 - if err := s.repo.AppendWaitlistEntry(ctx, db.WaitlistEntryParams{ - TenantID: tenant.ID, - ClassSessionID: *request.ClassSessionID, - CustomerName: customerName, - CustomerEmail: customerEmail, - Position: waitlistPosition, - }); err != nil { - return domain.CreateBookingResponse{}, err - } - } - if err := armReminderJobs(ctx, s.repo, tenant.ID, created.ID, startsAt, request.CustomerPhone); err != nil { return domain.CreateBookingResponse{}, err } diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index d4848d3..bf7604f 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -1387,17 +1387,10 @@ func (s *Service) CreateClassSchedule(ctx context.Context, principal domain.Prin if req.DayOfWeek < 0 || req.DayOfWeek > 6 { return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput) } - startsLocal := strings.TrimSpace(req.StartsLocal) - parts := strings.SplitN(startsLocal, ":", 2) - if len(parts) != 2 { - return domain.ClassSchedule{}, fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput) + startsLocal, err := normalizeScheduleLocalTime(req.StartsLocal) + if err != nil { + return domain.ClassSchedule{}, err } - hour, errH := strconv.Atoi(parts[0]) - minute, errM := strconv.Atoi(parts[1]) - if errH != nil || errM != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 { - return domain.ClassSchedule{}, fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput) - } - startsLocal = fmt.Sprintf("%02d:%02d:00", hour, minute) if req.LocationID != nil { location, err := s.repo.GetLocationByID(ctx, *req.LocationID) @@ -1445,6 +1438,129 @@ func (s *Service) CreateClassSchedule(ctx context.Context, principal domain.Prin return out, nil } +// UpdateClassSchedule patches a recurring rule. When ApplyToFuture is set and +// the time or location changed, already-materialized future sessions move to +// the new start time (same local date) and location, their live bookings +// follow, and confirmed bookings get reminders re-armed. A weekday change only +// steers sessions materialized afterwards — existing dates are kept. +func (s *Service) UpdateClassSchedule(ctx context.Context, principal domain.Principal, scheduleID string, req domain.UpdateClassScheduleRequest) (domain.ClassSchedule, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.ClassSchedule{}, ErrTenantMembership + } + tenantID := membership.Tenant.ID + + schedules, err := s.repo.ListClassSchedulesByTenant(ctx, tenantID) + if err != nil { + return domain.ClassSchedule{}, err + } + var current *db.ClassScheduleRecord + for i := range schedules { + if schedules[i].ID == scheduleID { + current = &schedules[i] + break + } + } + if current == nil { + return domain.ClassSchedule{}, ErrClassSessionNotFound + } + + params := db.UpdateClassScheduleParams{ + DayOfWeek: req.DayOfWeek, + LocationID: req.LocationID, + Active: req.Active, + } + if req.DayOfWeek != nil && (*req.DayOfWeek < 0 || *req.DayOfWeek > 6) { + return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput) + } + var startsLocal *string + if req.StartsLocal != nil { + normalized, err := normalizeScheduleLocalTime(*req.StartsLocal) + if err != nil { + return domain.ClassSchedule{}, err + } + startsLocal = &normalized + params.StartsLocal = startsLocal + } + if req.LocationID != nil && *req.LocationID != "" { + location, err := s.repo.GetLocationByID(ctx, *req.LocationID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ClassSchedule{}, ErrLocationNotFound + } + return domain.ClassSchedule{}, err + } + if location.TenantID != tenantID { + return domain.ClassSchedule{}, ErrLocationNotFound + } + } + + rec, err := s.repo.UpdateClassSchedule(ctx, tenantID, scheduleID, params) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return domain.ClassSchedule{}, ErrClassSessionNotFound + } + return domain.ClassSchedule{}, err + } + + if req.ApplyToFuture && (startsLocal != nil || req.LocationID != nil) { + now := time.Now().UTC() + if err := s.repo.RetimeFutureScheduleSessions(ctx, tenantID, scheduleID, startsLocal, req.LocationID, now); err != nil { + return domain.ClassSchedule{}, err + } + // Re-arm reminders for confirmed bookings on the moved sessions. + sessions, err := s.repo.ListClassSessionsBySchedule(ctx, tenantID, scheduleID, now) + if err != nil { + return domain.ClassSchedule{}, err + } + for _, session := range sessions { + sessionBookings, err := s.repo.ListBookingsBySession(ctx, session.ID) + if err != nil { + continue + } + for _, booking := range sessionBookings { + if booking.Status == "confirmed" { + bookings.RearmReminderJobs(ctx, s.repo, tenantID, booking) + } + } + } + } + + template, _ := s.repo.GetClassTemplateByID(ctx, rec.TemplateID) + out := domain.ClassSchedule{ + ID: rec.ID, + TenantID: rec.TenantID, + TemplateID: rec.TemplateID, + TemplateTitle: template.Title, + LocationID: rec.LocationID, + DayOfWeek: rec.DayOfWeek, + StartsLocal: rec.StartsLocal, + Active: rec.Active, + } + if rec.LocationID != nil { + if location, err := s.repo.GetLocationByID(ctx, *rec.LocationID); err == nil { + out.LocationName = location.Name + } + } + return out, nil +} + +// normalizeScheduleLocalTime validates "HH:MM" and returns the canonical +// "HH:MM:SS" form the schedules table stores. +func normalizeScheduleLocalTime(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + parts := strings.SplitN(trimmed, ":", 2) + if len(parts) != 2 { + return "", fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput) + } + hour, errH := strconv.Atoi(parts[0]) + minute, errM := strconv.Atoi(parts[1]) + if errH != nil || errM != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return "", fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput) + } + return fmt.Sprintf("%02d:%02d:00", hour, minute), nil +} + // DeleteClassSchedule removes a recurring rule. Sessions already materialized // stay in place; no new ones are written. func (s *Service) DeleteClassSchedule(ctx context.Context, principal domain.Principal, scheduleID string) error { diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go index d4c61ae..ec6f636 100644 --- a/apps/backend/internal/catalog/service_test.go +++ b/apps/backend/internal/catalog/service_test.go @@ -886,3 +886,110 @@ func TestCreateClassScheduleValidation(t *testing.T) { t.Fatalf("expected ErrClassTemplateNotFound, got %v", err) } } + +func TestUpdateClassScheduleRetimesFutureSessions(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148" + templateID := "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7" // 60min mobility class + + created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{ + TemplateID: templateID, DayOfWeek: 1, StartsLocal: "10:00", + }) + if err != nil { + t.Fatalf("create schedule: %v", err) + } + sessions, err := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC()) + if err != nil || len(sessions) == 0 { + t.Fatalf("expected materialized sessions, got %d err=%v", len(sessions), err) + } + // Seed a confirmed booking on the first session. + target := sessions[0] + if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{ + TenantID: tenantID, + ClassSessionID: &target.ID, + BookingMode: "class", + CustomerName: "Mover", + CustomerEmail: "mover@example.com", + StartsAt: target.StartsAt, + EndsAt: target.EndsAt, + Status: "confirmed", + Reference: "BK-MOVE-1", + }); err != nil { + t.Fatalf("seed booking: %v", err) + } + originalLocalHour := target.StartsAt.In(time.FixedZone("x", 0)).UTC().In(time.Local).Hour() + _ = originalLocalHour + + newTime := "15:30" + updated, err := svc.UpdateClassSchedule(ctx, owner, created.ID, domain.UpdateClassScheduleRequest{ + StartsLocal: &newTime, + ApplyToFuture: true, + }) + if err != nil { + t.Fatalf("update schedule: %v", err) + } + if updated.StartsLocal != "15:30:00" { + t.Fatalf("expected normalized 15:30:00, got %s", updated.StartsLocal) + } + + loc, _ := time.LoadLocation("Europe/Prague") + moved, err := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC()) + if err != nil || len(moved) == 0 { + t.Fatalf("expected moved sessions, got %d err=%v", len(moved), err) + } + for _, session := range moved { + local := session.StartsAt.In(loc) + if local.Hour() != 15 || local.Minute() != 30 { + t.Fatalf("session not retimed: %v local=%v", session.StartsAt, local) + } + if session.EndsAt.Sub(session.StartsAt) != 60*time.Minute { + t.Fatalf("expected 60min duration preserved, got %v", session.EndsAt.Sub(session.StartsAt)) + } + // Same local date as before — only the time-of-day moved. + if local.Weekday() != time.Monday { + t.Fatalf("session changed weekday: %v", local.Weekday()) + } + } + + // The seeded booking must follow its session's new window. + booking, err := repo.GetBookingByReference(ctx, "BK-MOVE-1") + if err != nil { + t.Fatalf("fetch booking: %v", err) + } + if !booking.StartsAt.Equal(moved[0].StartsAt) || !booking.EndsAt.Equal(moved[0].EndsAt) { + t.Fatalf("booking not synced: %v–%v vs session %v–%v", booking.StartsAt, booking.EndsAt, moved[0].StartsAt, moved[0].EndsAt) + } +} + +func TestUpdateClassScheduleWithoutCascade(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148" + + created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{ + TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 1, StartsLocal: "10:00", + }) + if err != nil { + t.Fatalf("create schedule: %v", err) + } + before, _ := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC()) + + newTime := "16:00" + if _, err := svc.UpdateClassSchedule(ctx, owner, created.ID, domain.UpdateClassScheduleRequest{ + StartsLocal: &newTime, + }); err != nil { + t.Fatalf("update without cascade: %v", err) + } + after, _ := repo.ListClassSessionsBySchedule(ctx, tenantID, created.ID, time.Now().UTC()) + if len(after) != len(before) { + t.Fatalf("session count changed without cascade: %d -> %d", len(before), len(after)) + } + for i := range after { + if !after[i].StartsAt.Equal(before[i].StartsAt) { + t.Fatal("session moved without applyToFuture") + } + } +} diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 9376173..933a3ed 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -127,25 +127,6 @@ func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingPa return created, err } -func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error { - _, err := r.pool.Exec(ctx, ` - INSERT INTO waitlist_entries (tenant_id, class_session_id, customer_name, customer_email, position) - VALUES ($1,$2,$3,$4,$5) - `, params.TenantID, params.ClassSessionID, params.CustomerName, params.CustomerEmail, params.Position) - return err -} - -// ResolveWaitlistEntry marks the pending ledger row for a promoted booking. -// Entries carry no booking_id, so the session + customer email pair is the -// practical key. -func (r *PGRepository) ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error { - _, err := r.pool.Exec(ctx, ` - UPDATE waitlist_entries SET status = 'resolved' - WHERE tenant_id = $1 AND class_session_id = $2 AND customer_email = $3 AND status = 'pending' - `, tenantID, sessionID, customerEmail) - return err -} - func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error { _, err := r.pool.Exec(ctx, ` INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for) diff --git a/apps/backend/internal/db/class_schedules.go b/apps/backend/internal/db/class_schedules.go index 539f4b3..51b4976 100644 --- a/apps/backend/internal/db/class_schedules.go +++ b/apps/backend/internal/db/class_schedules.go @@ -99,3 +99,89 @@ func (r *PGRepository) MaterializeScheduleSessions(ctx context.Context, tenantID `, tenantID, now, days) return err } + +// UpdateClassSchedule edits an existing recurring rule; nil fields are kept. +// The tenant guard makes the update a no-match error for foreign schedules. +func (r *PGRepository) UpdateClassSchedule(ctx context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error) { + var record ClassScheduleRecord + err := r.pool.QueryRow(ctx, ` + UPDATE class_schedules SET + day_of_week = COALESCE($3, day_of_week), + starts_local = COALESCE($4::time, starts_local), + location_id = CASE WHEN $5::text IS NULL THEN location_id WHEN $5::text = '' THEN NULL ELSE $5::uuid END, + active = COALESCE($6, active) + WHERE id = $1 AND tenant_id = $2 + RETURNING `+classScheduleColumns+` + `, scheduleID, tenantID, params.DayOfWeek, params.StartsLocal, params.LocationID, params.Active).Scan( + &record.ID, + &record.TenantID, + &record.TemplateID, + &record.LocationID, + &record.DayOfWeek, + &record.StartsLocal, + &record.Active, + &record.CreatedAt, + ) + return record, err +} + +// ListClassSessionsBySchedule returns materialized sessions of one rule, +// optionally only those starting after `after`. +func (r *PGRepository) ListClassSessionsBySchedule(ctx context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error) { + rows, err := r.pool.Query(ctx, ` + SELECT `+classSessionColumns+` + FROM class_sessions cs + INNER JOIN class_templates ct ON ct.id = cs.template_id + WHERE cs.tenant_id = $1 AND cs.schedule_id = $2 AND cs.starts_at > $3 + ORDER BY cs.starts_at ASC + `, tenantID, scheduleID, after) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []ClassSessionRecord + for rows.Next() { + var record ClassSessionRecord + if err := rows.Scan( + &record.ID, + &record.TenantID, + &record.TemplateID, + &record.LocationID, + &record.Title, + &record.StartsAt, + &record.EndsAt, + &record.Capacity, + &record.ScheduleID, + ); err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + +// RetimeFutureScheduleSessions shifts each future session of a rule to a new +// local start time (keeping its calendar date) and optionally a new location. +// Live bookings on those sessions are moved to the same window so reminders +// and calendars stay in sync. A nil startsLocal keeps session times. +func (r *PGRepository) RetimeFutureScheduleSessions(ctx context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) error { + _, err := r.pool.Exec(ctx, ` + WITH moved AS ( + UPDATE class_sessions cs SET + starts_at = CASE WHEN $4::time IS NULL THEN cs.starts_at + ELSE ((cs.starts_at AT TIME ZONE t.timezone)::date + $4::time) AT TIME ZONE t.timezone END, + ends_at = CASE WHEN $4::time IS NULL THEN cs.ends_at + ELSE ((cs.starts_at AT TIME ZONE t.timezone)::date + $4::time) AT TIME ZONE t.timezone + make_interval(mins => tpl.duration_minutes) END, + location_id = CASE WHEN $5::text IS NULL THEN cs.location_id WHEN $5::text = '' THEN NULL ELSE $5::uuid END + FROM tenants t, class_templates tpl + WHERE cs.tenant_id = $1 AND cs.schedule_id = $2 AND cs.starts_at > $3 + AND t.id = cs.tenant_id AND tpl.id = cs.template_id + RETURNING cs.id, cs.starts_at, cs.ends_at + ) + UPDATE bookings b SET starts_at = m.starts_at, ends_at = m.ends_at + FROM moved m + WHERE b.class_session_id = m.id AND b.status IN ('confirmed', 'waitlisted') + `, tenantID, scheduleID, now, startsLocal, locationID) + return err +} diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 3972636..aacfc5c 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -37,13 +37,14 @@ type Repository interface { DeleteClassTemplate(ctx context.Context, templateID string) error ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error) CreateClassSchedule(ctx context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error) + UpdateClassSchedule(ctx context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error) DeleteClassSchedule(ctx context.Context, tenantID string, scheduleID string) error MaterializeScheduleSessions(ctx context.Context, tenantID string, now time.Time, days int) error + ListClassSessionsBySchedule(ctx context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error) + RetimeFutureScheduleSessions(ctx context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) error ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) - AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error - ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error CreateReminderJob(ctx context.Context, params ReminderJobParams) error ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error @@ -278,8 +279,8 @@ type ServiceRecord struct { PriceCents int // IntakeFields is the raw JSON list of declared structured intake fields; // the catalog service parses and validates it into domain.IntakeField. - IntakeFields string - CreatedAt time.Time + IntakeFields string + CreatedAt time.Time } type CreateServiceParams struct { @@ -343,14 +344,14 @@ type ActivityEventRecord struct { } type ActivityEventParams struct { - TenantID string - ActorKind string + TenantID string + ActorKind string ActorSubject string - ActorName string - Action string - EntityType string - EntityID *string - Summary string + ActorName string + Action string + EntityType string + EntityID *string + Summary string } type CreateStaffParams struct { @@ -418,14 +419,14 @@ type CreateClassSessionParams struct { // ClassScheduleRecord is a weekly recurring rule that materializes concrete // class_sessions rows. DayOfWeek follows Go's time.Weekday (Sunday=0). type ClassScheduleRecord struct { - ID string - TenantID string - TemplateID string - LocationID *string - DayOfWeek int + ID string + TenantID string + TemplateID string + LocationID *string + DayOfWeek int StartsLocal string - Active bool - CreatedAt time.Time + Active bool + CreatedAt time.Time } type CreateClassScheduleParams struct { @@ -436,6 +437,15 @@ type CreateClassScheduleParams struct { StartsLocal string } +// UpdateClassScheduleParams carries optional rule fields; nil means keep. +// LocationID nil keeps the location, a pointer to "" clears it. +type UpdateClassScheduleParams struct { + DayOfWeek *int + StartsLocal *string + LocationID *string + Active *bool +} + type BookingRecord struct { ID string TenantID string @@ -453,8 +463,8 @@ type BookingRecord struct { ManageToken string Notes string // Intake is the raw JSON object of structured intake answers. - Intake string - CreatedAt time.Time + Intake string + CreatedAt time.Time } type CreateBookingParams struct { @@ -483,14 +493,6 @@ type CreatedBooking struct { ManageToken string } -type WaitlistEntryParams struct { - TenantID string - ClassSessionID string - CustomerName string - CustomerEmail string - Position int -} - type DashboardMetrics struct { BookingsCount int CancellationsCount int @@ -878,32 +880,31 @@ func (r *PGRepository) UpdateBookingSession(ctx context.Context, bookingID strin // ============================================ type MemoryRepository struct { - tenant TenantRecord - membership TenantMembershipRecord - brand BrandProfileRecord - services []ServiceRecord - rules []AvailabilityRuleRecord - classTemplates []ClassTemplateRecord - classSessions []ClassSessionRecord - classSchedules []ClassScheduleRecord - bookings []BookingRecord - waitlistSize int - reminderJobs []ReminderJobRecord - deliveryLogs []NotificationDeliveryLogParams - billingSnapshot BillingSnapshotRecord - recordedEvents map[string]struct{} - locations []LocationRecord - staff []StaffRecord - blockedDays []BlockedDayRecord - customers []CustomerRecord - workingHours []WorkingHoursRecord - smsSettings TenantSMSSettingsRecord - smsLogs []SMSUsageLogRecord - smsReports []SMSMonthlyReportRecord - emailTemplates []EmailTemplateRecord - tenantFeedToken string - staffFeedTokens map[string]string - activityEvents []ActivityEventRecord + tenant TenantRecord + membership TenantMembershipRecord + brand BrandProfileRecord + services []ServiceRecord + rules []AvailabilityRuleRecord + classTemplates []ClassTemplateRecord + classSessions []ClassSessionRecord + classSchedules []ClassScheduleRecord + bookings []BookingRecord + reminderJobs []ReminderJobRecord + deliveryLogs []NotificationDeliveryLogParams + billingSnapshot BillingSnapshotRecord + recordedEvents map[string]struct{} + locations []LocationRecord + staff []StaffRecord + blockedDays []BlockedDayRecord + customers []CustomerRecord + workingHours []WorkingHoursRecord + smsSettings TenantSMSSettingsRecord + smsLogs []SMSUsageLogRecord + smsReports []SMSMonthlyReportRecord + emailTemplates []EmailTemplateRecord + tenantFeedToken string + staffFeedTokens map[string]string + activityEvents []ActivityEventRecord notificationCursors map[string]time.Time } @@ -1582,6 +1583,109 @@ func (r *MemoryRepository) DeleteClassSchedule(_ context.Context, tenantID strin return fmt.Errorf("class schedule not found") } +// UpdateClassSchedule mirrors the PG implementation: nil fields are kept, +// a LocationID pointer to "" clears the location. +func (r *MemoryRepository) UpdateClassSchedule(_ context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error) { + for i, schedule := range r.classSchedules { + if schedule.ID != scheduleID || schedule.TenantID != tenantID { + continue + } + if params.DayOfWeek != nil { + r.classSchedules[i].DayOfWeek = *params.DayOfWeek + } + if params.StartsLocal != nil { + r.classSchedules[i].StartsLocal = normalizeLocalTime(*params.StartsLocal) + } + if params.LocationID != nil { + if *params.LocationID == "" { + r.classSchedules[i].LocationID = nil + } else { + loc := *params.LocationID + r.classSchedules[i].LocationID = &loc + } + } + if params.Active != nil { + r.classSchedules[i].Active = *params.Active + } + return r.classSchedules[i], nil + } + return ClassScheduleRecord{}, fmt.Errorf("class schedule not found") +} + +// ListClassSessionsBySchedule mirrors the PG implementation. +func (r *MemoryRepository) ListClassSessionsBySchedule(_ context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error) { + if tenantID != r.tenant.ID { + return nil, nil + } + var out []ClassSessionRecord + for _, session := range r.classSessions { + if session.ScheduleID != nil && *session.ScheduleID == scheduleID && session.StartsAt.After(after) { + out = append(out, session) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].StartsAt.Before(out[j].StartsAt) }) + return out, nil +} + +// RetimeFutureScheduleSessions mirrors the PG implementation: future sessions +// of the rule keep their local date but move to the new start time and +// template duration, and live bookings on them follow. +func (r *MemoryRepository) RetimeFutureScheduleSessions(_ context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) error { + if tenantID != r.tenant.ID { + return nil + } + loc, err := time.LoadLocation(r.tenant.Timezone) + if err != nil { + loc = time.UTC + } + var hour, minute int + if startsLocal != nil { + parts := strings.Split(normalizeLocalTime(*startsLocal), ":") + if len(parts) < 2 { + return nil + } + hour, _ = strconv.Atoi(parts[0]) + minute, _ = strconv.Atoi(parts[1]) + } + for i := range r.classSessions { + session := &r.classSessions[i] + if session.ScheduleID == nil || *session.ScheduleID != scheduleID || !session.StartsAt.After(now) { + continue + } + if startsLocal != nil { + day := session.StartsAt.In(loc) + newStart := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc) + var duration time.Duration + for _, tpl := range r.classTemplates { + if tpl.ID == session.TemplateID { + duration = time.Duration(tpl.DurationMinutes) * time.Minute + break + } + } + session.StartsAt = newStart + session.EndsAt = newStart.Add(duration) + } + if locationID != nil && *locationID != "" { + loc := *locationID + session.LocationID = &loc + } + } + for i := range r.bookings { + booking := &r.bookings[i] + if booking.ClassSessionID == nil || (booking.Status != "confirmed" && booking.Status != "waitlisted") { + continue + } + for _, session := range r.classSessions { + if session.ID == *booking.ClassSessionID && session.ScheduleID != nil && *session.ScheduleID == scheduleID { + booking.StartsAt = session.StartsAt + booking.EndsAt = session.EndsAt + break + } + } + } + return nil +} + // MaterializeScheduleSessions mirrors the PG implementation: for each active // rule, concrete sessions are added for matching weekdays within the next // `days` days. Sessions already materialized for a rule+start are skipped. @@ -1802,18 +1906,6 @@ func (r *MemoryRepository) seedDemoBookings() { r.bookings[0].Intake = `{"health_notes":"Sensitive skin","first_visit":"yes"}` } -func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error { - r.waitlistSize++ - return nil -} - -func (r *MemoryRepository) ResolveWaitlistEntry(_ context.Context, _, _, _ string) error { - if r.waitlistSize > 0 { - r.waitlistSize-- - } - return nil -} - func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error { reference := params.BookingID startsAt := time.Now().UTC() @@ -2489,8 +2581,8 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string } } r.workingHours = append(r.workingHours, WorkingHoursRecord{ - TenantID: tenantID, - DayOfWeek: dayOfWeek, + TenantID: tenantID, + DayOfWeek: dayOfWeek, StartsLocal: *params.StartsLocal, EndsLocal: *params.EndsLocal, }) diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 05d640a..c3db1fd 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -18,23 +18,23 @@ type DashboardKPI struct { } type UpcomingBooking struct { - ID string `json:"id"` - Reference string `json:"reference"` - CustomerName string `json:"customerName"` - CustomerEmail string `json:"customerEmail"` - Service string `json:"service,omitempty"` - Location string `json:"location,omitempty"` - StaffName string `json:"staffName,omitempty"` - Notes string `json:"notes,omitempty"` - Intake map[string]string `json:"intake,omitempty"` + ID string `json:"id"` + Reference string `json:"reference"` + CustomerName string `json:"customerName"` + CustomerEmail string `json:"customerEmail"` + Service string `json:"service,omitempty"` + Location string `json:"location,omitempty"` + StaffName string `json:"staffName,omitempty"` + Notes string `json:"notes,omitempty"` + Intake map[string]string `json:"intake,omitempty"` // IntakeLabels maps Intake keys to the service's declared field labels. IntakeLabels map[string]string `json:"intakeLabels,omitempty"` - StartsAt time.Time `json:"startsAt"` - EndsAt time.Time `json:"endsAt"` - Status string `json:"status"` - Label string `json:"label,omitempty"` - ClassSessionID *string `json:"classSessionId,omitempty"` - WaitlistPosition int `json:"waitlistPosition,omitempty"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + Status string `json:"status"` + Label string `json:"label,omitempty"` + ClassSessionID *string `json:"classSessionId,omitempty"` + WaitlistPosition int `json:"waitlistPosition,omitempty"` } type WidgetSnippet struct { @@ -169,9 +169,9 @@ type CreateBookingRequest struct { Notes string `json:"notes"` // Intake carries structured intake answers keyed by the service's // declared IntakeField keys; only declared keys are stored. - Intake map[string]string `json:"intake,omitempty"` - StartsAt string `json:"startsAt"` - EndsAt string `json:"endsAt"` + Intake map[string]string `json:"intake,omitempty"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt"` } type CreateBookingResponse struct { @@ -334,11 +334,11 @@ type CreateServiceRequest struct { } type UpdateServiceRequest struct { - Name *string `json:"name,omitempty"` - DurationMinutes *int `json:"durationMinutes,omitempty"` - BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"` - BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"` - PriceCents *int `json:"priceCents,omitempty"` + Name *string `json:"name,omitempty"` + DurationMinutes *int `json:"durationMinutes,omitempty"` + BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"` + BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"` + PriceCents *int `json:"priceCents,omitempty"` IntakeFields *[]IntakeField `json:"intakeFields,omitempty"` } @@ -413,16 +413,16 @@ type UpsertClassTemplateRequest struct { } type ClassSession struct { - ID string `json:"id"` - TenantID string `json:"tenantId"` - TemplateID string `json:"templateId"` - TemplateTitle string `json:"templateTitle"` - LocationID *string `json:"locationId,omitempty"` - LocationName string `json:"locationName,omitempty"` - StartsAt time.Time `json:"startsAt"` - EndsAt time.Time `json:"endsAt"` - Capacity int `json:"capacity"` - BookedCount int `json:"bookedCount"` + ID string `json:"id"` + TenantID string `json:"tenantId"` + TemplateID string `json:"templateId"` + TemplateTitle string `json:"templateTitle"` + LocationID *string `json:"locationId,omitempty"` + LocationName string `json:"locationName,omitempty"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + Capacity int `json:"capacity"` + BookedCount int `json:"bookedCount"` // WaitlistCount is the number of customers queued for a free seat. WaitlistCount int `json:"waitlistCount"` // ScheduleID links the session back to the recurring rule that generated @@ -492,6 +492,19 @@ type CreateClassScheduleRequest struct { StartsLocal string `json:"startsLocal" binding:"required"` // "HH:MM" } +// UpdateClassScheduleRequest patches a recurring rule; nil fields keep their +// current values. A LocationID of "" clears the location. ApplyToFuture +// additionally retimes already-materialized future sessions (and their live +// bookings) to the new start time and location; a day-of-week change only +// affects sessions materialized afterwards. +type UpdateClassScheduleRequest struct { + DayOfWeek *int `json:"dayOfWeek"` + StartsLocal *string `json:"startsLocal"` // "HH:MM" + LocationID *string `json:"locationId"` + Active *bool `json:"active"` + ApplyToFuture bool `json:"applyToFuture"` +} + // ============================================ // CUSTOMER MODELS // ============================================ @@ -530,25 +543,25 @@ type UpdateCustomerRequest struct { // ============================================ type CustomerBookingView struct { - Reference string `json:"reference"` - CustomerName string `json:"customerName"` - CustomerEmail string `json:"customerEmail"` - Service string `json:"service"` - BusinessName string `json:"businessName"` - TenantSlug string `json:"tenantSlug,omitempty"` - ServiceID *string `json:"serviceId,omitempty"` - StaffID *string `json:"staffId,omitempty"` + Reference string `json:"reference"` + CustomerName string `json:"customerName"` + CustomerEmail string `json:"customerEmail"` + Service string `json:"service"` + BusinessName string `json:"businessName"` + TenantSlug string `json:"tenantSlug,omitempty"` + ServiceID *string `json:"serviceId,omitempty"` + StaffID *string `json:"staffId,omitempty"` // ClassSessionID identifies session-bound bookings; they cannot be // rescheduled to arbitrary times (see RescheduleBooking). - ClassSessionID *string `json:"classSessionId,omitempty"` - StartsAt time.Time `json:"startsAt"` - EndsAt time.Time `json:"endsAt"` - Location string `json:"location"` - Status string `json:"status"` - Notes string `json:"notes,omitempty"` - Intake map[string]string `json:"intake,omitempty"` + ClassSessionID *string `json:"classSessionId,omitempty"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + Location string `json:"location"` + Status string `json:"status"` + Notes string `json:"notes,omitempty"` + Intake map[string]string `json:"intake,omitempty"` // IntakeLabels maps Intake keys to the service's declared field labels. - IntakeLabels map[string]string `json:"intakeLabels,omitempty"` + IntakeLabels map[string]string `json:"intakeLabels,omitempty"` // WaitlistPosition is the 1-based queue spot for waitlisted class // bookings; zero for any other status. WaitlistPosition int `json:"waitlistPosition,omitempty"` diff --git a/apps/backend/migrations/00020_drop_waitlist_entries.sql b/apps/backend/migrations/00020_drop_waitlist_entries.sql new file mode 100644 index 0000000..ae5e04f --- /dev/null +++ b/apps/backend/migrations/00020_drop_waitlist_entries.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- waitlist_entries was written on waitlist join/promotion but never read: +-- every displayed position derives from bookings.created_at ordering via +-- GetWaitlistPosition. Dropping the dead ledger rather than keeping a +-- write-only table that implies state it does not hold. +DROP TABLE IF EXISTS waitlist_entries; + +-- +goose Down +CREATE TABLE IF NOT EXISTS waitlist_entries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + class_session_id uuid NOT NULL REFERENCES class_sessions(id) ON DELETE CASCADE, + customer_name text NOT NULL, + customer_email text NOT NULL, + position integer NOT NULL, + status text NOT NULL DEFAULT 'pending', + created_at timestamptz NOT NULL DEFAULT now() +); diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index cdd5f06..5774266 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -1395,6 +1395,35 @@ paths: "404": description: Template or location not found /v1/catalog/class-schedules/{scheduleID}: + patch: + tags: [Catalog] + operationId: updateClassSchedule + security: + - bearerAuth: [] + parameters: + - in: path + name: scheduleID + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateClassScheduleRequest" + responses: + "200": + description: Updated class schedule + content: + application/json: + schema: + $ref: "#/components/schemas/ClassSchedule" + "400": + description: Invalid request + "404": + description: Class schedule not found delete: tags: [Catalog] operationId: deleteClassSchedule @@ -3054,6 +3083,30 @@ components: startsLocal: type: string description: Tenant-local start time, HH:MM + UpdateClassScheduleRequest: + type: object + description: >- + Patches a recurring rule; absent fields are kept. locationId set to the + empty string clears the location. When applyToFuture is true and time or + location changed, already-materialized future sessions move to the new + start time (keeping their local date) and location, and their live + bookings follow. A dayOfWeek change only steers sessions materialized + afterwards. + properties: + dayOfWeek: + type: integer + minimum: 0 + maximum: 6 + startsLocal: + type: string + description: Tenant-local start time, HH:MM + locationId: + type: string + description: Location UUID, or empty string to clear + active: + type: boolean + applyToFuture: + type: boolean PlanEntitlements: type: object required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index d52f132..226c096 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -452,6 +452,11 @@ const dictionaries = { "dashboard.class.startsLocal": "Čas", "dashboard.class.addSchedule": "Přidat pravidlo", "dashboard.class.noSchedules": "Zatím žádná opakující se pravidla.", + "dashboard.class.editSchedule": "Upravit pravidlo", + "dashboard.class.applyToFuture": "Použít na budoucí lekce", + "dashboard.class.applyToFutureHint": "Přesune již vytvořené lekce na nový čas a místo, rezervace se přesunou s nimi.", + "dashboard.class.ruleActive": "Pravidlo je aktivní", + "dashboard.class.inactive": "neaktivní", "dashboard.class.dow.0": "Neděle", "dashboard.class.dow.1": "Pondělí", "dashboard.class.dow.2": "Úterý", @@ -1083,6 +1088,11 @@ const dictionaries = { "dashboard.class.startsLocal": "Time", "dashboard.class.addSchedule": "Add rule", "dashboard.class.noSchedules": "No recurring rules yet.", + "dashboard.class.editSchedule": "Edit rule", + "dashboard.class.applyToFuture": "Apply to future sessions", + "dashboard.class.applyToFutureHint": "Moves already-created sessions to the new time and location; bookings move with them.", + "dashboard.class.ruleActive": "Rule is active", + "dashboard.class.inactive": "inactive", "dashboard.class.dow.0": "Sunday", "dashboard.class.dow.1": "Monday", "dashboard.class.dow.2": "Tuesday", diff --git a/apps/frontend/src/routes/dashboard/zones-page.tsx b/apps/frontend/src/routes/dashboard/zones-page.tsx index c12a884..bd60b1e 100644 --- a/apps/frontend/src/routes/dashboard/zones-page.tsx +++ b/apps/frontend/src/routes/dashboard/zones-page.tsx @@ -3,7 +3,7 @@ import { apiClient } from "../../lib/api-client"; import { Input } from "../../components/ui/input"; import { Select } from "../../components/ui/select"; import { getInitials } from "../../components/dashboard/types"; -import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon } from "../../components/dashboard/icons"; +import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon, CheckIcon, XIcon } from "../../components/dashboard/icons"; import { DashboardLayout, useDashboardData } from "./layout"; function ZonesPage() { @@ -35,6 +35,13 @@ function ZonesPage() { const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal(""); const [newSchedLocationId, setNewSchedLocationId] = createSignal(""); const [schedSaving, setSchedSaving] = createSignal(false); + const [editingSchedId, setEditingSchedId] = createSignal(null); + const [editSchedDayOfWeek, setEditSchedDayOfWeek] = createSignal("1"); + const [editSchedStartsLocal, setEditSchedStartsLocal] = createSignal(""); + const [editSchedLocationId, setEditSchedLocationId] = createSignal(""); + const [editSchedApplyFuture, setEditSchedApplyFuture] = createSignal(true); + const [editSchedActive, setEditSchedActive] = createSignal(true); + const [schedEditSaving, setSchedEditSaving] = createSignal(false); const [expandedSessionId, setExpandedSessionId] = createSignal(null); const [promoteBusy, setPromoteBusy] = createSignal(null); // Booking IDs whose promote attempt hit a full session — awaiting the @@ -142,8 +149,8 @@ function ZonesPage() { if (!bearer || tab !== "classes") return []; if (bearer.startsWith("demo.")) { return [ - { id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true }, - { id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true }, + { id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationId: "z1", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true }, + { id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationId: "z1", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true }, ]; } const response = await (apiClient as any).GET("/v1/catalog/class-schedules", authHeaders()); @@ -405,6 +412,42 @@ function ZonesPage() { } }; + const startEditSchedule = (sch: any) => { + setEditingSchedId(sch.id); + setEditSchedDayOfWeek(String(sch.dayOfWeek)); + setEditSchedStartsLocal(String(sch.startsLocal).slice(0, 5)); + setEditSchedLocationId(sch.locationId ?? ""); + setEditSchedApplyFuture(true); + setEditSchedActive(sch.active !== false); + }; + + const handleSaveSchedule = async () => { + const bearer = data.token(); + const id = editingSchedId(); + if (!id) return; + if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; } + setSchedEditSaving(true); + try { + await (apiClient as any).PATCH(`/v1/catalog/class-schedules/${id}`, { + ...authHeaders(), + body: { + dayOfWeek: parseInt(editSchedDayOfWeek(), 10), + startsLocal: editSchedStartsLocal(), + locationId: editSchedLocationId(), + active: editSchedActive(), + applyToFuture: editSchedApplyFuture(), + }, + }); + setEditingSchedId(null); + void refetchSchedules(); + void refetchSessions(); + } catch { + data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed."); + } finally { + setSchedEditSaving(false); + } + }; + const handleDeleteSchedule = async (id: string) => { const bearer = data.token(); if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; } @@ -713,19 +756,68 @@ function ZonesPage() {
{(sch: any) => ( -
-
-
-
-

{sch.templateTitle}

-

- {`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`} - {sch.locationName ? ` • ${sch.locationName}` : ""} -

+ +
+
+
+

{sch.templateTitle}

+

+ {`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`} + {sch.locationName ? ` • ${sch.locationName}` : ""} + {!sch.active ? ` • ${data.i18n.t("dashboard.class.inactive")}` : ""} +

+
+
+
+ + +
+
+ } + > +
+
+ setEditSchedStartsLocal(e.currentTarget.value)} /> + setEditSchedActive(e.currentTarget.checked)} class="mt-0.5 accent-accent" /> + {data.i18n.t("dashboard.class.ruleActive")} + + +
+ +
- -
+ )}
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 71f7fea..deb5459 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -429,7 +429,7 @@ export interface paths { delete: operations["deleteClassSchedule"]; options?: never; head?: never; - patch?: never; + patch: operations["updateClassSchedule"]; trace?: never; }; "/v1/catalog/class-sessions": { @@ -1912,6 +1912,16 @@ export interface components { primaryColor?: string; siteUrl?: string; }; + /** @description Patches a recurring rule; absent fields are kept. locationId set to the empty string clears the location. When applyToFuture is true and time or location changed, already-materialized future sessions move to the new start time (keeping their local date) and location, and their live bookings follow. A dayOfWeek change only steers sessions materialized afterwards. */ + UpdateClassScheduleRequest: { + active?: boolean; + applyToFuture?: boolean; + dayOfWeek?: number; + /** @description Location UUID, or empty string to clear */ + locationId?: string; + /** @description Tenant-local start time, HH:MM */ + startsLocal?: string; + }; UpdateEmailTemplateRequest: { body?: string; isEnabled?: boolean; @@ -2904,6 +2914,46 @@ export interface operations { }; }; }; + updateClassSchedule: { + parameters: { + query?: never; + header?: never; + path: { + scheduleID: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateClassScheduleRequest"]; + }; + }; + responses: { + /** @description Updated class schedule */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClassSchedule"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Class schedule not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listClassSessions: { parameters: { query?: never; diff --git a/project.md b/project.md index 57e94a4..e3f55f9 100644 --- a/project.md +++ b/project.md @@ -228,6 +228,14 @@ - `service-report.csv` gains a `completed` column - verified live: `/v1/metrics/services` returns correct aggregates for seeded trailing-window bookings; `intakeLabels` present in dashboard summary; modal renders declared labels in CS; focused tests cover blocked-day capacity (tenant + staff scope) and service aggregation - remaining: live staging validation on Neon + pgx repository path +- `[done]` Milestone 32: Series-aware schedule edits + waitlist ledger removal + - `PATCH /v1/catalog/class-schedules/:id` patches a recurring rule (`dayOfWeek`, `startsLocal`, `locationId`, `active`); absent fields are kept, `locationId: ""` clears + - `applyToFuture` retimes already-materialized future sessions to the new local start time on their existing calendar dates (tenant-timezone aware), applies the new location, moves confirmed/waitlisted bookings to the new window, and re-arms reminders via exported `bookings.RearmReminderJobs` + - a `dayOfWeek` change steers only sessions materialized afterwards — existing dates stay put (documented semantics) + - zones-page schedule rows gain an inline editor (weekday/time/location + active toggle + apply-to-future checkbox) in CS and EN + - `waitlist_entries` dropped (migration 00020): the ledger was written on join/promotion but never read — positions derive from bookings' `created_at` + - verified live: PATCH retime cascades to session and booking windows; without `applyToFuture` sessions stay; 400/404 paths correct; focused tests cover cascade and no-cascade paths + - remaining: live staging validation on Neon + pgx repository path ## 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. @@ -478,11 +486,11 @@ Key entities: Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle: - **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. -- **Series-aware session edits**: M29's rules generate sessions, but editing a rule means delete-and-recreate; an "apply to future sessions of this rule" update (time shift, capacity change) would remove the workaround. - **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach. - **Scheduled report delivery**: period-end exports emailed automatically (cron + existing mail transport) instead of manual downloads — pairs naturally with M28's report endpoints. - **Intake reporting**: M31 shows labels, but answers aren't queryable — filtering or aggregating yes-no answers (e.g. "first visits this month") needs a stored projection, not JSONB scans; defer until a reporting request exists. -- **Waitlist ledger cleanup**: `waitlist_entries` is vestigial — every displayed position derives from booking `created_at`, so the table is written but never read; drop it or wire it as the source of truth, don't leave both half-alive. +- **Bulk session shift**: M32 retimes future sessions of one rule; a genuine weekday *move* (relocate existing sessions to the new weekday, not just steer future materialization) and multi-rule shifts (holiday weeks) are still manual — defer until requested. +- **Per-session capacity override**: session capacity still comes only from the template; letting staff override capacity on one session (or one rule) is a small schema addition if a real need appears. ## Product Principles - calm, premium, structured UX