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:
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user