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 }