errcheck: add proper error handling with slog.Error for tx.Rollback, key generation, and s3/dav operations. Add nolint comments for intentionally discarded DB scan errors and HTTP write errors. unused: remove dead code (svcRow type, processImage, nonDepositPaymentType, generateSecureCode, colorBold, nGreen, nRed) gosimple S1021: merge var declaration with assignment in manage.go ineffassign: remove dead assignments in settings.go, till.go, images.go Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
253 lines
6.9 KiB
Go
253 lines
6.9 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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
}
|