Files
Crussell/backend/handlers/scheduling/scheduled-cleanup.go
T
popertotsandSisyphus 407de74b51
CI / Frontend deps check (push) Successful in 22s
CI / Go vulnerabilities (push) Successful in 32s
CI / Go build (push) Successful in 32s
CI / go mod tidy (push) Successful in 13s
CI / Knip (push) Failing after 33s
CI / Frontend build (push) Successful in 1m12s
CI / Svelte strict check (push) Has been skipped
CI / Frontend QC (audit) (push) Has been skipped
CI / Frontend QC (typecheck) (push) Has been skipped
CI / Frontend QC (lint) (push) Has been skipped
CI / Go vet (push) Successful in 57s
CI / golangci-lint (push) Successful in 1m8s
CI / Tests (prod) (push) Successful in 1m45s
CI / Tests (dev) (push) Successful in 2m5s
CI / Race (prod) (push) Successful in 3m27s
CI / Race (dev) (push) Successful in 4m52s
fix: restore test-used functions, silence tx.Rollback closed errors, prune knip dead code
Restore processImage (images.go) and nonDepositPaymentType (handlers.go) with //nolint:unused — used in test files.
Fix 97 tx.Rollback defers to silently discard expected "tx is closed" error after commit.
Frontend: remove 44 unused shadcn-svelte files, 2 dead components, 9 stale npm deps, prune unused exports.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-09 19:20:25 +01:00

253 lines
7.0 KiB
Go

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
}