package scheduling import ( "context" "encoding/json" "fmt" "log" "net/http" "time" "crussell/db" "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"` DurationMinutes int `json:"duration_minutes"` Description string `json:"description,omitempty"` CronExpression *string `json:"cron_expression,omitempty"` } // --- 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 } // 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 == "" { http.Error(w, "missing id parameter", http.StatusBadRequest) 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', 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' ) `) return err }