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
@@ -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 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) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
@@ -110,6 +110,7 @@ func (r *PGRepository) GetClassSessionByID(ctx context.Context, sessionID string
|
||||
&record.StartsAt,
|
||||
&record.EndsAt,
|
||||
&record.Capacity,
|
||||
&record.ScheduleID,
|
||||
)
|
||||
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) {
|
||||
var record ClassSessionRecord
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO class_sessions (tenant_id, template_id, location_id, starts_at, ends_at, capacity)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO class_sessions (tenant_id, template_id, location_id, starts_at, ends_at, capacity, schedule_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
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 {
|
||||
return record, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +35,10 @@ type Repository interface {
|
||||
CreateClassTemplate(ctx context.Context, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
||||
UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, 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)
|
||||
ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error)
|
||||
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
|
||||
@@ -392,6 +397,7 @@ type ClassSessionRecord struct {
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
Capacity int32
|
||||
ScheduleID *string
|
||||
}
|
||||
|
||||
type CreateClassSessionParams struct {
|
||||
@@ -401,6 +407,28 @@ type CreateClassSessionParams struct {
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
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 {
|
||||
@@ -849,6 +877,7 @@ type MemoryRepository struct {
|
||||
rules []AvailabilityRuleRecord
|
||||
classTemplates []ClassTemplateRecord
|
||||
classSessions []ClassSessionRecord
|
||||
classSchedules []ClassScheduleRecord
|
||||
bookings []BookingRecord
|
||||
waitlistSize int
|
||||
reminderJobs []ReminderJobRecord
|
||||
@@ -1470,6 +1499,7 @@ func (r *MemoryRepository) CreateClassSession(_ context.Context, params CreateCl
|
||||
StartsAt: params.StartsAt,
|
||||
EndsAt: params.EndsAt,
|
||||
Capacity: params.Capacity,
|
||||
ScheduleID: params.ScheduleID,
|
||||
}
|
||||
for _, template := range r.classTemplates {
|
||||
if template.ID == params.TemplateID {
|
||||
@@ -1496,6 +1526,122 @@ func (r *MemoryRepository) DeleteClassSession(_ context.Context, sessionID strin
|
||||
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) {
|
||||
if tenantID != r.tenant.ID {
|
||||
return nil, nil
|
||||
@@ -1613,6 +1759,11 @@ func (r *MemoryRepository) seedDemoBookings() {
|
||||
serviceID := r.services[0].ID
|
||||
staffID := r.staff[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) {
|
||||
r.bookings = append(r.bookings, BookingRecord{
|
||||
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) {
|
||||
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
|
||||
INNER JOIN class_templates ct ON ct.id = cs.template_id
|
||||
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.EndsAt,
|
||||
&record.Capacity,
|
||||
&record.ScheduleID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user