Files
Crussell/backend/handlers/scheduling/scheduled-cleanup.go
T
popertotsandSisyphus 35bc021857
CI / Env docs check (push) Successful in 25s
CI / Docker compose check (push) Successful in 24s
CI / Frontend deps check (push) Successful in 30s
CI / Frontend major deps (push) Successful in 33s
CI / Nginx config check (push) Successful in 59s
CI / Go build (push) Successful in 1m15s
CI / Secrets scan (push) Successful in 1m16s
CI / Knip (push) Successful in 56s
CI / Frontend a11y check (push) Successful in 55s
CI / Frontend build (push) Successful in 1m27s
CI / go mod tidy (push) Successful in 17s
CI / Go vulnerabilities (push) Successful in 2m17s
CI / Go vet (prod) (push) Successful in 2m46s
CI / Go vet (dev) (push) Successful in 2m50s
CI / Staticcheck (prod) (push) Successful in 2m55s
CI / Staticcheck (dev) (push) Successful in 3m13s
CI / Frontend QC (audit) (push) Successful in 41s
CI / golangci-lint (push) Failing after 3m37s
CI / Frontend QC (typecheck) (push) Successful in 1m30s
CI / Frontend QC (lint) (push) Successful in 2m4s
CI / Security scan (prod) (push) Successful in 4m43s
CI / Security scan (dev) (push) Successful in 4m44s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Svelte strict check (push) Successful in 33s
fix: replace err.Error() string match with errors.Is(err, pgx.ErrTxClosed)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-11 17:53:29 +01:00

256 lines
7.0 KiB
Go

package scheduling
import (
"context"
"errors"
"fmt"
"log/slog"
"crussell/db"
"github.com/jackc/pgx/v5"
)
// 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 && !errors.Is(err, pgx.ErrTxClosed) {
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 && !errors.Is(err, pgx.ErrTxClosed) {
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 && !errors.Is(err, pgx.ErrTxClosed) {
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 && !errors.Is(err, pgx.ErrTxClosed) {
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 && !errors.Is(err, pgx.ErrTxClosed) {
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
}