feat(backend): update scheduling handlers

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-18 16:26:39 +01:00
co-authored by Sisyphus
parent 6ff35a5cc7
commit 6c89fea118
6 changed files with 367 additions and 111 deletions
+15 -3
View File
@@ -9,15 +9,16 @@ import (
"time" "time"
"crussell/db" "crussell/db"
"crussell/internal/validators"
"crussell/mw" "crussell/mw"
"log" "log"
) )
// --- Types --- // --- Types ---
type DefaultHours struct { type DefaultHours struct {
Weekday int `json:"weekday"` Weekday int `json:"weekday" validate:"gte=0,lte=6"`
StartTime string `json:"startTime"` StartTime string `json:"startTime" validate:"required"`
EndTime string `json:"endTime"` EndTime string `json:"endTime" validate:"required"`
IsOpen bool `json:"isOpen"` IsOpen bool `json:"isOpen"`
} }
@@ -63,6 +64,16 @@ func UpdateDefaultHours(w http.ResponseWriter, r *http.Request) {
return return
} }
for _, h := range hours {
if err := validators.Validate.Struct(&h); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
// M8
// L5
for _, h := range hours { for _, h := range hours {
if !isValidTime15Min(h.StartTime) { if !isValidTime15Min(h.StartTime) {
http.Error(w, "start_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest) http.Error(w, "start_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest)
@@ -425,6 +436,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
) sub), 0) AS total_duration ) sub), 0) AS total_duration
FROM bookings b FROM bookings b
WHERE b.start_time >= $1 AND b.start_time <= $2 WHERE b.start_time >= $1 AND b.start_time <= $2
AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'pending_release', 'deposit_lapsed')
ORDER BY b.start_time ORDER BY b.start_time
`, start, end) `, start, end)
@@ -7,23 +7,24 @@ import (
"time" "time"
"crussell/db" "crussell/db"
"crussell/internal/validators"
) )
type ExceptionalHours struct { type ExceptionalHours struct {
ID int `json:"id"` ID int `json:"id"`
GroupID int `json:"groupId"` GroupID int `json:"groupId"`
Weekday int `json:"weekday"` Weekday int `json:"weekday" validate:"gte=0,lte=6"`
StartTime string `json:"startTime"` StartTime string `json:"startTime" validate:"required"`
EndTime string `json:"endTime"` EndTime string `json:"endTime" validate:"required"`
IsOpen bool `json:"isOpen"` IsOpen bool `json:"isOpen"`
} }
type ExceptionalGroup struct { type ExceptionalGroup struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name" validate:"required"`
Description string `json:"description"` Description string `json:"description" validate:"required"`
Hours []ExceptionalHours `json:"hours,omitempty"` Hours []ExceptionalHours `json:"hours,omitempty" validate:"required,min=7,max=7,dive"`
WeekStarts []string `json:"weekStarts,omitempty"` WeekStarts []string `json:"weekStarts,omitempty" validate:"required,min=1,dive,required"`
} }
// --- List Groups with Hours and Applications --- // --- List Groups with Hours and Applications ---
@@ -122,6 +123,14 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := validators.Validate.Struct(&g); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
if len(g.Hours) != 7 { if len(g.Hours) != 7 {
http.Error(w, "must provide exactly 7 weekday entries (0-6)", http.StatusBadRequest) http.Error(w, "must provide exactly 7 weekday entries (0-6)", http.StatusBadRequest)
return return
@@ -262,8 +271,8 @@ func DeleteExceptionalGroup(w http.ResponseWriter, r *http.Request) {
// --- Update Applied Weeks (replaces all applications for a group) --- // --- Update Applied Weeks (replaces all applications for a group) ---
func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) { func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
GroupID int `json:"groupId"` GroupID int `json:"groupId" validate:"required"`
WeekStarts []string `json:"weekStarts"` WeekStarts []string `json:"weekStarts" validate:"required,min=1,dive,required"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -271,6 +280,14 @@ func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
// Validate and parse weeks // Validate and parse weeks
var parsedWeeks []time.Time var parsedWeeks []time.Time
ukLocation, _ := time.LoadLocation("Europe/London") ukLocation, _ := time.LoadLocation("Europe/London")
@@ -705,7 +705,6 @@ func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) {
} }
} }
// TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users // TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users
// CAN see blocked time slots in the blockers field. // CAN see blocked time slots in the blockers field.
func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"testing" "testing"
"crussell/db" "crussell/db"
"crussell/testutils/testdb"
"crussell/testutils/jwt" "crussell/testutils/jwt"
"crussell/testutils/testdb"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -23,4 +23,4 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
pool.Close() pool.Close()
os.Exit(code) os.Exit(code)
} }
+120 -71
View File
@@ -358,6 +358,7 @@ func CleanupOldReservations(ctx context.Context) error {
OR (description LIKE 'RESERVATION:admin:walkin:%' AND created_at < $3) OR (description LIKE 'RESERVATION:admin:walkin:%' AND created_at < $3)
OR (description LIKE 'RESERVATION:admin:callin:%' AND created_at < $3) OR (description LIKE 'RESERVATION:admin:callin:%' AND created_at < $3)
OR (description LIKE 'RESERVATION:edit_request:%' AND created_at < $4) OR (description LIKE 'RESERVATION:edit_request:%' AND created_at < $4)
OR (description LIKE 'PAYMENT_IN_FLIGHT:%' AND start_time + (duration_minutes * INTERVAL '1 minute') < NOW())
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo) `, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo)
return err return err
} }
@@ -581,11 +582,14 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error {
return tx.Commit(ctx) return tx.Commit(ctx)
} }
// CleanupExpiredDeposits cancels bookings where the deposit deadline (24h before // CleanupExpiredDeposits marks bookings as pending_release when the deposit
// start_time) has passed without payment. Confirmed bookings are set to 'no_deposit' // deadline (24h before start_time) has passed without payment. The booking is
// with an admin notification. Pending bookings are silently cancelled. // kept alive in a vulnerable state — the slot becomes available for others to
// book, and if another booking claims it, the original is cancelled with
// deposit forfeited.
// //
// TODO: notify the user once the notification system (email/SMS) is set up. // If the deposit IS paid after the deadline but before the appointment, the
// booking flips back to confirmed.
func CleanupExpiredDeposits(ctx context.Context) error { func CleanupExpiredDeposits(ctx context.Context) error {
tx, err := db.DB.Begin(ctx) tx, err := db.DB.Begin(ctx)
if err != nil { if err != nil {
@@ -593,10 +597,15 @@ func CleanupExpiredDeposits(ctx context.Context) error {
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
// Confirmed bookings past deposit deadline → no_deposit // Collect all evicted booking IDs from both updates so we can notify
_, err = tx.Exec(ctx, ` // and clean up in one pass instead of re-scanning via updated_at = NOW().
type evictedBooking struct{ id, userID string }
var evicted []evictedBooking
// Confirmed bookings past deposit deadline → pending_release
rows, err := tx.Query(ctx, `
UPDATE bookings UPDATE bookings
SET status = 'no_deposit', updated_at = NOW() SET status = 'pending_release', updated_at = NOW()
WHERE deposit_required = true WHERE deposit_required = true
AND status = 'confirmed' AND status = 'confirmed'
AND start_time - INTERVAL '24 hours' < NOW() AND start_time - INTERVAL '24 hours' < NOW()
@@ -606,15 +615,25 @@ func CleanupExpiredDeposits(ctx context.Context) error {
AND p.status = 'completed' AND p.status = 'completed'
AND p.created_at < bookings.start_time AND p.created_at < bookings.start_time
) )
RETURNING id, user_id
`) `)
if err != nil { if err != nil {
return fmt.Errorf("failed to expire confirmed booking deposits: %w", err) return fmt.Errorf("failed to expire confirmed booking deposits: %w", err)
} }
for rows.Next() {
var b evictedBooking
if err := rows.Scan(&b.id, &b.userID); err != nil {
rows.Close()
return fmt.Errorf("failed to scan evicted confirmed booking: %w", err)
}
evicted = append(evicted, b)
}
rows.Close()
// Pending bookings past deposit deadline → silent cancel // Pending bookings past deposit deadline → pending_release
_, err = tx.Exec(ctx, ` rows, err = tx.Query(ctx, `
UPDATE bookings UPDATE bookings
SET status = 'client_cancelled', updated_at = NOW() SET status = 'pending_release', updated_at = NOW()
WHERE deposit_required = true WHERE deposit_required = true
AND status = 'pending' AND status = 'pending'
AND start_time - INTERVAL '24 hours' < NOW() AND start_time - INTERVAL '24 hours' < NOW()
@@ -624,37 +643,56 @@ func CleanupExpiredDeposits(ctx context.Context) error {
AND p.status = 'completed' AND p.status = 'completed'
AND p.created_at < bookings.start_time AND p.created_at < bookings.start_time
) )
RETURNING id, user_id
`) `)
if err != nil { if err != nil {
return fmt.Errorf("failed to cancel pending expired bookings: %w", err) return fmt.Errorf("failed to expire pending expired bookings: %w", err)
}
for rows.Next() {
var b evictedBooking
if err := rows.Scan(&b.id, &b.userID); err != nil {
rows.Close()
return fmt.Errorf("failed to scan evicted pending booking: %w", err)
}
evicted = append(evicted, b)
}
rows.Close()
if len(evicted) == 0 {
return tx.Commit(ctx)
}
// Create admin notifications and clean up time_blockers for evicted bookings.
ids := make([]string, len(evicted))
userIDs := make([]string, len(evicted))
for i, b := range evicted {
ids[i] = b.id
userIDs[i] = b.userID
} }
// Create admin notifications for no_deposit bookings
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
INSERT INTO admin_notifications (reason, booking_id, user_id) INSERT INTO admin_notifications (reason, booking_id, user_id)
SELECT 'no_deposit', id, user_id SELECT 'deposit_not_paid_by_deadline', unnest($1::text[]), unnest($2::text[])
FROM bookings WHERE NOT EXISTS (
WHERE status = 'no_deposit' SELECT 1 FROM admin_notifications an
AND updated_at = NOW() WHERE an.booking_id = ANY($1) AND an.reason = 'deposit_not_paid_by_deadline'
AND NOT EXISTS ( )
SELECT 1 FROM admin_notifications an `, ids, userIDs)
WHERE an.booking_id = bookings.id AND an.reason = 'no_deposit'
)
`)
if err != nil { if err != nil {
return fmt.Errorf("failed to create no_deposit notifications: %w", err) return fmt.Errorf("failed to create pending_release notifications: %w", err)
} }
// Clean up reservation time_blockers for affected bookings
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
DELETE FROM time_blockers tb DELETE FROM time_blockers tb
USING bookings b USING unnest($1::text[]) AS evicted_ids(id)
WHERE tb.description LIKE 'RESERVATION:user:' || b.user_id || '%' WHERE tb.created_by = ANY($2::text[])
AND b.status IN ('no_deposit', 'client_cancelled') OR tb.description ILIKE ANY(
AND b.updated_at = NOW() SELECT 'RESERVATION:user:' || u || '%'
`) FROM unnest($2::text[]) AS u
)
`, ids, userIDs)
if err != nil { if err != nil {
return fmt.Errorf("failed to cleanup time_blockers for expired deposits: %w", err) return fmt.Errorf("failed to cleanup time_blockers for pending_release bookings: %w", err)
} }
return tx.Commit(ctx) return tx.Commit(ctx)
@@ -714,30 +752,34 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
expiredCards = append(expiredCards, card) expiredCards = append(expiredCards, card)
} }
for _, card := range expiredCards { if len(expiredCards) > 0 {
_, err = tx.Exec(ctx, ` ids := make([]string, len(expiredCards))
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at) balances := make([]float64, len(expiredCards))
VALUES (NULL, $1, NOW()) for i, c := range expiredCards {
`, card.balance) ids[i] = c.id
if err != nil { balances[i] = c.balance
return fmt.Errorf("failed to insert expired balance for card %s: %w", card.id, err)
} }
_, err = tx.Exec(ctx, ` if _, err = tx.Exec(ctx, `
INSERT INTO gift_card_expired_balances (original_balance, expired_at)
SELECT unnest($1::numeric[]), NOW()
`, balances); err != nil {
return fmt.Errorf("failed to batch insert expired balances: %w", err)
}
if _, err = tx.Exec(ctx, `
UPDATE gift_cards UPDATE gift_cards
SET amount_remaining = 0, last_used_at = NOW() SET amount_remaining = 0, last_used_at = NOW()
WHERE id = $1 WHERE id = ANY($1)
`, card.id) `, ids); err != nil {
if err != nil { return fmt.Errorf("failed to batch zero expired cards: %w", err)
return fmt.Errorf("failed to zero out expired card %s: %w", card.id, err)
} }
_, err = tx.Exec(ctx, ` if _, err = tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, notes)
VALUES ($1, 'expire', $2, 'system', NULL, NULL, 'card expired after 24 months unused') SELECT unnest($1::text[]), 'expire', unnest($2::numeric[]), 'system', 'card expired after 24 months unused'
`, card.id, card.balance) `, ids, balances); err != nil {
if err != nil { return fmt.Errorf("failed to batch insert expire transactions: %w", err)
return fmt.Errorf("failed to record expire transaction for card %s: %w", card.id, err)
} }
} }
@@ -747,9 +789,9 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
// CleanupIdleAccounts deletes user accounts that have been idle for extended periods. // CleanupIdleAccounts deletes user accounts that have been idle for extended periods.
// //
// Legal basis (GDPR Article 5(1)(e) - Storage Limitation): // Legal basis (GDPR Article 5(1)(e) - Storage Limitation):
// - No money: 2 years idle (legitimate interest in customer relationship weakens) // - No money: 2 years idle (legitimate interest in customer relationship weakens)
// - With money: 5 years idle (Scottish prescriptive period for contract claims, // - With money: 5 years idle (Scottish prescriptive period for contract claims,
// Prescription and Limitation (Scotland) Act 1973 s.6) // Prescription and Limitation (Scotland) Act 1973 s.6)
// //
// When an account with balance is deleted: // When an account with balance is deleted:
// 1. Balance moves to gift_card_expired_balances for recovery claims // 1. Balance moves to gift_card_expired_balances for recovery claims
@@ -767,9 +809,11 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
// - 59 months idle (with balance): "Your account will be deleted in 30 days. Balance: £X" // - 59 months idle (with balance): "Your account will be deleted in 30 days. Balance: £X"
// Include account ID in all emails for future recovery claims. // Include account ID in all emails for future recovery claims.
// Query: SELECT u.id, u.email, u.last_login_at, COALESCE(b.balance, 0) as balance // Query: SELECT u.id, u.email, u.last_login_at, COALESCE(b.balance, 0) as balance
// FROM users u LEFT JOIN user_giftcard_balances b ON u.id = b.user_id //
// WHERE u.account_role NOT IN ('admin', 'guest') // FROM users u LEFT JOIN user_giftcard_balances b ON u.id = b.user_id
// AND u.last_login_at < NOW() - INTERVAL '18 months' // WHERE u.account_role NOT IN ('admin', 'guest')
// AND u.last_login_at < NOW() - INTERVAL '18 months'
//
// Store sent warnings in account_email_warnings table to avoid duplicates. // Store sent warnings in account_email_warnings table to avoid duplicates.
func CleanupIdleAccounts(ctx context.Context) error { func CleanupIdleAccounts(ctx context.Context) error {
tx, err := db.DB.Begin(ctx) tx, err := db.DB.Begin(ctx)
@@ -809,27 +853,33 @@ func CleanupIdleAccounts(ctx context.Context) error {
accountsWithBalance = append(accountsWithBalance, acc) accountsWithBalance = append(accountsWithBalance, acc)
} }
for _, acc := range accountsWithBalance { if len(accountsWithBalance) > 0 {
_, err = tx.Exec(ctx, ` ids := make([]string, len(accountsWithBalance))
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at) balances := make([]float64, len(accountsWithBalance))
VALUES ($1, $2, NOW()) for i, a := range accountsWithBalance {
`, acc.id, acc.balance) ids[i] = a.id
if err != nil { balances[i] = a.balance
return fmt.Errorf("failed to insert expired balance for account %s: %w", acc.id, err)
} }
_, err = tx.Exec(ctx, ` if _, err = tx.Exec(ctx, `
INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
SELECT unnest($1::text[]), unnest($2::numeric[]), NOW()
`, ids, balances); err != nil {
return fmt.Errorf("failed to batch insert expired balances: %w", err)
}
if _, err = tx.Exec(ctx, `
UPDATE user_giftcard_balances UPDATE user_giftcard_balances
SET balance = 0, updated_at = NOW() SET balance = 0, updated_at = NOW()
WHERE user_id = $1 WHERE user_id = ANY($1)
`, acc.id) `, ids); err != nil {
if err != nil { return fmt.Errorf("failed to batch zero account balances: %w", err)
return fmt.Errorf("failed to zero balance for account %s: %w", acc.id, err)
} }
_, err = tx.Exec(ctx, "SELECT anonymize_user($1)", acc.id) for _, id := range ids {
if err != nil { if _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id); err != nil {
return fmt.Errorf("failed to anonymize idle account %s: %w", acc.id, err) return fmt.Errorf("failed to anonymize idle account %s: %w", id, err)
}
} }
} }
@@ -849,7 +899,6 @@ func CleanupIdleAccounts(ctx context.Context) error {
defer rowsNoBalance.Close() defer rowsNoBalance.Close()
var accountsNoBalance []string var accountsNoBalance []string
for rowsNoBalance.Next() { for rowsNoBalance.Next() {
var id string var id string
if err := rowsNoBalance.Scan(&id); err != nil { if err := rowsNoBalance.Scan(&id); err != nil {
@@ -857,10 +906,10 @@ func CleanupIdleAccounts(ctx context.Context) error {
} }
accountsNoBalance = append(accountsNoBalance, id) accountsNoBalance = append(accountsNoBalance, id)
} }
rowsNoBalance.Close()
for _, id := range accountsNoBalance { for _, id := range accountsNoBalance {
_, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id) if _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id); err != nil {
if err != nil {
return fmt.Errorf("failed to anonymize idle account %s: %w", id, err) return fmt.Errorf("failed to anonymize idle account %s: %w", id, err)
} }
} }
+204 -25
View File
@@ -1859,9 +1859,6 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) {
// --- Tests for CleanupExpiredDeposits --- // --- Tests for CleanupExpiredDeposits ---
// TestCleanupExpiredDeposits_ExpiredConfirmed verifies that a confirmed booking
// past its deposit deadline with no payment gets set to 'no_deposit', creates
// an admin notification, and cleans up the reservation time blocker.
func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
resetTestData(t) resetTestData(t)
@@ -1872,7 +1869,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
// Create booking past deposit deadline (e.g. starting 12h from now, deadline was 12h ago)
startTime := time.Now().Add(12 * time.Hour) startTime := time.Now().Add(12 * time.Hour)
var bookingID string var bookingID string
err = db.DB.QueryRow(ctx, ` err = db.DB.QueryRow(ctx, `
@@ -1884,7 +1880,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
} }
// Create reservation time blocker
_, err = db.DB.Exec(ctx, ` _, err = db.DB.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description) INSERT INTO time_blockers (start_time, duration_minutes, description)
VALUES ($1, 60, $2) VALUES ($1, 60, $2)
@@ -1893,33 +1888,29 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
t.Fatalf("failed to create reservation time blocker: %v", err) t.Fatalf("failed to create reservation time blocker: %v", err)
} }
// Run cleanup
err = CleanupExpiredDeposits(ctx) err = CleanupExpiredDeposits(ctx)
if err != nil { if err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err) t.Fatalf("CleanupExpiredDeposits failed: %v", err)
} }
// Verify status updated to 'no_deposit'
var status string var status string
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil { if err != nil {
t.Fatalf("failed to query booking: %v", err) t.Fatalf("failed to query booking: %v", err)
} }
if status != "no_deposit" { if status != "pending_release" {
t.Errorf("expected status 'no_deposit', got '%s'", status) t.Errorf("expected status 'pending_release', got '%s'", status)
} }
// Verify admin notification was created
var notifCount int var notifCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'no_deposit'", bookingID).Scan(&notifCount) err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(&notifCount)
if err != nil { if err != nil {
t.Fatalf("failed to query admin notifications: %v", err) t.Fatalf("failed to query admin notifications: %v", err)
} }
if notifCount != 1 { if notifCount != 1 {
t.Errorf("expected 1 no_deposit admin notification, got %d", notifCount) t.Errorf("expected 1 deposit_not_paid_by_deadline notification, got %d", notifCount)
} }
// Verify reservation time blocker was deleted
var tbCount int var tbCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
if err != nil { if err != nil {
@@ -1930,8 +1921,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
} }
} }
// TestCleanupExpiredDeposits_ExpiredPending verifies that a pending booking
// past its deposit deadline with no payment gets silently cancelled.
func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
resetTestData(t) resetTestData(t)
@@ -1953,7 +1942,6 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
t.Fatalf("failed to create booking: %v", err) t.Fatalf("failed to create booking: %v", err)
} }
// Create reservation time blocker
_, err = db.DB.Exec(ctx, ` _, err = db.DB.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description) INSERT INTO time_blockers (start_time, duration_minutes, description)
VALUES ($1, 60, $2) VALUES ($1, 60, $2)
@@ -1962,33 +1950,29 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
t.Fatalf("failed to create reservation time blocker: %v", err) t.Fatalf("failed to create reservation time blocker: %v", err)
} }
// Run cleanup
err = CleanupExpiredDeposits(ctx) err = CleanupExpiredDeposits(ctx)
if err != nil { if err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err) t.Fatalf("CleanupExpiredDeposits failed: %v", err)
} }
// Verify status updated to 'client_cancelled' (silent cancel)
var status string var status string
err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil { if err != nil {
t.Fatalf("failed to query booking: %v", err) t.Fatalf("failed to query booking: %v", err)
} }
if status != "client_cancelled" { if status != "pending_release" {
t.Errorf("expected status 'client_cancelled', got '%s'", status) t.Errorf("expected status 'pending_release', got '%s'", status)
} }
// Verify NO admin notification was created
var notifCount int var notifCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'no_deposit'", bookingID).Scan(&notifCount) err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(&notifCount)
if err != nil { if err != nil {
t.Fatalf("failed to query admin notifications: %v", err) t.Fatalf("failed to query admin notifications: %v", err)
} }
if notifCount != 0 { if notifCount != 1 {
t.Errorf("expected 0 no_deposit admin notifications, got %d", notifCount) t.Errorf("expected 1 deposit_not_paid_by_deadline notification, got %d", notifCount)
} }
// Verify reservation time blocker was deleted
var tbCount int var tbCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
if err != nil { if err != nil {
@@ -2651,3 +2635,198 @@ func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) {
t.Error("expected old till_sale's idempotency_key to be cleared") t.Error("expected old till_sale's idempotency_key to be cleared")
} }
} }
// =============================================================================
// CleanupExpiredDeposits - pending_release status
// =============================================================================
func TestCleanupExpiredDeposits_SetsPendingRelease(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
soon := time.Now().Add(1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = 'confirmed', deposit_required = true WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking: %v", err)
}
if err := CleanupExpiredDeposits(context.Background()); err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
var status string
err = db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "pending_release" {
t.Errorf("expected status 'pending_release', got %q", status)
}
}
func TestCleanupExpiredDeposits_DoesNotAffectPaidBookings(t *testing.T) {
resetTestData(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) })
soon := time.Now().Add(1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) })
_, err = db.DB.Exec(context.Background(),
"UPDATE bookings SET status = 'confirmed', deposit_required = true WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) })
if err := CleanupExpiredDeposits(context.Background()); err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
var status string
err = db.DB.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "confirmed" {
t.Errorf("expected status 'confirmed' (deposit was paid), got %q", status)
}
}
// =============================================================================
// CleanupExpiredLoyaltyRedemptions Tests
// =============================================================================
func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(t *testing.T) {
resetTestData(t)
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
// Insert expired pending redemption
_, err = db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'pending', NOW() - INTERVAL '1 day')
`, userID)
if err != nil {
t.Fatalf("failed to insert expired redemption: %v", err)
}
// Insert non-expired pending redemption (should be preserved)
_, err = db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'pending', NOW() + INTERVAL '7 days')
`, userID)
if err != nil {
t.Fatalf("failed to insert active redemption: %v", err)
}
// Insert applied redemption (different status, should be preserved)
_, err = db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'applied', NOW() - INTERVAL '1 day')
`, userID)
if err != nil {
t.Fatalf("failed to insert applied redemption: %v", err)
}
err = CleanupExpiredLoyaltyRedemptions(ctx)
if err != nil {
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
}
var remaining int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&remaining)
if err != nil {
t.Fatalf("failed to count redemptions: %v", err)
}
if remaining != 2 {
t.Errorf("expected 2 remaining redemptions (active + applied), got %d", remaining)
}
}
func TestCleanupExpiredLoyaltyRedemptions_NoExpired(t *testing.T) {
resetTestData(t)
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) })
// Only active redemptions
_, err = db.DB.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'pending', NOW() + INTERVAL '7 days')
`, userID)
if err != nil {
t.Fatalf("failed to insert active redemption: %v", err)
}
err = CleanupExpiredLoyaltyRedemptions(ctx)
if err != nil {
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
}
var count int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&count)
if err != nil {
t.Fatalf("failed to count: %v", err)
}
if count != 1 {
t.Errorf("expected 1 remaining (no expired), got %d", count)
}
}
func TestCleanupExpiredLoyaltyRedemptions_Empty(t *testing.T) {
resetTestData(t)
err := CleanupExpiredLoyaltyRedemptions(context.Background())
if err != nil {
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
}
}