Files
Crussell/backend/handlers/scheduling/time-blockers.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

1007 lines
33 KiB
Go

package scheduling
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"crussell/db"
"crussell/clock"
"crussell/internal/validators"
"crussell/mw"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/robfig/cron/v3"
)
// --- Types ---
type TimeBlocker struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
DurationMinutes int `json:"duration_minutes"`
Description string `json:"description,omitempty"`
CronExpression *string `json:"cron_expression,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedBy *string `json:"created_by,omitempty"`
}
type CreateTimeBlockerRequest struct {
StartTime time.Time `json:"start_time" validate:"required"`
DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"`
Description string `json:"description,omitempty" validate:"omitempty,max=500"`
CronExpression *string `json:"cron_expression,omitempty" validate:"omitempty,max=500"`
}
// --- List Time Blockers ---
// GET /api/admin/time-blockers
// Returns:
// - Future one-off blockers (cron_expression IS NULL AND start_time >= now)
// - ALL recurring blockers (cron_expression IS NOT NULL)
func ListTimeBlockers(w http.ResponseWriter, r *http.Request) {
// Optional date range filtering
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
var rows pgx.Rows
var err error
if startStr != "" && endStr != "" {
// Filter by date range
start, err1 := time.Parse("2006-01-02", startStr)
end, err2 := time.Parse("2006-01-02", endStr)
if err1 != nil || err2 != nil {
http.Error(w, "invalid date format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, londonLocation)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, londonLocation)
// Get one-off blockers in range + ALL recurring blockers
rows, err = db.Conn.Query(r.Context(), `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers
WHERE (cron_expression IS NULL AND start_time >= $1 AND start_time <= $2)
OR (cron_expression IS NOT NULL)
ORDER BY
CASE WHEN cron_expression IS NULL THEN 0 ELSE 1 END,
start_time DESC
`, start, end)
} else {
// Get future one-off blockers + ALL recurring blockers
now := clock.Now()
rows, err = db.Conn.Query(r.Context(), `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers
WHERE (cron_expression IS NULL AND start_time >= $1)
OR (cron_expression IS NOT NULL)
ORDER BY
CASE WHEN cron_expression IS NULL THEN 0 ELSE 1 END,
start_time DESC
LIMIT 100
`, now)
}
if err != nil {
http.Error(w, "failed to fetch time blockers", http.StatusInternalServerError)
return
}
defer rows.Close()
var blockers []TimeBlocker
for rows.Next() {
var b TimeBlocker
if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil {
http.Error(w, "failed to scan time blocker", http.StatusInternalServerError)
return
}
blockers = append(blockers, b)
}
if err := rows.Err(); err != nil {
http.Error(w, "error iterating time blockers", http.StatusInternalServerError)
return
}
if blockers == nil {
blockers = []TimeBlocker{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(blockers)
}
// --- Create Time Blocker ---
// POST /api/admin/time-blockers
func CreateTimeBlocker(w http.ResponseWriter, r *http.Request) {
var req CreateTimeBlockerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Validate required fields
if req.StartTime.IsZero() {
http.Error(w, "start_time is required", http.StatusBadRequest)
return
}
if req.DurationMinutes <= 0 {
http.Error(w, "duration_minutes must be greater than 0", http.StatusBadRequest)
return
}
// Get admin user ID from context
var createdBy *string
if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
createdBy = &userID
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Insert the time blocker
var blocker TimeBlocker
err = tx.QueryRow(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, start_time, duration_minutes, description, cron_expression, created_at, created_by
`, req.StartTime, req.DurationMinutes, req.Description, req.CronExpression, createdBy).Scan(
&blocker.ID, &blocker.StartTime, &blocker.DurationMinutes, &blocker.Description,
&blocker.CronExpression, &blocker.CreatedAt, &blocker.CreatedBy,
)
if err != nil {
http.Error(w, "failed to create time blocker", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(blocker)
}
// --- Delete Time Blocker ---
// DELETE /api/admin/time-blockers/{id}
func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" || !validators.IsValidID(id) {
http.Error(w, "time blocker not found", http.StatusNotFound)
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
result, err := tx.Exec(r.Context(), `
DELETE FROM time_blockers WHERE id = $1
`, id)
if err != nil {
http.Error(w, "failed to delete time blocker", http.StatusInternalServerError)
return
}
rowsAffected := result.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "time blocker not found", http.StatusNotFound)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Helper: Get Time Blockers in Range ---
// --- Helper: Get Time Blockers in Range ---
// Returns blockers for the given date range, expanded for recurring blockers
// Used by GetAvailableHours to subtract blocked time from available slots
func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBlocker, error) {
rows, err := db.Conn.Query(ctx, `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers
WHERE (cron_expression IS NULL AND start_time >= $1 AND start_time <= $2)
OR (cron_expression IS NOT NULL)
ORDER BY start_time
`, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
var blockers []TimeBlocker
for rows.Next() {
var b TimeBlocker
if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil {
return nil, err
}
if b.CronExpression != nil {
occurrences := expandCronOccurrences(b, start, end)
blockers = append(blockers, occurrences...)
} else {
blockers = append(blockers, b)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return blockers, nil
}
// expandCronOccurrences expands a recurring blocker to all occurrences within a date range
// The cron expression defines the pattern, and the blocker's start_time provides the time-of-day
func expandCronOccurrences(blocker TimeBlocker, rangeStart, rangeEnd time.Time) []TimeBlocker {
if blocker.CronExpression == nil {
return nil
}
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
schedule, err := parser.Parse(*blocker.CronExpression)
if err != nil {
log.Printf("Invalid cron expression '%s': %v", *blocker.CronExpression, err)
return nil
}
// Get the time-of-day from the blocker's start_time in London time,
// so the recurrence fires at the same wall-clock time regardless of DST.
blockerLondon := blocker.StartTime.In(londonLocation)
blockerHour := blockerLondon.Hour()
blockerMinute := blockerLondon.Minute()
var occurrences []TimeBlocker
// Start from the beginning of the range using London timezone,
// ensuring the same wall-clock time applies year-round.
current := time.Date(rangeStart.Year(), rangeStart.Month(), rangeStart.Day(), blockerHour, blockerMinute, 0, 0, londonLocation)
// Find the first occurrence on or after rangeStart
firstNext := schedule.Next(current.Add(-time.Second))
if firstNext.Before(rangeStart) {
current = schedule.Next(firstNext)
} else {
current = firstNext
}
// Collect all occurrences within the range
for current.Before(rangeEnd) || current.Equal(rangeEnd) {
// Create a new blocker instance for this occurrence
occurrence := TimeBlocker{
ID: blocker.ID,
StartTime: current,
DurationMinutes: blocker.DurationMinutes,
Description: blocker.Description,
CronExpression: blocker.CronExpression,
CreatedAt: blocker.CreatedAt,
CreatedBy: blocker.CreatedBy,
}
occurrences = append(occurrences, occurrence)
// Get next occurrence
next := schedule.Next(current)
if next.Equal(current) {
break // Prevent infinite loop if schedule isn't advancing
}
current = next
}
return occurrences
}
// --- Helper: Check Time Blocker Overlap ---
// Returns (hasOverlap, blockerDescription, error)
// Used by booking handlers to check for blocker conflicts
func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time) (bool, string, error) {
// Get all blockers in an expanded range that could overlap
// We need to look further back because recurring blockers could span multiple periods
searchStart := startTime.AddDate(0, -1, 0) // Look back 1 month for recurring patterns
searchEnd := endTime
blockers, err := GetTimeBlockersInRange(ctx, searchStart, searchEnd)
if err != nil {
return false, "", err
}
// Check each blocker (one-off or expanded recurring) for overlap
for _, blocker := range blockers {
blockerEnd := blocker.StartTime.Add(time.Duration(blocker.DurationMinutes) * time.Minute)
// Check if the booking overlaps with the blocker
// Overlap condition: booking_start < blocker_end AND booking_end > blocker_start
if startTime.Before(blockerEnd) && endTime.After(blocker.StartTime) {
desc := blocker.Description
if desc == "" {
desc = "Time blocked"
}
if blocker.CronExpression != nil {
desc = fmt.Sprintf("%s (recurring: %s)", desc, *blocker.CronExpression)
}
return true, desc, nil
}
}
return false, "", nil
}
// CleanupOldReservations deletes expired reservations:
// - Logged-in (RESERVATION:user): older than 1 hour
// - Anonymous (RESERVATION:anon): older than 10 minutes
// - Admin walk-in (RESERVATION:admin:walkin:%): older than 15 minutes
// - Admin call-in (RESERVATION:admin:callin:%): older than 15 minutes
func CleanupOldReservations(ctx context.Context) error {
oneHourAgo := clock.Now().Add(-1 * time.Hour)
tenMinutesAgo := clock.Now().Add(-10 * time.Minute)
fifteenMinutesAgo := clock.Now().Add(-15 * time.Minute)
twentyFourHoursAgo := clock.Now().Add(-24 * time.Hour)
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
DELETE FROM time_blockers
WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1)
OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2)
OR (description LIKE 'RESERVATION:admin:walkin:%' AND created_at < $3)
OR (description LIKE 'RESERVATION:admin:callin:%' AND created_at < $3)
OR (description LIKE 'RESERVATION:edit_request:%' AND created_at < $4)
OR (description LIKE 'PAYMENT_IN_FLIGHT:%' AND start_time + (duration_minutes * INTERVAL '1 minute') < NOW())
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// AnonymizeStaleGuestAccounts anonymizes personal data for guest accounts
// whose last booking was more than 6 months ago (UK GDPR storage limitation).
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
// Active/pending bookings are excluded so the salon can still contact the guest.
func AnonymizeStaleGuestAccounts(ctx context.Context) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
UPDATE users SET
n_first_name = 'Guest',
n_last_name = 'Anonymized',
email = 'anon-' || id || '@anon.invalid',
phone = '000000000000',
date_of_birth = '1900-01-01',
profile_pic_url = NULL,
referral_code = NULL,
notes = NULL,
data_retention_consent = FALSE,
updated_at = NOW()
WHERE account_role = 'guest'
AND NOT EXISTS (SELECT 1 FROM bookings WHERE user_id = users.id AND status IN ('pending', 'confirmed'))
AND EXISTS (SELECT 1 FROM bookings WHERE user_id = users.id GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months')
`)
if err != nil {
return err
}
// Anonymize patch test records for stale guests (medical-adjacent PII)
_, err = tx.Exec(ctx, `
UPDATE user_patch_tests SET user_id = NULL
WHERE user_id IN (
SELECT id FROM users
WHERE account_role = 'guest'
AND n_first_name = 'Guest'
AND n_last_name = 'Anonymized'
)
`)
if err != nil {
return err
}
// Anonymize referral relationships for stale guests
_, err = tx.Exec(ctx, `
UPDATE user_referrals SET referrer_id = NULL
WHERE referrer_id IN (
SELECT id FROM users
WHERE account_role = 'guest'
AND n_first_name = 'Guest'
AND n_last_name = 'Anonymized'
)
`)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
UPDATE user_referrals SET referred_id = NULL
WHERE referred_id IN (
SELECT id FROM users
WHERE account_role = 'guest'
AND n_first_name = 'Guest'
AND n_last_name = 'Anonymized'
)
`)
if err != nil {
return err
}
// Anonymize admin notification references for stale guests
_, err = tx.Exec(ctx, `
UPDATE admin_notifications SET user_id = NULL
WHERE user_id IN (
SELECT id FROM users
WHERE account_role = 'guest'
AND n_first_name = 'Guest'
AND n_last_name = 'Anonymized'
)
`)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func CleanupExpiredLoyaltyRedemptions(ctx context.Context) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
DELETE FROM loyalty_redemptions
WHERE status = 'pending'
AND expires_at < NOW()
`)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// CleanupExpiredFinancialRecords deletes granular payment/refund records whose
// retention period has expired and replaces them with monthly aggregates.
//
// Retention: MAX(p.created_at + 7 years, user_anonymized_at + 1 year)
// - Walk-in records (no user): 7 years from payment creation
// - Active users: 7 years from payment creation
// - Anonymized users: 7 years from payment creation AND 1 year since anonymization
//
// The function is idempotent — running it twice produces the same result.
const retentionFilter = `AND (
b.user_id IS NULL
OR u.id IS NULL
OR NOT (u.account_role = 'guest' AND (u.email LIKE 'anon-%@anon.invalid' OR u.email LIKE 'deleted+%@deleted.invalid'))
OR u.updated_at < NOW() - INTERVAL '1 year'
)`
func CleanupExpiredFinancialRecords(ctx context.Context) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO financial_aggregates (month, total_payments, total_square_fees, total_cash, total_online, total_in_person, total_discounts, total_giftcard, total_tips, total_deposits, total_balances, total_partials, total_vat_amount, total_net_amount, booking_count)
SELECT
DATE_TRUNC('month', p.created_at)::date AS month,
COALESCE(SUM(p.amount), 0) AS total_payments,
COALESCE(SUM(p.fees), 0) AS total_square_fees,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'cash'), 0) AS total_cash,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'online_square'), 0) AS total_online,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'in_person_card'), 0) AS total_in_person,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'discount'), 0) AS total_discounts,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'giftcard'), 0) AS total_giftcard,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'tip'), 0) AS total_tips,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'deposit'), 0) AS total_deposits,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'balance'), 0) AS total_balances,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'partial'), 0) AS total_partials,
COALESCE(SUM(p.vat_amount), 0) AS total_vat_amount,
COALESCE(SUM(p.net_amount), 0) AS total_net_amount,
COALESCE(COUNT(DISTINCT p.booking_id), 0) AS booking_count
FROM payments p
LEFT JOIN bookings b ON p.booking_id = b.id
LEFT JOIN users u ON b.user_id = u.id
WHERE p.created_at < NOW() - INTERVAL '7 years'
` + retentionFilter + `
GROUP BY DATE_TRUNC('month', p.created_at)::date
ON CONFLICT (month) DO UPDATE SET
total_payments = financial_aggregates.total_payments + EXCLUDED.total_payments,
total_square_fees = financial_aggregates.total_square_fees + EXCLUDED.total_square_fees,
total_cash = financial_aggregates.total_cash + EXCLUDED.total_cash,
total_online = financial_aggregates.total_online + EXCLUDED.total_online,
total_in_person = financial_aggregates.total_in_person + EXCLUDED.total_in_person,
total_discounts = financial_aggregates.total_discounts + EXCLUDED.total_discounts,
total_giftcard = financial_aggregates.total_giftcard + EXCLUDED.total_giftcard,
total_tips = financial_aggregates.total_tips + EXCLUDED.total_tips,
total_deposits = financial_aggregates.total_deposits + EXCLUDED.total_deposits,
total_balances = financial_aggregates.total_balances + EXCLUDED.total_balances,
total_partials = financial_aggregates.total_partials + EXCLUDED.total_partials,
total_vat_amount = financial_aggregates.total_vat_amount + EXCLUDED.total_vat_amount,
total_net_amount = financial_aggregates.total_net_amount + EXCLUDED.total_net_amount,
booking_count = financial_aggregates.booking_count + EXCLUDED.booking_count
`)
if err != nil {
return fmt.Errorf("failed to aggregate expired payments: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO financial_aggregates (month, total_refunds)
SELECT
DATE_TRUNC('month', r.created_at)::date AS month,
COALESCE(SUM(r.amount), 0) AS total_refunds
FROM refunds r
JOIN payments p ON r.payment_id = p.id
LEFT JOIN bookings b ON p.booking_id = b.id
LEFT JOIN users u ON b.user_id = u.id
WHERE p.created_at < NOW() - INTERVAL '7 years'
` + retentionFilter + `
GROUP BY DATE_TRUNC('month', r.created_at)::date
ON CONFLICT (month) DO UPDATE SET
total_refunds = financial_aggregates.total_refunds + EXCLUDED.total_refunds
`)
if err != nil {
return fmt.Errorf("failed to aggregate expired refunds: %w", err)
}
_, err = tx.Exec(ctx, `
DELETE FROM payments p
USING bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE p.booking_id = b.id
AND p.created_at < NOW() - INTERVAL '7 years'
` + retentionFilter + `
`)
if err != nil {
return fmt.Errorf("failed to delete expired payments: %w", err)
}
_, err = tx.Exec(ctx, `
DELETE FROM refunds r
USING payments p
LEFT JOIN bookings b ON p.booking_id = b.id
LEFT JOIN users u ON b.user_id = u.id
WHERE r.payment_id = p.id
AND p.created_at < NOW() - INTERVAL '7 years'
` + retentionFilter + `
`)
if err != nil {
return fmt.Errorf("failed to delete expired refunds: %w", err)
}
return tx.Commit(ctx)
}
// CleanupExpiredDeposits marks bookings as pending_release when the deposit
// deadline (24h before start_time) has passed without payment. The booking is
// kept alive in a vulnerable state — the slot becomes available for others to
// book, and if another booking claims it, the original is cancelled with
// deposit forfeited.
//
// If the deposit IS paid after the deadline but before the appointment, the
// booking flips back to confirmed.
func CleanupExpiredDeposits(ctx context.Context) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
// Collect all evicted booking IDs from both updates so we can notify
// and clean up in one pass instead of re-scanning via updated_at = NOW().
type evictedBooking struct{ id, userID string }
var evicted []evictedBooking
// Confirmed bookings past deposit deadline → pending_release
rows, err := tx.Query(ctx, `
UPDATE bookings
SET status = 'pending_release', updated_at = NOW()
WHERE deposit_required = true
AND status = 'confirmed'
AND start_time - INTERVAL '24 hours' < NOW()
AND NOT EXISTS (
SELECT 1 FROM payments p
WHERE p.booking_id = bookings.id
AND p.status = 'completed'
AND p.created_at < bookings.start_time
)
RETURNING id, user_id
`)
if err != nil {
return fmt.Errorf("failed to expire confirmed booking deposits: %w", err)
}
for rows.Next() {
var b evictedBooking
if err := rows.Scan(&b.id, &b.userID); err != nil {
rows.Close()
return fmt.Errorf("failed to scan evicted confirmed booking: %w", err)
}
evicted = append(evicted, b)
}
rows.Close()
// Pending bookings past deposit deadline → pending_release
rows, err = tx.Query(ctx, `
UPDATE bookings
SET status = 'pending_release', updated_at = NOW()
WHERE deposit_required = true
AND status = 'pending'
AND start_time - INTERVAL '24 hours' < NOW()
AND NOT EXISTS (
SELECT 1 FROM payments p
WHERE p.booking_id = bookings.id
AND p.status = 'completed'
AND p.created_at < bookings.start_time
)
RETURNING id, user_id
`)
if err != nil {
return fmt.Errorf("failed to expire pending expired bookings: %w", err)
}
for rows.Next() {
var b evictedBooking
if err := rows.Scan(&b.id, &b.userID); err != nil {
rows.Close()
return fmt.Errorf("failed to scan evicted pending booking: %w", err)
}
evicted = append(evicted, b)
}
rows.Close()
if len(evicted) == 0 {
return tx.Commit(ctx)
}
// Create admin notifications and clean up time_blockers for evicted bookings.
ids := make([]string, len(evicted))
userIDs := make([]string, len(evicted))
for i, b := range evicted {
ids[i] = b.id
userIDs[i] = b.userID
}
_, err = tx.Exec(ctx, `
INSERT INTO admin_notifications (reason, booking_id, user_id)
SELECT 'deposit_not_paid_by_deadline', unnest($1::text[]), unnest($2::text[])
WHERE NOT EXISTS (
SELECT 1 FROM admin_notifications an
WHERE an.booking_id = ANY($1) AND an.reason = 'deposit_not_paid_by_deadline'
)
`, ids, userIDs)
if err != nil {
return fmt.Errorf("failed to create pending_release notifications: %w", err)
}
_, err = tx.Exec(ctx, `
DELETE FROM time_blockers tb
USING unnest($1::text[]) AS evicted_ids(id)
WHERE tb.created_by = ANY($2::text[])
OR tb.description ILIKE ANY(
SELECT 'RESERVATION:user:' || u || '%'
FROM unnest($2::text[]) AS u
)
`, ids, userIDs)
if err != nil {
return fmt.Errorf("failed to cleanup time_blockers for pending_release bookings: %w", err)
}
return tx.Commit(ctx)
}
// CleanupExpiredGiftCards expires gift cards unused for 24 months (rolling expiry).
//
// Legal basis:
// - UK Consumer Rights Act 2015: Expiry terms must be "fair and transparent"
// - CMA guidance: 24 months is industry standard (John Lewis, M&S, Sainsbury's)
// - Under 12 months risks being challenged as unfair contract term
//
// This function:
// 1. Finds unredeemed cards (redeemed_by IS NULL) unused for 24+ months
// 2. Inserts into gift_card_expired_balances for recovery claims
// 3. Sets amount_remaining to 0
// 4. Records transaction in gift_card_transactions
//
// Once redeemed to an account, the balance doesn't expire (but the account can
// be deleted after idle time per GDPR - see CleanupIdleAccounts).
//
// Pre-expiry email warnings are intentionally omitted: gift cards are unowned
// (bought as gifts, change hands) until redeemed to an account. After redemption,
// the balance is covered by CleanupIdleAccounts warnings.
func CleanupExpiredGiftCards(ctx context.Context) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
rows, err := tx.Query(ctx, `
SELECT id, amount_remaining
FROM gift_cards
WHERE redeemed_by IS NULL
AND amount_remaining > 0
AND last_used_at < NOW() - INTERVAL '24 months'
`)
if err != nil {
return fmt.Errorf("failed to query expired gift cards: %w", err)
}
defer rows.Close()
var expiredCards []struct {
id string
balance float64
}
for rows.Next() {
var card struct {
id string
balance float64
}
if err := rows.Scan(&card.id, &card.balance); err != nil {
return fmt.Errorf("failed to scan expired gift card: %w", err)
}
expiredCards = append(expiredCards, card)
}
if len(expiredCards) > 0 {
ids := make([]string, len(expiredCards))
balances := make([]float64, len(expiredCards))
for i, c := range expiredCards {
ids[i] = c.id
balances[i] = c.balance
}
if _, err = tx.Exec(ctx, `
INSERT INTO gift_card_expired_balances (original_balance, expired_at)
SELECT unnest($1::numeric[]), NOW()
`, balances); err != nil {
return fmt.Errorf("failed to batch insert expired balances: %w", err)
}
if _, err = tx.Exec(ctx, `
UPDATE gift_cards
SET amount_remaining = 0, last_used_at = NOW()
WHERE id = ANY($1)
`, ids); err != nil {
return fmt.Errorf("failed to batch zero expired cards: %w", err)
}
if _, err = tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, notes)
SELECT unnest($1::text[]), 'expire', unnest($2::numeric[]), 'system', 'card expired after 24 months unused'
`, ids, balances); err != nil {
return fmt.Errorf("failed to batch insert expire transactions: %w", err)
}
}
return tx.Commit(ctx)
}
// CleanupIdleAccounts deletes user accounts that have been idle for extended periods.
//
// Legal basis (GDPR Article 5(1)(e) - Storage Limitation):
// - No money: 2 years idle (legitimate interest in customer relationship weakens)
// - With money: 5 years idle (Scottish prescriptive period for contract claims,
// Prescription and Limitation (Scotland) Act 1973 s.6)
//
// When an account with balance is deleted:
// 1. Balance moves to gift_card_expired_balances for recovery claims
// 2. Account is anonymized (PII removed, account ID retained)
// 3. User can recover balance with account ID (no deadline imposed)
//
// This transforms "forfeiture" into "dormancy", reducing legal risk from 40-50%
// to 10-20% per CMA unfair terms analysis.
//
// TODO: Email Integration
// Before deleting accounts, send warning emails:
// - 18 months idle (no balance): "Your account will be deleted in 6 months due to inactivity"
// - 23 months idle (no balance): "Your account will be deleted in 30 days"
// - 4 years idle (with balance): "Your account will be deleted in 1 year. Balance: £X"
// - 59 months idle (with balance): "Your account will be deleted in 30 days. Balance: £X"
// Include account ID in all emails for future recovery claims.
// Query: SELECT u.id, u.email, u.last_login_at, COALESCE(b.balance, 0) as balance
//
// FROM users u LEFT JOIN user_giftcard_balances b ON u.id = b.user_id
// WHERE u.account_role NOT IN ('admin', 'guest')
// AND u.last_login_at < NOW() - INTERVAL '18 months'
//
// Store sent warnings in account_email_warnings table to avoid duplicates.
func CleanupIdleAccounts(ctx context.Context) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
rowsWithBalance, err := tx.Query(ctx, `
SELECT u.id, COALESCE(b.balance, 0) as balance
FROM users u
LEFT JOIN user_giftcard_balances b ON u.id = b.user_id
WHERE u.account_role != 'admin'
AND u.account_role != 'guest'
AND (u.last_login_at IS NULL OR u.last_login_at < NOW() - INTERVAL '5 years')
AND (b.balance IS NULL OR b.balance > 0)
AND NOT (u.email LIKE 'deleted+%@deleted.invalid' OR u.email LIKE 'anon-%@anon.invalid')
`)
if err != nil {
return fmt.Errorf("failed to query idle accounts with balance: %w", err)
}
defer rowsWithBalance.Close()
var accountsWithBalance []struct {
id string
balance float64
}
for rowsWithBalance.Next() {
var acc struct {
id string
balance float64
}
if err := rowsWithBalance.Scan(&acc.id, &acc.balance); err != nil {
return fmt.Errorf("failed to scan idle account with balance: %w", err)
}
accountsWithBalance = append(accountsWithBalance, acc)
}
if len(accountsWithBalance) > 0 {
ids := make([]string, len(accountsWithBalance))
balances := make([]float64, len(accountsWithBalance))
for i, a := range accountsWithBalance {
ids[i] = a.id
balances[i] = a.balance
}
if _, err = tx.Exec(ctx, `
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
SELECT unnest($1::text[]), unnest($2::numeric[]), NOW()
`, ids, balances); err != nil {
return fmt.Errorf("failed to batch insert expired balances: %w", err)
}
if _, err = tx.Exec(ctx, `
UPDATE user_giftcard_balances
SET balance = 0, updated_at = NOW()
WHERE user_id = ANY($1)
`, ids); err != nil {
return fmt.Errorf("failed to batch zero account balances: %w", err)
}
if _, err = tx.Exec(ctx, `
SELECT anonymize_user(unnest($1::text[]))
`, ids); err != nil {
return fmt.Errorf("failed to anonymize idle accounts: %w", err)
}
}
rowsNoBalance, err := tx.Query(ctx, `
SELECT u.id
FROM users u
LEFT JOIN user_giftcard_balances b ON u.id = b.user_id
WHERE u.account_role != 'admin'
AND u.account_role != 'guest'
AND (u.last_login_at IS NULL OR u.last_login_at < NOW() - INTERVAL '2 years')
AND (b.balance IS NULL OR b.balance = 0)
AND NOT (u.email LIKE 'deleted+%@deleted.invalid' OR u.email LIKE 'anon-%@anon.invalid')
`)
if err != nil {
return fmt.Errorf("failed to query idle accounts without balance: %w", err)
}
defer rowsNoBalance.Close()
var accountsNoBalance []string
for rowsNoBalance.Next() {
var id string
if err := rowsNoBalance.Scan(&id); err != nil {
return fmt.Errorf("failed to scan idle account without balance: %w", err)
}
accountsNoBalance = append(accountsNoBalance, id)
}
rowsNoBalance.Close()
if _, err = tx.Exec(ctx, `
SELECT anonymize_user(unnest($1::text[]))
`, accountsNoBalance); err != nil {
return fmt.Errorf("failed to anonymize idle accounts: %w", err)
}
return tx.Commit(ctx)
}
// CleanupOldIdempotencyKeys clears idempotency keys from bookings, payments, and
// till_sales that are older than 24 hours and no longer pending. This prevents
// unbounded table growth while preserving keys for recent in-flight requests.
func CleanupOldIdempotencyKeys(ctx context.Context) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
UPDATE bookings
SET idempotency_key = NULL
WHERE idempotency_key IS NOT NULL
AND created_at < NOW() - INTERVAL '24 hours'
AND status != 'pending'
`)
if err != nil {
return fmt.Errorf("failed to cleanup booking idempotency keys: %w", err)
}
_, err = tx.Exec(ctx, `
UPDATE payments
SET idempotency_key = NULL
WHERE idempotency_key IS NOT NULL
AND created_at < NOW() - INTERVAL '24 hours'
AND status != 'pending'
`)
if err != nil {
return fmt.Errorf("failed to cleanup payment idempotency keys: %w", err)
}
_, err = tx.Exec(ctx, `
UPDATE till_sales
SET idempotency_key = NULL
WHERE idempotency_key IS NOT NULL
AND created_at < NOW() - INTERVAL '24 hours'
`)
if err != nil {
return fmt.Errorf("failed to cleanup till sale idempotency keys: %w", err)
}
return tx.Commit(ctx)
}
// CleanupOldNameHistory removes name_history entries older than 6 months.
// WHY: GDPR requires data minimization — name change history doesn't need
// to be retained indefinitely. 6 months provides a reasonable window for
// displaying former names on booking receipts and admin views.
func CleanupOldNameHistory(ctx context.Context) error {
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
DELETE FROM name_history
WHERE changed_at < NOW() - INTERVAL '6 months'
`)
if err != nil {
return fmt.Errorf("failed to cleanup old name history: %w", err)
}
return tx.Commit(ctx)
}