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:
2026-08-22 00:34:50 +01:00
parent 69a854d857
commit 9a75ebc794
3 changed files with 379 additions and 31 deletions
+119 -14
View File
@@ -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
@@ -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
+224
View File
@@ -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)
}
}
+35 -16
View File
@@ -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)
}
}