feat: admin notification system with priority ordering, bell icon, and /notifications page

Two-tier notification system: new_booking (all public bookings) + pending_booking (notes/today).
Priority-sorted queue, unread count polling, enriched responses with user_name/booking_start_time.
Fix critical bug: edit_requested cleanup was broken (wrong reason string in 3 handlers).
Add 15 new tests covering priority ordering, enrichment, and notification creation flows.
Update Admin Manual, Technical Manual, and gap backlog docs.
This commit is contained in:
2026-05-16 23:41:18 +01:00
parent c3501ae89a
commit 7fc58f58d9
19 changed files with 1608 additions and 106 deletions
+9 -6
View File
@@ -36,17 +36,20 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
return "", "", err
}
claims, err := token.AsMap(ctx)
if err != nil {
return "", "", err
var uidVal interface{}
if err := token.Get("user_id", &uidVal); err != nil {
return "", "", fmt.Errorf("invalid user_id claim")
}
userID, ok := claims["user_id"].(string)
userID, ok := uidVal.(string)
if !ok {
return "", "", fmt.Errorf("invalid user_id claim")
}
role, ok = claims["role"].(string)
var roleVal interface{}
if err := token.Get("role", &roleVal); err != nil {
return "", "", fmt.Errorf("invalid role claim")
}
role, ok = roleVal.(string)
if !ok {
return "", "", fmt.Errorf("invalid role claim")
}
+2 -2
View File
@@ -1720,7 +1720,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) {
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
@@ -1773,7 +1773,7 @@ func TestAdminBookings_ApproveEditRequest(t *testing.T) {
var ackTime *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
+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
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 needsApproval {
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)
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 {
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -2169,6 +2179,10 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
if paymentCount > 0 {
db.DB.Exec(r.Context(), `UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) WHERE id = $1`, booking.User.ID)
}
// TODO: When online payments are live, create 'deposit_paid' notification here for:
// - Online deposit payments (user pays deposit via Square)
// - Early/late balance payments made online by the user
// NOT for admin-recorded in-person payments — admin already knows about those.
}
w.Header().Set("Content-Type", "application/json")
+188 -10
View File
@@ -2644,7 +2644,7 @@ func TestCreateEditRequest(t *testing.T) {
var notifCount int
err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL`,
WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL`,
bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
@@ -2790,7 +2790,7 @@ func TestDeleteEditRequest(t *testing.T) {
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
@@ -2821,7 +2821,7 @@ func TestDeleteEditRequest(t *testing.T) {
var notifCount int
err = db.DB.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
@@ -2885,7 +2885,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
@@ -2895,7 +2895,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
var ackTime *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
@@ -2937,7 +2937,7 @@ func TestAdminApproveEditRequest(t *testing.T) {
var ackTimeAfter *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTimeAfter)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
@@ -2996,7 +2996,7 @@ func TestAdminRejectEditRequest(t *testing.T) {
// Create admin notification
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
@@ -3006,7 +3006,7 @@ func TestAdminRejectEditRequest(t *testing.T) {
var ackTime *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
@@ -3048,7 +3048,7 @@ func TestAdminRejectEditRequest(t *testing.T) {
var ackTimeAfter *time.Time
err = db.DB.QueryRow(context.Background(),
`SELECT acknowledged_at FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'`,
WHERE booking_id = $1 AND reason = 'edit_requested'`,
bookingID).Scan(&ackTimeAfter)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
@@ -3485,7 +3485,7 @@ func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) {
// Create admin notification for the initial edit request
_, err = db.DB.Exec(context.Background(),
`INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_request', $1, $2)`,
VALUES ('edit_requested', $1, $2)`,
bookingID, userID)
if err != nil {
t.Fatalf("failed to create admin notification: %v", err)
@@ -4661,3 +4661,181 @@ func TestGuestBooking_SkipsDepositCheck(t *testing.T) {
t.Errorf("expected guest to bypass deposit check, got %d. body: %s", w2.Code, w2.Body.String())
}
}
// =============================================================================
// Booking Notification Creation Tests
// =============================================================================
func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var bookingID string
err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
var notifCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 new_booking notification, got %d", notifCount)
}
}
func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
notes := "Please do French tips with gold foil"
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
Notes: &notes,
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var bookingID string
err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
var newBookingCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(&newBookingCount)
if err != nil {
t.Fatalf("failed to query new_booking notifications: %v", err)
}
if newBookingCount != 1 {
t.Errorf("expected 1 new_booking notification, got %d", newBookingCount)
}
var pendingCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount)
if err != nil {
t.Fatalf("failed to query pending_booking notifications: %v", err)
}
if pendingCount != 1 {
t.Errorf("expected 1 pending_booking notification for booking with notes, got %d", pendingCount)
}
}
func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) {
resetTestData(t)
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
token := jwt.GenerateUserToken(userID)
futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second)
futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location())
req := CreateBookingRequest{
StartTime: futureTime,
ServiceIDs: []string{serviceID},
}
handler := http.HandlerFunc(CreateBookingHandler)
w := makeRequest(handler, "POST", "/api/bookings", req, token)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var bookingID string
err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to get booking ID: %v", err)
}
var pendingCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount)
if err != nil {
t.Fatalf("failed to query pending_booking notifications: %v", err)
}
if pendingCount != 0 {
t.Errorf("expected 0 pending_booking notifications for booking without notes, got %d", pendingCount)
}
}
+10 -13
View File
@@ -917,7 +917,7 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
// Delete the admin notification for this edit request
_, err = tx.Exec(r.Context(), `
DELETE FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request' AND user_id = $2
WHERE booking_id = $1 AND reason = 'edit_requested' AND user_id = $2
`, bookingID, userID)
if err != nil {
log.Printf("Failed to delete admin notification for booking %s: %v", bookingID, err)
@@ -1086,10 +1086,10 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Delete existing admin notification for edit_request before creating new one (refreshes timestamp)
// Always create low-priority notification for edit requests
_, err = tx.Exec(r.Context(), `
DELETE FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_request'
WHERE booking_id = $1 AND reason = 'edit_requested'
`, bookingID)
if err != nil {
log.Printf("Failed to delete old admin notification for booking %s: %v", bookingID, err)
@@ -1097,20 +1097,18 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Create admin notification with reason 'edit_request'
_, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`, "edit_request", bookingID, userID)
VALUES ('edit_requested', $1, $2)
`, bookingID, userID)
if err != nil {
log.Printf("Failed to create admin notification for edit request %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// If booking status is 'pending', acknowledge existing pending_booking notification and create new one
// If booking is pending, also create high-priority approval notification
if currentStatus == "pending" {
// Acknowledge existing pending_booking notification
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
SET acknowledged_at = NOW()
@@ -1120,11 +1118,10 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err)
}
// Create new pending_booking notification (admin will see the edit request)
_, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`, "pending_booking", bookingID, userID)
VALUES ('pending_booking', $1, $2)
`, bookingID, userID)
if err != nil {
log.Printf("Failed to create pending booking notification for %s: %v", bookingID, err)
}
@@ -1411,7 +1408,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
SET acknowledged_at = NOW()
WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL
WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL
`, bookingID)
if err != nil {
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
@@ -1485,7 +1482,7 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
SET acknowledged_at = NOW()
WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL
WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL
`, bookingID)
if err != nil {
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
@@ -21,6 +21,9 @@ type AdminNotification struct {
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"`
}
@@ -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)
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)
@@ -98,12 +133,18 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
var n AdminNotification
var bookingID sql.NullString
var userID sql.NullString
var userName sql.NullString
var bookingStartTime sql.NullTime
var acknowledgedAt sql.NullTime
err := rows.Scan(
&n.ID,
&n.Reason,
&bookingID,
&userID,
&userName,
&bookingStartTime,
&acknowledgedAt,
&n.CreatedAt,
)
if err != nil {
@@ -118,6 +159,15 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
if userID.Valid {
n.UserID = &userID.String
}
if userName.Valid {
n.UserName = &userName.String
}
if bookingStartTime.Valid {
n.BookingStartTime = &bookingStartTime.Time
}
if acknowledgedAt.Valid {
n.AcknowledgedAt = &acknowledgedAt.Time
}
notifications = append(notifications, n)
}
@@ -137,6 +187,27 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
}
}
// GET /api/admin/notifications/unread-count
// Returns the count of unacknowledged notifications for the bell icon.
func GetUnreadCount(w http.ResponseWriter, r *http.Request) {
var count int
err := db.DB.QueryRow(r.Context(),
`SELECT COUNT(*) FROM admin_notifications WHERE acknowledged_at IS NULL`,
).Scan(&count)
if err != nil {
log.Printf("Failed to count unread notifications: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]int{"count": count}); err != nil {
log.Printf("Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
if idStr == "" {
@@ -0,0 +1,559 @@
//go:build test
// +build test
package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/db"
"crussell/mw"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
func makeExtendedAdminRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
rctx := chi.NewRouteContext()
if id, _ := extractIDFromPath(path); id != "" {
rctx.URLParams.Add("id", id)
}
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, "admin001")
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func createTestUser(t *testing.T) string {
t.Helper()
var userID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Test', 'User', 'test@test.com', '+447700900000', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
return userID
}
func createNotification(t *testing.T, reason, userID string, acknowledged bool) int {
t.Helper()
var notificationID int
if userID == "" {
query := `INSERT INTO admin_notifications (reason) VALUES ($1) RETURNING id`
if acknowledged {
query = `INSERT INTO admin_notifications (reason, acknowledged_at) VALUES ($1, NOW()) RETURNING id`
}
err := db.DB.QueryRow(context.Background(), query, reason).Scan(&notificationID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
} else {
query := `INSERT INTO admin_notifications (reason, user_id) VALUES ($1, $2) RETURNING id`
if acknowledged {
query = `INSERT INTO admin_notifications (reason, user_id, acknowledged_at) VALUES ($1, $2, NOW()) RETURNING id`
}
err := db.DB.QueryRow(context.Background(), query, reason, userID).Scan(&notificationID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
}
return notificationID
}
// =============================================================================
// include_acknowledged param tests
// =============================================================================
func TestNotifications_IncludeAcknowledged_Default(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, false)
createNotification(t, "cancelled_booking", userID, true)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Errorf("expected 1 unacknowledged notification, got %d", len(resp.Notifications))
}
}
func TestNotifications_IncludeAcknowledged_True(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, false)
createNotification(t, "cancelled_booking", userID, true)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 2 {
t.Errorf("expected 2 notifications with include_acknowledged=true, got %d", len(resp.Notifications))
}
}
func TestNotifications_IncludeAcknowledged_ResponseHasAcknowledgedAt(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, true)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
if resp.Notifications[0].AcknowledgedAt == nil {
t.Error("expected acknowledged_at to be set for acknowledged notification")
}
}
// =============================================================================
// Priority ordering tests
// =============================================================================
func TestNotifications_PriorityOrdering(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
// Create notifications in reverse priority order
reasons := []string{
"1_week_no_pay",
"affiliate_claim",
"pending_booking",
"cancelled_booking",
}
for _, reason := range reasons {
createNotification(t, reason, userID, false)
}
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 4 {
t.Fatalf("expected 4 notifications, got %d", len(resp.Notifications))
}
// Verify priority order: pending_booking(1) > cancelled_booking(3) > affiliate_claim(6) > 1_week_no_pay(10)
expectedOrder := []string{"pending_booking", "cancelled_booking", "affiliate_claim", "1_week_no_pay"}
for i, expected := range expectedOrder {
if resp.Notifications[i].Reason != expected {
t.Errorf("position %d: expected %s, got %s", i, expected, resp.Notifications[i].Reason)
}
}
}
func TestNotifications_PriorityOrdering_OldestFirstWithinPriority(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
// Create two pending_booking notifications with a time gap
createNotification(t, "pending_booking", userID, false)
time.Sleep(10 * time.Millisecond)
createNotification(t, "pending_booking", userID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 2 {
t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications))
}
// Oldest first within same priority
if resp.Notifications[0].CreatedAt.After(resp.Notifications[1].CreatedAt) {
t.Error("expected oldest notification first within same priority level")
}
}
func TestNotifications_AllNotifications_NewestFirst(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, false)
time.Sleep(10 * time.Millisecond)
createNotification(t, "cancelled_booking", userID, true)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 2 {
t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications))
}
// Newest first when include_acknowledged=true
if resp.Notifications[0].CreatedAt.Before(resp.Notifications[1].CreatedAt) {
t.Error("expected newest notification first when include_acknowledged=true")
}
}
// =============================================================================
// GetUnreadCount tests
// =============================================================================
func TestNotifications_UnreadCount(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, false)
createNotification(t, "cancelled_booking", userID, false)
createNotification(t, "affiliate_claim", userID, true)
handler := http.HandlerFunc(GetUnreadCount)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]int
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp["count"] != 2 {
t.Errorf("expected count 2, got %d", resp["count"])
}
}
func TestNotifications_UnreadCount_Zero(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, true)
handler := http.HandlerFunc(GetUnreadCount)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil)
var resp map[string]int
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp["count"] != 0 {
t.Errorf("expected count 0, got %d", resp["count"])
}
}
func TestNotifications_UnreadCount_Empty(t *testing.T) {
resetTestData(t)
handler := http.HandlerFunc(GetUnreadCount)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil)
var resp map[string]int
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp["count"] != 0 {
t.Errorf("expected count 0, got %d", resp["count"])
}
}
// =============================================================================
// New notification reason tests
// =============================================================================
func TestNotifications_NewBookingReason(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "new_booking", userID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
if resp.Notifications[0].Reason != "new_booking" {
t.Errorf("expected reason new_booking, got %s", resp.Notifications[0].Reason)
}
}
func TestNotifications_EditRequestedReason(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "edit_requested", userID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
if resp.Notifications[0].Reason != "edit_requested" {
t.Errorf("expected reason edit_requested, got %s", resp.Notifications[0].Reason)
}
}
func TestNotifications_Priority_NewBookingBelowPendingBooking(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "new_booking", userID, false)
createNotification(t, "pending_booking", userID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 2 {
t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications))
}
if resp.Notifications[0].Reason != "pending_booking" {
t.Errorf("expected pending_booking first, got %s", resp.Notifications[0].Reason)
}
}
// =============================================================================
// Combined param tests
// =============================================================================
func TestNotifications_IncludeAcknowledgedWithReasonFilter(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
createNotification(t, "pending_booking", userID, false)
createNotification(t, "pending_booking", userID, true)
createNotification(t, "cancelled_booking", userID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true&reason=pending_booking", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 2 {
t.Errorf("expected 2 pending_booking notifications, got %d", len(resp.Notifications))
}
}
// =============================================================================
// Response enrichment tests (user_name, booking_start_time)
// =============================================================================
func TestNotifications_ResponseEnriched_WithUserAndBooking(t *testing.T) {
resetTestData(t)
// Create a service
var serviceID string
err := db.DB.QueryRow(context.Background(), `
INSERT INTO services (name, description, price, duration_minutes, is_active)
VALUES ('Manicure', 'Test service', 25.00, 30, true)
RETURNING id
`).Scan(&serviceID)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
// Create a user
var userID string
err = db.DB.QueryRow(context.Background(), `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ('Alice', 'Smith', 'alice@test.com', '+447700900001', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create a booking
var bookingID string
err = db.DB.QueryRow(context.Background(), `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, NOW() + INTERVAL '3 days', 'pending')
RETURNING id
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Create notification with both user_id and booking_id
createNotificationWithBooking(t, "pending_booking", userID, bookingID, false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
n := resp.Notifications[0]
if n.UserName == nil || *n.UserName != "Alice Smith" {
t.Errorf("expected user_name 'Alice Smith', got %v", n.UserName)
}
if n.BookingStartTime == nil {
t.Error("expected booking_start_time to be set")
}
}
func TestNotifications_ResponseEnriched_NoUserOrBooking(t *testing.T) {
resetTestData(t)
// Create notification without user_id or booking_id
createNotification(t, "1_week_no_pay", "", false)
handler := http.HandlerFunc(GetNotifications)
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
n := resp.Notifications[0]
if n.UserName != nil {
t.Errorf("expected user_name to be nil, got %v", n.UserName)
}
if n.BookingStartTime != nil {
t.Errorf("expected booking_start_time to be nil, got %v", n.BookingStartTime)
}
}
// =============================================================================
// Acknowledge notification tests (extended)
// =============================================================================
func TestNotifications_Acknowledge_ViaExtendedHandler(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
notifID := createNotification(t, "pending_booking", userID, false)
handler := http.HandlerFunc(AcknowledgeNotification)
w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%d/acknowledge", notifID), nil)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify acknowledged
var ackTime *time.Time
err := db.DB.QueryRow(context.Background(),
"SELECT acknowledged_at FROM admin_notifications WHERE id = $1", notifID).Scan(&ackTime)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
}
if ackTime == nil {
t.Error("expected acknowledged_at to be set")
}
}
func TestNotifications_Acknowledge_AlreadyAcknowledged_Extended(t *testing.T) {
resetTestData(t)
userID := createTestUser(t)
notifID := createNotification(t, "pending_booking", userID, true)
handler := http.HandlerFunc(AcknowledgeNotification)
w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%d/acknowledge", notifID), nil)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404 for already acknowledged, got %d", w.Code)
}
}
// createNotificationWithBooking creates a notification with both user_id and booking_id
func createNotificationWithBooking(t *testing.T, reason, userID, bookingID string, acknowledged bool) int {
t.Helper()
var notificationID int
query := `INSERT INTO admin_notifications (reason, user_id, booking_id) VALUES ($1, $2, $3) RETURNING id`
if acknowledged {
query = `INSERT INTO admin_notifications (reason, user_id, booking_id, acknowledged_at) VALUES ($1, $2, $3, NOW()) RETURNING id`
}
err := db.DB.QueryRow(context.Background(), query, reason, userID, bookingID).Scan(&notificationID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
return notificationID
}
// Ensure test compilation
var _ = func() *pgxpool.Pool { return nil }
@@ -199,7 +199,7 @@ func TestNotifications_ListFilterByReason(t *testing.T) {
}
// Create notifications with different reasons
reasons := []string{"pending_booking", "cancelled_booking", "edit_request"}
reasons := []string{"pending_booking", "cancelled_booking", "edit_requested"}
for _, reason := range reasons {
_, err := db.DB.Exec(context.Background(), `
INSERT INTO admin_notifications (reason, user_id)
+1
View File
@@ -45,6 +45,7 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// TODO: Create 'user_anonymized' notification for admin audit trail
}
// Delete CardDAV contact (non-blocking, best-effort)
+1
View File
@@ -264,6 +264,7 @@ r.Route("/admin/users", func(r chi.Router) {
r.Route("/admin/notifications", func(r chi.Router) {
r.Get("/", notifications.GetNotifications)
r.Get("/unread-count", notifications.GetUnreadCount)
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
})
@@ -62,6 +62,51 @@
let overlappingBookings = $state<OverlappingBooking[]>([]);
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 {
id: string;
start_time: string;
@@ -401,7 +446,6 @@
</div>
{/if}
<!-- Customer Contact Info -->
<!-- Customer Contact Info -->
<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">
@@ -425,6 +469,14 @@
</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 -->
<div>
<label for="booking-notes" class="mb-2 block text-sm font-medium"> Booking Notes </label>
@@ -546,6 +598,12 @@
</div>
<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
variant="destructive"
onclick={() => (showDeclineConfirm = true)}
@@ -553,7 +611,6 @@
>
Decline Booking
</Button>
<div class="flex gap-2">
<Button
onclick={handleApprove}
disabled={submitting}
@@ -1,8 +1,10 @@
<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 { Skeleton } from '$lib/components/ui/skeleton';
import { navigating, page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
// Centralized link definition
const links = [
@@ -53,6 +55,40 @@
// Derived values for auth state - ensures reactivity
let isAuthenticated = $derived(authStore.isAuthenticated);
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>
<nav
@@ -104,8 +140,27 @@
{/each}
</div>
<!-- Desktop Login Button -->
<div class="hidden items-center md:flex">
<div class="hidden items-center gap-4 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'}
<Button href="/login">Login</Button>
{/if}
@@ -149,6 +204,20 @@
{/if}
{/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}
<Skeleton class="mt-2 h-8 w-full rounded" />
{: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'
);
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 (
id SERIAL PRIMARY KEY,
+23
View File
@@ -699,6 +699,28 @@ for ((i=0; i<${#PENDING_BOOKING_IDS[@]}; i++)); do
done
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
# ===========================================================================
@@ -1004,6 +1026,7 @@ echo -e " Bookings — total : $TOTAL_BOOKINGS"
echo -e " Cancellations : $cancel_count"
echo -e " Confirmed : $confirmed_count | Still pending: $skipped_count"
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 " Time blockers : $count_blockers"
echo -e " Schedule groups : $sched_success/3"
+2 -2
View File
@@ -171,11 +171,11 @@
},
"active": "0e456d61bc5b6ded",
"lastOpenFiles": [
"Crussell/Overview.md",
"Crussell/Future Work - Gap Backlog.md",
"Crussell/Technical Manual.md",
"Crussell/User Manual.md",
"Crussell/Admin Manual.md",
"Crussell/Overview.md",
"Crussell/Future Work - Gap Backlog.md",
"Crussell/Test Implementation Plan.md",
"Crussell/Crussell Nails.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
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 |
| ----- | -------------------------------------------------------- | -------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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`. |
| 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. |
@@ -143,7 +143,7 @@ Require paid accounts, API approval, or external service credentials. **Do not a
│ │
│ #3 Approval decline ✅──→ #13 Booking reschedule │
│ │
│ #5 Admin notification panel ──→ #15 Preferences UI │
~~#5 Admin notification panel~~ ✅ ──→ #15 Preferences UI │
│ ──→ #48 Waitlist (removed) │
│ │
│ #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) ✅
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.
13. **#12** Booking cancellation from user account (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/pending-approvals` | Pending approval queue |
| 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 |
| GET | `/api/admin/time-blockers` | List time blockers |
| POST | `/api/admin/time-blockers` | Create time blocker |
@@ -261,7 +262,7 @@ src/lib/components/
| `payment_type` | `deposit`, `full`, `tip`, `balance`, `partial` |
| `payment_method` | `online_square`, `in_person_card`, `cash`, `giftcard`, `discount` |
| `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` |
| `milestone_type` | `per_user_booking_count`, `global_booking_count`, `anniversary` |
| `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 Stamps:**