fix: booking hardening — notification flood caps (C5) + pending-release eviction refunds (C4)
- C5: new_booking, pending_booking, cancelled_booking and edit_requested admin notifications are flood-capped per reason (pre-check logs suppression; atomic fold inside the INSERT), so a booking/cancellation flood cannot bury the operator's notification centre. - C4: EvictPendingReleaseOverlapping no longer re-sells a slot over a customer's money. Evicted pending_release bookings that carry a paid deposit are refunded FIRST — through payments.ProcessCancellationRefundTx (exported cancellation-refund machinery) inside the same transaction, full-refund override (business is re-selling the slot) — and only THEN flipped to 'deposit_lapsed'. Rows are SELECTed FOR UPDATE first so the guard predicate stays true; a refund failure aborts the eviction so the caller rolls the whole transaction back. Card refunds record 'pending' and settle via the pending-refund sweep post-commit. - Reschedule-fee audit payload whitespace alignment fix.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/internal/adminnotify"
|
||||
"crussell/internal/dav"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
@@ -2331,10 +2332,20 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Always create low-priority notification for all bookings
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('new_booking', $1, $2)
|
||||
`, booking.ID, userID); err != nil {
|
||||
// Always create low-priority notification for all bookings. C5: the
|
||||
// unacknowledged 'new_booking' queue is flood-capped at
|
||||
// adminnotify.MaxUnacknowledgedCriticalLogs (pre-check logs the
|
||||
// suppression; the fold inside the INSERT enforces it atomically), so a
|
||||
// booking flood cannot bury the operator's notification centre.
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "new_booking") {
|
||||
log.Printf("Suppressed new_booking admin notification for booking %s — unacknowledged 'new_booking' queue at the cap", booking.ID)
|
||||
} else if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
SELECT 'new_booking', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'new_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, booking.ID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -2353,9 +2364,15 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if needsApproval {
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2)
|
||||
`, booking.ID, userID); err != nil {
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "pending_booking") {
|
||||
log.Printf("Suppressed pending_booking admin notification for booking %s — unacknowledged 'pending_booking' queue at the cap", booking.ID)
|
||||
} else if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
SELECT 'pending_booking', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'pending_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, booking.ID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create pending approval notification for booking %s: %v", booking.ID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -3051,9 +3068,15 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)
|
||||
`, "cancelled_booking", bookingID, userID); err != nil {
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "cancelled_booking") {
|
||||
log.Printf("Suppressed cancelled_booking admin notification for booking %s — unacknowledged 'cancelled_booking' queue at the cap", bookingID)
|
||||
} else if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
SELECT 'cancelled_booking', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'cancelled_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, bookingID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -3974,7 +3997,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// booking's total paid, which is what the retention would have drawn on.
|
||||
if payInfo, payErr := payments.NewPaymentService().GetBookingPaymentInfo(r.Context(), bookingID); payErr == nil {
|
||||
payments.InsertAdminAuditCharge(r.Context(), adminID, bookingUserID, "admin_reschedule_fee_forgiven", map[string]any{
|
||||
"booking_id": bookingID,
|
||||
"booking_id": bookingID,
|
||||
"forgiven_amount": payInfo.TotalPaid,
|
||||
})
|
||||
} else {
|
||||
@@ -4104,14 +4127,63 @@ type EvictedBooking struct {
|
||||
UserID string
|
||||
}
|
||||
|
||||
// evictedBookingPaymentInfo mirrors payments.GetBookingPaymentInfo (start time,
|
||||
// total amount, and net paid minus prior completed/pending refunds) but reads
|
||||
// through the eviction's own transaction so the totals are consistent with the
|
||||
// same tx that ProcessCancellationRefundTx refunds against. The exclusions
|
||||
// (discount / on-the-house rows, tips) match exactly what the cancellation-refund
|
||||
// machinery treats as refundable.
|
||||
func evictedBookingPaymentInfo(ctx context.Context, tx pgx.Tx, bookingID string) (startTime time.Time, totalAmount, totalPaid float64, err error) {
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT b.start_time,
|
||||
COALESCE(b.total_amount, 0),
|
||||
COALESCE(pt.total_paid, 0) - COALESCE(rr.total_refunded, 0)
|
||||
FROM bookings b
|
||||
LEFT JOIN (
|
||||
SELECT booking_id, SUM(amount) AS total_paid
|
||||
FROM payments
|
||||
WHERE booking_id = $1 AND status = 'completed'
|
||||
AND payment_method NOT IN ('discount', 'on_the_house')
|
||||
AND payment_type <> 'tip'
|
||||
GROUP BY booking_id
|
||||
) pt ON b.id = pt.booking_id
|
||||
LEFT JOIN (
|
||||
SELECT p.booking_id, SUM(r.amount) AS total_refunded
|
||||
FROM refunds r
|
||||
JOIN payments p ON r.payment_id = p.id
|
||||
WHERE p.booking_id = $1 AND r.status IN ('completed', 'pending')
|
||||
AND p.payment_type <> 'tip'
|
||||
GROUP BY p.booking_id
|
||||
) rr ON b.id = rr.booking_id
|
||||
WHERE b.id = $1
|
||||
`, bookingID).Scan(&startTime, &totalAmount, &totalPaid)
|
||||
return
|
||||
}
|
||||
|
||||
// EvictPendingReleaseOverlapping evicts any pending_release bookings whose slot
|
||||
// overlaps with [startTime, endTime). The PAYMENT_IN_FLIGHT guard prevents
|
||||
// evicting a booking that a user is currently paying for (the 5-minute
|
||||
// time_blocker window). Returns the list of evicted bookings (id + user_id)
|
||||
// for any caller that needs to react (e.g. notify the affected user).
|
||||
//
|
||||
// Money-safety (C4): a pending_release booking may already carry a paid
|
||||
// deposit, and evicting it re-sells the slot to someone else — the customer
|
||||
// must not lose that money. Every evicted booking's refund is therefore
|
||||
// processed FIRST (through the same exported cancellation-refund machinery the
|
||||
// admin cancellation handler uses — payments.ProcessCancellationRefundTx — in
|
||||
// this same transaction so the refund rows commit atomically with the status
|
||||
// flip), and only THEN is the booking flipped to 'deposit_lapsed'. The
|
||||
// eviction is business-initiated (the salon is re-selling the customer's
|
||||
// slot), so the full-refund override applies: the business keeps nothing,
|
||||
// mirroring a business-initiated admin cancellation. Card refunds are recorded
|
||||
// 'pending' and settled post-commit by the sweep-pending-square-refunds job
|
||||
// (the post-commit Square pass cannot run here — the caller owns the commit).
|
||||
// A refund failure aborts the eviction so the caller rolls the whole
|
||||
// transaction back rather than re-selling a slot over a customer's money.
|
||||
func EvictPendingReleaseOverlapping(ctx context.Context, tx pgx.Tx, startTime, endTime time.Time) ([]EvictedBooking, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
UPDATE bookings SET status = 'deposit_lapsed', updated_at = NOW()
|
||||
SELECT id, user_id
|
||||
FROM bookings
|
||||
WHERE status = 'pending_release'
|
||||
AND start_time < $2
|
||||
AND end_time > $1
|
||||
@@ -4120,12 +4192,11 @@ func EvictPendingReleaseOverlapping(ctx context.Context, tx pgx.Tx, startTime, e
|
||||
WHERE description = 'PAYMENT_IN_FLIGHT:' || bookings.id
|
||||
AND start_time + (duration_minutes * INTERVAL '1 minute') > NOW()
|
||||
)
|
||||
RETURNING id, user_id
|
||||
FOR UPDATE
|
||||
`, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var evicted []EvictedBooking
|
||||
for rows.Next() {
|
||||
@@ -4135,7 +4206,41 @@ func EvictPendingReleaseOverlapping(ctx context.Context, tx pgx.Tx, startTime, e
|
||||
}
|
||||
evicted = append(evicted, e)
|
||||
}
|
||||
return evicted, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Refund FIRST — before any status flip. The refund rows must exist (or the
|
||||
// eviction must abort) before the customer's slot is re-sold.
|
||||
for _, e := range evicted {
|
||||
bookingStart, totalAmount, totalPaid, infoErr := evictedBookingPaymentInfo(ctx, tx, e.ID)
|
||||
if infoErr != nil {
|
||||
return nil, fmt.Errorf("failed to read payment info for evicted booking %s: %w", e.ID, infoErr)
|
||||
}
|
||||
if totalPaid <= 0 {
|
||||
continue
|
||||
}
|
||||
// forceFullRefund=true: the eviction is the business taking the slot
|
||||
// back, so the notice-tier retention would be unfair — the salon keeps
|
||||
// nothing, exactly like a business-initiated admin cancellation.
|
||||
if _, refundErr := payments.ProcessCancellationRefundTx(ctx, tx, e.ID, totalAmount, totalPaid, bookingStart, clock.Now(), "deposit_lapsed", nil, true); refundErr != nil {
|
||||
return nil, fmt.Errorf("failed to process cancellation refund for evicted booking %s: %w", e.ID, refundErr)
|
||||
}
|
||||
}
|
||||
|
||||
// THEN flip the status. The FOR UPDATE row locks above hold the booking in
|
||||
// pending_release until the caller commits, so the guard predicate stays true.
|
||||
for _, e := range evicted {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE bookings SET status = 'deposit_lapsed', updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'pending_release'
|
||||
`, e.ID); err != nil {
|
||||
return nil, fmt.Errorf("failed to flip evicted booking %s to deposit_lapsed: %w", e.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return evicted, nil
|
||||
}
|
||||
|
||||
// TODO: notify the evicted user that their slot was released
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/handlers/user"
|
||||
"crussell/internal/adminnotify"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"crussell/testutils"
|
||||
@@ -7701,3 +7702,226 @@ func TestGetAllUserBookings_TotalCountMatches(t *testing.T) {
|
||||
t.Errorf("expected no nextCursor on last page, got %q", *resp3.NextCursor)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvictPendingReleaseOverlapping_RefundsPaidDeposit pins C4: evicting a
|
||||
// pending_release booking that already carries a paid deposit must NOT lose the
|
||||
// customer's money while the slot is re-sold. The eviction refunds every
|
||||
// payment through the exported cancellation-refund machinery
|
||||
// (payments.ProcessCancellationRefundTx) inside the same transaction, then
|
||||
// flips the booking to 'deposit_lapsed'. The eviction is business-initiated,
|
||||
// so the full-refund override applies: a refund row is created, the user's
|
||||
// balance is credited, and the status flips to the terminal state.
|
||||
func TestEvictPendingReleaseOverlapping_RefundsPaidDeposit(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
future := clock.Now().Add(72 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set pending_release: %v", err)
|
||||
}
|
||||
|
||||
// Customer paid a £20 cash deposit before the eviction.
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 20.00, "cash", "deposit", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create deposit payment: %v", err)
|
||||
}
|
||||
|
||||
evicted, err := EvictPendingReleaseOverlapping(ctx, db.TxFromContext(ctx), future.Add(30*time.Minute), future.Add(90*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err)
|
||||
}
|
||||
if len(evicted) != 1 || evicted[0].ID != bookingID {
|
||||
t.Fatalf("expected 1 evicted booking (%s), got %+v", bookingID, evicted)
|
||||
}
|
||||
|
||||
// Status flipped to the terminal deposit_lapsed state.
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "deposit_lapsed" {
|
||||
t.Errorf("expected booking status 'deposit_lapsed', got %q", status)
|
||||
}
|
||||
|
||||
// Refund row created via the cancellation-refund machinery.
|
||||
var rPaymentID, rStatus, rReason, rOrigin string
|
||||
var rAmount float64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT payment_id, amount, status, reason, origin
|
||||
FROM refunds WHERE booking_id = $1
|
||||
`, bookingID).Scan(&rPaymentID, &rAmount, &rStatus, &rReason, &rOrigin); err != nil {
|
||||
t.Fatalf("failed to query refund row: %v", err)
|
||||
}
|
||||
if rPaymentID != paymentID {
|
||||
t.Errorf("expected refund for payment %s, got %s", paymentID, rPaymentID)
|
||||
}
|
||||
if rAmount != 20.0 {
|
||||
t.Errorf("expected refund amount 20.00, got %.2f", rAmount)
|
||||
}
|
||||
if rStatus != "completed" {
|
||||
t.Errorf("expected refund status 'completed' (cash refund credited), got %q", rStatus)
|
||||
}
|
||||
if rReason != "deposit_lapsed" {
|
||||
t.Errorf("expected refund reason 'deposit_lapsed', got %q", rReason)
|
||||
}
|
||||
if rOrigin != "cancellation" {
|
||||
t.Errorf("expected refund origin 'cancellation', got %q", rOrigin)
|
||||
}
|
||||
|
||||
// Balance check: the cash refund credited the booking user's balance.
|
||||
var balance float64
|
||||
if err := tx.QueryRow(ctx, "SELECT COALESCE(balance, 0) FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance); err != nil {
|
||||
t.Fatalf("failed to query user balance: %v", err)
|
||||
}
|
||||
if balance != 20.0 {
|
||||
t.Errorf("expected user balance 20.00, got %.2f", balance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvictPendingReleaseOverlapping_CardRefundPending pins the card half of
|
||||
// C4: an evicted booking paid by card records its refund as 'pending' (the
|
||||
// post-commit sweep-pending-square-refunds job settles it against Square), so
|
||||
// card money is protected too — the row exists in the same transaction that
|
||||
// flips the status, and no money is silently kept.
|
||||
func TestEvictPendingReleaseOverlapping_CardRefundPending(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
future := clock.Now().Add(72 * time.Hour)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, future)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", bookingID); err != nil {
|
||||
t.Fatalf("failed to set pending_release: %v", err)
|
||||
}
|
||||
|
||||
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 30.00, "online_square", "deposit", "completed")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create deposit payment: %v", err)
|
||||
}
|
||||
|
||||
evicted, err := EvictPendingReleaseOverlapping(ctx, db.TxFromContext(ctx), future.Add(30*time.Minute), future.Add(90*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("EvictPendingReleaseOverlapping failed: %v", err)
|
||||
}
|
||||
if len(evicted) != 1 || evicted[0].ID != bookingID {
|
||||
t.Fatalf("expected 1 evicted booking (%s), got %+v", bookingID, evicted)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
||||
t.Fatalf("failed to query booking status: %v", err)
|
||||
}
|
||||
if status != "deposit_lapsed" {
|
||||
t.Errorf("expected booking status 'deposit_lapsed', got %q", status)
|
||||
}
|
||||
|
||||
// Card refunds are recorded 'pending' for the post-commit Square sweep.
|
||||
var rPaymentID, rStatus, rReason string
|
||||
var rSquareRefundID *string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT payment_id, status, reason, square_refund_id
|
||||
FROM refunds WHERE booking_id = $1
|
||||
`, bookingID).Scan(&rPaymentID, &rStatus, &rReason, &rSquareRefundID); err != nil {
|
||||
t.Fatalf("failed to query refund row: %v", err)
|
||||
}
|
||||
if rPaymentID != paymentID {
|
||||
t.Errorf("expected refund for payment %s, got %s", paymentID, rPaymentID)
|
||||
}
|
||||
if rStatus != "pending" {
|
||||
t.Errorf("expected refund status 'pending' (Square sweep settles it), got %q", rStatus)
|
||||
}
|
||||
if rReason != "deposit_lapsed" {
|
||||
t.Errorf("expected refund reason 'deposit_lapsed', got %q", rReason)
|
||||
}
|
||||
if rSquareRefundID != nil {
|
||||
t.Errorf("expected no Square refund id yet, got %q", *rSquareRefundID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateBooking_Notifications_NewBookingFloodCap pins C5 for the
|
||||
// 'new_booking' insert site: the unacknowledged queue is flood-capped at
|
||||
// adminnotify.MaxUnacknowledgedCriticalLogs, so a booking flood cannot bury
|
||||
// the operator's notification centre. The booking itself is still created
|
||||
// (suppression only drops the notification), and the queue stays bounded.
|
||||
func TestCreateBooking_Notifications_NewBookingFloodCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(tx, serviceID)
|
||||
|
||||
// Fill the unacknowledged 'new_booking' queue to the cap before the booking
|
||||
// is created, so the insert site must suppress instead of growing it.
|
||||
for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, user_id, created_at)
|
||||
VALUES ('new_booking', $1, NOW())
|
||||
`, userID); err != nil {
|
||||
t.Fatalf("failed to seed new_booking notification %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "new_booking") {
|
||||
t.Fatal("expected the unacknowledged new_booking queue to be at the cap")
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
futureTime := clock.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
|
||||
req := CreateBookingRequest{
|
||||
StartTime: futureTime,
|
||||
ServiceIDs: []string{serviceID},
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(CreateBookingHandler)
|
||||
w := makeRequest(handler, "POST", "/api/bookings", req, token, ctx)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The booking was still created, but the new_booking queue stayed at the cap.
|
||||
var n int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'new_booking'`).Scan(&n); err != nil {
|
||||
t.Fatalf("failed to count new_booking notifications: %v", err)
|
||||
}
|
||||
if n != adminnotify.MaxUnacknowledgedCriticalLogs {
|
||||
t.Errorf("expected the new_booking queue to stay capped at %d, got %d", adminnotify.MaxUnacknowledgedCriticalLogs, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/internal/adminnotify"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"database/sql"
|
||||
@@ -96,11 +97,18 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("ALERT: failed to delete admin_notifications: %v", err)
|
||||
}
|
||||
|
||||
// Notify admins about the cancellation
|
||||
if _, err := tx.Exec(r.Context(), `
|
||||
// Notify admins about the cancellation — C5: the 'cancelled_booking' queue
|
||||
// is flood-capped per reason so a cancellation flood cannot bury the
|
||||
// operator's notification centre.
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "cancelled_booking") {
|
||||
log.Printf("ALERT: suppressed cancelled_booking admin notification — unacknowledged 'cancelled_booking' queue at the cap")
|
||||
} else if _, err := tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
VALUES ('cancelled_booking', $1, $2)
|
||||
`, bookingID, userID); err != nil {
|
||||
SELECT 'cancelled_booking', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'cancelled_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, bookingID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("ALERT: failed to create admin notification for cancellation: %v", err)
|
||||
}
|
||||
|
||||
@@ -238,12 +246,15 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// be self-referential.
|
||||
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
|
||||
if originalStatus != "pending" {
|
||||
notificationQuery := `
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "cancelled_booking") {
|
||||
log.Printf("Suppressed cancelled_booking admin notification for booking %s — unacknowledged 'cancelled_booking' queue at the cap", bookingID)
|
||||
} else if _, err = tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
SELECT 'cancelled_booking', $1, user_id FROM bookings WHERE id = $1
|
||||
`
|
||||
_, err = tx.Exec(r.Context(), notificationQuery, bookingID)
|
||||
if err != nil {
|
||||
AND (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'cancelled_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $2
|
||||
`, bookingID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1669,11 +1680,15 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "edit_requested") {
|
||||
log.Printf("Suppressed edit_requested admin notification for booking %s — unacknowledged 'edit_requested' queue at the cap", bookingID)
|
||||
} else if _, err = tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
VALUES ('edit_requested', $1, $2)
|
||||
`, bookingID, userID)
|
||||
if err != nil {
|
||||
SELECT 'edit_requested', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'edit_requested'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, bookingID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create admin notification for edit request %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1690,11 +1705,15 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
if adminnotify.CriticalLogsCapExceeded(r.Context(), tx, "pending_booking") {
|
||||
log.Printf("Suppressed pending_booking admin notification for booking %s — unacknowledged 'pending_booking' queue at the cap", bookingID)
|
||||
} else if _, err = tx.Exec(r.Context(), `
|
||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||
VALUES ('pending_booking', $1, $2)
|
||||
`, bookingID, userID)
|
||||
if err != nil {
|
||||
SELECT 'pending_booking', $1, $2
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'pending_booking'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
`, bookingID, userID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
|
||||
log.Printf("Failed to create pending booking notification for %s: %v", bookingID, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user