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
+9 -6
View File
@@ -36,17 +36,20 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
return "", "", err
}
claims, err := token.AsMap(ctx)
if err != nil {
return "", "", err
var uidVal interface{}
if err := token.Get("user_id", &uidVal); err != nil {
return "", "", fmt.Errorf("invalid user_id claim")
}
userID, ok := claims["user_id"].(string)
userID, ok := uidVal.(string)
if !ok {
return "", "", fmt.Errorf("invalid user_id claim")
}
role, ok = claims["role"].(string)
var roleVal interface{}
if err := token.Get("role", &roleVal); err != nil {
return "", "", fmt.Errorf("invalid role claim")
}
role, ok = roleVal.(string)
if !ok {
return "", "", fmt.Errorf("invalid role claim")
}
+2 -2
View File
@@ -1720,7 +1720,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) {
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
@@ -1773,7 +1773,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) {
var ackTime *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
+25 -11
View File
@@ -1672,26 +1672,36 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Determine notification reason: has notes OR booking is for today
notificationReason := "pending_booking"
// Always create low-priority notification for all bookings
if _, err := tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('new_booking', $1, $2)
`, booking.ID, userID); err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// If booking needs approval (has notes or is for today), also create high-priority notification
needsApproval := false
if req.Notes != nil && *req.Notes != "" {
notificationReason = "pending_booking" // Notes means pending approval too
needsApproval = true
} else {
// Check if booking is for today (same day in London timezone)
london, _ := time.LoadLocation("Europe/London")
now := time.Now().In(london)
bookingDay := req.StartTime.In(london)
if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.YearDay() {
notificationReason = "pending_booking" // Today's booking
needsApproval = true
}
}
if _, err := tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)
`, notificationReason, booking.ID, userID); err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
if needsApproval {
if _, err := tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2)
`, booking.ID, userID); err != nil {
log.Printf("Failed to create pending approval notification for booking %s: %v", booking.ID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
@@ -2169,6 +2179,10 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
if paymentCount > 0 {
db.DB.Exec(r.Context(), `UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) WHERE id = $1`, booking.User.ID)
}
// TODO: When online payments are live, create 'deposit_paid' notification here for:
// - Online deposit payments (user pays deposit via Square)
// - Early/late balance payments made online by the user
// NOT for admin-recorded in-person payments — admin already knows about those.
}
w.Header().Set("Content-Type", "application/json")
+188 -10
View File
@@ -2644,7 +2644,7 @@ func TestCreateEditRequest(t *testing.T) {
var notifCount int
err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL`,
WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL`,
bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
@@ -2790,7 +2790,7 @@ func TestDeleteEditRequest(t *testing.T) {
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
@@ -2821,7 +2821,7 @@ func TestDeleteEditRequest(t *testing.T) {
var notifCount int
err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
@@ -2885,7 +2885,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
@@ -2895,7 +2895,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
var ackTime *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
@@ -2937,7 +2937,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
var ackTimeAfter *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTimeAfter)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
@@ -2996,7 +2996,7 @@ func TestAdminRejectEditRequest(t *testing.T) {
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
@@ -3006,7 +3006,7 @@ func TestAdminRejectEditRequest(t *testing.T) {
var ackTime *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
@@ -3048,7 +3048,7 @@ func TestAdminRejectEditRequest(t *testing.T) {
var ackTimeAfter *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTimeAfter)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
@@ -3485,7 +3485,7 @@ func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) {
// Create admin notification for the initial edit request
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
@@ -4661,3 +4661,181 @@ func TestGuestBooking_SkipsDepositCheck(t *testing.T) {
t.Errorf("expected guest to bypass deposit check, got %d. body: %s", w2.Code, w2.Body.String())
}
}
// =============================================================================
// Booking Notification Creation Tests
// =============================================================================
func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var bookingID string
err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
var notifCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 new_booking notification, got %d", notifCount)
}
}
func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
notes := "Please do French tips with gold foil"
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
Notes: &notes,
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var bookingID string
err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
var newBookingCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(&newBookingCount)
if err != nil {
t.Fatalf("failed to query new_booking notifications: %v", err)
}
if newBookingCount != 1 {
t.Errorf("expected 1 new_booking notification, got %d", newBookingCount)
}
var pendingCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount)
if err != nil {
t.Fatalf("failed to query pending_booking notifications: %v", err)
}
if pendingCount != 1 {
t.Errorf("expected 1 pending_booking notification for booking with notes, got %d", pendingCount)
}
}
func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var bookingID string
err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
var pendingCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount)
if err != nil {
t.Fatalf("failed to query pending_booking notifications: %v", err)
}
if pendingCount != 0 {
t.Errorf("expected 0 pending_booking notifications for booking without notes, got %d", pendingCount)
}
}
+10 -13
View File
@@ -917,7 +917,7 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
// Delete the admin notification for this edit request
_, err = tx.Exec(r.Context(), `
DELETE FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request' AND user_id = $2
WHERE booking_id = $1 AND reason = 'edit_requested' AND user_id = $2
`, bookingID, userID)
if err != nil {
log.Printf("Failed to delete admin notification for booking %s: %v", bookingID, err)
@@ -1086,10 +1086,10 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Delete existing admin notification for edit_request before creating new one (refreshes timestamp)
// Always create low-priority notification for edit requests
_, err = tx.Exec(r.Context(), `
DELETE FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'
WHERE booking_id = $1 AND reason = 'edit_requested'
`, bookingID)
if err != nil {
log.Printf("Failed to delete old admin notification for booking %s: %v", bookingID, err)
@@ -1097,20 +1097,18 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Create admin notification with reason 'edit_request'
_, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`, "edit_request", bookingID, userID)
VALUES ('edit_requested', $1, $2)
`, bookingID, userID)
if err != nil {
log.Printf("Failed to create admin notification for edit request %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// If booking status is 'pending', acknowledge existing pending_booking notification and create new one
// If booking is pending, also create high-priority approval notification
if currentStatus == "pending" {
// Acknowledge existing pending_booking notification
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
SET acknowledged_at = NOW()
@@ -1120,11 +1118,10 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err)
}
// Create new pending_booking notification (admin will see the edit request)
_, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`, "pending_booking", bookingID, userID)
VALUES ('pending_booking', $1, $2)
`, bookingID, userID)
if err != nil {
log.Printf("Failed to create pending booking notification for %s: %v", bookingID, err)
}
@@ -1411,7 +1408,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
SET acknowledged_at = NOW()
WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL
WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL
`, bookingID)
if err != nil {
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
@@ -1485,7 +1482,7 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
SET acknowledged_at = NOW()
WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL
WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL
`, bookingID)
if err != nil {
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
+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 == "" {
@@ -0,0 +1,559 @@
//go:build test
// +build test
package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/db"
"crussell/mw"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func makeExtendedAdminRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
rctx := chi.NewRouteContext()
if id, _ := extractIDFromPath(path); id != "" {
rctx.URLParams.Add("id", id)
}
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, "admin001")
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func createTestUser(t *testing.T) string {
t.Helper()
var userID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'test@test.com', '+447700900000', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
return userID
}
func createNotification(t *testing.T, reason, userID string, acknowledged bool) int {
t.Helper()
var notificationID int
if userID == "" {
query := `INSERT INTO admin_notifications (reason) VALUES ($1) RETURNING id`
if acknowledged {
query = `INSERT INTO admin_notifications (reason, acknowledged_at) VALUES ($1, NOW()) RETURNING id`
}
err := db.DB.QueryRow(context.Background(), query, reason).Scan(&notificationID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
} else {
query := `INSERT INTO admin_notifications (reason, user_id) VALUES ($1, $2) RETURNING id`
if acknowledged {
query = `INSERT INTO admin_notifications (reason, user_id, acknowledged_at) VALUES ($1, $2, NOW()) RETURNING id`
}
err := db.DB.QueryRow(context.Background(), query, reason, userID).Scan(&notificationID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
}
return notificationID
}
// =============================================================================
// include_acknowledged param tests
// =============================================================================
func TestNotifications_IncludeAcknowledged_Default(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, false)
createNotification(t, "cancelled_booking", userID, true)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Errorf("expected 1 unacknowledged notification, got %d", len(resp.Notifications))
}
}
func TestNotifications_IncludeAcknowledged_True(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, false)
createNotification(t, "cancelled_booking", userID, true)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 2 {
t.Errorf("expected 2 notifications with include_acknowledged=true, got %d", len(resp.Notifications))
}
}
func TestNotifications_IncludeAcknowledged_ResponseHasAcknowledgedAt(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, true)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
if resp.Notifications[0].AcknowledgedAt == nil {
t.Error("expected acknowledged_at to be set for acknowledged notification")
}
}
// =============================================================================
// Priority ordering tests
// =============================================================================
func TestNotifications_PriorityOrdering(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
// Create notifications in reverse priority order
reasons := []string{
"1_week_no_pay",
"affiliate_claim",
"pending_booking",
"cancelled_booking",
}
for _, reason := range reasons {
createNotification(t, reason, userID, false)
}
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 4 {
t.Fatalf("expected 4 notifications, got %d", len(resp.Notifications))
}
// Verify priority order: pending_booking(1) > cancelled_booking(3) > affiliate_claim(6) > 1_week_no_pay(10)
expectedOrder := []string{"pending_booking", "cancelled_booking", "affiliate_claim", "1_week_no_pay"}
for i, expected := range expectedOrder {
if resp.Notifications[i].Reason != expected {
t.Errorf("position %d: expected %s, got %s", i, expected, resp.Notifications[i].Reason)
}
}
}
func TestNotifications_PriorityOrdering_OldestFirstWithinPriority(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
// Create two pending_booking notifications with a time gap
createNotification(t, "pending_booking", userID, false)
time.Sleep(10 * time.Millisecond)
createNotification(t, "pending_booking", userID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 2 {
t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications))
}
// Oldest first within same priority
if resp.Notifications[0].CreatedAt.After(resp.Notifications[1].CreatedAt) {
t.Error("expected oldest notification first within same priority level")
}
}
func TestNotifications_AllNotifications_NewestFirst(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, false)
time.Sleep(10 * time.Millisecond)
createNotification(t, "cancelled_booking", userID, true)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 2 {
t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications))
}
// Newest first when include_acknowledged=true
if resp.Notifications[0].CreatedAt.Before(resp.Notifications[1].CreatedAt) {
t.Error("expected newest notification first when include_acknowledged=true")
}
}
// =============================================================================
// GetUnreadCount tests
// =============================================================================
func TestNotifications_UnreadCount(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, false)
createNotification(t, "cancelled_booking", userID, false)
createNotification(t, "affiliate_claim", userID, true)
handler := http.HandlerFunc(GetUnreadCount)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]int
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp["count"] != 2 {
t.Errorf("expected count 2, got %d", resp["count"])
}
}
func TestNotifications_UnreadCount_Zero(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, true)
handler := http.HandlerFunc(GetUnreadCount)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil)
var resp map[string]int
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp["count"] != 0 {
t.Errorf("expected count 0, got %d", resp["count"])
}
}
func TestNotifications_UnreadCount_Empty(t *testing.T) {
resetTestData(t)
handler := http.HandlerFunc(GetUnreadCount)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil)
var resp map[string]int
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp["count"] != 0 {
t.Errorf("expected count 0, got %d", resp["count"])
}
}
// =============================================================================
// New notification reason tests
// =============================================================================
func TestNotifications_NewBookingReason(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "new_booking", userID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
if resp.Notifications[0].Reason != "new_booking" {
t.Errorf("expected reason new_booking, got %s", resp.Notifications[0].Reason)
}
}
func TestNotifications_EditRequestedReason(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "edit_requested", userID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
if resp.Notifications[0].Reason != "edit_requested" {
t.Errorf("expected reason edit_requested, got %s", resp.Notifications[0].Reason)
}
}
func TestNotifications_Priority_NewBookingBelowPendingBooking(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "new_booking", userID, false)
createNotification(t, "pending_booking", userID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 2 {
t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications))
}
if resp.Notifications[0].Reason != "pending_booking" {
t.Errorf("expected pending_booking first, got %s", resp.Notifications[0].Reason)
}
}
// =============================================================================
// Combined param tests
// =============================================================================
func TestNotifications_IncludeAcknowledgedWithReasonFilter(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, false)
createNotification(t, "pending_booking", userID, true)
createNotification(t, "cancelled_booking", userID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true&reason=pending_booking", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 2 {
t.Errorf("expected 2 pending_booking notifications, got %d", len(resp.Notifications))
}
}
// =============================================================================
// Response enrichment tests (user_name, booking_start_time)
// =============================================================================
func TestNotifications_ResponseEnriched_WithUserAndBooking(t *testing.T) {
resetTestData(t)
// Create a service
var serviceID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO services (name, description, price, duration_minutes, is_active)
VALUES ('Manicure', 'Test service', 25.00, 30, true)
RETURNING id
`).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a user
var userID string
err = db.DB.QueryRow(context.Background(), `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Alice', 'Smith', 'alice@test.com', '+447700900001', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create a booking
var bookingID string
err = db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, NOW() + INTERVAL '3 days', 'pending')
RETURNING id
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Create notification with both user_id and booking_id
createNotificationWithBooking(t, "pending_booking", userID, bookingID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
n := resp.Notifications[0]
if n.UserName == nil || *n.UserName != "Alice Smith" {
t.Errorf("expected user_name 'Alice Smith', got %v", n.UserName)
}
if n.BookingStartTime == nil {
t.Error("expected booking_start_time to be set")
}
}
func TestNotifications_ResponseEnriched_NoUserOrBooking(t *testing.T) {
resetTestData(t)
// Create notification without user_id or booking_id
createNotification(t, "1_week_no_pay", "", false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
n := resp.Notifications[0]
if n.UserName != nil {
t.Errorf("expected user_name to be nil, got %v", n.UserName)
}
if n.BookingStartTime != nil {
t.Errorf("expected booking_start_time to be nil, got %v", n.BookingStartTime)
}
}
// =============================================================================
// Acknowledge notification tests (extended)
// =============================================================================
func TestNotifications_Acknowledge_ViaExtendedHandler(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
notifID := createNotification(t, "pending_booking", userID, false)
handler := http.HandlerFunc(AcknowledgeNotification)
w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%d/acknowledge", notifID), nil)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify acknowledged
var ackTime *time.Time
err := db.DB.QueryRow(context.Background(),
"SELECT acknowledged_at FROM admin_notifications WHERE id = $1", notifID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
}
if ackTime == nil {
t.Error("expected acknowledged_at to be set")
}
}
func TestNotifications_Acknowledge_AlreadyAcknowledged_Extended(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
notifID := createNotification(t, "pending_booking", userID, true)
handler := http.HandlerFunc(AcknowledgeNotification)
w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%d/acknowledge", notifID), nil)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404 for already acknowledged, got %d", w.Code)
}
}
// createNotificationWithBooking creates a notification with both user_id and booking_id
func createNotificationWithBooking(t *testing.T, reason, userID, bookingID string, acknowledged bool) int {
t.Helper()
var notificationID int
query := `INSERT INTO admin_notifications (reason, user_id, booking_id) VALUES ($1, $2, $3) RETURNING id`
if acknowledged {
query = `INSERT INTO admin_notifications (reason, user_id, booking_id, acknowledged_at) VALUES ($1, $2, $3, NOW()) RETURNING id`
}
err := db.DB.QueryRow(context.Background(), query, reason, userID, bookingID).Scan(&notificationID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
return notificationID
}
// Ensure test compilation
var _ = func() *pgxpool.Pool { return nil }
@@ -199,7 +199,7 @@ func TestNotifications_ListFilterByReason(t *testing.T) {
}
// Create notifications with different reasons
reasons := []string{"pending_booking", "cancelled_booking", "edit_request"}
reasons := []string{"pending_booking", "cancelled_booking", "edit_requested"}
for _, reason := range reasons {
_, err := db.DB.Exec(context.Background(), `
INSERT INTO admin_notifications (reason, user_id)
+1
View File
@@ -45,6 +45,7 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// TODO: Create 'user_anonymized' notification for admin audit trail
}
// Delete CardDAV contact (non-blocking, best-effort)
+5 -4
View File
@@ -262,10 +262,11 @@ r.Route("/admin/users", func(r chi.Router) {
r.Get("/pending-approvals", today.GetPendingApprovalsHandler)
})
r.Route("/admin/notifications", func(r chi.Router) {
r.Get("/", notifications.GetNotifications)
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
})
r.Route("/admin/notifications", func(r chi.Router) {
r.Get("/", notifications.GetNotifications)
r.Get("/unread-count", notifications.GetUnreadCount)
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
})
r.Route("/admin/time-blockers", func(r chi.Router) {
r.Get("/", scheduling.ListTimeBlockers)