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.
271 lines
7.5 KiB
Go
271 lines
7.5 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"crussell/db"
|
|
"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 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 {
|
|
Notifications []AdminNotification `json:"notifications"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"per_page"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// 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
|
|
perPage := 20
|
|
|
|
if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 {
|
|
page = p
|
|
}
|
|
if pp, err := strconv.Atoi(r.URL.Query().Get("per_page")); err == nil && pp > 0 {
|
|
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
|
|
`
|
|
countQuery := `
|
|
SELECT COUNT(*) FROM admin_notifications
|
|
`
|
|
|
|
args := []any{}
|
|
countArgs := []any{}
|
|
param := 1
|
|
|
|
if !includeAcknowledged {
|
|
baseQuery += fmt.Sprintf(" WHERE an.acknowledged_at IS NULL")
|
|
countQuery += ` WHERE acknowledged_at IS NULL`
|
|
}
|
|
|
|
if reasonFilter != "" {
|
|
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++
|
|
}
|
|
|
|
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)
|
|
|
|
// Count
|
|
var total int
|
|
err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total)
|
|
if err != nil {
|
|
log.Printf("Failed to count notifications: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Query
|
|
rows, err := db.DB.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()
|
|
|
|
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)
|
|
}
|
|
|
|
resp := AdminNotificationListResponse{
|
|
Notifications: notifications,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
Total: total,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
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.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 == "" {
|
|
http.Error(w, "Notification ID is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
id, err := strconv.Atoi(idStr)
|
|
if err != nil {
|
|
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
query := `
|
|
UPDATE admin_notifications
|
|
SET acknowledged_at = NOW()
|
|
WHERE id = $1 AND acknowledged_at IS NULL
|
|
`
|
|
|
|
cmdTag, err := db.DB.Exec(r.Context(), query, id)
|
|
if err != nil {
|
|
log.Printf("Failed to acknowledge notification %d: %v", id, 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
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
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
|
|
}
|