feat(classes): weekly recurring schedules with lazy session materialization

class_schedules rules generate concrete session rows for a rolling 28-day
horizon — no cron; materialize runs idempotently on dashboard and public
reads via a partial unique index on (schedule_id, starts_at). Rule delete
stops future sessions; existing sessions keep bookings and overrides.

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-18 17:31:17 +02:00
1 parent 7b9080eb1c
commit ad60167a31
15 files changed
+1029 -7

No files matched your search

+45
View File
@@ -1115,6 +1115,51 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
} }
c.JSON(http.StatusOK, gin.H{"status": "deleted"}) c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}) })
protected.GET("/catalog/class-schedules", func(c *gin.Context) {
response, err := catalogService.ListClassSchedules(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/class-schedules", func(c *gin.Context) {
var request domain.CreateClassScheduleRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateClassSchedule(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassTemplateNotFound), 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.StatusCreated, 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 {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound):
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================ // ============================================
// CUSTOMER BOOKING MANAGEMENT API (Public) // CUSTOMER BOOKING MANAGEMENT API (Public)
@@ -160,6 +160,11 @@ func (s *Service) Availability(ctx context.Context, tenantSlug string, filter Av
}, nil }, nil
} }
// Recurring schedules write their concrete sessions here so the public
// booking page sees them without a background job. Idempotent.
if err := s.repo.MaterializeScheduleSessions(ctx, tenant.ID, now, 28); err != nil {
return domain.PublicAvailability{}, err
}
classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8) classSessions, err := s.repo.ListClassSessionsByTenant(ctx, tenant.ID, windowStart, 8)
if err != nil { if err != nil {
return domain.PublicAvailability{}, err return domain.PublicAvailability{}, err
+150
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/mail" "net/mail"
"strconv"
"strings" "strings"
"time" "time"
@@ -1088,8 +1089,14 @@ func (s *Service) DeleteClassTemplate(ctx context.Context, principal domain.Prin
return s.repo.DeleteClassTemplate(ctx, templateID) return s.repo.DeleteClassTemplate(ctx, templateID)
} }
// scheduleHorizonDays is the rolling window that recurring class schedules
// materialize concrete sessions for. Sessions are written idempotently on
// read, so the horizon stays populated without a background job.
const scheduleHorizonDays = 28
// ListClassSessions returns upcoming sessions enriched with location names and // ListClassSessions returns upcoming sessions enriched with location names and
// live booked counts for the management UI. // live booked counts for the management UI.
func (s *Service) ListClassSessions(ctx context.Context, principal domain.Principal) ([]domain.ClassSession, error) { func (s *Service) ListClassSessions(ctx context.Context, principal domain.Principal) ([]domain.ClassSession, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil { if err != nil {
@@ -1098,6 +1105,9 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
tenantID := membership.Tenant.ID tenantID := membership.Tenant.ID
now := time.Now().UTC() now := time.Now().UTC()
if err := s.repo.MaterializeScheduleSessions(ctx, tenantID, now, scheduleHorizonDays); err != nil {
return nil, err
}
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, now, 100) sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, now, 100)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1153,6 +1163,7 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
Capacity: int(session.Capacity), Capacity: int(session.Capacity),
BookedCount: booked, BookedCount: booked,
WaitlistCount: waitlisted, WaitlistCount: waitlisted,
ScheduleID: session.ScheduleID,
} }
} }
return out, nil return out, nil
@@ -1287,6 +1298,145 @@ func (s *Service) CreateClassSession(ctx context.Context, principal domain.Princ
}, nil }, nil
} }
// ListClassSchedules returns the tenant's weekly recurring rules enriched
// with template titles and location names.
func (s *Service) ListClassSchedules(ctx context.Context, principal domain.Principal) ([]domain.ClassSchedule, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
tenantID := membership.Tenant.ID
schedules, err := s.repo.ListClassSchedulesByTenant(ctx, tenantID)
if err != nil {
return nil, err
}
if len(schedules) == 0 {
return []domain.ClassSchedule{}, nil
}
templates, err := s.repo.ListClassTemplatesByTenant(ctx, tenantID)
if err != nil {
return nil, err
}
templateTitles := make(map[string]string, len(templates))
for _, tpl := range templates {
templateTitles[tpl.ID] = tpl.Title
}
locations, err := s.repo.ListLocationsByTenant(ctx, tenantID)
if err != nil {
return nil, err
}
locationNames := make(map[string]string, len(locations))
for _, loc := range locations {
locationNames[loc.ID] = loc.Name
}
out := make([]domain.ClassSchedule, len(schedules))
for i, sch := range schedules {
out[i] = domain.ClassSchedule{
ID: sch.ID,
TenantID: sch.TenantID,
TemplateID: sch.TemplateID,
TemplateTitle: templateTitles[sch.TemplateID],
LocationID: sch.LocationID,
LocationName: locationNames[deref(sch.LocationID)],
DayOfWeek: sch.DayOfWeek,
StartsLocal: sch.StartsLocal,
Active: sch.Active,
}
}
return out, nil
}
// CreateClassSchedule adds a weekly recurring rule for a class template, then
// immediately materializes the horizon so the new sessions appear at once.
func (s *Service) CreateClassSchedule(ctx context.Context, principal domain.Principal, req domain.CreateClassScheduleRequest) (domain.ClassSchedule, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.ClassSchedule{}, ErrTenantMembership
}
tenantID := membership.Tenant.ID
if err := s.requireTenantClassTemplate(ctx, tenantID, req.TemplateID); err != nil {
return domain.ClassSchedule{}, err
}
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)
}
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)
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.CreateClassSchedule(ctx, db.CreateClassScheduleParams{
TenantID: tenantID,
TemplateID: req.TemplateID,
LocationID: req.LocationID,
DayOfWeek: req.DayOfWeek,
StartsLocal: startsLocal,
})
if err != nil {
return domain.ClassSchedule{}, err
}
if err := s.repo.MaterializeScheduleSessions(ctx, tenantID, time.Now().UTC(), scheduleHorizonDays); err != nil {
return domain.ClassSchedule{}, err
}
template, _ := s.repo.GetClassTemplateByID(ctx, req.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
}
// 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 {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
if err := s.repo.DeleteClassSchedule(ctx, membership.Tenant.ID, scheduleID); err != nil {
if strings.Contains(err.Error(), "not found") {
return ErrClassSessionNotFound
}
return err
}
return nil
}
func (s *Service) DeleteClassSession(ctx context.Context, principal domain.Principal, sessionID string) error { func (s *Service) DeleteClassSession(ctx context.Context, principal domain.Principal, sessionID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil { if err != nil {
@@ -797,3 +797,92 @@ func TestUpdateClassTemplate(t *testing.T) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err) t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
} }
} }
func TestClassScheduleMaterializesSessions(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
schedules, err := svc.ListClassSchedules(ctx, owner)
if err != nil {
t.Fatalf("list schedules: %v", err)
}
if len(schedules) != 0 {
t.Fatalf("expected 0 schedules in fresh repo, got %d", len(schedules))
}
locID := "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
created, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7",
LocationID: &locID,
DayOfWeek: 1,
StartsLocal: "15:30",
})
if err != nil {
t.Fatalf("create schedule: %v", err)
}
if !created.Active || created.StartsLocal != "15:30:00" {
t.Fatalf("unexpected created schedule: %+v", created)
}
sessions, err := svc.ListClassSessions(ctx, owner)
if err != nil {
t.Fatalf("list sessions: %v", err)
}
var generated int
for _, session := range sessions {
if session.ScheduleID != nil && *session.ScheduleID == created.ID {
generated++
if session.StartsAt.Weekday() != time.Monday {
t.Fatalf("generated session on wrong weekday: %v", session.StartsAt)
}
}
}
if generated < 3 {
t.Fatalf("expected >=3 materialized sessions over the horizon, got %d", generated)
}
sessions2, err := svc.ListClassSessions(ctx, owner)
if err != nil {
t.Fatalf("list sessions 2: %v", err)
}
var generated2 int
for _, session := range sessions2 {
if session.ScheduleID != nil && *session.ScheduleID == created.ID {
generated2++
}
}
if generated2 != generated {
t.Fatalf("materialize not idempotent: %d then %d", generated, generated2)
}
if err := svc.DeleteClassSchedule(ctx, owner, created.ID); err != nil {
t.Fatalf("delete schedule: %v", err)
}
schedules, _ = svc.ListClassSchedules(ctx, owner)
if len(schedules) != 0 {
t.Fatalf("expected 0 schedules after delete, got %d", len(schedules))
}
}
func TestCreateClassScheduleValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 7, StartsLocal: "10:00",
}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for bad weekday, got %v", err)
}
if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "d13fe5fd-727f-4d69-bfd8-47f1b92a2cf7", DayOfWeek: 2, StartsLocal: "25:00",
}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for bad time, got %v", err)
}
if _, err := svc.CreateClassSchedule(ctx, owner, domain.CreateClassScheduleRequest{
TemplateID: "00000000-0000-0000-0000-000000000099", DayOfWeek: 2, StartsLocal: "10:00",
}); !errors.Is(err, ErrClassTemplateNotFound) {
t.Fatalf("expected ErrClassTemplateNotFound, got %v", err)
}
}
+101
View File
@@ -0,0 +1,101 @@
package db
import (
"context"
"fmt"
"time"
)
const classScheduleColumns = "id, tenant_id, template_id, location_id, day_of_week, starts_local::text, active, created_at"
func (r *PGRepository) ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+classScheduleColumns+`
FROM class_schedules
WHERE tenant_id = $1
ORDER BY day_of_week ASC, starts_local ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ClassScheduleRecord
for rows.Next() {
var record ClassScheduleRecord
if err := rows.Scan(
&record.ID,
&record.TenantID,
&record.TemplateID,
&record.LocationID,
&record.DayOfWeek,
&record.StartsLocal,
&record.Active,
&record.CreatedAt,
); err != nil {
return nil, err
}
records = append(records, record)
}
return records, rows.Err()
}
func (r *PGRepository) CreateClassSchedule(ctx context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error) {
var record ClassScheduleRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO class_schedules (tenant_id, template_id, location_id, day_of_week, starts_local)
VALUES ($1, $2, $3, $4, $5::time)
RETURNING `+classScheduleColumns+`
`, params.TenantID, params.TemplateID, params.LocationID, params.DayOfWeek, params.StartsLocal).Scan(
&record.ID,
&record.TenantID,
&record.TemplateID,
&record.LocationID,
&record.DayOfWeek,
&record.StartsLocal,
&record.Active,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) DeleteClassSchedule(ctx context.Context, tenantID string, scheduleID string) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM class_schedules WHERE id = $1 AND tenant_id = $2`, scheduleID, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("class schedule not found")
}
return nil
}
// MaterializeScheduleSessions writes concrete session rows for each active
// rule over the next `days` days in the tenant's timezone. The partial unique
// index on (schedule_id, starts_at) makes this idempotent: sessions already
// materialized are skipped, and sessions the staff edited or cancelled stay
// untouched.
func (r *PGRepository) MaterializeScheduleSessions(ctx context.Context, tenantID string, now time.Time, days int) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO class_sessions (tenant_id, template_id, location_id, starts_at, ends_at, capacity, schedule_id)
SELECT
s.tenant_id,
s.template_id,
s.location_id,
(d.day + s.starts_local) AT TIME ZONE t.timezone,
(d.day + s.starts_local) AT TIME ZONE t.timezone + make_interval(mins => tpl.duration_minutes),
tpl.capacity,
s.id
FROM class_schedules s
INNER JOIN tenants t ON t.id = s.tenant_id
INNER JOIN class_templates tpl ON tpl.id = s.template_id
CROSS JOIN generate_series(0, $3::int - 1) AS g(i)
CROSS JOIN LATERAL (SELECT ($2::timestamptz AT TIME ZONE t.timezone)::date + g.i AS day) d
WHERE s.tenant_id = $1
AND s.active
AND EXTRACT(ISODOW FROM d.day)::int = ((s.day_of_week + 6) % 7) + 1
AND (d.day + s.starts_local) AT TIME ZONE t.timezone > $2
ON CONFLICT (schedule_id, starts_at) WHERE schedule_id IS NOT NULL DO NOTHING
`, tenantID, now, days)
return err
}
+5 -4
View File
@@ -5,7 +5,7 @@ import (
) )
const classTemplateColumns = "id, tenant_id, title, duration_minutes, capacity, created_at" const classTemplateColumns = "id, tenant_id, title, duration_minutes, capacity, created_at"
const classSessionColumns = "cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity" const classSessionColumns = "cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity, cs.schedule_id"
func (r *PGRepository) ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error) { func (r *PGRepository) ListClassTemplatesByTenant(ctx context.Context, tenantID string) ([]ClassTemplateRecord, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
@@ -110,6 +110,7 @@ func (r *PGRepository) GetClassSessionByID(ctx context.Context, sessionID string
&record.StartsAt, &record.StartsAt,
&record.EndsAt, &record.EndsAt,
&record.Capacity, &record.Capacity,
&record.ScheduleID,
) )
return record, err return record, err
} }
@@ -117,10 +118,10 @@ func (r *PGRepository) GetClassSessionByID(ctx context.Context, sessionID string
func (r *PGRepository) CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) { func (r *PGRepository) CreateClassSession(ctx context.Context, params CreateClassSessionParams) (ClassSessionRecord, error) {
var record ClassSessionRecord var record ClassSessionRecord
err := r.pool.QueryRow(ctx, ` err := r.pool.QueryRow(ctx, `
INSERT INTO class_sessions (tenant_id, template_id, location_id, starts_at, ends_at, capacity) INSERT INTO class_sessions (tenant_id, template_id, location_id, starts_at, ends_at, capacity, schedule_id)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id RETURNING id
`, params.TenantID, params.TemplateID, params.LocationID, params.StartsAt, params.EndsAt, params.Capacity).Scan(&record.ID) `, params.TenantID, params.TemplateID, params.LocationID, params.StartsAt, params.EndsAt, params.Capacity, params.ScheduleID).Scan(&record.ID)
if err != nil { if err != nil {
return record, err return record, err
} }
+151
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -34,6 +35,10 @@ type Repository interface {
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error) CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error)
DeleteClassTemplate(ctx context.Context, templateID string) error DeleteClassTemplate(ctx context.Context, templateID string) error
ListClassSchedulesByTenant(ctx context.Context, tenantID string) ([]ClassScheduleRecord, error)
CreateClassSchedule(ctx context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error)
DeleteClassSchedule(ctx context.Context, tenantID string, scheduleID string) error
MaterializeScheduleSessions(ctx context.Context, tenantID string, now time.Time, days int) 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)
@@ -392,6 +397,7 @@ type ClassSessionRecord struct {
StartsAt time.Time StartsAt time.Time
EndsAt time.Time EndsAt time.Time
Capacity int32 Capacity int32
ScheduleID *string
} }
type CreateClassSessionParams struct { type CreateClassSessionParams struct {
@@ -401,6 +407,28 @@ type CreateClassSessionParams struct {
StartsAt time.Time StartsAt time.Time
EndsAt time.Time EndsAt time.Time
Capacity int32 Capacity int32
ScheduleID *string
}
// 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
StartsLocal string
Active bool
CreatedAt time.Time
}
type CreateClassScheduleParams struct {
TenantID string
TemplateID string
LocationID *string
DayOfWeek int
StartsLocal string
} }
type BookingRecord struct { type BookingRecord struct {
@@ -849,6 +877,7 @@ type MemoryRepository struct {
rules []AvailabilityRuleRecord rules []AvailabilityRuleRecord
classTemplates []ClassTemplateRecord classTemplates []ClassTemplateRecord
classSessions []ClassSessionRecord classSessions []ClassSessionRecord
classSchedules []ClassScheduleRecord
bookings []BookingRecord bookings []BookingRecord
waitlistSize int waitlistSize int
reminderJobs []ReminderJobRecord reminderJobs []ReminderJobRecord
@@ -1470,6 +1499,7 @@ func (r *MemoryRepository) CreateClassSession(_ context.Context, params CreateCl
StartsAt: params.StartsAt, StartsAt: params.StartsAt,
EndsAt: params.EndsAt, EndsAt: params.EndsAt,
Capacity: params.Capacity, Capacity: params.Capacity,
ScheduleID: params.ScheduleID,
} }
for _, template := range r.classTemplates { for _, template := range r.classTemplates {
if template.ID == params.TemplateID { if template.ID == params.TemplateID {
@@ -1496,6 +1526,122 @@ func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID strin
return pgx.ErrNoRows return pgx.ErrNoRows
} }
func (r *MemoryRepository) ListClassSchedulesByTenant(_ context.Context, tenantID string) ([]ClassScheduleRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
var out []ClassScheduleRecord
for _, schedule := range r.classSchedules {
if schedule.TenantID == tenantID {
out = append(out, schedule)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].DayOfWeek != out[j].DayOfWeek {
return out[i].DayOfWeek < out[j].DayOfWeek
}
return out[i].StartsLocal < out[j].StartsLocal
})
return out, nil
}
func (r *MemoryRepository) CreateClassSchedule(_ context.Context, params CreateClassScheduleParams) (ClassScheduleRecord, error) {
record := ClassScheduleRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
TemplateID: params.TemplateID,
LocationID: params.LocationID,
DayOfWeek: params.DayOfWeek,
StartsLocal: params.StartsLocal,
Active: true,
CreatedAt: time.Now().UTC(),
}
r.classSchedules = append(r.classSchedules, record)
return record, nil
}
func (r *MemoryRepository) DeleteClassSchedule(_ context.Context, tenantID string, scheduleID string) error {
for i, schedule := range r.classSchedules {
if schedule.ID == scheduleID && schedule.TenantID == tenantID {
r.classSchedules = append(r.classSchedules[:i], r.classSchedules[i+1:]...)
return nil
}
}
return fmt.Errorf("class schedule not found")
}
// 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.
func (r *MemoryRepository) MaterializeScheduleSessions(_ context.Context, tenantID string, now time.Time, days int) error {
if tenantID != r.tenant.ID {
return nil
}
loc, err := time.LoadLocation(r.tenant.Timezone)
if err != nil {
loc = time.UTC
}
localNow := now.In(loc)
startDate := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, loc)
existing := make(map[string]bool, len(r.classSessions))
for _, session := range r.classSessions {
if session.ScheduleID != nil {
existing[*session.ScheduleID+session.StartsAt.UTC().Format(time.RFC3339)] = true
}
}
for _, schedule := range r.classSchedules {
if !schedule.Active {
continue
}
var template ClassTemplateRecord
for _, tpl := range r.classTemplates {
if tpl.ID == schedule.TemplateID {
template = tpl
break
}
}
if template.ID == "" {
continue
}
for i := 0; i < days; i++ {
day := startDate.AddDate(0, 0, i)
if int(day.Weekday()) != schedule.DayOfWeek {
continue
}
parts := strings.Split(schedule.StartsLocal, ":")
if len(parts) < 2 {
continue
}
hour, _ := strconv.Atoi(parts[0])
minute, _ := strconv.Atoi(parts[1])
startsAt := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc)
if !startsAt.After(now) {
continue
}
key := schedule.ID + startsAt.UTC().Format(time.RFC3339)
if existing[key] {
continue
}
existing[key] = true
scheduleID := schedule.ID
r.classSessions = append(r.classSessions, ClassSessionRecord{
ID: uuid.NewString(),
TenantID: tenantID,
TemplateID: schedule.TemplateID,
LocationID: schedule.LocationID,
Title: template.Title,
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Duration(template.DurationMinutes) * time.Minute),
Capacity: int32(template.Capacity),
ScheduleID: &scheduleID,
})
}
}
return nil
}
func (r *MemoryRepository) ListClassTemplatesByTenant(_ context.Context, tenantID string) ([]ClassTemplateRecord, error) { func (r *MemoryRepository) ListClassTemplatesByTenant(_ context.Context, tenantID string) ([]ClassTemplateRecord, error) {
if tenantID != r.tenant.ID { if tenantID != r.tenant.ID {
return nil, nil return nil, nil
@@ -1613,6 +1759,11 @@ func (r *MemoryRepository) seedDemoBookings() {
serviceID := r.services[0].ID serviceID := r.services[0].ID
staffID := r.staff[0].ID staffID := r.staff[0].ID
locationID := r.locations[0].ID locationID := r.locations[0].ID
templateID := r.classTemplates[0].ID
r.classSchedules = append(r.classSchedules,
ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 3, StartsLocal: "10:00:00", Active: true, CreatedAt: now},
ClassScheduleRecord{ID: uuid.NewString(), TenantID: r.tenant.ID, TemplateID: templateID, LocationID: &locationID, DayOfWeek: 6, StartsLocal: "09:30:00", Active: true, CreatedAt: now},
)
seed := func(name, email, status string, startsAt time.Time) { seed := func(name, email, status string, startsAt time.Time) {
r.bookings = append(r.bookings, BookingRecord{ r.bookings = append(r.bookings, BookingRecord{
ID: uuid.NewString(), ID: uuid.NewString(),
+2 -1
View File
@@ -138,7 +138,7 @@ func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenant
func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) { func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID string, from time.Time, limit int) ([]ClassSessionRecord, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity SELECT cs.id, cs.tenant_id, cs.template_id, cs.location_id, ct.title, cs.starts_at, cs.ends_at, cs.capacity, cs.schedule_id
FROM class_sessions cs FROM class_sessions cs
INNER JOIN class_templates ct ON ct.id = cs.template_id INNER JOIN class_templates ct ON ct.id = cs.template_id
WHERE cs.tenant_id = $1 AND cs.starts_at >= $2 WHERE cs.tenant_id = $1 AND cs.starts_at >= $2
@@ -162,6 +162,7 @@ func (r *PGRepository) ListClassSessionsByTenant(ctx context.Context, tenantID s
&record.StartsAt, &record.StartsAt,
&record.EndsAt, &record.EndsAt,
&record.Capacity, &record.Capacity,
&record.ScheduleID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+25
View File
@@ -405,6 +405,9 @@ type ClassSession struct {
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
// it; nil for manually created sessions.
ScheduleID *string `json:"scheduleId,omitempty"`
} }
// SessionRosterEntry is one booking on a class session roster. WaitlistPosition // SessionRosterEntry is one booking on a class session roster. WaitlistPosition
@@ -447,6 +450,28 @@ type CreateClassSessionRequest struct {
Capacity *int `json:"capacity,omitempty"` Capacity *int `json:"capacity,omitempty"`
} }
// ClassSchedule is a weekly recurring rule for a class template. Concrete
// sessions are materialized for a rolling horizon; DayOfWeek follows Go's
// time.Weekday (Sunday=0) and StartsLocal is an "HH:MM" tenant-local time.
type ClassSchedule 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"`
DayOfWeek int `json:"dayOfWeek"`
StartsLocal string `json:"startsLocal"`
Active bool `json:"active"`
}
type CreateClassScheduleRequest struct {
TemplateID string `json:"templateId" binding:"required"`
LocationID *string `json:"locationId,omitempty"`
DayOfWeek int `json:"dayOfWeek" binding:"min=0,max=6"`
StartsLocal string `json:"startsLocal" binding:"required"` // "HH:MM"
}
// ============================================ // ============================================
// CUSTOMER MODELS // CUSTOMER MODELS
// ============================================ // ============================================
@@ -0,0 +1,27 @@
-- +goose Up
-- Weekly recurring rules for class sessions. Rules materialize concrete
-- session rows for a rolling horizon (the service writes them); sessions
-- keep a nullable schedule_id link so regeneration is idempotent via the
-- partial unique index and deleting a rule stops future sessions without
-- touching existing ones.
CREATE TABLE class_schedules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
template_id uuid NOT NULL REFERENCES class_templates(id) ON DELETE CASCADE,
location_id uuid REFERENCES locations(id) ON DELETE SET NULL,
day_of_week integer NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
starts_local time NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX class_schedules_tenant_idx ON class_schedules (tenant_id);
ALTER TABLE class_sessions
ADD COLUMN schedule_id uuid REFERENCES class_schedules(id) ON DELETE SET NULL;
CREATE UNIQUE INDEX class_sessions_schedule_start_idx
ON class_sessions (schedule_id, starts_at)
WHERE schedule_id IS NOT NULL;
-- +goose Down
ALTER TABLE class_sessions DROP COLUMN IF EXISTS schedule_id;
DROP TABLE IF EXISTS class_schedules;
+114
View File
@@ -1350,6 +1350,68 @@ paths:
$ref: "#/components/schemas/SessionRoster" $ref: "#/components/schemas/SessionRoster"
"404": "404":
description: Class session not found description: Class session not found
/v1/catalog/class-schedules:
get:
tags: [Catalog]
operationId: listClassSchedules
security:
- bearerAuth: []
summary: Weekly recurring rules for class sessions
description: >
Each rule materializes concrete class_sessions for a rolling 28-day
horizon in the tenant timezone. Sessions are written idempotently on
read (list class sessions or public availability), so no background
job is needed. Deleting a rule stops future materialization; sessions
already created stay in place and keep their bookings.
responses:
"200":
description: Recurring class schedules
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ClassSchedule"
post:
tags: [Catalog]
operationId: createClassSchedule
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateClassScheduleRequest"
responses:
"201":
description: Class schedule created (sessions materialized immediately)
content:
application/json:
schema:
$ref: "#/components/schemas/ClassSchedule"
"400":
description: Invalid request
"404":
description: Template or location not found
/v1/catalog/class-schedules/{scheduleID}:
delete:
tags: [Catalog]
operationId: deleteClassSchedule
security:
- bearerAuth: []
parameters:
- in: path
name: scheduleID
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Class schedule deleted
"404":
description: Class schedule not found
# ---------------- Calendar ---------------- # ---------------- Calendar ----------------
/v1/calendar/feed: /v1/calendar/feed:
get: get:
@@ -2755,6 +2817,10 @@ components:
type: integer type: integer
waitlistCount: waitlistCount:
type: integer type: integer
scheduleId:
type: string
format: uuid
description: Recurring rule that generated this session; absent for manual sessions
SessionRosterEntry: SessionRosterEntry:
type: object type: object
required: [bookingId, reference, customerName, customerEmail, status, createdAt] required: [bookingId, reference, customerName, customerEmail, status, createdAt]
@@ -2825,6 +2891,54 @@ components:
format: date-time format: date-time
capacity: capacity:
type: integer type: integer
ClassSchedule:
type: object
required: [id, tenantId, templateId, templateTitle, dayOfWeek, startsLocal, active]
properties:
id:
type: string
format: uuid
tenantId:
type: string
format: uuid
templateId:
type: string
format: uuid
templateTitle:
type: string
locationId:
type: string
format: uuid
locationName:
type: string
dayOfWeek:
type: integer
minimum: 0
maximum: 6
description: 0=Sunday ... 6=Saturday
startsLocal:
type: string
description: Tenant-local start time, HH:MM:SS
active:
type: boolean
CreateClassScheduleRequest:
type: object
required: [templateId, dayOfWeek, startsLocal]
properties:
templateId:
type: string
format: uuid
locationId:
type: string
format: uuid
dayOfWeek:
type: integer
minimum: 0
maximum: 6
description: 0=Sunday ... 6=Saturday
startsLocal:
type: string
description: Tenant-local start time, HH:MM
PlanEntitlements: PlanEntitlements:
type: object type: object
required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking] required: [maxLocations, maxStaff, emailReminders, advancedReporting, widgetEmbedding, umamiTracking]
@@ -446,6 +446,19 @@ const dictionaries = {
"dashboard.class.queuePosition": "v pořadí", "dashboard.class.queuePosition": "v pořadí",
"dashboard.class.showRoster": "Účastníci", "dashboard.class.showRoster": "Účastníci",
"dashboard.class.hideRoster": "Skrýt", "dashboard.class.hideRoster": "Skrýt",
"dashboard.class.recurring": "Opakující se lekce",
"dashboard.class.recurringHint": "Týdenní pravidla automaticky vytvářejí lekce na příštích 28 dní. Smazání pravidla nechává již vytvořené lekce nedotčené.",
"dashboard.class.weekday": "Den v týdnu",
"dashboard.class.startsLocal": "Čas",
"dashboard.class.addSchedule": "Přidat pravidlo",
"dashboard.class.noSchedules": "Zatím žádná opakující se pravidla.",
"dashboard.class.dow.0": "Neděle",
"dashboard.class.dow.1": "Pondělí",
"dashboard.class.dow.2": "Úterý",
"dashboard.class.dow.3": "Středa",
"dashboard.class.dow.4": "Čtvrtek",
"dashboard.class.dow.5": "Pátek",
"dashboard.class.dow.6": "Sobota",
"dashboard.waitlistPosition": "Pořadí v čekací listině", "dashboard.waitlistPosition": "Pořadí v čekací listině",
"dashboard.booking.promote": "Potvrdit z čekací listiny", "dashboard.booking.promote": "Potvrdit z čekací listiny",
"dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?", "dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?",
@@ -1052,6 +1065,19 @@ const dictionaries = {
"dashboard.class.queuePosition": "in queue", "dashboard.class.queuePosition": "in queue",
"dashboard.class.showRoster": "Roster", "dashboard.class.showRoster": "Roster",
"dashboard.class.hideRoster": "Hide", "dashboard.class.hideRoster": "Hide",
"dashboard.class.recurring": "Recurring classes",
"dashboard.class.recurringHint": "Weekly rules automatically create sessions for the next 28 days. Deleting a rule leaves existing sessions untouched.",
"dashboard.class.weekday": "Weekday",
"dashboard.class.startsLocal": "Time",
"dashboard.class.addSchedule": "Add rule",
"dashboard.class.noSchedules": "No recurring rules yet.",
"dashboard.class.dow.0": "Sunday",
"dashboard.class.dow.1": "Monday",
"dashboard.class.dow.2": "Tuesday",
"dashboard.class.dow.3": "Wednesday",
"dashboard.class.dow.4": "Thursday",
"dashboard.class.dow.5": "Friday",
"dashboard.class.dow.6": "Saturday",
"dashboard.waitlistPosition": "Waitlist position", "dashboard.waitlistPosition": "Waitlist position",
"dashboard.booking.promote": "Promote from waitlist", "dashboard.booking.promote": "Promote from waitlist",
"dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?", "dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?",
@@ -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 } from "../../components/dashboard/icons"; import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout"; import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() { function ZonesPage() {
@@ -30,6 +30,11 @@ function ZonesPage() {
const [newSessionLocationId, setNewSessionLocationId] = createSignal(""); const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal(""); const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
const [sessionSaving, setSessionSaving] = createSignal(false); const [sessionSaving, setSessionSaving] = createSignal(false);
const [newSchedTemplateId, setNewSchedTemplateId] = createSignal("");
const [newSchedDayOfWeek, setNewSchedDayOfWeek] = createSignal("3");
const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal("");
const [newSchedLocationId, setNewSchedLocationId] = createSignal("");
const [schedSaving, setSchedSaving] = 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
@@ -131,6 +136,24 @@ function ZonesPage() {
}, },
); );
const [classSchedules, { refetch: refetchSchedules }] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
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 },
];
}
const response = await (apiClient as any).GET("/v1/catalog/class-schedules", authHeaders());
return (response.data as any[]) ?? [];
},
);
const weekdayName = (dow: number) =>
data.i18n.t(`dashboard.class.dow.${dow}`) as string;
const [roster, { refetch: refetchRoster }] = createResource( const [roster, { refetch: refetchRoster }] = createResource(
() => [data.token(), expandedSessionId()] as const, () => [data.token(), expandedSessionId()] as const,
async ([bearer, sessionId]) => { async ([bearer, sessionId]) => {
@@ -357,6 +380,42 @@ function ZonesPage() {
} }
}; };
const handleAddSchedule = async () => {
const bearer = data.token();
if (!newSchedTemplateId() || !newSchedStartsLocal()) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setSchedSaving(true);
try {
await (apiClient as any).POST("/v1/catalog/class-schedules", {
...authHeaders(),
body: {
templateId: newSchedTemplateId(),
dayOfWeek: parseInt(newSchedDayOfWeek(), 10),
startsLocal: newSchedStartsLocal(),
...(newSchedLocationId() ? { locationId: newSchedLocationId() } : {}),
},
});
setNewSchedTemplateId(""); setNewSchedStartsLocal(""); setNewSchedLocationId("");
void refetchSchedules();
void refetchSessions();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally {
setSchedSaving(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; }
try {
await (apiClient as any).DELETE(`/v1/catalog/class-schedules/${id}`, authHeaders());
void refetchSchedules();
} catch {
data.setDemoNotice(cs() ? "Smazání selhalo." : "Delete failed.");
}
};
const typeLabel = (type: string) => { const typeLabel = (type: string) => {
const map: Record<string, string> = { room: data.i18n.t("dashboard.zone.rooms"), private: data.i18n.t("dashboard.zone.private"), hall: data.i18n.t("dashboard.zone.hall") }; const map: Record<string, string> = { room: data.i18n.t("dashboard.zone.rooms"), private: data.i18n.t("dashboard.zone.private"), hall: data.i18n.t("dashboard.zone.hall") };
return map[type] || type; return map[type] || type;
@@ -607,6 +666,72 @@ function ZonesPage() {
</Show> </Show>
</div> </div>
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.recurring")}</h3>
<p class="text-sm text-ink-muted mt-1">{data.i18n.t("dashboard.class.recurringHint")}</p>
</div>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<Select
label={data.i18n.t("dashboard.class.template")}
value={newSchedTemplateId()}
onChange={(v) => setNewSchedTemplateId(v)}
options={[
{ value: "", label: "—" },
...(classTemplates() ?? []).map((t: any) => ({ value: t.id, label: t.title })),
]}
/>
<Select
label={data.i18n.t("dashboard.class.weekday")}
value={newSchedDayOfWeek()}
onChange={(v) => setNewSchedDayOfWeek(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={newSchedStartsLocal()} onInput={(e) => setNewSchedStartsLocal(e.currentTarget.value)} />
<Select
label={data.i18n.t("dashboard.class.location")}
value={newSchedLocationId()}
onChange={(v) => setNewSchedLocationId(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
]}
/>
<div class="flex items-end">
<button onClick={() => void handleAddSchedule()} disabled={schedSaving() || !newSchedTemplateId() || !newSchedStartsLocal()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{schedSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addSchedule")}
</button>
</div>
</div>
</div>
<Show when={(classSchedules() ?? []).length > 0} fallback={
<div class="p-10 text-center">
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.class.noSchedules")}</p>
</div>
}>
<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>
</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>
)}
</For>
</div>
</Show>
</div>
<div class="surface-card overflow-hidden"> <div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border"> <div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.sessions")}</h3> <h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.sessions")}</h3>
+153
View File
@@ -396,6 +396,42 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/catalog/class-schedules": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Weekly recurring rules for class sessions
* @description Each rule materializes concrete class_sessions for a rolling 28-day horizon in the tenant timezone. Sessions are written idempotently on read (list class sessions or public availability), so no background job is needed. Deleting a rule stops future materialization; sessions already created stay in place and keep their bookings.
*/
get: operations["listClassSchedules"];
put?: never;
post: operations["createClassSchedule"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/class-schedules/{scheduleID}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteClassSchedule"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/class-sessions": { "/v1/catalog/class-sessions": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1315,6 +1351,23 @@ export interface components {
*/ */
planCode: "starter" | "pro" | "business"; planCode: "starter" | "pro" | "business";
}; };
ClassSchedule: {
active: boolean;
/** @description 0=Sunday ... 6=Saturday */
dayOfWeek: number;
/** Format: uuid */
id: string;
/** Format: uuid */
locationId?: string;
locationName?: string;
/** @description Tenant-local start time, HH:MM:SS */
startsLocal: string;
/** Format: uuid */
templateId: string;
templateTitle: string;
/** Format: uuid */
tenantId: string;
};
ClassSession: { ClassSession: {
bookedCount: number; bookedCount: number;
capacity: number; capacity: number;
@@ -1325,6 +1378,11 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
locationId?: string; locationId?: string;
locationName?: string; locationName?: string;
/**
* Format: uuid
* @description Recurring rule that generated this session; absent for manual sessions
*/
scheduleId?: string;
/** Format: date-time */ /** Format: date-time */
startsAt: string; startsAt: string;
/** Format: uuid */ /** Format: uuid */
@@ -1375,6 +1433,16 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
status: "confirmed" | "waitlisted"; status: "confirmed" | "waitlisted";
}; };
CreateClassScheduleRequest: {
/** @description 0=Sunday ... 6=Saturday */
dayOfWeek: number;
/** Format: uuid */
locationId?: string;
/** @description Tenant-local start time, HH:MM */
startsLocal: string;
/** Format: uuid */
templateId: string;
};
CreateClassSessionRequest: { CreateClassSessionRequest: {
capacity?: number; capacity?: number;
/** Format: uuid */ /** Format: uuid */
@@ -2677,6 +2745,91 @@ export interface operations {
}; };
}; };
}; };
listClassSchedules: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Recurring class schedules */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClassSchedule"][];
};
};
};
};
createClassSchedule: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateClassScheduleRequest"];
};
};
responses: {
/** @description Class schedule created (sessions materialized immediately) */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ClassSchedule"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Template or location not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteClassSchedule: {
parameters: {
query?: never;
header?: never;
path: {
scheduleID: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Class schedule deleted */
200: {
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 -1
View File
@@ -204,6 +204,15 @@
- verified live: staff report shows 4/3/1, 180/10560 minutes, 1% utilization, 3 600 Kč; service report aggregates correctly; browser download produced the real file; focused tests cover both reports and the capacity relationship - verified live: staff report shows 4/3/1, 180/10560 minutes, 1% utilization, 3 600 Kč; service report aggregates correctly; browser download produced the real file; focused tests cover both reports and the capacity relationship
- remaining: live staging validation on Neon + pgx repository path - remaining: live staging validation on Neon + pgx repository path
- `[done/partial]` Milestone 29: Recurring class schedules
- `class_schedules` table (migration 00018): weekly rules — template, optional location, `day_of_week` (Go weekday, Sun=0), `starts_local` time, `active`; sessions gained a nullable `schedule_id` link with a partial unique index on `(schedule_id, starts_at)` for idempotent regeneration
- no cron/batch scheduler: rules materialize concrete session rows lazily on read — `ListClassSessions` (dashboard) and `Availability` (public booking) both call `MaterializeScheduleSessions(tenant, now, 28d)`; PG does it in a single INSERT…SELECT with `ON CONFLICT DO NOTHING`, memory repo mirrors it; a live-verify bug (HH:MM:SS parse) was caught and fixed
- deleting a rule stops future sessions; sessions already materialized stay in place with their bookings — per-session capacity overrides and manual deletes survive regeneration
- `GET/POST/DELETE /v1/catalog/class-schedules` with tenant scoping, template/location ownership checks, weekday 0-6 and HH:MM validation
- dashboard: "Opakující se lekce" card between templates and sessions on the classes tab — rule form (template, weekday, time, optional location) + rule list; sessions expose `scheduleId` so rule-generated sessions are identifiable
- verified live: seeded Wed 10:00 + Sat 09:30 rules generated 8 sessions at correct local times; create → 4 Monday sessions materialized immediately; delete → rule gone, sessions remain; dayOfWeek 9 rejected; public availability converts local→UTC correctly; focused tests cover materialization, idempotency, validation
- remaining: live staging validation on Neon + pgx repository path; no bulk series edit (delete-and-recreate by design)
## 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.
@@ -453,7 +462,7 @@ 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.
- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios. - **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.
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light. - **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
- **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.
- **Service performance panel**: M28's service-report aggregation could surface in the dashboard (revenue by service, top performers) once the metric set proves stable — the CSV is the probe. - **Service performance panel**: M28's service-report aggregation could surface in the dashboard (revenue by service, top performers) once the metric set proves stable — the CSV is the probe.