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 return "", "", err
} }
claims, err := token.AsMap(ctx) var uidVal interface{}
if err != nil { if err := token.Get("user_id", &uidVal); err != nil {
return "", "", err return "", "", fmt.Errorf("invalid user_id claim")
} }
userID, ok := uidVal.(string)
userID, ok := claims["user_id"].(string)
if !ok { if !ok {
return "", "", fmt.Errorf("invalid user_id claim") 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 { if !ok {
return "", "", fmt.Errorf("invalid role claim") return "", "", fmt.Errorf("invalid role claim")
} }
+2 -2
View File
@@ -1720,7 +1720,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) {
// Create admin notification // Create admin notification
_, err = db.DB.Exec(context.Background(), _, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id) `INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`, VALUES ('edit_requested', $1, $2)`,
bookingID, userID) bookingID, userID)
if err != nil { if err != nil {
t.Fatalf("failed to create admin notification: %v", err) t.Fatalf("failed to create admin notification: %v", err)
@@ -1773,7 +1773,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) {
var ackTime *time.Time var ackTime *time.Time
err = db.DB.QueryRow(context.Background(), err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications `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) bookingID).Scan(&ackTime)
if err != nil { if err != nil {
t.Fatalf("failed to query notification: %v", err) t.Fatalf("failed to query notification: %v", err)
+22 -8
View File
@@ -1672,27 +1672,37 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// Determine notification reason: has notes OR booking is for today // Always create low-priority notification for all bookings
notificationReason := "pending_booking" 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 != "" { if req.Notes != nil && *req.Notes != "" {
notificationReason = "pending_booking" // Notes means pending approval too needsApproval = true
} else { } else {
// Check if booking is for today (same day in London timezone)
london, _ := time.LoadLocation("Europe/London") london, _ := time.LoadLocation("Europe/London")
now := time.Now().In(london) now := time.Now().In(london)
bookingDay := req.StartTime.In(london) bookingDay := req.StartTime.In(london)
if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.YearDay() { if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.YearDay() {
notificationReason = "pending_booking" // Today's booking needsApproval = true
} }
} }
if needsApproval {
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2)
`, notificationReason, booking.ID, userID); err != nil { `, booking.ID, userID); err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err) log.Printf("Failed to create pending approval notification for booking %s: %v", booking.ID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
}
if err := tx.Commit(r.Context()); err != nil { if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -2169,6 +2179,10 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
if paymentCount > 0 { if paymentCount > 0 {
db.DB.Exec(r.Context(), `UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) WHERE id = $1`, booking.User.ID) 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") w.Header().Set("Content-Type", "application/json")
+188 -10
View File
@@ -2644,7 +2644,7 @@ func TestCreateEditRequest(t *testing.T) {
var notifCount int var notifCount int
err = db.DB.QueryRow(context.Background(), err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications `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) bookingID).Scan(&notifCount)
if err != nil { if err != nil {
t.Fatalf("failed to query notifications: %v", err) t.Fatalf("failed to query notifications: %v", err)
@@ -2790,7 +2790,7 @@ func TestDeleteEditRequest(t *testing.T) {
// Create admin notification // Create admin notification
_, err = db.DB.Exec(context.Background(), _, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id) `INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`, VALUES ('edit_requested', $1, $2)`,
bookingID, userID) bookingID, userID)
if err != nil { if err != nil {
t.Fatalf("failed to create admin notification: %v", err) t.Fatalf("failed to create admin notification: %v", err)
@@ -2821,7 +2821,7 @@ func TestDeleteEditRequest(t *testing.T) {
var notifCount int var notifCount int
err = db.DB.QueryRow(context.Background(), err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications `SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`, WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&notifCount) bookingID).Scan(&notifCount)
if err != nil { if err != nil {
t.Fatalf("failed to query notifications: %v", err) t.Fatalf("failed to query notifications: %v", err)
@@ -2885,7 +2885,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
// Create admin notification // Create admin notification
_, err = db.DB.Exec(context.Background(), _, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id) `INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`, VALUES ('edit_requested', $1, $2)`,
bookingID, userID) bookingID, userID)
if err != nil { if err != nil {
t.Fatalf("failed to create admin notification: %v", err) t.Fatalf("failed to create admin notification: %v", err)
@@ -2895,7 +2895,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
var ackTime *time.Time var ackTime *time.Time
err = db.DB.QueryRow(context.Background(), err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications `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) bookingID).Scan(&ackTime)
if err != nil { if err != nil {
t.Fatalf("failed to query notification: %v", err) t.Fatalf("failed to query notification: %v", err)
@@ -2937,7 +2937,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
var ackTimeAfter *time.Time var ackTimeAfter *time.Time
err = db.DB.QueryRow(context.Background(), err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications `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) bookingID).Scan(&ackTimeAfter)
if err != nil { if err != nil {
t.Fatalf("failed to query notification: %v", err) t.Fatalf("failed to query notification: %v", err)
@@ -2996,7 +2996,7 @@ func TestAdminRejectEditRequest(t *testing.T) {
// Create admin notification // Create admin notification
_, err = db.DB.Exec(context.Background(), _, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id) `INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`, VALUES ('edit_requested', $1, $2)`,
bookingID, userID) bookingID, userID)
if err != nil { if err != nil {
t.Fatalf("failed to create admin notification: %v", err) t.Fatalf("failed to create admin notification: %v", err)
@@ -3006,7 +3006,7 @@ func TestAdminRejectEditRequest(t *testing.T) {
var ackTime *time.Time var ackTime *time.Time
err = db.DB.QueryRow(context.Background(), err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications `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) bookingID).Scan(&ackTime)
if err != nil { if err != nil {
t.Fatalf("failed to query notification: %v", err) t.Fatalf("failed to query notification: %v", err)
@@ -3048,7 +3048,7 @@ func TestAdminRejectEditRequest(t *testing.T) {
var ackTimeAfter *time.Time var ackTimeAfter *time.Time
err = db.DB.QueryRow(context.Background(), err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications `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) bookingID).Scan(&ackTimeAfter)
if err != nil { if err != nil {
t.Fatalf("failed to query notification: %v", err) 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 // Create admin notification for the initial edit request
_, err = db.DB.Exec(context.Background(), _, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id) `INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`, VALUES ('edit_requested', $1, $2)`,
bookingID, userID) bookingID, userID)
if err != nil { if err != nil {
t.Fatalf("failed to create admin notification: %v", err) 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()) 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 // Delete the admin notification for this edit request
_, err = tx.Exec(r.Context(), ` _, err = tx.Exec(r.Context(), `
DELETE FROM admin_notifications 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) `, bookingID, userID)
if err != nil { if err != nil {
log.Printf("Failed to delete admin notification for booking %s: %v", bookingID, err) 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(), ` _, err = tx.Exec(r.Context(), `
DELETE FROM admin_notifications DELETE FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request' WHERE booking_id = $1 AND reason = 'edit_requested'
`, bookingID) `, bookingID)
if err != nil { if err != nil {
log.Printf("Failed to delete old admin notification for booking %s: %v", bookingID, err) 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 return
} }
// Create admin notification with reason 'edit_request'
_, err = tx.Exec(r.Context(), ` _, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3) VALUES ('edit_requested', $1, $2)
`, "edit_request", bookingID, userID) `, bookingID, userID)
if err != nil { if err != nil {
log.Printf("Failed to create admin notification for edit request %s: %v", bookingID, err) log.Printf("Failed to create admin notification for edit request %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return 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" { if currentStatus == "pending" {
// Acknowledge existing pending_booking notification
_, err = tx.Exec(r.Context(), ` _, err = tx.Exec(r.Context(), `
UPDATE admin_notifications UPDATE admin_notifications
SET acknowledged_at = NOW() 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) 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(), ` _, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3) VALUES ('pending_booking', $1, $2)
`, "pending_booking", bookingID, userID) `, bookingID, userID)
if err != nil { if err != nil {
log.Printf("Failed to create pending booking notification for %s: %v", bookingID, err) 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(), ` _, err = tx.Exec(r.Context(), `
UPDATE admin_notifications UPDATE admin_notifications
SET acknowledged_at = NOW() 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) `, bookingID)
if err != nil { if err != nil {
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err) 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(), ` _, err = tx.Exec(r.Context(), `
UPDATE admin_notifications UPDATE admin_notifications
SET acknowledged_at = NOW() 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) `, bookingID)
if err != nil { if err != nil {
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err) log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
@@ -21,6 +21,9 @@ type AdminNotification struct {
Reason string `json:"reason"` Reason string `json:"reason"`
BookingID *string `json:"booking_id,omitempty"` BookingID *string `json:"booking_id,omitempty"`
UserID *string `json:"user_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"` CreatedAt time.Time `json:"created_at"`
} }
@@ -32,6 +35,10 @@ type AdminNotificationListResponse struct {
} }
// GET /api/admin/notifications // 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) { func GetNotifications(w http.ResponseWriter, r *http.Request) {
// Parse query params // Parse query params
page := 1 page := 1
@@ -44,33 +51,61 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
perPage = pp perPage = pp
} }
includeAcknowledged := r.URL.Query().Get("include_acknowledged") == "true"
reasonFilter := r.URL.Query().Get("reason") reasonFilter := r.URL.Query().Get("reason")
baseQuery := ` baseQuery := `
SELECT id, reason, booking_id, user_id, created_at SELECT an.id, an.reason, an.booking_id, an.user_id,
FROM admin_notifications u.n_first_name || ' ' || u.n_last_name AS user_name,
WHERE acknowledged_at IS NULL 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 := ` countQuery := `
SELECT COUNT(*) FROM admin_notifications SELECT COUNT(*) FROM admin_notifications
WHERE acknowledged_at IS NULL
` `
args := []any{} args := []any{}
countArgs := []any{} countArgs := []any{}
param := 1 param := 1
// Optional reason filter if !includeAcknowledged {
baseQuery += fmt.Sprintf(" WHERE an.acknowledged_at IS NULL")
countQuery += ` WHERE acknowledged_at IS NULL`
}
if reasonFilter != "" { if reasonFilter != "" {
baseQuery += fmt.Sprintf(" AND reason = $%d", param) if !includeAcknowledged {
baseQuery += fmt.Sprintf(" AND an.reason = $%d", param)
countQuery += fmt.Sprintf(" AND 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) args = append(args, reasonFilter)
countArgs = append(countArgs, reasonFilter) countArgs = append(countArgs, reasonFilter)
param++ param++
} }
// ORDER & pagination if includeAcknowledged {
baseQuery += " ORDER BY created_at DESC" 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) baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", param, param+1)
args = append(args, perPage, (page-1)*perPage) args = append(args, perPage, (page-1)*perPage)
@@ -98,12 +133,18 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
var n AdminNotification var n AdminNotification
var bookingID sql.NullString var bookingID sql.NullString
var userID sql.NullString var userID sql.NullString
var userName sql.NullString
var bookingStartTime sql.NullTime
var acknowledgedAt sql.NullTime
err := rows.Scan( err := rows.Scan(
&n.ID, &n.ID,
&n.Reason, &n.Reason,
&bookingID, &bookingID,
&userID, &userID,
&userName,
&bookingStartTime,
&acknowledgedAt,
&n.CreatedAt, &n.CreatedAt,
) )
if err != nil { if err != nil {
@@ -118,6 +159,15 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
if userID.Valid { if userID.Valid {
n.UserID = &userID.String 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) 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) { func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
if idStr == "" { 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 // 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 { for _, reason := range reasons {
_, err := db.DB.Exec(context.Background(), ` _, err := db.DB.Exec(context.Background(), `
INSERT INTO admin_notifications (reason, user_id) 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) http.Error(w, "server error", http.StatusInternalServerError)
return return
} }
// TODO: Create 'user_anonymized' notification for admin audit trail
} }
// Delete CardDAV contact (non-blocking, best-effort) // Delete CardDAV contact (non-blocking, best-effort)
+1
View File
@@ -264,6 +264,7 @@ r.Route("/admin/users", func(r chi.Router) {
r.Route("/admin/notifications", func(r chi.Router) { r.Route("/admin/notifications", func(r chi.Router) {
r.Get("/", notifications.GetNotifications) r.Get("/", notifications.GetNotifications)
r.Get("/unread-count", notifications.GetUnreadCount)
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification) r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
}) })
@@ -62,6 +62,51 @@
let overlappingBookings = $state<OverlappingBooking[]>([]); let overlappingBookings = $state<OverlappingBooking[]>([]);
let loadingOverlaps = $state(false); let loadingOverlaps = $state(false);
function getBookingDateTime(): string {
if (!booking?.start_time) return '';
const d = new Date(booking.start_time);
return d.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
}) + ' at ' + d.toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
function getTotalCost(): number {
if (!booking?.services?.length) return 0;
let total = 0;
for (const service of booking.services) {
if (!service?.service_id) continue;
const override = serviceOverrides[service.service_id];
if (override && hasPriceChanged(service.service_id)) {
total += parseFloat(override.price) || 0;
} else {
total += service.price || 0;
}
}
return Math.round(total * 100) / 100;
}
function getTotalDuration(): number {
if (!booking?.services?.length) return 0;
let total = 0;
for (const service of booking.services) {
if (!service?.service_id) continue;
const override = serviceOverrides[service.service_id];
if (override && hasDurationChanged(service.service_id)) {
total += parseInt(override.duration) || 0;
} else {
total += service.duration_minutes || 0;
}
}
return total;
}
interface OverlappingBooking { interface OverlappingBooking {
id: string; id: string;
start_time: string; start_time: string;
@@ -401,7 +446,6 @@
</div> </div>
{/if} {/if}
<!-- Customer Contact Info -->
<!-- Customer Contact Info --> <!-- Customer Contact Info -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
@@ -425,6 +469,14 @@
</div> </div>
</div> </div>
<!-- Booking Date & Time -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-2 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Booking Date & Time
</h3>
<div class="text-lg font-medium">{getBookingDateTime()}</div>
</div>
<!-- Booking Notes --> <!-- Booking Notes -->
<div> <div>
<label for="booking-notes" class="mb-2 block text-sm font-medium"> Booking Notes </label> <label for="booking-notes" class="mb-2 block text-sm font-medium"> Booking Notes </label>
@@ -546,6 +598,12 @@
</div> </div>
<Modal.Footer class="flex items-center justify-between gap-2"> <Modal.Footer class="flex items-center justify-between gap-2">
<div class="flex items-center gap-4 text-sm text-gray-600">
<span class="font-medium">Total: £{getTotalCost().toFixed(2)}</span>
<span class="text-gray-400">|</span>
<span>{getTotalDuration()} min</span>
</div>
<div class="flex items-center gap-2">
<Button <Button
variant="destructive" variant="destructive"
onclick={() => (showDeclineConfirm = true)} onclick={() => (showDeclineConfirm = true)}
@@ -553,7 +611,6 @@
> >
Decline Booking Decline Booking
</Button> </Button>
<div class="flex gap-2">
<Button <Button
onclick={handleApprove} onclick={handleApprove}
disabled={submitting} disabled={submitting}
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { navigating, page } from '$app/stores';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import { navigating, page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
// Centralized link definition // Centralized link definition
const links = [ const links = [
@@ -53,6 +55,40 @@
// Derived values for auth state - ensures reactivity // Derived values for auth state - ensures reactivity
let isAuthenticated = $derived(authStore.isAuthenticated); let isAuthenticated = $derived(authStore.isAuthenticated);
let isLoading = $derived(authStore.isLoading); let isLoading = $derived(authStore.isLoading);
// Notification bell state
let unreadCount = $state(0);
let pollInterval: ReturnType<typeof setInterval> | null = null;
async function fetchUnreadCount() {
if (!authStore.isAuthenticated || !authStore.currentToken) return;
try {
const res = await fetch('/api/admin/notifications/unread-count', {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (res.ok) {
const data = await res.json();
unreadCount = data.count;
}
} catch {
// Silently fail - bell is non-critical
}
}
onMount(() => {
if (authStore.isAuthenticated) {
fetchUnreadCount();
if (authStore.currentUser?.role === 'admin') {
pollInterval = setInterval(fetchUnreadCount, 60000);
}
}
});
onDestroy(() => {
if (pollInterval) {
clearInterval(pollInterval);
}
});
</script> </script>
<nav <nav
@@ -104,8 +140,27 @@
{/each} {/each}
</div> </div>
<!-- Desktop Login Button --> <div class="hidden items-center gap-4 md:flex">
<div class="hidden items-center md:flex"> {#if isAuthenticated}
<a
href="/notifications"
class="relative text-gray-600 hover:text-primary"
aria-label="Notifications"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
{#if unreadCount > 0}
<span
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
{/if}
</a>
{/if}
{#if !isLoading && !isAuthenticated && $page.url.pathname !== '/login'} {#if !isLoading && !isAuthenticated && $page.url.pathname !== '/login'}
<Button href="/login">Login</Button> <Button href="/login">Login</Button>
{/if} {/if}
@@ -149,6 +204,20 @@
{/if} {/if}
{/each} {/each}
{#if isAuthenticated}
<a
href="/notifications"
class="flex items-center justify-center gap-2 rounded px-3 py-2 text-primary hover:text-gray-800"
>
<span>Notifications</span>
{#if unreadCount > 0}
<span class="flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
{/if}
</a>
{/if}
{#if isLoading} {#if isLoading}
<Skeleton class="mt-2 h-8 w-full rounded" /> <Skeleton class="mt-2 h-8 w-full rounded" />
{:else if !isAuthenticated} {:else if !isAuthenticated}
@@ -0,0 +1,428 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Button } from '$lib/components/ui/button';
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
import UserModal from '$lib/components/admin/UserModal.svelte';
import { toast } from 'svelte-sonner';
interface Notification {
id: number;
reason: string;
booking_id?: string;
user_id?: string;
user_name?: string;
booking_start_time?: string;
acknowledged_at?: string;
created_at: string;
}
let notifications = $state<Notification[]>([]);
let loading = $state(true);
let error = $state(false);
let page = $state(1);
let perPage = $state(20);
let total = $state(0);
let includeAcknowledged = $state(false);
let showApprovalModal = $state(false);
let selectedBooking = $state<any>(null);
let showBookingModal = $state(false);
let showUserModal = $state(false);
let selectedUserId = $state<string | null>(null);
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
$effect(() => {
if (!browser) return;
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated || authStore.currentUser?.role !== 'admin') {
pageState = 'unauthorized';
return;
}
pageState = 'authorized';
});
const reasonLabels: Record<string, string> = {
pending_booking: 'Booking Pending Approval',
edit_request: 'Customer Requested Booking Change',
edit_requested: 'Booking Edit Requested',
new_booking: 'New Booking Received',
cancelled_booking: 'Booking Cancelled',
late_cancellation: 'Late Cancellation (< 24h)',
no_deposit: 'Deposit Issue',
deposit_paid: 'Deposit Payment Received',
affiliate_claim: 'Affiliate Referral Claimed',
'1_month_no_pay': 'No Payments in 1 Month',
'1_week_no_pay': 'No Payments in 1 Week'
};
function hasAction(reason: string): string | null {
switch (reason) {
case 'pending_booking':
return 'approve';
case 'edit_request':
case 'edit_requested':
case 'new_booking':
return 'view';
case 'late_cancellation':
case 'no_deposit':
case '1_week_no_pay':
case '1_month_no_pay':
return 'see_user';
default:
return null;
}
}
async function fetchNotifications() {
loading = true;
error = false;
try {
const params = new URLSearchParams({
page: page.toString(),
per_page: perPage.toString(),
include_acknowledged: includeAcknowledged.toString()
});
const response = await fetch(`/api/admin/notifications?${params}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (!response.ok) {
throw new Error(`Failed: ${response.status}`);
}
const data = await response.json();
notifications = data.notifications;
total = data.total;
} catch {
error = true;
} finally {
loading = false;
}
}
async function fetchBookingDetails(bookingId: string): Promise<any> {
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (!response.ok) return null;
return response.json();
}
async function handleAction(notification: Notification) {
if (!notification.acknowledged_at) {
await fetch(`/api/admin/notifications/${notification.id}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
}
const action = hasAction(notification.reason);
if (action === 'approve' && notification.booking_id) {
const booking = await fetchBookingDetails(notification.booking_id);
if (booking) {
selectedBooking = booking;
showApprovalModal = true;
} else {
toast.error('Could not load booking details');
}
} else if (action === 'view' && notification.booking_id) {
const booking = await fetchBookingDetails(notification.booking_id);
if (booking) {
selectedBooking = booking;
showBookingModal = true;
} else {
toast.error('Could not load booking details');
}
} else if (action === 'see_user' && notification.user_id) {
selectedUserId = notification.user_id;
showUserModal = true;
}
}
async function handleAcknowledge(notification: Notification) {
try {
const response = await fetch(`/api/admin/notifications/${notification.id}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
if (!response.ok) {
toast.error('Failed to acknowledge notification');
return;
}
notifications = notifications.filter((n) => n.id !== notification.id);
total = Math.max(0, total - 1);
if (notifications.length === 0 && page > 1) {
page--;
await fetchNotifications();
}
} catch {
toast.error('Network error');
}
}
function handleApproved() {
showApprovalModal = false;
selectedBooking = null;
fetchNotifications();
}
function toggleView() {
includeAcknowledged = !includeAcknowledged;
page = 1;
fetchNotifications();
}
function prevPage() {
if (page > 1) {
page--;
fetchNotifications();
}
}
function nextPage() {
if (page * perPage < total) {
page++;
fetchNotifications();
}
}
function formatRelative(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
const diffWeek = Math.floor(diffDay / 7);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
if (diffWeek < 5) return `${diffWeek}w ago`;
return d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
}
function formatBookingDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const bookingDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000);
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Tomorrow';
if (diffDays === -1) return 'Yesterday';
if (diffDays > 1 && diffDays <= 6) {
return d.toLocaleDateString('en-GB', { weekday: 'long' });
}
if (diffDays > 6 && diffDays <= 13) {
return 'Next ' + d.toLocaleDateString('en-GB', { weekday: 'long' });
}
return d.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
}
function getNotificationTitle(n: Notification): string {
const base = reasonLabels[n.reason] || n.reason;
if (n.booking_start_time) {
return `${base}${formatBookingDate(n.booking_start_time)}`;
}
return base;
}
function getNotificationSubtitle(n: Notification): string {
const parts = [formatRelative(n.created_at)];
if (n.user_name) {
parts.push(n.user_name);
}
return parts.join(' — ');
}
const totalPages = Math.ceil(total / perPage);
onMount(() => {
if (pageState === 'authorized') {
fetchNotifications();
}
});
</script>
{#if pageState === 'loading'}
<div class="mx-auto max-w-3xl space-y-4 p-6 pt-24">
<Skeleton class="h-8 w-48" />
{#each Array(5) as _, i (i)}
<Skeleton class="h-24 w-full" />
{/each}
</div>
{:else if pageState === 'unauthorized'}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="max-w-md text-center">
<svg
class="mx-auto mb-4 h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h2 class="mb-2 text-xl font-semibold text-gray-900">Coming Soon</h2>
<p class="text-gray-600">
Notifications are available for admin accounts. This feature will be enabled for all users
before launch.
</p>
</div>
</div>
{:else if error}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="max-w-md text-center">
<h2 class="mb-2 text-xl font-semibold text-gray-900">Unable to Load Notifications</h2>
<p class="mb-4 text-gray-600">Something went wrong. Please try again.</p>
<Button onclick={fetchNotifications}>Retry</Button>
</div>
</div>
{:else}
<div class="mx-auto max-w-3xl p-6 pt-24">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900">Notifications</h1>
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={includeAcknowledged}
onchange={toggleView}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
Show acknowledged
</label>
</div>
{#if notifications.length === 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 py-16 text-center">
<svg
class="mx-auto mb-3 h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<p class="text-gray-500">
{includeAcknowledged ? 'No notifications yet' : 'No unread notifications'}
</p>
</div>
{:else}
<div class="space-y-3">
{#each notifications as n (n.id)}
<div
transition:fly={{ x: 100, duration: 250, easing: cubicOut }}
class="rounded-lg border p-4 transition-colors {n.acknowledged_at
? 'border-gray-200 bg-gray-50'
: 'border-gray-300 bg-white'}"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
{#if !n.acknowledged_at}
<span class="h-2 w-2 shrink-0 rounded-full bg-primary"></span>
{/if}
<h3 class="font-medium text-gray-900">{getNotificationTitle(n)}</h3>
</div>
<p class="mt-1 text-sm text-gray-500">{getNotificationSubtitle(n)}</p>
</div>
<div class="flex shrink-0 items-center gap-2 sm:flex-col sm:items-end">
{#if hasAction(n.reason)}
<Button size="sm" onclick={() => handleAction(n)}>
{#if hasAction(n.reason) === 'approve'}
Approve Booking
{:else if hasAction(n.reason) === 'see_user'}
See User
{:else}
See Booking
{/if}
</Button>
{/if}
{#if !n.acknowledged_at}
<Button size="sm" variant="outline" onclick={() => handleAcknowledge(n)}>
Acknowledge
</Button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{#if totalPages > 1}
<div class="mt-6 flex items-center justify-between">
<p class="text-sm text-gray-500">
{(page - 1) * perPage + 1}{Math.min(page * perPage, total)} of {total}
</p>
<div class="flex gap-2">
<Button size="sm" variant="outline" onclick={prevPage} disabled={page <= 1}>
Previous
</Button>
<Button
size="sm"
variant="outline"
onclick={nextPage}
disabled={page * perPage >= total}
>
Next
</Button>
</div>
</div>
{/if}
{/if}
</div>
{/if}
{#if showApprovalModal && selectedBooking}
<ApprovalModal
bind:open={showApprovalModal}
booking={selectedBooking}
onApproved={handleApproved}
/>
{/if}
{#if showBookingModal && selectedBooking}
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking.id} />
{/if}
{#if showUserModal && selectedUserId}
<UserModal bind:open={showUserModal} userId={selectedUserId} />
{/if}
+1 -1
View File
@@ -523,7 +523,7 @@ INSERT INTO business_settings (
'https://www.website.co.uk' 'https://www.website.co.uk'
); );
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'no_deposit', 'deposit_paid', 'edit_request'); CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'no_deposit', 'deposit_paid', 'edit_request', 'new_booking', 'edit_requested');
CREATE TABLE admin_notifications ( CREATE TABLE admin_notifications (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
+23
View File
@@ -699,6 +699,28 @@ for ((i=0; i<${#PENDING_BOOKING_IDS[@]}; i++)); do
done done
echo "${C_GREEN}✅ Confirmed $confirmed_count bookings, left $skipped_count pending for admin review${C_RESET}" echo "${C_GREEN}✅ Confirmed $confirmed_count bookings, left $skipped_count pending for admin review${C_RESET}"
# ===========================================================================
# 5b. USER BOOKINGS WITH NOTES (triggers pending_booking notifications)
# These use the public endpoint so notifications are created.
# ===========================================================================
echo -e "\n${C_BLUE}📝 Creating User Bookings with Notes (notification triggers)..."
USER_NOTE_COUNT=0
# Emma — booking with special request notes (triggers new_booking + pending_booking)
D_NOTE1=$(open_day "$(TZ=Europe/London date -d "$TODAY +8 days" +%Y-%m-%d)")
if create_booking "$EMMA_TOKEN" "$(format_london_time "$D_NOTE1" "$SLOT_E")" "[\"$(get_svc 1)\"]" "Hi! Could I please have a nude base with white french tips and a single gold foil accent on the ring finger? Also I have a slight nail ridge on my left thumb - nothing major but worth noting." "Emma - French tips + gold foil (+8 days)"; then
USER_NOTE_COUNT=$((USER_NOTE_COUNT+1))
fi
# Isla — booking with notes for a different day (triggers new_booking + pending_booking)
D_NOTE2=$(open_day "$(TZ=Europe/London date -d "$TODAY +10 days" +%Y-%m-%d)")
if create_booking "$ISLA_TOKEN" "$(format_london_time "$D_NOTE2" "$SLOT_E")" "[\"$(get_svc 1)\",\"$(get_svc 5)\"]" "I'd like a chrome/mirror-ball effect on all nails if possible. Also I'm thinking of getting married soon so want to trial a bridal look - can we discuss options?" "Isla - Chrome trial (+10 days)"; then
USER_NOTE_COUNT=$((USER_NOTE_COUNT+1))
fi
echo "${C_GREEN}✅ Created $USER_NOTE_COUNT User Bookings with Notes (pending notifications)${C_RESET}"
# =========================================================================== # ===========================================================================
# 6. GUEST BOOKINGS # 6. GUEST BOOKINGS
# =========================================================================== # ===========================================================================
@@ -1004,6 +1026,7 @@ echo -e " Bookings — total : $TOTAL_BOOKINGS"
echo -e " Cancellations : $cancel_count" echo -e " Cancellations : $cancel_count"
echo -e " Confirmed : $confirmed_count | Still pending: $skipped_count" echo -e " Confirmed : $confirmed_count | Still pending: $skipped_count"
echo -e " Bookings — guest : $count_guest" echo -e " Bookings — guest : $count_guest"
echo -e " Bookings — w/ notes: $USER_NOTE_COUNT (pending notifications)"
echo -e " Payments : $payment_count completed bookings" echo -e " Payments : $payment_count completed bookings"
echo -e " Time blockers : $count_blockers" echo -e " Time blockers : $count_blockers"
echo -e " Schedule groups : $sched_success/3" echo -e " Schedule groups : $sched_success/3"
+2 -2
View File
@@ -171,11 +171,11 @@
}, },
"active": "0e456d61bc5b6ded", "active": "0e456d61bc5b6ded",
"lastOpenFiles": [ "lastOpenFiles": [
"Crussell/Overview.md",
"Crussell/Future Work - Gap Backlog.md",
"Crussell/Technical Manual.md", "Crussell/Technical Manual.md",
"Crussell/User Manual.md", "Crussell/User Manual.md",
"Crussell/Admin Manual.md", "Crussell/Admin Manual.md",
"Crussell/Overview.md",
"Crussell/Future Work - Gap Backlog.md",
"Crussell/Test Implementation Plan.md", "Crussell/Test Implementation Plan.md",
"Crussell/Crussell Nails.md", "Crussell/Crussell Nails.md",
"Crussell/Backend/bookings.md", "Crussell/Backend/bookings.md",
+69
View File
@@ -328,6 +328,75 @@ The window shows the full booking details so you can review them before making y
--- ---
## Notifications
The notifications page keeps you informed about everything happening with your bookings and customers. You'll find a bell icon in the top-right corner of the navigation bar — if there's a red dot on it, you have unread notifications.
### How to Access
Click the **bell icon** in the top-right corner of the website to go to the Notifications page. The bell shows a number indicating how many unread notifications you have.
### What You'll See
Notifications are sorted by importance, with the most urgent at the top:
1. **Booking Pending Approval** — A booking needs your review and approval
2. **Booking Cancelled** — A booking was cancelled
3. **Late Cancellation (< 24h)** — A customer cancelled less than 24 hours before their appointment
4. **Deposit Issue** — A deposit-related problem
5. **Deposit Payment Received** — A deposit payment came through
6. **Affiliate Referral Claimed** — Someone used a referral code
7. **Booking Edit Requested** — A customer wants to change their booking
8. **New Booking Received** — A new booking was made online
9. **No Payments in 1 Month** — No payments recorded in the last month
10. **No Payments in 1 Week** — No payments recorded in the last week
Within each priority level, older notifications appear first so you see the ones that have been waiting longest.
### What Each Notification Does
**Booking Pending Approval:**
- Shows **"Approve Booking"** button
- Opens the approval window where you can confirm or decline the booking
- You can also adjust the price, duration, and add notes before confirming
**New Booking Received / Booking Edit Requested:**
- Shows **"See Booking"** button
- Opens a read-only view of the booking details
- No approval needed — just for your awareness
**Late Cancellation / Deposit Issue / No Payments:**
- Shows **"See User"** button
- Opens the customer's full profile so you can review their history, spending, and notes
**All other notifications:**
- Show only the **"Acknowledge"** button
- These are informational — no further action needed
### Acknowledging Notifications
Every notification has an **"Acknowledge"** button. Clicking it marks the notification as seen and removes it from the default view.
When you click **"Approve Booking"**, **"See Booking"**, or **"See User"**, the notification is automatically acknowledged for you — you don't need to click Acknowledge separately.
### Viewing Old Notifications
At the top of the Notifications page, there's a **"Show acknowledged"** toggle. Turn it on to see all notifications you've already acknowledged, sorted newest first. This is useful if you need to look back at something you've already dealt with.
### Pagination
If you have many notifications, they're split into pages of 20. Use the **Previous** and **Next** buttons at the bottom to navigate.
### How Notifications Are Created
- **Every booking** made through the website creates a "New Booking Received" notification
- If a booking has **notes** attached or is for **today**, an additional "Booking Pending Approval" notification is also created so you know it needs your attention
- **Edit requests** from customers create a "Booking Edit Requested" notification
- If the booking being edited is still **pending**, an additional "Booking Pending Approval" notification is also created
- Admin-created bookings (walk-ins, call-ins) do **not** create notifications — you already know about them because you created them
---
## Uploading Portfolio Images ## Uploading Portfolio Images
The portfolio is the salon's gallery of nail art photos that customers can browse on the website. The portfolio is the salon's gallery of nail art photos that customers can browse on the website.
@@ -20,7 +20,7 @@ No external dependencies. No paid services. No API keys needed.
| # | Gap | Effort | Area | Notes | | # | Gap | Effort | Area | Notes |
| ----- | -------------------------------------------------------- | -------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----- | -------------------------------------------------------- | -------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 5 | **Admin notification panel** | M (1-2d) | Frontend | Backend fully wired (GET/acknowledge). No frontend UI to display notifications. Admin has no visibility into pending bookings, cancellations, no-shows. | | ~~5~~ | ~~**Admin notification panel**~~ | ~~M (1-2d)~~ | ~~Frontend~~ | ~~Backend fully wired (GET/acknowledge). No frontend UI to display notifications. Admin has no visibility into pending bookings, cancellations, no-shows.~~ Two-tier system live: `new_booking` (all) + `pending_booking` (notes/today). Priority ordering, bell icon, `/notifications` page, acknowledge flow, enriched responses.
| 6 | **Reservation/anonymization cron** | S (2-3h) | Backend | `CleanupOldReservations()` and `AnonymizeStaleGuestAccounts()` only fire on availability fetch. If no one fetches availability, expired reservations persist and stale guests aren't anonymized. Should be a background ticker in `main.go`. | | 6 | **Reservation/anonymization cron** | S (2-3h) | Backend | `CleanupOldReservations()` and `AnonymizeStaleGuestAccounts()` only fire on availability fetch. If no one fetches availability, expired reservations persist and stale guests aren't anonymized. Should be a background ticker in `main.go`. |
| 7 | **GDPR data export endpoint** | M (1d) | Backend | `export_all_user_data()` SQL function exists (JSON export). No Go handler wired. Required for GDPR Article 15 SAR requests. | | 7 | **GDPR data export endpoint** | M (1d) | Backend | `export_all_user_data()` SQL function exists (JSON export). No Go handler wired. Required for GDPR Article 15 SAR requests. |
| 8 | **VAT/Tax export endpoints** | M (1-2d) | Backend | `get_vat_return_data()`, `export_sales_transactions()` SQL functions exist. No admin API to trigger them. Needed for HMRC compliance. | | 8 | **VAT/Tax export endpoints** | M (1-2d) | Backend | `get_vat_return_data()`, `export_sales_transactions()` SQL functions exist. No admin API to trigger them. Needed for HMRC compliance. |
@@ -143,7 +143,7 @@ Require paid accounts, API approval, or external service credentials. **Do not a
│ │ │ │
│ #3 Approval decline ✅──→ #13 Booking reschedule │ │ #3 Approval decline ✅──→ #13 Booking reschedule │
│ │ │ │
│ #5 Admin notification panel ──→ #15 Preferences UI │ ~~#5 Admin notification panel~~ ✅ ──→ #15 Preferences UI │
│ ──→ #48 Waitlist (removed) │ │ ──→ #48 Waitlist (removed) │
│ │ │ │
│ #2 Walk-in guest fix ──→ #9 Reservation transition │ │ #2 Walk-in guest fix ──→ #9 Reservation transition │
@@ -179,7 +179,7 @@ Require paid accounts, API approval, or external service credentials. **Do not a
9. **#2** Wire WalkInCreateModal guest booking (1-2h) ✅ 9. **#2** Wire WalkInCreateModal guest booking (1-2h) ✅
10. **#3** ApprovalModal decline/cancel (2-3h) ✅ 10. **#3** ApprovalModal decline/cancel (2-3h) ✅
11. **#5** Admin notification panel (1-2d) 11. ~~**#5** Admin notification panel (1-2d)~~
12. **#4** CurrentAppointment Extend + Cancel actions (1d) — skip TakePayment (blocked on E1). **Edit** ✅ — now opens `EditBookingModal` for service management. 12. **#4** CurrentAppointment Extend + Cancel actions (1d) — skip TakePayment (blocked on E1). **Edit** ✅ — now opens `EditBookingModal` for service management.
13. **#12** Booking cancellation from user account (2-3h) 13. **#12** Booking cancellation from user account (2-3h)
14. **#40** No-show tracking dashboard (2-3h) 14. **#40** No-show tracking dashboard (2-3h)
+33 -1
View File
@@ -230,6 +230,7 @@ src/lib/components/
| GET | `/api/admin/today/appointments` | Today's appointments | | GET | `/api/admin/today/appointments` | Today's appointments |
| GET | `/api/admin/today/pending-approvals` | Pending approval queue | | GET | `/api/admin/today/pending-approvals` | Pending approval queue |
| GET | `/api/admin/notifications` | List notifications | | GET | `/api/admin/notifications` | List notifications |
| GET | `/api/admin/notifications/unread-count` | Unread count for bell icon |
| POST | `/api/admin/notifications/{id}/acknowledge` | Acknowledge notification | | POST | `/api/admin/notifications/{id}/acknowledge` | Acknowledge notification |
| GET | `/api/admin/time-blockers` | List time blockers | | GET | `/api/admin/time-blockers` | List time blockers |
| POST | `/api/admin/time-blockers` | Create time blocker | | POST | `/api/admin/time-blockers` | Create time blocker |
@@ -261,7 +262,7 @@ src/lib/components/
| `payment_type` | `deposit`, `full`, `tip`, `balance`, `partial` | | `payment_type` | `deposit`, `full`, `tip`, `balance`, `partial` |
| `payment_method` | `online_square`, `in_person_card`, `cash`, `giftcard`, `discount` | | `payment_method` | `online_square`, `in_person_card`, `cash`, `giftcard`, `discount` |
| `payment_status` | `pending`, `completed`, `failed`, `refunded` | | `payment_status` | `pending`, `completed`, `failed`, `refunded` |
| `admin_notification_reason` | `pending_booking`, `cancelled_booking`, `rescheduled_booking`, `1_week_no_pay`, `1_month_no_pay`, `affiliate_claim`, `late_cancellation`, `no_deposit`, `deposit_paid`, `edit_request` | | `admin_notification_reason` | `pending_booking`, `cancelled_booking`, `rescheduled_booking`, `1_week_no_pay`, `1_month_no_pay`, `affiliate_claim`, `late_cancellation`, `no_deposit`, `deposit_paid`, `edit_request`, `new_booking`, `edit_requested` |
| `campaign_type` | `time_based`, `milestone` | | `campaign_type` | `time_based`, `milestone` |
| `milestone_type` | `per_user_booking_count`, `global_booking_count`, `anniversary` | | `milestone_type` | `per_user_booking_count`, `global_booking_count`, `anniversary` |
| `milestone_unit` | `bookings`, `months`, `years` | | `milestone_unit` | `bookings`, `months`, `years` |
@@ -452,6 +453,37 @@ src/lib/components/
--- ---
### Notifications
**How it works:** Two-tier notification system. Every public booking creates a `new_booking` notification (low priority, acknowledge-only). If the booking has notes or is for today, an additional `pending_booking` notification is also created (high priority, approve/deny action).
**Endpoints:**
- `GET /api/admin/notifications` — List notifications. Query params: `page`, `per_page`, `include_acknowledged` (bool), `reason` (filter). Default: unacknowledged only, sorted by priority CASE WHEN then oldest-first. With `include_acknowledged=true`: all notifications, newest-first.
- `GET /api/admin/notifications/unread-count` — Returns `{"count": N}` for the bell icon.
- `POST /api/admin/notifications/{id}/acknowledge` — Sets `acknowledged_at = NOW()`. Idempotent (404 if already acknowledged).
**Priority order** (SQL CASE WHEN):
| Priority | Reason | Frontend Action |
|----------|--------|-----------------|
| 1 | `pending_booking` | Approve/Decline (ApprovalModal) |
| 2 | `cancelled_booking` | Acknowledge |
| 3 | `late_cancellation` | Acknowledge + See User |
| 4 | `no_deposit` | Acknowledge + See User |
| 5 | `deposit_paid` | Acknowledge |
| 6 | `affiliate_claim` | Acknowledge |
| 7 | `edit_requested` | See Booking (BookingModal) |
| 8 | `new_booking` | See Booking (BookingModal) |
| 9 | `1_month_no_pay` | Acknowledge + See User |
| 10 | `1_week_no_pay` | Acknowledge + See User |
**Auto-acknowledge behavior:** Clicking "Approve Booking", "See Booking", or "See User" automatically acknowledges the notification before opening the modal. The standalone "Acknowledge" button is for dismissing without action.
**Creation sources:**
- Public bookings (`POST /api/bookings`) → always `new_booking`, plus `pending_booking` if notes or today
- Edit requests (`POST /api/bookings/{id}/edit-request`) → always `edit_requested`, plus `pending_booking` if booking status is pending
- Admin bookings (`POST /api/admin/bookings`) → no notifications (admin already knows)
### Loyalty & Discount System ### Loyalty & Discount System
**Loyalty Stamps:** **Loyalty Stamps:**