mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
PATCH /v1/catalog/class-schedules/:id patches a recurring rule and, with applyToFuture, retimes materialized future sessions to the new local start time on their existing dates — moving confirmed and waitlisted bookings with them and re-arming reminders. Zones-page schedule rows gain an inline editor (CS/EN). Also drops the vestigial waitlist_entries table (migration 00020): positions derive from bookings.created_at, so the ledger was written but never read. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
188 lines
6.7 KiB
Go
188 lines
6.7 KiB
Go
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
|
|
}
|
|
|
|
// UpdateClassSchedule edits an existing recurring rule; nil fields are kept.
|
|
// The tenant guard makes the update a no-match error for foreign schedules.
|
|
func (r *PGRepository) UpdateClassSchedule(ctx context.Context, tenantID string, scheduleID string, params UpdateClassScheduleParams) (ClassScheduleRecord, error) {
|
|
var record ClassScheduleRecord
|
|
err := r.pool.QueryRow(ctx, `
|
|
UPDATE class_schedules SET
|
|
day_of_week = COALESCE($3, day_of_week),
|
|
starts_local = COALESCE($4::time, starts_local),
|
|
location_id = CASE WHEN $5::text IS NULL THEN location_id WHEN $5::text = '' THEN NULL ELSE $5::uuid END,
|
|
active = COALESCE($6, active)
|
|
WHERE id = $1 AND tenant_id = $2
|
|
RETURNING `+classScheduleColumns+`
|
|
`, scheduleID, tenantID, params.DayOfWeek, params.StartsLocal, params.LocationID, params.Active).Scan(
|
|
&record.ID,
|
|
&record.TenantID,
|
|
&record.TemplateID,
|
|
&record.LocationID,
|
|
&record.DayOfWeek,
|
|
&record.StartsLocal,
|
|
&record.Active,
|
|
&record.CreatedAt,
|
|
)
|
|
return record, err
|
|
}
|
|
|
|
// ListClassSessionsBySchedule returns materialized sessions of one rule,
|
|
// optionally only those starting after `after`.
|
|
func (r *PGRepository) ListClassSessionsBySchedule(ctx context.Context, tenantID string, scheduleID string, after time.Time) ([]ClassSessionRecord, error) {
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT `+classSessionColumns+`
|
|
FROM class_sessions cs
|
|
INNER JOIN class_templates ct ON ct.id = cs.template_id
|
|
WHERE cs.tenant_id = $1 AND cs.schedule_id = $2 AND cs.starts_at > $3
|
|
ORDER BY cs.starts_at ASC
|
|
`, tenantID, scheduleID, after)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var records []ClassSessionRecord
|
|
for rows.Next() {
|
|
var record ClassSessionRecord
|
|
if err := rows.Scan(
|
|
&record.ID,
|
|
&record.TenantID,
|
|
&record.TemplateID,
|
|
&record.LocationID,
|
|
&record.Title,
|
|
&record.StartsAt,
|
|
&record.EndsAt,
|
|
&record.Capacity,
|
|
&record.ScheduleID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
return records, rows.Err()
|
|
}
|
|
|
|
// RetimeFutureScheduleSessions shifts each future session of a rule to a new
|
|
// local start time (keeping its calendar date) and optionally a new location.
|
|
// Live bookings on those sessions are moved to the same window so reminders
|
|
// and calendars stay in sync. A nil startsLocal keeps session times.
|
|
func (r *PGRepository) RetimeFutureScheduleSessions(ctx context.Context, tenantID string, scheduleID string, startsLocal *string, locationID *string, now time.Time) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
WITH moved AS (
|
|
UPDATE class_sessions cs SET
|
|
starts_at = CASE WHEN $4::time IS NULL THEN cs.starts_at
|
|
ELSE ((cs.starts_at AT TIME ZONE t.timezone)::date + $4::time) AT TIME ZONE t.timezone END,
|
|
ends_at = CASE WHEN $4::time IS NULL THEN cs.ends_at
|
|
ELSE ((cs.starts_at AT TIME ZONE t.timezone)::date + $4::time) AT TIME ZONE t.timezone + make_interval(mins => tpl.duration_minutes) END,
|
|
location_id = CASE WHEN $5::text IS NULL THEN cs.location_id WHEN $5::text = '' THEN NULL ELSE $5::uuid END
|
|
FROM tenants t, class_templates tpl
|
|
WHERE cs.tenant_id = $1 AND cs.schedule_id = $2 AND cs.starts_at > $3
|
|
AND t.id = cs.tenant_id AND tpl.id = cs.template_id
|
|
RETURNING cs.id, cs.starts_at, cs.ends_at
|
|
)
|
|
UPDATE bookings b SET starts_at = m.starts_at, ends_at = m.ends_at
|
|
FROM moved m
|
|
WHERE b.class_session_id = m.id AND b.status IN ('confirmed', 'waitlisted')
|
|
`, tenantID, scheduleID, now, startsLocal, locationID)
|
|
return err
|
|
}
|