diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 69ccc38..e06511d 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -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 diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index b980df4..53b10e8 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -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) + } +} diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index 4e3a95f..f35b88f 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -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) } }