mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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:
1 parent
7b9080eb1c
commit
ad60167a31
15 files changed
+1029
-7
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user