Merge pull request #17 from Dvorinka/feat/m32-series-schedule-edits

feat: series-aware schedule edits + waitlist ledger removal (M32)
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-22 21:03:04 +02:00
commit e6f1ec38a4
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"`
@@ -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":
description: Template or location not found
/v1/catalog/class-schedules/{scheduleID}:
patch:
tags: [Catalog]
operationId: updateClassSchedule
security:
- bearerAuth: []
parameters:
- in: path
name: scheduleID
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateClassScheduleRequest"
responses:
"200":
description: Updated class schedule
content:
application/json:
schema:
$ref: "#/components/schemas/ClassSchedule"
"400":
description: Invalid request
"404":
description: Class schedule not found
delete:
tags: [Catalog]
operationId: deleteClassSchedule
@@ -3054,6 +3083,30 @@ components:
startsLocal:
type: string
description: Tenant-local start time, HH:MM
UpdateClassScheduleRequest:
type: object
description: >-
Patches a recurring rule; absent fields are kept. locationId set to the
empty string clears the location. When applyToFuture is true and time or
location changed, already-materialized future sessions move to the new
start time (keeping their local date) and location, and their live
bookings follow. A dayOfWeek change only steers sessions materialized
afterwards.
properties:
dayOfWeek:
type: integer
minimum: 0
maximum: 6
startsLocal:
type: string
description: Tenant-local start time, HH:MM
locationId:
type: string
description: Location UUID, or empty string to clear
active:
type: boolean
applyToFuture:
type: boolean
PlanEntitlements:
type: object
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
@@ -452,6 +452,11 @@ const dictionaries = {
"dashboard.class.startsLocal": "Čas",
"dashboard.class.addSchedule": "Přidat pravidlo",
"dashboard.class.noSchedules": "Zatím žádná opakující se pravidla.",
"dashboard.class.editSchedule": "Upravit pravidlo",
"dashboard.class.applyToFuture": "Použít na budoucí lekce",
"dashboard.class.applyToFutureHint": "Přesune již vytvořené lekce na nový čas a místo, rezervace se přesunou s nimi.",
"dashboard.class.ruleActive": "Pravidlo je aktivní",
"dashboard.class.inactive": "neaktivní",
"dashboard.class.dow.0": "Neděle",
"dashboard.class.dow.1": "Pondělí",
"dashboard.class.dow.2": "Úterý",
@@ -1083,6 +1088,11 @@ const dictionaries = {
"dashboard.class.startsLocal": "Time",
"dashboard.class.addSchedule": "Add rule",
"dashboard.class.noSchedules": "No recurring rules yet.",
"dashboard.class.editSchedule": "Edit rule",
"dashboard.class.applyToFuture": "Apply to future sessions",
"dashboard.class.applyToFutureHint": "Moves already-created sessions to the new time and location; bookings move with them.",
"dashboard.class.ruleActive": "Rule is active",
"dashboard.class.inactive": "inactive",
"dashboard.class.dow.0": "Sunday",
"dashboard.class.dow.1": "Monday",
"dashboard.class.dow.2": "Tuesday",
+106 -14
View File
@@ -3,7 +3,7 @@ import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { getInitials } from "../../components/dashboard/types";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon, CheckIcon, XIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() {
@@ -35,6 +35,13 @@ function ZonesPage() {
const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal("");
const [newSchedLocationId, setNewSchedLocationId] = createSignal("");
const [schedSaving, setSchedSaving] = createSignal(false);
const [editingSchedId, setEditingSchedId] = createSignal<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 [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
// Booking IDs whose promote attempt hit a full session — awaiting the
@@ -142,8 +149,8 @@ function ZonesPage() {
if (!bearer || tab !== "classes") return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true },
{ id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true },
{ id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationId: "z1", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true },
{ id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationId: "z1", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true },
];
}
const response = await (apiClient as any).GET("/v1/catalog/class-schedules", authHeaders());
@@ -405,6 +412,42 @@ function ZonesPage() {
}
};
const startEditSchedule = (sch: any) => {
setEditingSchedId(sch.id);
setEditSchedDayOfWeek(String(sch.dayOfWeek));
setEditSchedStartsLocal(String(sch.startsLocal).slice(0, 5));
setEditSchedLocationId(sch.locationId ?? "");
setEditSchedApplyFuture(true);
setEditSchedActive(sch.active !== false);
};
const handleSaveSchedule = async () => {
const bearer = data.token();
const id = editingSchedId();
if (!id) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setSchedEditSaving(true);
try {
await (apiClient as any).PATCH(`/v1/catalog/class-schedules/${id}`, {
...authHeaders(),
body: {
dayOfWeek: parseInt(editSchedDayOfWeek(), 10),
startsLocal: editSchedStartsLocal(),
locationId: editSchedLocationId(),
active: editSchedActive(),
applyToFuture: editSchedApplyFuture(),
},
});
setEditingSchedId(null);
void refetchSchedules();
void refetchSessions();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally {
setSchedEditSaving(false);
}
};
const handleDeleteSchedule = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
@@ -713,19 +756,68 @@ function ZonesPage() {
<div class="divide-y divide-border/60">
<For each={classSchedules() ?? []}>
{(sch: any) => (
<div class="flex items-center justify-between p-4 px-6">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
<div>
<p class="font-medium text-ink">{sch.templateTitle}</p>
<p class="text-sm text-ink-muted">
{`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`}
{sch.locationName ? ` • ${sch.locationName}` : ""}
</p>
<Show
when={editingSchedId() === sch.id}
fallback={
<div class="flex items-center justify-between p-4 px-6">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
<div>
<p class="font-medium text-ink">{sch.templateTitle}</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>
<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>
</Show>
)}
</For>
</div>
+51 -1
View File
@@ -429,7 +429,7 @@ export interface paths {
delete: operations["deleteClassSchedule"];
options?: never;
head?: never;
patch?: never;
patch: operations["updateClassSchedule"];
trace?: never;
};
"/v1/catalog/class-sessions": {
@@ -1912,6 +1912,16 @@ export interface components {
primaryColor?: string;
siteUrl?: string;
};
/** @description Patches a recurring rule; absent fields are kept. locationId set to the empty string clears the location. When applyToFuture is true and time or location changed, already-materialized future sessions move to the new start time (keeping their local date) and location, and their live bookings follow. A dayOfWeek change only steers sessions materialized afterwards. */
UpdateClassScheduleRequest: {
active?: boolean;
applyToFuture?: boolean;
dayOfWeek?: number;
/** @description Location UUID, or empty string to clear */
locationId?: string;
/** @description Tenant-local start time, HH:MM */
startsLocal?: string;
};
UpdateEmailTemplateRequest: {
body?: string;
isEnabled?: boolean;
@@ -2904,6 +2914,46 @@ export interface operations {
};
};
};
updateClassSchedule: {
parameters: {
query?: never;
header?: never;
path: {
scheduleID: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateClassScheduleRequest"];
};
};
responses: {
/** @description Updated class schedule */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClassSchedule"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Class schedule not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listClassSessions: {
parameters: {
query?: never;
+10 -2
View File
@@ -228,6 +228,14 @@
- `service-report.csv` gains a `completed` column
- verified live: `/v1/metrics/services` returns correct aggregates for seeded trailing-window bookings; `intakeLabels` present in dashboard summary; modal renders declared labels in CS; focused tests cover blocked-day capacity (tenant + staff scope) and service aggregation
- remaining: live staging validation on Neon + pgx repository path
- `[done]` Milestone 32: Series-aware schedule edits + waitlist ledger removal
- `PATCH /v1/catalog/class-schedules/:id` patches a recurring rule (`dayOfWeek`, `startsLocal`, `locationId`, `active`); absent fields are kept, `locationId: ""` clears
- `applyToFuture` retimes already-materialized future sessions to the new local start time on their existing calendar dates (tenant-timezone aware), applies the new location, moves confirmed/waitlisted bookings to the new window, and re-arms reminders via exported `bookings.RearmReminderJobs`
- a `dayOfWeek` change steers only sessions materialized afterwards — existing dates stay put (documented semantics)
- zones-page schedule rows gain an inline editor (weekday/time/location + active toggle + apply-to-future checkbox) in CS and EN
- `waitlist_entries` dropped (migration 00020): the ledger was written on join/promotion but never read — positions derive from bookings' `created_at`
- verified live: PATCH retime cascades to session and booking windows; without `applyToFuture` sessions stay; 400/404 paths correct; focused tests cover cascade and no-cascade paths
- remaining: live staging validation on Neon + pgx repository path
## Overview
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
@@ -478,11 +486,11 @@ Key entities:
Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle:
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
- **Series-aware session edits**: M29's rules generate sessions, but editing a rule means delete-and-recreate; an "apply to future sessions of this rule" update (time shift, capacity change) would remove the workaround.
- **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach.
- **Scheduled report delivery**: period-end exports emailed automatically (cron + existing mail transport) instead of manual downloads — pairs naturally with M28's report endpoints.
- **Intake reporting**: M31 shows labels, but answers aren't queryable — filtering or aggregating yes-no answers (e.g. "first visits this month") needs a stored projection, not JSONB scans; defer until a reporting request exists.
- **Waitlist ledger cleanup**: `waitlist_entries` is vestigial — every displayed position derives from booking `created_at`, so the table is written but never read; drop it or wire it as the source of truth, don't leave both half-alive.
- **Bulk session shift**: M32 retimes future sessions of one rule; a genuine weekday *move* (relocate existing sessions to the new weekday, not just steer future materialization) and multi-rule shifts (holiday weeks) are still manual — defer until requested.
- **Per-session capacity override**: session capacity still comes only from the template; letting staff override capacity on one session (or one rule) is a small schema addition if a real need appears.
## Product Principles
- calm, premium, structured UX