package jobs import ( "context" "errors" "fmt" "log/slog" "time" "crussell/auth" "crussell/db" authHandlers "crussell/handlers/auth" "crussell/handlers/payments" "crussell/handlers/scheduling" "crussell/handlers/user" "crussell/mw" "github.com/jackc/pgx/v5" ) // RegisterAll registers every background maintenance job on the scheduler. // Call once during server startup, before s.Start(). func RegisterAll(s *Scheduler) { // === HIGH FREQUENCY — every 5 minutes === s.Register(Job{ Name: "cleanup-reservations", Schedule: "*/5 * * * *", Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.CleanupOldReservations, }) s.Register(Job{ Name: "cleanup-expired-deposits", Schedule: "*/5 * * * *", Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.CleanupExpiredDeposits, }) s.Register(Job{ Name: "cleanup-rate-limiters", Schedule: "*/5 * * * *", Timeout: 30 * time.Second, Concurrency: 1, Handler: mw.CleanupAllRateLimiters, }) s.Register(Job{ Name: "cleanup-gdpr-export-cache", Schedule: "*/5 * * * *", Timeout: 10 * time.Second, Concurrency: 1, Handler: user.CleanupGDPRExportCache, }) s.Register(Job{ Name: "sweep-pending-square-refunds", Schedule: "*/5 * * * *", Timeout: 60 * time.Second, Concurrency: 1, Handler: payments.SweepPendingSquareRefunds, }) // Offset from the refund sweep (which also writes payments rows) by one // minute to avoid the two sweeps contending on the same table. s.Register(Job{ Name: "sweep-stale-pending-payments", Schedule: "1,6,11,16,21,26,31,36,41,46,51,56 * * * *", Timeout: 60 * time.Second, Concurrency: 1, Handler: payments.SweepStalePendingPayments, }) // Cancels terminal (card-machine) checkouts still pending at Square after // an hour — a never-polled checkout would otherwise sit live indefinitely // and complete into an invisible, untracked charge. s.Register(Job{ Name: "sweep-stale-terminal-checkouts", Schedule: "*/15 * * * *", Timeout: 60 * time.Second, Concurrency: 1, Handler: payments.SweepStaleTerminalCheckouts, }) // === MID FREQUENCY — every minute (progressive rate limiter was on 30s) === s.Register(Job{ Name: "cleanup-progressive-rate-limiter", Schedule: "* * * * *", Timeout: 10 * time.Second, Concurrency: 1, Handler: mw.CleanupProgressiveRateLimiter, }) // === MID FREQUENCY — hourly === s.Register(Job{ Name: "cleanup-expired-loyalty-redemptions", Schedule: "0 * * * *", Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.CleanupExpiredLoyaltyRedemptions, }) s.Register(Job{ Name: "cleanup-old-idempotency-keys", Schedule: "0 * * * *", Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.CleanupOldIdempotencyKeys, }) s.Register(Job{ Name: "cleanup-revoked-jtis", Schedule: "0 * * * *", Timeout: 30 * time.Second, Concurrency: 1, Handler: auth.CleanupRevokedJTIs, }) s.Register(Job{ Name: "cleanup-stale-login-entries", Schedule: "0 * * * *", Timeout: 30 * time.Second, Concurrency: 1, Handler: authHandlers.CleanupStaleLoginEntries, }) // === LOW FREQUENCY — daily, off-peak (staggered to avoid DB contention) === s.Register(Job{ Name: "anonymize-stale-guest-accounts", Schedule: "0 3 * * *", Timeout: 5 * time.Minute, Concurrency: 1, Handler: scheduling.AnonymizeStaleGuestAccounts, }) s.Register(Job{ Name: "cleanup-expired-financial-records", Schedule: "0 4 * * *", Timeout: 10 * time.Minute, Concurrency: 1, Handler: scheduling.CleanupExpiredFinancialRecords, }) s.Register(Job{ Name: "cleanup-idle-accounts", Schedule: "30 3 * * *", Timeout: 5 * time.Minute, Concurrency: 1, Handler: scheduling.CleanupIdleAccounts, }) s.Register(Job{ Name: "cleanup-expired-gift-cards", Schedule: "0 5 * * *", Timeout: 5 * time.Minute, Concurrency: 1, Handler: scheduling.CleanupExpiredGiftCards, }) s.Register(Job{ Name: "cleanup-old-name-history", Schedule: "30 4 * * *", Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.CleanupOldNameHistory, }) // === STAGED HOURS CHANGE === s.Register(Job{ Name: "apply-default-hours", Schedule: "5 0 * * *", // Daily at 00:05 — after midnight to avoid race Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.ApplyScheduledDefaultHours, }) // === BUSINESS LOGIC JOBS === s.Register(Job{ Name: "notify-unpaid-1-week", Schedule: "0 7 * * *", // Daily at 7am — end of business day + 7 days Timeout: 2 * time.Minute, Concurrency: 1, Handler: scheduling.NotifyUnpaidOneWeek, }) s.Register(Job{ Name: "notify-unpaid-1-month", Schedule: "30 7 * * *", // Daily at 7:30am (staggered from notify-unpaid-1-week) Timeout: 2 * time.Minute, Concurrency: 1, Handler: scheduling.NotifyUnpaidOneMonth, }) s.Register(Job{ Name: "transition-discount-campaigns", Schedule: "0 * * * *", // Hourly Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.TransitionDiscountCampaigns, }) s.Register(Job{ Name: "cleanup-verification-codes", Schedule: "0 2 * * *", // Daily at 2am Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.CleanupExpiredVerificationCodes, }) s.Register(Job{ Name: "cleanup-refresh-tokens", Schedule: "0 2 * * *", // Daily at 2am Timeout: 30 * time.Second, Concurrency: 1, Handler: scheduling.CleanupExpiredRefreshTokens, }) // Staggered from cleanup-verification-codes / cleanup-refresh-tokens // (both at 0 2 * * *) to avoid DB contention. s.Register(Job{ Name: "sweep-square-webhook-events", Schedule: "30 2 * * *", // Daily at 2:30am Timeout: 30 * time.Second, Concurrency: 1, Handler: SweepSquareWebhookEvents, }) } // SweepSquareWebhookEvents deletes square_webhook_events rows older than 90 // days. Every accepted webhook event_id is stored permanently for restart-safe // dedup (payment.updated fires on every payment update), so without retention // the table would grow without bound. 90 days comfortably exceeds Square's // webhook replay window while keeping the table bounded. func SweepSquareWebhookEvents(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() tag, err := tx.Exec(ctx, ` DELETE FROM square_webhook_events WHERE received_at < NOW() - INTERVAL '90 days' `) if err != nil { return 0, fmt.Errorf("failed to sweep square webhook events: %w", err) } return int(tag.RowsAffected()), tx.Commit(ctx) }