package notifications import ( "context" "crussell/db" "crussell/internal/adminnotify" "crussell/internal/validators" "database/sql" "encoding/json" "errors" "fmt" "log" "log/slog" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) // Structs returned in JSON type AdminNotification struct { ID string `json:"id"` Reason string `json:"reason"` BookingID *string `json:"booking_id,omitempty"` UserID *string `json:"user_id,omitempty"` UserName *string `json:"user_name,omitempty"` BookingStartTime *time.Time `json:"booking_start_time,omitempty"` // Money-critical event detail (amount / square id / description): populated // by the 'critical_payment_log' / 'refund_failed' insert sites when they // adopt the admin_notifications event-detail columns (see the column // contract on adminnotify.go) so the operator can see WHAT happened without // opening the CRITICAL logs. Amount *float64 `json:"amount,omitempty"` SquareID *string `json:"square_id,omitempty"` Description *string `json:"description,omitempty"` AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` CreatedAt time.Time `json:"created_at"` } // AdminNotificationSuppression is one flood-cap suppression counter for a // capped reason whose unacknowledged queue is still at the cap. type AdminNotificationSuppression struct { Reason string `json:"reason"` SuppressedCount int `json:"suppressed_count"` LastSuppressedAt time.Time `json:"last_suppressed_at"` } type AdminNotificationListResponse struct { Notifications []AdminNotification `json:"notifications"` Page int `json:"page"` PerPage int `json:"per_page"` Total int `json:"total"` NextCursor *string `json:"next_cursor,omitempty"` // Suppressed is how many money-critical alerts were dropped by the flood cap // (adminnotify.MaxUnacknowledgedCriticalLogs) while each reason's // unacknowledged queue stayed at the cap — the "suppressed this cycle" // count. It resets once the operator works the queue down. SuppressedDetails // carries the per-reason breakdown. Suppressed int `json:"suppressed"` SuppressedDetails []AdminNotificationSuppression `json:"suppressed_details,omitempty"` } // parseCursor splits a "createdAt|id" cursor string into its components. // GET /api/admin/notifications // Query params: // // cursor, per_page — pagination (cursor-based) // include_acknowledged — if "true", returns all notifications sorted newest-first. // Default (false/omitted): only unacknowledged, sorted by priority then oldest-first. // Money-critical reasons ('critical_payment_log', 'refund_failed', // 'refresh_token_reuse', 'gift_card_purchased_for_friend') sort ABOVE routine // notifications so the operator's only pager surfaces money/security events first. // reason — filter to a single reason. // The response additionally carries the flood-cap "suppressed this cycle" count // (suppressed / suppressed_details) for reasons whose unacknowledged queue is // still at adminnotify.MaxUnacknowledgedCriticalLogs. func GetNotifications(w http.ResponseWriter, r *http.Request) { // Parse query params perPage := 20 cursorStr := r.URL.Query().Get("cursor") if pp, err := strconv.Atoi(r.URL.Query().Get("per_page")); err == nil && pp > 0 && pp <= 100 { perPage = pp } includeAcknowledged := r.URL.Query().Get("include_acknowledged") == "true" reasonFilter := r.URL.Query().Get("reason") baseQuery := ` SELECT an.id, an.reason, an.booking_id, an.user_id, u.n_first_name || ' ' || u.n_last_name AS user_name, b.start_time AS booking_start_time, an.amount, an.square_id, an.description, an.acknowledged_at, an.created_at FROM admin_notifications an LEFT JOIN users u ON an.user_id = u.id LEFT JOIN bookings b ON an.booking_id = b.id ` args := []any{} param := 1 hasWhere := false addWhere := func(condition string) { if !hasWhere { baseQuery += " WHERE " + condition hasWhere = true } else { baseQuery += " AND " + condition } } if !includeAcknowledged { addWhere("an.acknowledged_at IS NULL") } if reasonFilter != "" { addWhere(fmt.Sprintf("an.reason = $%d", param)) args = append(args, reasonFilter) param++ } // Cursor-based pagination: (created_at, id) if cursorStr != "" { cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) if err != nil { http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest) return } addWhere(fmt.Sprintf("(an.created_at, an.id) < ($%d, $%d)", param, param+1)) args = append(args, cursorCreatedAt, cursorID) param += 2 } if includeAcknowledged { baseQuery += " ORDER BY an.created_at DESC, an.id DESC" } else { baseQuery += ` ORDER BY CASE an.reason WHEN 'critical_payment_log' THEN 1 WHEN 'refund_failed' THEN 2 WHEN 'refresh_token_reuse' THEN 3 WHEN 'gift_card_purchased_for_friend' THEN 4 WHEN 'pending_booking' THEN 5 WHEN 'cancelled_booking' THEN 6 WHEN 'late_cancellation' THEN 7 WHEN 'deposit_paid' THEN 8 WHEN 'affiliate_claim' THEN 9 WHEN 'edit_requested' THEN 10 WHEN 'new_booking' THEN 11 WHEN '1_month_no_pay' THEN 12 WHEN '1_week_no_pay' THEN 13 WHEN 'rescheduled_booking' THEN 14 WHEN 'edit_request' THEN 15 WHEN 'deposit_not_paid_by_deadline' THEN 16 WHEN 'default_hours_changed' THEN 17 ELSE 18 END, an.created_at ASC, an.id ASC` } baseQuery += fmt.Sprintf(" LIMIT $%d", param) args = append(args, perPage+1) var total int countWhere := "" if !includeAcknowledged { countWhere += " WHERE an.acknowledged_at IS NULL" } if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM admin_notifications an"+countWhere).Scan(&total); err != nil { log.Printf("Failed to scan notification count: %v", err) } // Query rows, err := db.Conn.Query(r.Context(), baseQuery, args...) if err != nil { log.Printf("Failed to fetch notifications: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() var notifications []AdminNotification for rows.Next() { var n AdminNotification var bookingID sql.NullString var userID sql.NullString var userName sql.NullString var bookingStartTime sql.NullTime var amount sql.NullFloat64 var squareID sql.NullString var description sql.NullString var acknowledgedAt sql.NullTime err := rows.Scan( &n.ID, &n.Reason, &bookingID, &userID, &userName, &bookingStartTime, &amount, &squareID, &description, &acknowledgedAt, &n.CreatedAt, ) if err != nil { log.Printf("Failed to scan notification row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if bookingID.Valid { n.BookingID = &bookingID.String } if userID.Valid { n.UserID = &userID.String } if userName.Valid { n.UserName = &userName.String } if bookingStartTime.Valid { n.BookingStartTime = &bookingStartTime.Time } if amount.Valid { n.Amount = &amount.Float64 } if squareID.Valid { n.SquareID = &squareID.String } if description.Valid { n.Description = &description.String } if acknowledgedAt.Valid { n.AcknowledgedAt = &acknowledgedAt.Time } notifications = append(notifications, n) } if err := rows.Err(); err != nil { log.Printf("Row iteration error: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } var nextCursor *string if len(notifications) > perPage { notifications = notifications[:perPage] last := notifications[len(notifications)-1] cursor := last.CreatedAt.Format(time.RFC3339Nano) + "|" + last.ID nextCursor = &cursor } resp := AdminNotificationListResponse{ Notifications: notifications, PerPage: perPage, Total: total, NextCursor: nextCursor, } if suppressions, err := adminnotify.ActiveSuppressions(r.Context(), db.Conn); err != nil { log.Printf("Failed to fetch flood-cap suppressions: %v", err) } else { resp.SuppressedDetails = make([]AdminNotificationSuppression, 0, len(suppressions)) for _, s := range suppressions { resp.Suppressed += s.SuppressedCount resp.SuppressedDetails = append(resp.SuppressedDetails, AdminNotificationSuppression{ Reason: s.Reason, SuppressedCount: s.SuppressedCount, LastSuppressedAt: s.LastSuppressedAt, }) } } if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // GET /api/admin/notifications/unread-count // Returns the count of unacknowledged notifications for the bell icon. func GetUnreadCount(w http.ResponseWriter, r *http.Request) { var count int err := db.Conn.QueryRow(r.Context(), `SELECT COUNT(*) FROM admin_notifications WHERE acknowledged_at IS NULL`, ).Scan(&count) if err != nil { log.Printf("Failed to count unread notifications: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(map[string]int{"count": count}); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") if idStr == "" || !validators.IsValidID(idStr) { http.Error(w, "invalid notification ID", http.StatusBadRequest) return } tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() query := ` UPDATE admin_notifications SET acknowledged_at = NOW() WHERE id = $1 AND acknowledged_at IS NULL ` cmdTag, err := tx.Exec(r.Context(), query, idStr) if err != nil { log.Printf("Failed to acknowledge notification %s: %v", idStr, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if cmdTag.RowsAffected() == 0 { http.Error(w, "Notification not found or already acknowledged", http.StatusNotFound) return } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(map[string]string{ "status": "ok", }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func AcknowledgePendingBookingNotification(tx any, ctx context.Context, bookingID string) error { query := ` UPDATE admin_notifications SET acknowledged_at = NOW() WHERE booking_id = $1 AND reason = 'pending_booking' AND acknowledged_at IS NULL ` // Use type assertion to get the Exec method - pgx.Tx satisfies this interface execer, ok := tx.(interface { Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) }) if !ok { log.Printf("Warning: cannot acknowledge notification - tx does not satisfy Execer interface for booking %s", bookingID) return nil // Don't fail the main operation if notification ack fails } _, err := execer.Exec(ctx, query, bookingID) if err != nil { log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err) return err } return nil }