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) { server.router.POST("/v1/public/bookings", publicRateLimiter.Middleware(), func(c *gin.Context) {
var request struct { var request struct {
TenantSlug string `json:"tenantSlug" binding:"required"` TenantSlug string `json:"tenantSlug" binding:"required"`
BookingMode string `json:"bookingMode" binding:"required"` BookingMode string `json:"bookingMode" binding:"required"`
ServiceID *string `json:"serviceId"` ServiceID *string `json:"serviceId"`
ClassSessionID *string `json:"classSessionId"` ClassSessionID *string `json:"classSessionId"`
StaffID *string `json:"staffId"` StaffID *string `json:"staffId"`
LocationID *string `json:"locationId"` LocationID *string `json:"locationId"`
CustomerName string `json:"customerName" binding:"required"` CustomerName string `json:"customerName" binding:"required"`
CustomerEmail string `json:"customerEmail" binding:"required,email"` CustomerEmail string `json:"customerEmail" binding:"required,email"`
CustomerPhone string `json:"customerPhone"` CustomerPhone string `json:"customerPhone"`
Notes string `json:"notes"` Notes string `json:"notes"`
Intake map[string]string `json:"intake,omitempty"` Intake map[string]string `json:"intake,omitempty"`
StartsAt string `json:"startsAt" binding:"required"` StartsAt string `json:"startsAt" binding:"required"`
EndsAt string `json:"endsAt" binding:"required"` EndsAt string `json:"endsAt" binding:"required"`
} }
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) 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) 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) { protected.DELETE("/catalog/class-schedules/:scheduleID", func(c *gin.Context) {
err := catalogService.DeleteClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("scheduleID")) err := catalogService.DeleteClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("scheduleID"))
if err != nil { if err != nil {
@@ -518,13 +518,10 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st
if err != nil { if err != nil {
return domain.MoveBookingResponse{}, err return domain.MoveBookingResponse{}, err
} }
confirmed, waitlisted := 0, 0 confirmed := 0
for _, other := range targetBookings { for _, other := range targetBookings {
switch other.Status { if other.Status == "confirmed" {
case "confirmed":
confirmed++ confirmed++
case "waitlisted":
waitlisted++
} }
} }
@@ -534,30 +531,15 @@ func (s *CustomerService) MoveBookingToSession(ctx context.Context, reference st
newStatus = "waitlisted" 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 { if err := s.repo.UpdateBookingSession(ctx, booking.ID, target.ID, target.StartsAt, target.EndsAt, newStatus); err != nil {
return domain.MoveBookingResponse{}, err 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-arm reminders for the new window (confirmed only; a queue spot
// re-arms on promotion through the shared confirm path). // re-arms on promotion through the shared confirm path).
if err := s.repo.DeletePendingReminderJobsForBooking(ctx, booking.ID); err == nil && newStatus == "confirmed" { if newStatus == "confirmed" {
_ = armReminderJobs(ctx, s.repo, booking.TenantID, booking.ID, target.StartsAt, booking.CustomerPhone) 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. // 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 { if err != nil {
t.Fatalf("get session: %v", err) 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{ created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: moveTenantID, TenantID: moveTenantID,
ClassSessionID: &sessionID, 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) { if session.Capacity > 0 && confirmed >= int(session.Capacity) {
break break
} }
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil { if err := confirmWaitlistedBooking(ctx, repo, tenantID, booking); err != nil {
return promoted, err return promoted, err
} }
confirmed++ 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 return db.BookingRecord{}, err
} }
booking.Status = "confirmed" booking.Status = "confirmed"
return booking, nil return booking, nil
} }
// confirmWaitlistedBooking flips a waitlisted booking to confirmed, resolves // confirmWaitlistedBooking flips a waitlisted booking to confirmed and
// the ledger entry, and re-arms reminder jobs — a booking promoted after its // re-arms reminder jobs — a booking promoted after its reminder already
// reminder already dispatched-as-skipped would otherwise go unreminded. // dispatched-as-skipped would otherwise go unreminded.
func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID, sessionID string, booking db.BookingRecord) error { func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID string, booking db.BookingRecord) error {
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil { if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
return err 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) _ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID)
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone) _ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
return nil
} }
// armReminderJobs schedules the email reminder — and the SMS reminder when the // 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 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 { if err := armReminderJobs(ctx, s.repo, tenant.ID, created.ID, startsAt, request.CustomerPhone); err != nil {
return domain.CreateBookingResponse{}, err 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 { if req.DayOfWeek < 0 || req.DayOfWeek > 6 {
return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput) return domain.ClassSchedule{}, fmt.Errorf("%w: dayOfWeek must be 0-6", ErrInvalidInput)
} }
startsLocal := strings.TrimSpace(req.StartsLocal) startsLocal, err := normalizeScheduleLocalTime(req.StartsLocal)
parts := strings.SplitN(startsLocal, ":", 2) if err != nil {
if len(parts) != 2 { return domain.ClassSchedule{}, err
return domain.ClassSchedule{}, 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 domain.ClassSchedule{}, fmt.Errorf("%w: startsLocal must be HH:MM", ErrInvalidInput)
}
startsLocal = fmt.Sprintf("%02d:%02d:00", hour, minute)
if req.LocationID != nil { if req.LocationID != nil {
location, err := s.repo.GetLocationByID(ctx, *req.LocationID) 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 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 // DeleteClassSchedule removes a recurring rule. Sessions already materialized
// stay in place; no new ones are written. // stay in place; no new ones are written.
func (s *Service) DeleteClassSchedule(ctx context.Context, principal domain.Principal, scheduleID string) error { 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) 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 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 { func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
_, err := r.pool.Exec(ctx, ` _, err := r.pool.Exec(ctx, `
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for) 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) `, tenantID, now, days)
return err 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 DeleteClassTemplate(ctx context.Context, templateID string) error
ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error) ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error)
CreateClassSchedule(ctx context.Context, params CreateClassScheduleParams) (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 DeleteClassSchedule(ctx context.Context, tenantID string, scheduleID string) error
MaterializeScheduleSessions(ctx context.Context, tenantID string, now time.Time, days int) 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) ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error) ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error)
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, 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 CreateReminderJob(ctx context.Context, params ReminderJobParams) error
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error) ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
@@ -278,8 +279,8 @@ type ServiceRecord struct {
PriceCents int PriceCents int
// IntakeFields is the raw JSON list of declared structured intake fields; // IntakeFields is the raw JSON list of declared structured intake fields;
// the catalog service parses and validates it into domain.IntakeField. // the catalog service parses and validates it into domain.IntakeField.
IntakeFields string IntakeFields string
CreatedAt time.Time CreatedAt time.Time
} }
type CreateServiceParams struct { type CreateServiceParams struct {
@@ -343,14 +344,14 @@ type ActivityEventRecord struct {
} }
type ActivityEventParams struct { type ActivityEventParams struct {
TenantID string TenantID string
ActorKind string ActorKind string
ActorSubject string ActorSubject string
ActorName string ActorName string
Action string Action string
EntityType string EntityType string
EntityID *string EntityID *string
Summary string Summary string
} }
type CreateStaffParams struct { type CreateStaffParams struct {
@@ -418,14 +419,14 @@ type CreateClassSessionParams struct {
// ClassScheduleRecord is a weekly recurring rule that materializes concrete // ClassScheduleRecord is a weekly recurring rule that materializes concrete
// class_sessions rows. DayOfWeek follows Go's time.Weekday (Sunday=0). // class_sessions rows. DayOfWeek follows Go's time.Weekday (Sunday=0).
type ClassScheduleRecord struct { type ClassScheduleRecord struct {
ID string ID string
TenantID string TenantID string
TemplateID string TemplateID string
LocationID *string LocationID *string
DayOfWeek int DayOfWeek int
StartsLocal string StartsLocal string
Active bool Active bool
CreatedAt time.Time CreatedAt time.Time
} }
type CreateClassScheduleParams struct { type CreateClassScheduleParams struct {
@@ -436,6 +437,15 @@ type CreateClassScheduleParams struct {
StartsLocal string 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 { type BookingRecord struct {
ID string ID string
TenantID string TenantID string
@@ -453,8 +463,8 @@ type BookingRecord struct {
ManageToken string ManageToken string
Notes string Notes string
// Intake is the raw JSON object of structured intake answers. // Intake is the raw JSON object of structured intake answers.
Intake string Intake string
CreatedAt time.Time CreatedAt time.Time
} }
type CreateBookingParams struct { type CreateBookingParams struct {
@@ -483,14 +493,6 @@ type CreatedBooking struct {
ManageToken string ManageToken string
} }
type WaitlistEntryParams struct {
TenantID string
ClassSessionID string
CustomerName string
CustomerEmail string
Position int
}
type DashboardMetrics struct { type DashboardMetrics struct {
BookingsCount int BookingsCount int
CancellationsCount int CancellationsCount int
@@ -878,32 +880,31 @@ func (r *PGRepository) UpdateBookingSession(ctx context.Context, bookingID strin
// ============================================ // ============================================
type MemoryRepository struct { type MemoryRepository struct {
tenant TenantRecord tenant TenantRecord
membership TenantMembershipRecord membership TenantMembershipRecord
brand BrandProfileRecord brand BrandProfileRecord
services []ServiceRecord services []ServiceRecord
rules []AvailabilityRuleRecord rules []AvailabilityRuleRecord
classTemplates []ClassTemplateRecord classTemplates []ClassTemplateRecord
classSessions []ClassSessionRecord classSessions []ClassSessionRecord
classSchedules []ClassScheduleRecord classSchedules []ClassScheduleRecord
bookings []BookingRecord bookings []BookingRecord
waitlistSize int reminderJobs []ReminderJobRecord
reminderJobs []ReminderJobRecord deliveryLogs []NotificationDeliveryLogParams
deliveryLogs []NotificationDeliveryLogParams billingSnapshot BillingSnapshotRecord
billingSnapshot BillingSnapshotRecord recordedEvents map[string]struct{}
recordedEvents map[string]struct{} locations []LocationRecord
locations []LocationRecord staff []StaffRecord
staff []StaffRecord blockedDays []BlockedDayRecord
blockedDays []BlockedDayRecord customers []CustomerRecord
customers []CustomerRecord workingHours []WorkingHoursRecord
workingHours []WorkingHoursRecord smsSettings TenantSMSSettingsRecord
smsSettings TenantSMSSettingsRecord smsLogs []SMSUsageLogRecord
smsLogs []SMSUsageLogRecord smsReports []SMSMonthlyReportRecord
smsReports []SMSMonthlyReportRecord emailTemplates []EmailTemplateRecord
emailTemplates []EmailTemplateRecord tenantFeedToken string
tenantFeedToken string staffFeedTokens map[string]string
staffFeedTokens map[string]string activityEvents []ActivityEventRecord
activityEvents []ActivityEventRecord
notificationCursors map[string]time.Time notificationCursors map[string]time.Time
} }
@@ -1582,6 +1583,109 @@ func (r *MemoryRepository) DeleteClassSchedule(_ context.Context, tenantID strin
return fmt.Errorf("class schedule not found") 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 // MaterializeScheduleSessions mirrors the PG implementation: for each active
// rule, concrete sessions are added for matching weekdays within the next // rule, concrete sessions are added for matching weekdays within the next
// `days` days. Sessions already materialized for a rule+start are skipped. // `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"}` 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 { func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
reference := params.BookingID reference := params.BookingID
startsAt := time.Now().UTC() startsAt := time.Now().UTC()
@@ -2489,8 +2581,8 @@ func (r *MemoryRepository) UpdateWorkingHours(_ context.Context, tenantID string
} }
} }
r.workingHours = append(r.workingHours, WorkingHoursRecord{ r.workingHours = append(r.workingHours, WorkingHoursRecord{
TenantID: tenantID, TenantID: tenantID,
DayOfWeek: dayOfWeek, DayOfWeek: dayOfWeek,
StartsLocal: *params.StartsLocal, StartsLocal: *params.StartsLocal,
EndsLocal: *params.EndsLocal, EndsLocal: *params.EndsLocal,
}) })
+62 -49
View File
@@ -18,23 +18,23 @@ type DashboardKPI struct {
} }
type UpcomingBooking struct { type UpcomingBooking struct {
ID string `json:"id"` ID string `json:"id"`
Reference string `json:"reference"` Reference string `json:"reference"`
CustomerName string `json:"customerName"` CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"` CustomerEmail string `json:"customerEmail"`
Service string `json:"service,omitempty"` Service string `json:"service,omitempty"`
Location string `json:"location,omitempty"` Location string `json:"location,omitempty"`
StaffName string `json:"staffName,omitempty"` StaffName string `json:"staffName,omitempty"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
Intake map[string]string `json:"intake,omitempty"` Intake map[string]string `json:"intake,omitempty"`
// IntakeLabels maps Intake keys to the service's declared field labels. // 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"`
StartsAt time.Time `json:"startsAt"` StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"` EndsAt time.Time `json:"endsAt"`
Status string `json:"status"` Status string `json:"status"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
ClassSessionID *string `json:"classSessionId,omitempty"` ClassSessionID *string `json:"classSessionId,omitempty"`
WaitlistPosition int `json:"waitlistPosition,omitempty"` WaitlistPosition int `json:"waitlistPosition,omitempty"`
} }
type WidgetSnippet struct { type WidgetSnippet struct {
@@ -169,9 +169,9 @@ type CreateBookingRequest struct {
Notes string `json:"notes"` Notes string `json:"notes"`
// Intake carries structured intake answers keyed by the service's // Intake carries structured intake answers keyed by the service's
// declared IntakeField keys; only declared keys are stored. // declared IntakeField keys; only declared keys are stored.
Intake map[string]string `json:"intake,omitempty"` Intake map[string]string `json:"intake,omitempty"`
StartsAt string `json:"startsAt"` StartsAt string `json:"startsAt"`
EndsAt string `json:"endsAt"` EndsAt string `json:"endsAt"`
} }
type CreateBookingResponse struct { type CreateBookingResponse struct {
@@ -334,11 +334,11 @@ type CreateServiceRequest struct {
} }
type UpdateServiceRequest struct { type UpdateServiceRequest struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
DurationMinutes *int `json:"durationMinutes,omitempty"` DurationMinutes *int `json:"durationMinutes,omitempty"`
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"` BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"` BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
PriceCents *int `json:"priceCents,omitempty"` PriceCents *int `json:"priceCents,omitempty"`
IntakeFields *[]IntakeField `json:"intakeFields,omitempty"` IntakeFields *[]IntakeField `json:"intakeFields,omitempty"`
} }
@@ -413,16 +413,16 @@ type UpsertClassTemplateRequest struct {
} }
type ClassSession struct { type ClassSession struct {
ID string `json:"id"` ID string `json:"id"`
TenantID string `json:"tenantId"` TenantID string `json:"tenantId"`
TemplateID string `json:"templateId"` TemplateID string `json:"templateId"`
TemplateTitle string `json:"templateTitle"` TemplateTitle string `json:"templateTitle"`
LocationID *string `json:"locationId,omitempty"` LocationID *string `json:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty"` LocationName string `json:"locationName,omitempty"`
StartsAt time.Time `json:"startsAt"` StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"` EndsAt time.Time `json:"endsAt"`
Capacity int `json:"capacity"` Capacity int `json:"capacity"`
BookedCount int `json:"bookedCount"` BookedCount int `json:"bookedCount"`
// WaitlistCount is the number of customers queued for a free seat. // WaitlistCount is the number of customers queued for a free seat.
WaitlistCount int `json:"waitlistCount"` WaitlistCount int `json:"waitlistCount"`
// ScheduleID links the session back to the recurring rule that generated // 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" 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 // CUSTOMER MODELS
// ============================================ // ============================================
@@ -530,25 +543,25 @@ type UpdateCustomerRequest struct {
// ============================================ // ============================================
type CustomerBookingView struct { type CustomerBookingView struct {
Reference string `json:"reference"` Reference string `json:"reference"`
CustomerName string `json:"customerName"` CustomerName string `json:"customerName"`
CustomerEmail string `json:"customerEmail"` CustomerEmail string `json:"customerEmail"`
Service string `json:"service"` Service string `json:"service"`
BusinessName string `json:"businessName"` BusinessName string `json:"businessName"`
TenantSlug string `json:"tenantSlug,omitempty"` TenantSlug string `json:"tenantSlug,omitempty"`
ServiceID *string `json:"serviceId,omitempty"` ServiceID *string `json:"serviceId,omitempty"`
StaffID *string `json:"staffId,omitempty"` StaffID *string `json:"staffId,omitempty"`
// ClassSessionID identifies session-bound bookings; they cannot be // ClassSessionID identifies session-bound bookings; they cannot be
// rescheduled to arbitrary times (see RescheduleBooking). // rescheduled to arbitrary times (see RescheduleBooking).
ClassSessionID *string `json:"classSessionId,omitempty"` ClassSessionID *string `json:"classSessionId,omitempty"`
StartsAt time.Time `json:"startsAt"` StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"` EndsAt time.Time `json:"endsAt"`
Location string `json:"location"` Location string `json:"location"`
Status string `json:"status"` Status string `json:"status"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
Intake map[string]string `json:"intake,omitempty"` Intake map[string]string `json:"intake,omitempty"`
// IntakeLabels maps Intake keys to the service's declared field labels. // 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 // WaitlistPosition is the 1-based queue spot for waitlisted class
// bookings; zero for any other status. // bookings; zero for any other status.
WaitlistPosition int `json:"waitlistPosition,omitempty"` WaitlistPosition int `json:"waitlistPosition,omitempty"`
@@ -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()
);
+53
View File
@@ -1395,6 +1395,35 @@ paths:
"404": "404":
description: Template or location not found description: Template or location not found
/v1/catalog/class-schedules/{scheduleID}: /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: delete:
tags: [Catalog] tags: [Catalog]
operationId: deleteClassSchedule operationId: deleteClassSchedule
@@ -3054,6 +3083,30 @@ components:
startsLocal: startsLocal:
type: string type: string
description: Tenant-local start time, HH:MM 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: PlanEntitlements:
type: object type: object
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking] required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
@@ -452,6 +452,11 @@ const dictionaries = {
"dashboard.class.startsLocal": "Čas", "dashboard.class.startsLocal": "Čas",
"dashboard.class.addSchedule": "Přidat pravidlo", "dashboard.class.addSchedule": "Přidat pravidlo",
"dashboard.class.noSchedules": "Zatím žádná opakující se pravidla.", "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.0": "Neděle",
"dashboard.class.dow.1": "Pondělí", "dashboard.class.dow.1": "Pondělí",
"dashboard.class.dow.2": "Úterý", "dashboard.class.dow.2": "Úterý",
@@ -1083,6 +1088,11 @@ const dictionaries = {
"dashboard.class.startsLocal": "Time", "dashboard.class.startsLocal": "Time",
"dashboard.class.addSchedule": "Add rule", "dashboard.class.addSchedule": "Add rule",
"dashboard.class.noSchedules": "No recurring rules yet.", "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.0": "Sunday",
"dashboard.class.dow.1": "Monday", "dashboard.class.dow.1": "Monday",
"dashboard.class.dow.2": "Tuesday", "dashboard.class.dow.2": "Tuesday",
+106 -14
View File
@@ -3,7 +3,7 @@ import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select"; import { Select } from "../../components/ui/select";
import { getInitials } from "../../components/dashboard/types"; 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"; import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() { function ZonesPage() {
@@ -35,6 +35,13 @@ function ZonesPage() {
const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal(""); const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal("");
const [newSchedLocationId, setNewSchedLocationId] = createSignal(""); const [newSchedLocationId, setNewSchedLocationId] = createSignal("");
const [schedSaving, setSchedSaving] = createSignal(false); const [schedSaving, setSchedSaving] = createSignal(false);
const [editingSchedId, setEditingSchedId] = createSignal<string | null>(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<string | null>(null); const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null); const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
// Booking IDs whose promote attempt hit a full session — awaiting the // Booking IDs whose promote attempt hit a full session — awaiting the
@@ -142,8 +149,8 @@ function ZonesPage() {
if (!bearer || tab !== "classes") return []; if (!bearer || tab !== "classes") return [];
if (bearer.startsWith("demo.")) { if (bearer.startsWith("demo.")) {
return [ 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: "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", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30: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()); 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 handleDeleteSchedule = async (id: string) => {
const bearer = data.token(); const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; } 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() {
<div class="divide-y divide-border/60"> <div class="divide-y divide-border/60">
<For each={classSchedules() ?? []}> <For each={classSchedules() ?? []}>
{(sch: any) => ( {(sch: any) => (
<div class="flex items-center justify-between p-4 px-6"> <Show
<div class="flex items-center gap-4"> when={editingSchedId() === sch.id}
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div> fallback={
<div> <div class="flex items-center justify-between p-4 px-6">
<p class="font-medium text-ink">{sch.templateTitle}</p> <div class="flex items-center gap-4">
<p class="text-sm text-ink-muted"> <div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
{`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`} <div>
{sch.locationName ? ` • ${sch.locationName}` : ""} <p class="font-medium text-ink">{sch.templateTitle}</p>
</p> <p class="text-sm text-ink-muted">
{`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`}
{sch.locationName ? ` • ${sch.locationName}` : ""}
{!sch.active ? ` • ${data.i18n.t("dashboard.class.inactive")}` : ""}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<button onClick={() => startEditSchedule(sch)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle/40 rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.class.editSchedule")}><PencilIcon /></button>
<button onClick={() => void handleDeleteSchedule(sch.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
</div>
</div>
}
>
<div class="p-4 px-6 bg-canvas-subtle/40">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Select
label={data.i18n.t("dashboard.class.weekday")}
value={editSchedDayOfWeek()}
onChange={(v) => setEditSchedDayOfWeek(v)}
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: weekdayName(d) }))}
/>
<Input type="time" label={data.i18n.t("dashboard.class.startsLocal")} value={editSchedStartsLocal()} onInput={(e) => setEditSchedStartsLocal(e.currentTarget.value)} />
<Select
label={data.i18n.t("dashboard.class.location")}
value={editSchedLocationId()}
onChange={(v) => setEditSchedLocationId(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
]}
/>
</div>
<label class="flex items-start gap-2 mt-4 cursor-pointer">
<input type="checkbox" checked={editSchedActive()} onChange={(e) => setEditSchedActive(e.currentTarget.checked)} class="mt-0.5 accent-accent" />
<span class="text-sm font-medium text-ink">{data.i18n.t("dashboard.class.ruleActive")}</span>
</label>
<label class="flex items-start gap-2 mt-3 cursor-pointer">
<input type="checkbox" checked={editSchedApplyFuture()} onChange={(e) => setEditSchedApplyFuture(e.currentTarget.checked)} class="mt-0.5 accent-accent" />
<span>
<span class="text-sm font-medium text-ink">{data.i18n.t("dashboard.class.applyToFuture")}</span>
<span class="block text-xs text-ink-muted">{data.i18n.t("dashboard.class.applyToFutureHint")}</span>
</span>
</label>
<div class="flex items-center gap-2 mt-4">
<button onClick={() => void handleSaveSchedule()} disabled={schedEditSaving() || !editSchedStartsLocal()} class="btn-primary text-sm disabled:opacity-60">
<CheckIcon /> {schedEditSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.saveChanges")}
</button>
<button onClick={() => setEditingSchedId(null)} class="btn-secondary text-sm">
<XIcon /> {data.i18n.t("dashboard.cancel")}
</button>
</div> </div>
</div> </div>
<button onClick={() => void handleDeleteSchedule(sch.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button> </Show>
</div>
)} )}
</For> </For>
</div> </div>
+51 -1
View File
@@ -429,7 +429,7 @@ export interface paths {
delete: operations["deleteClassSchedule"]; delete: operations["deleteClassSchedule"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; patch: operations["updateClassSchedule"];
trace?: never; trace?: never;
}; };
"/v1/catalog/class-sessions": { "/v1/catalog/class-sessions": {
@@ -1912,6 +1912,16 @@ export interface components {
primaryColor?: string; primaryColor?: string;
siteUrl?: 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: { UpdateEmailTemplateRequest: {
body?: string; body?: string;
isEnabled?: boolean; 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: { listClassSessions: {
parameters: { parameters: {
query?: never; query?: never;
+10 -2
View File
@@ -228,6 +228,14 @@
- `service-report.csv` gains a `completed` column - `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 - 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 - 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 ## 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. 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: 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. - **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. - **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. - **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. - **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 ## Product Principles
- calm, premium, structured UX - calm, premium, structured UX