mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-29 07:54:44 +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
|
||||
}
|
||||
Reference in new issue
Block a user