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:
+9
-6
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(¬ifCount)
|
||||
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(¬ifCount)
|
||||
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(¬ifCount)
|
||||
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: ¬es,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(¬ificationID)
|
||||
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(¬ificationID)
|
||||
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(¬ificationID)
|
||||
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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user