diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index f672892..eae90db 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -92,16 +92,32 @@ func UpdateDefaultHours(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) - for _, h := range hours { - _, err := tx.Exec(r.Context(), ` - UPDATE working_hours - SET start_time=$1,end_time=$2,is_open=$3 - WHERE weekday=$4 - `, h.StartTime, h.EndTime, h.IsOpen, h.Weekday) - if err != nil { - http.Error(w, "failed to update default hours", http.StatusInternalServerError) - return - } + weekdays := make([]int, len(hours)) + startTimes := make([]string, len(hours)) + endTimes := make([]string, len(hours)) + isOpenFlags := make([]bool, len(hours)) + for i, h := range hours { + weekdays[i] = h.Weekday + startTimes[i] = h.StartTime + endTimes[i] = h.EndTime + isOpenFlags[i] = h.IsOpen + } + + if _, err := tx.Exec(r.Context(), ` + UPDATE working_hours AS wh + SET start_time = v.start_time, + end_time = v.end_time, + is_open = v.is_open + FROM ( + SELECT unnest($1::smallint[]) AS weekday, + unnest($2::time[]) AS start_time, + unnest($3::time[]) AS end_time, + unnest($4::boolean[]) AS is_open + ) v + WHERE wh.weekday = v.weekday + `, weekdays, startTimes, endTimes, isOpenFlags); err != nil { + http.Error(w, "failed to update default hours", http.StatusInternalServerError) + return } if err := tx.Commit(r.Context()); err != nil { diff --git a/backend/handlers/scheduling/exceptional-hours.go b/backend/handlers/scheduling/exceptional-hours.go index 5e7f474..129a9fe 100644 --- a/backend/handlers/scheduling/exceptional-hours.go +++ b/backend/handlers/scheduling/exceptional-hours.go @@ -218,15 +218,12 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) { } // Insert applications - for _, weekStart := range parsedWeeks { - _, err := tx.Exec(r.Context(), ` - INSERT INTO exceptional_group_applications (group_id, week_start) - VALUES ($1, $2) - `, g.ID, weekStart) - if err != nil { - http.Error(w, "failed to insert application", http.StatusInternalServerError) - return - } + if _, err := tx.Exec(r.Context(), ` + INSERT INTO exceptional_group_applications (group_id, week_start) + SELECT $1, unnest($2::date[]) + `, g.ID, parsedWeeks); err != nil { + http.Error(w, "failed to insert applications", http.StatusInternalServerError) + return } if err := tx.Commit(r.Context()); err != nil { @@ -328,15 +325,12 @@ func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) { } // Insert new applications - for _, weekStart := range parsedWeeks { - _, err := tx.Exec(r.Context(), ` - INSERT INTO exceptional_group_applications (group_id, week_start) - VALUES ($1, $2) - `, req.GroupID, weekStart) - if err != nil { - http.Error(w, "failed to insert application", http.StatusInternalServerError) - return - } + if _, err := tx.Exec(r.Context(), ` + INSERT INTO exceptional_group_applications (group_id, week_start) + SELECT $1, unnest($2::date[]) + `, req.GroupID, parsedWeeks); err != nil { + http.Error(w, "failed to insert applications", http.StatusInternalServerError) + return } if err := tx.Commit(r.Context()); err != nil { diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go index a44db2f..1e3f4c8 100644 --- a/backend/handlers/scheduling/time-blockers.go +++ b/backend/handlers/scheduling/time-blockers.go @@ -197,12 +197,11 @@ func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) { // 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) { - // Get one-off blockers in range 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 + 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 { @@ -216,30 +215,15 @@ func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBl if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil { return nil, err } - blockers = append(blockers, b) - } - rows.Close() - - // Get ALL recurring blockers and expand them - recurringRows, 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 NOT NULL - `) - if err != nil { - return nil, err - } - defer recurringRows.Close() - - for recurringRows.Next() { - var b TimeBlocker - if err := recurringRows.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) } - - // Expand recurring blocker to occurrences within range - occurrences := expandCronOccurrences(b, start, end) - blockers = append(blockers, occurrences...) + } + if err := rows.Err(); err != nil { + return nil, err } return blockers, nil @@ -459,6 +443,14 @@ func CleanupExpiredLoyaltyRedemptions(ctx context.Context) error { // - 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 { @@ -486,12 +478,7 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error { 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' - 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' - ) + ` + 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, @@ -521,12 +508,7 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error { 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' - 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' - ) + ` + 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 @@ -541,12 +523,7 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error { LEFT JOIN users u ON b.user_id = u.id WHERE p.booking_id = b.id AND p.created_at < NOW() - INTERVAL '7 years' - 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' - ) + ` + retentionFilter + ` `) if err != nil { return fmt.Errorf("failed to delete expired payments: %w", err) @@ -559,12 +536,7 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error { LEFT JOIN users u ON b.user_id = u.id WHERE r.payment_id = p.id AND p.created_at < NOW() - INTERVAL '7 years' - 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' - ) + ` + retentionFilter + ` `) if err != nil { return fmt.Errorf("failed to delete expired refunds: %w", err) @@ -867,10 +839,10 @@ func CleanupIdleAccounts(ctx context.Context) error { return fmt.Errorf("failed to batch zero account balances: %w", err) } - for _, id := range ids { - if _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id); err != nil { - return fmt.Errorf("failed to anonymize idle account %s: %w", id, 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) } } @@ -899,10 +871,10 @@ func CleanupIdleAccounts(ctx context.Context) error { } rowsNoBalance.Close() - for _, id := range accountsNoBalance { - if _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id); err != nil { - return fmt.Errorf("failed to anonymize idle account %s: %w", id, err) - } + 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)