feat: admin notification system with priority ordering, bell icon, and /notifications page

Two-tier notification system: new_booking (all public bookings) + pending_booking (notes/today).
Priority-sorted queue, unread count polling, enriched responses with user_name/booking_start_time.
Fix critical bug: edit_requested cleanup was broken (wrong reason string in 3 handlers).
Add 15 new tests covering priority ordering, enrichment, and notification creation flows.
Update Admin Manual, Technical Manual, and gap backlog docs.
This commit is contained in:
2026-05-16 23:41:18 +01:00
parent c3501ae89a
commit 7fc58f58d9
19 changed files with 1608 additions and 106 deletions
+86 -15
View File
@@ -17,11 +17,14 @@ import (
// Structs returned in JSON
type AdminNotification struct {
ID int `json:"id"`
Reason string `json:"reason"`
BookingID *string `json:"booking_id,omitempty"`
UserID *string `json:"user_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
ID int `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 {
@@ -32,6 +35,10 @@ type AdminNotificationListResponse struct {
}
// GET /api/admin/notifications
// Query params:
// page, per_page — pagination (default page=1, per_page=20)
// 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
page := 1
@@ -44,33 +51,61 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
perPage = pp
}
includeAcknowledged := r.URL.Query().Get("include_acknowledged") == "true"
reasonFilter := r.URL.Query().Get("reason")
baseQuery := `
SELECT id, reason, booking_id, user_id, created_at
FROM admin_notifications
WHERE acknowledged_at IS NULL
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
`
countQuery := `
SELECT COUNT(*) FROM admin_notifications
WHERE acknowledged_at IS NULL
`
args := []any{}
countArgs := []any{}
param := 1
// Optional reason filter
if !includeAcknowledged {
baseQuery += fmt.Sprintf(" WHERE an.acknowledged_at IS NULL")
countQuery += ` WHERE acknowledged_at IS NULL`
}
if reasonFilter != "" {
baseQuery += fmt.Sprintf(" AND reason = $%d", param)
countQuery += fmt.Sprintf(" AND reason = $%d", param)
if !includeAcknowledged {
baseQuery += fmt.Sprintf(" AND an.reason = $%d", param)
countQuery += fmt.Sprintf(" AND reason = $%d", param)
} else {
baseQuery += fmt.Sprintf(" WHERE an.reason = $%d", param)
countQuery += fmt.Sprintf(" WHERE reason = $%d", param)
}
args = append(args, reasonFilter)
countArgs = append(countArgs, reasonFilter)
param++
}
// ORDER & pagination
baseQuery += " ORDER BY created_at DESC"
if includeAcknowledged {
baseQuery += " ORDER BY an.created_at DESC"
} else {
baseQuery += ` ORDER BY CASE an.reason
WHEN 'pending_booking' THEN 1
WHEN 'cancelled_booking' THEN 2
WHEN 'late_cancellation' THEN 3
WHEN 'no_deposit' THEN 4
WHEN 'deposit_paid' THEN 5
WHEN 'affiliate_claim' THEN 6
WHEN 'edit_requested' THEN 7
WHEN 'new_booking' THEN 8
WHEN '1_month_no_pay' THEN 9
WHEN '1_week_no_pay' THEN 10
ELSE 11
END, an.created_at ASC`
}
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", param, param+1)
args = append(args, perPage, (page-1)*perPage)
@@ -95,15 +130,21 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
notifications := []AdminNotification{}
for rows.Next() {
var n AdminNotification
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 {
@@ -118,6 +159,15 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
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)
}
@@ -137,6 +187,27 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
}
}
// 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.DB.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
}
w.Header().Set("Content-Type", "application/json")
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 == "" {