Files
Crussell/backend/handlers/notifications/notifications.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

301 lines
8.2 KiB
Go

package notifications
import (
"context"
"crussell/db"
"crussell/internal/validators"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/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"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
CreatedAt time.Time `json:"created_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"`
}
// 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.
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.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 'pending_booking' THEN 1
WHEN 'cancelled_booking' THEN 2
WHEN 'late_cancellation' THEN 3
WHEN 'deposit_paid' THEN 4
WHEN 'affiliate_claim' THEN 5
WHEN 'edit_requested' THEN 6
WHEN 'new_booking' THEN 7
WHEN '1_month_no_pay' THEN 8
WHEN '1_week_no_pay' THEN 9
ELSE 10
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"
}
db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM admin_notifications an"+countWhere).Scan(&total)
// 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 acknowledgedAt sql.NullTime
err := rows.Scan(
&n.ID,
&n.Reason,
&bookingID,
&userID,
&userName,
&bookingStartTime,
&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 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 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 tx.Rollback(r.Context())
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
}
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
func AcknowledgePendingBookingNotification(tx interface{}, 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 ...interface{}) (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
}