package scheduling import ( "context" "fmt" "log/slog" "crussell/db" ) // NotifyUnpaidOneWeek inserts admin_notifications for bookings that ended // 7+ days ago with no completed payment. Runs daily. // TODO: Notify affected user via email/SMS when SMTP is wired (E5). func NotifyUnpaidOneWeek(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 && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() rows, err := tx.Query(ctx, ` SELECT b.id, b.user_id FROM bookings b WHERE b.status NOT IN ('client_cancelled', 'we_cancelled') AND NOT EXISTS ( SELECT 1 FROM payments p WHERE p.booking_id = b.id AND p.status = 'completed' ) AND b.end_time >= NOW() - INTERVAL '30 days' AND b.end_time < NOW() - INTERVAL '7 days' AND NOT EXISTS ( SELECT 1 FROM admin_notifications an WHERE an.booking_id = b.id AND an.reason = '1_week_no_pay' ) `) if err != nil { return 0, fmt.Errorf("failed to query unpaid 1-week bookings: %w", err) } defer rows.Close() var ids, userIDs []string for rows.Next() { var id, userID string if err := rows.Scan(&id, &userID); err != nil { return 0, fmt.Errorf("failed to scan row: %w", err) } ids = append(ids, id) userIDs = append(userIDs, userID) } if err := rows.Err(); err != nil { return 0, fmt.Errorf("rows iteration error: %w", err) } if len(ids) == 0 { if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("failed to commit: %w", err) } return 0, nil } _, err = tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, booking_id, user_id) SELECT '1_week_no_pay', unnest($1::text[]), unnest($2::text[]) `, ids, userIDs) if err != nil { return 0, fmt.Errorf("failed to insert 1_week_no_pay notifications: %w", err) } if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("failed to commit: %w", err) } return len(ids), nil } // NotifyUnpaidOneMonth inserts admin_notifications for bookings that ended // 30+ days ago with no completed payment. Runs daily. // TODO: Notify affected user via email/SMS when SMTP is wired (E5). func NotifyUnpaidOneMonth(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 && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() rows, err := tx.Query(ctx, ` SELECT b.id, b.user_id FROM bookings b WHERE b.status NOT IN ('client_cancelled', 'we_cancelled') AND NOT EXISTS ( SELECT 1 FROM payments p WHERE p.booking_id = b.id AND p.status = 'completed' ) AND b.end_time < NOW() - INTERVAL '30 days' AND NOT EXISTS ( SELECT 1 FROM admin_notifications an WHERE an.booking_id = b.id AND an.reason = '1_month_no_pay' ) `) if err != nil { return 0, fmt.Errorf("failed to query unpaid 1-month bookings: %w", err) } defer rows.Close() var ids, userIDs []string for rows.Next() { var id, userID string if err := rows.Scan(&id, &userID); err != nil { return 0, fmt.Errorf("failed to scan row: %w", err) } ids = append(ids, id) userIDs = append(userIDs, userID) } if err := rows.Err(); err != nil { return 0, fmt.Errorf("rows iteration error: %w", err) } if len(ids) == 0 { if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("failed to commit: %w", err) } return 0, nil } _, err = tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, booking_id, user_id) SELECT '1_month_no_pay', unnest($1::text[]), unnest($2::text[]) `, ids, userIDs) if err != nil { return 0, fmt.Errorf("failed to insert 1_month_no_pay notifications: %w", err) } if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("failed to commit: %w", err) } return len(ids), nil } // TransitionDiscountCampaigns auto-transitions campaign statuses based on // dates and redemption limits: // - draft → active when start_date <= NOW() // - active → completed when end_date < NOW() or max_redemptions reached func TransitionDiscountCampaigns(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 && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() result, err := tx.Exec(ctx, ` UPDATE discount_campaigns SET status = 'active' WHERE status = 'draft' AND campaign_type = 'time_based' AND start_date <= NOW() `) if err != nil { return 0, fmt.Errorf("failed to activate campaigns: %w", err) } activated := result.RowsAffected() result, err = tx.Exec(ctx, ` UPDATE discount_campaigns SET status = 'completed' WHERE status = 'active' AND ( end_date < NOW() OR (max_redemptions IS NOT NULL AND times_redeemed >= max_redemptions) ) `) if err != nil { return 0, fmt.Errorf("failed to complete campaigns: %w", err) } completed := result.RowsAffected() if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("failed to commit: %w", err) } return int(activated + completed), nil } // CleanupExpiredVerificationCodes deletes expired verification codes and // used codes older than 30 days. func CleanupExpiredVerificationCodes(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 && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() result, err := tx.Exec(ctx, ` DELETE FROM verification_codes WHERE (used_at IS NOT NULL AND used_at < NOW() - INTERVAL '30 days') OR (expires_at < NOW() AND used_at IS NULL) `) if err != nil { return 0, fmt.Errorf("failed to cleanup verification codes: %w", err) } if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("failed to commit: %w", err) } return int(result.RowsAffected()), nil } // CleanupExpiredRefreshTokens deletes expired refresh tokens and // revoked tokens older than 90 days. func CleanupExpiredRefreshTokens(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 && err.Error() != "tx is closed" { slog.Error("failed to rollback transaction", "err", err) } }() result, err := tx.Exec(ctx, ` DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked = TRUE AND created_at < NOW() - INTERVAL '90 days') `) if err != nil { return 0, fmt.Errorf("failed to cleanup refresh tokens: %w", err) } if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("failed to commit: %w", err) } return int(result.RowsAffected()), nil }