package scheduling import ( "context" "encoding/json" "fmt" "log" "net/http" "time" "crussell/db" "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 ukLocation, _ := time.LoadLocation("Europe/London") start, err1 := time.ParseInLocation("2006-01-02", startStr, ukLocation) end, err2 := time.ParseInLocation("2006-01-02", endStr, ukLocation) 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, ukLocation) end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation) // Get one-off blockers in range + ALL recurring blockers rows, err = db.DB.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 := time.Now() rows, err = db.DB.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 } // Insert the time blocker var blocker TimeBlocker err := db.DB.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 } 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 } result, err := db.DB.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 } 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) { // Get one-off blockers in range rows, err := db.DB.Query(ctx, ` SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by FROM time_blockers WHERE cron_expression IS NULL AND description NOT LIKE 'RESERVATION:%' AND start_time >= $1 AND start_time <= $2 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 } blockers = append(blockers, b) } rows.Close() // Get ALL recurring blockers and expand them recurringRows, err := db.DB.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 } // Expand recurring blocker to occurrences within range occurrences := expandCronOccurrences(b, start, end) blockers = append(blockers, occurrences...) } 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 blockerHour := blocker.StartTime.Hour() blockerMinute := blocker.StartTime.Minute() // Use UK timezone for expansion ukLocation, _ := time.LoadLocation("Europe/London") var occurrences []TimeBlocker // Start from the beginning of the range current := time.Date(rangeStart.Year(), rangeStart.Month(), rangeStart.Day(), blockerHour, blockerMinute, 0, 0, ukLocation) // 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 := time.Now().Add(-1 * time.Hour) tenMinutesAgo := time.Now().Add(-10 * time.Minute) fifteenMinutesAgo := time.Now().Add(-15 * time.Minute) twentyFourHoursAgo := time.Now().Add(-24 * time.Hour) _, err := db.DB.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) `, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo) return err } // 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 { _, err := db.DB.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 id NOT IN ( SELECT user_id FROM bookings WHERE status IN ('pending', 'confirmed') ) AND id IN ( SELECT user_id FROM bookings WHERE user_id IS NOT NULL 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 = db.DB.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 = db.DB.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 = db.DB.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 = db.DB.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' ) `) return err } func CleanupExpiredLoyaltyRedemptions(ctx context.Context) error { _, err := db.DB.Exec(ctx, ` DELETE FROM loyalty_redemptions WHERE status = 'pending' AND expires_at < NOW() `) return err } // 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. func CleanupExpiredFinancialRecords(ctx context.Context) error { tx, err := db.DB.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, 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(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' 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' ) 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, 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' 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' ) 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' 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' ) `) 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' 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' ) `) if err != nil { return fmt.Errorf("failed to delete expired refunds: %w", err) } return tx.Commit(ctx) } // CleanupExpiredDeposits cancels bookings where the deposit deadline (24h before // start_time) has passed without payment. Confirmed bookings are set to 'no_deposit' // with an admin notification. Pending bookings are silently cancelled. // // TODO: notify the user once the notification system (email/SMS) is set up. func CleanupExpiredDeposits(ctx context.Context) error { tx, err := db.DB.Begin(ctx) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) // Confirmed bookings past deposit deadline → no_deposit _, err = tx.Exec(ctx, ` UPDATE bookings SET status = 'no_deposit', 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 ) `) if err != nil { return fmt.Errorf("failed to expire confirmed booking deposits: %w", err) } // Pending bookings past deposit deadline → silent cancel _, err = tx.Exec(ctx, ` UPDATE bookings SET status = 'client_cancelled', 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 ) `) if err != nil { return fmt.Errorf("failed to cancel pending expired bookings: %w", err) } // Create admin notifications for no_deposit bookings _, err = tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, booking_id, user_id) SELECT 'no_deposit', id, user_id FROM bookings WHERE status = 'no_deposit' AND updated_at = NOW() AND NOT EXISTS ( SELECT 1 FROM admin_notifications an WHERE an.booking_id = bookings.id AND an.reason = 'no_deposit' ) `) if err != nil { return fmt.Errorf("failed to create no_deposit notifications: %w", err) } // Clean up reservation time_blockers for affected bookings _, err = tx.Exec(ctx, ` DELETE FROM time_blockers tb USING bookings b WHERE tb.description LIKE 'RESERVATION:user:' || b.user_id || '%' AND b.status IN ('no_deposit', 'client_cancelled') AND b.updated_at = NOW() `) if err != nil { return fmt.Errorf("failed to cleanup time_blockers for expired deposits: %w", err) } return tx.Commit(ctx) }