feat: series-aware schedule edits + waitlist ledger removal (M32)

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>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-22 20:59:34 +02:00
1 parent 55fc0d06e3
commit 65a693cf38
17 files changed
+840 -230

No files matched your search

+32 -12
View File
@@ -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 {
@@ -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.
@@ -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: "[email protected]",
Position: 1,
}); err != nil {
t.Fatalf("seed waitlist entry: %v", err)
}
}
created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: moveTenantID,
ClassSessionID: &sessionID,
+14 -8
View File
@@ -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
-13
View File
@@ -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
}
+126 -10
View File
@@ -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 {
@@ -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: "[email protected]",
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")
}
}
}
-19
View File
@@ -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)
@@ -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
}
+160 -68
View File
@@ -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,
})
+62 -49
View File
@@ -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"`