diff --git a/backend/auth/jwt.go b/backend/auth/jwt.go index d31d3c2..8b81133 100644 --- a/backend/auth/jwt.go +++ b/backend/auth/jwt.go @@ -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") } diff --git a/backend/handlers/admin/bookings_test.go b/backend/handlers/admin/bookings_test.go index ab2f428..774b00d 100644 --- a/backend/handlers/admin/bookings_test.go +++ b/backend/handlers/admin/bookings_test.go @@ -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) diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 1ef0002..5b26e8a 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -1672,26 +1672,36 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { } } - // Determine notification reason: has notes OR booking is for today - notificationReason := "pending_booking" + // Always create low-priority notification for all bookings + if _, err := tx.Exec(r.Context(), ` + INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('new_booking', $1, $2) + `, booking.ID, userID); err != nil { + log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // If booking needs approval (has notes or is for today), also create high-priority notification + needsApproval := false if req.Notes != nil && *req.Notes != "" { - notificationReason = "pending_booking" // Notes means pending approval too + needsApproval = true } else { - // Check if booking is for today (same day in London timezone) london, _ := time.LoadLocation("Europe/London") now := time.Now().In(london) bookingDay := req.StartTime.In(london) if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.YearDay() { - notificationReason = "pending_booking" // Today's booking + needsApproval = true } } - if _, err := tx.Exec(r.Context(), ` - INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) - `, notificationReason, booking.ID, userID); err != nil { - log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return + if needsApproval { + if _, err := tx.Exec(r.Context(), ` + INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2) + `, booking.ID, userID); err != nil { + log.Printf("Failed to create pending approval notification for booking %s: %v", booking.ID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } } if err := tx.Commit(r.Context()); err != nil { @@ -2169,6 +2179,10 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { if paymentCount > 0 { db.DB.Exec(r.Context(), `UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) WHERE id = $1`, booking.User.ID) } + // TODO: When online payments are live, create 'deposit_paid' notification here for: + // - Online deposit payments (user pays deposit via Square) + // - Early/late balance payments made online by the user + // NOT for admin-recorded in-person payments — admin already knows about those. } w.Header().Set("Content-Type", "application/json") diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index 2df7b4e..b0af989 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -2644,7 +2644,7 @@ func TestCreateEditRequest(t *testing.T) { var notifCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications - WHERE booking_id = $1 AND reason = 'edit_request' AND acknowledged_at IS NULL`, + WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL`, bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) @@ -2790,7 +2790,7 @@ func TestDeleteEditRequest(t *testing.T) { // Create admin notification _, err = db.DB.Exec(context.Background(), `INSERT INTO admin_notifications (reason, booking_id, user_id) - VALUES ('edit_request', $1, $2)`, + VALUES ('edit_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) @@ -2821,7 +2821,7 @@ func TestDeleteEditRequest(t *testing.T) { var notifCount int err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications - WHERE booking_id = $1 AND reason = 'edit_request'`, + WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query notifications: %v", err) @@ -2885,7 +2885,7 @@ func TestAdminApproveEditRequest(t *testing.T) { // Create admin notification _, err = db.DB.Exec(context.Background(), `INSERT INTO admin_notifications (reason, booking_id, user_id) - VALUES ('edit_request', $1, $2)`, + VALUES ('edit_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) @@ -2895,7 +2895,7 @@ func TestAdminApproveEditRequest(t *testing.T) { var ackTime *time.Time err = db.DB.QueryRow(context.Background(), `SELECT acknowledged_at FROM admin_notifications - WHERE booking_id = $1 AND reason = 'edit_request'`, + WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTime) if err != nil { t.Fatalf("failed to query notification: %v", err) @@ -2937,7 +2937,7 @@ func TestAdminApproveEditRequest(t *testing.T) { var ackTimeAfter *time.Time err = db.DB.QueryRow(context.Background(), `SELECT acknowledged_at FROM admin_notifications - WHERE booking_id = $1 AND reason = 'edit_request'`, + WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTimeAfter) if err != nil { t.Fatalf("failed to query notification: %v", err) @@ -2996,7 +2996,7 @@ func TestAdminRejectEditRequest(t *testing.T) { // Create admin notification _, err = db.DB.Exec(context.Background(), `INSERT INTO admin_notifications (reason, booking_id, user_id) - VALUES ('edit_request', $1, $2)`, + VALUES ('edit_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) @@ -3006,7 +3006,7 @@ func TestAdminRejectEditRequest(t *testing.T) { var ackTime *time.Time err = db.DB.QueryRow(context.Background(), `SELECT acknowledged_at FROM admin_notifications - WHERE booking_id = $1 AND reason = 'edit_request'`, + WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTime) if err != nil { t.Fatalf("failed to query notification: %v", err) @@ -3048,7 +3048,7 @@ func TestAdminRejectEditRequest(t *testing.T) { var ackTimeAfter *time.Time err = db.DB.QueryRow(context.Background(), `SELECT acknowledged_at FROM admin_notifications - WHERE booking_id = $1 AND reason = 'edit_request'`, + WHERE booking_id = $1 AND reason = 'edit_requested'`, bookingID).Scan(&ackTimeAfter) if err != nil { t.Fatalf("failed to query notification: %v", err) @@ -3485,7 +3485,7 @@ func TestBookings_RequestEdit_AlreadyHasPending(t *testing.T) { // Create admin notification for the initial edit request _, err = db.DB.Exec(context.Background(), `INSERT INTO admin_notifications (reason, booking_id, user_id) - VALUES ('edit_request', $1, $2)`, + VALUES ('edit_requested', $1, $2)`, bookingID, userID) if err != nil { t.Fatalf("failed to create admin notification: %v", err) @@ -4661,3 +4661,181 @@ func TestGuestBooking_SkipsDepositCheck(t *testing.T) { t.Errorf("expected guest to bypass deposit check, got %d. body: %s", w2.Code, w2.Body.String()) } } + +// ============================================================================= +// Booking Notification Creation Tests +// ============================================================================= + +func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) + req := CreateBookingRequest{ + StartTime: futureTime, + ServiceIDs: []string{serviceID}, + } + + handler := http.HandlerFunc(CreateBookingHandler) + w := makeRequest(handler, "POST", "/api/bookings", req, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var bookingID string + err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to get booking ID: %v", err) + } + + var notifCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(¬ifCount) + if err != nil { + t.Fatalf("failed to query notifications: %v", err) + } + if notifCount != 1 { + t.Errorf("expected 1 new_booking notification, got %d", notifCount) + } +} + +func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) + notes := "Please do French tips with gold foil" + req := CreateBookingRequest{ + StartTime: futureTime, + ServiceIDs: []string{serviceID}, + Notes: ¬es, + } + + handler := http.HandlerFunc(CreateBookingHandler) + w := makeRequest(handler, "POST", "/api/bookings", req, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var bookingID string + err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to get booking ID: %v", err) + } + + var newBookingCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'new_booking'", bookingID).Scan(&newBookingCount) + if err != nil { + t.Fatalf("failed to query new_booking notifications: %v", err) + } + if newBookingCount != 1 { + t.Errorf("expected 1 new_booking notification, got %d", newBookingCount) + } + + var pendingCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount) + if err != nil { + t.Fatalf("failed to query pending_booking notifications: %v", err) + } + if pendingCount != 1 { + t.Errorf("expected 1 pending_booking notification for booking with notes, got %d", pendingCount) + } +} + +func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) { + resetTestData(t) + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + token := jwt.GenerateUserToken(userID) + + futureTime := time.Now().Add(72 * time.Hour).Truncate(time.Second) + futureTime = time.Date(futureTime.Year(), futureTime.Month(), futureTime.Day(), 10, 0, 0, 0, futureTime.Location()) + req := CreateBookingRequest{ + StartTime: futureTime, + ServiceIDs: []string{serviceID}, + } + + handler := http.HandlerFunc(CreateBookingHandler) + w := makeRequest(handler, "POST", "/api/bookings", req, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) + } + + var bookingID string + err = db.DB.QueryRow(context.Background(), "SELECT id FROM bookings WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", userID).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to get booking ID: %v", err) + } + + var pendingCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'", bookingID).Scan(&pendingCount) + if err != nil { + t.Fatalf("failed to query pending_booking notifications: %v", err) + } + if pendingCount != 0 { + t.Errorf("expected 0 pending_booking notifications for booking without notes, got %d", pendingCount) + } +} diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index 739c4db..17b0995 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -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) diff --git a/backend/handlers/notifications/notifications.go b/backend/handlers/notifications/notifications.go index 0d55213..95d41a3 100644 --- a/backend/handlers/notifications/notifications.go +++ b/backend/handlers/notifications/notifications.go @@ -17,11 +17,14 @@ import ( // Structs returned in JSON type AdminNotification struct { - ID int `json:"id"` - Reason string `json:"reason"` - BookingID *string `json:"booking_id,omitempty"` - UserID *string `json:"user_id,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID int `json:"id"` + Reason string `json:"reason"` + BookingID *string `json:"booking_id,omitempty"` + UserID *string `json:"user_id,omitempty"` + UserName *string `json:"user_name,omitempty"` + BookingStartTime *time.Time `json:"booking_start_time,omitempty"` + AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` + CreatedAt time.Time `json:"created_at"` } type AdminNotificationListResponse struct { @@ -32,6 +35,10 @@ type AdminNotificationListResponse struct { } // GET /api/admin/notifications +// Query params: +// page, per_page — pagination (default page=1, per_page=20) +// include_acknowledged — if "true", returns all notifications sorted newest-first. +// Default (false/omitted): only unacknowledged, sorted by priority then oldest-first. func GetNotifications(w http.ResponseWriter, r *http.Request) { // Parse query params page := 1 @@ -44,33 +51,61 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) { perPage = pp } + includeAcknowledged := r.URL.Query().Get("include_acknowledged") == "true" reasonFilter := r.URL.Query().Get("reason") baseQuery := ` - SELECT id, reason, booking_id, user_id, created_at - FROM admin_notifications - WHERE acknowledged_at IS NULL + SELECT an.id, an.reason, an.booking_id, an.user_id, + u.n_first_name || ' ' || u.n_last_name AS user_name, + b.start_time AS booking_start_time, + an.acknowledged_at, an.created_at + FROM admin_notifications an + LEFT JOIN users u ON an.user_id = u.id + LEFT JOIN bookings b ON an.booking_id = b.id ` countQuery := ` SELECT COUNT(*) FROM admin_notifications - WHERE acknowledged_at IS NULL ` args := []any{} countArgs := []any{} param := 1 - // Optional reason filter + if !includeAcknowledged { + baseQuery += fmt.Sprintf(" WHERE an.acknowledged_at IS NULL") + countQuery += ` WHERE acknowledged_at IS NULL` + } + if reasonFilter != "" { - baseQuery += fmt.Sprintf(" AND reason = $%d", param) - countQuery += fmt.Sprintf(" AND reason = $%d", param) + if !includeAcknowledged { + baseQuery += fmt.Sprintf(" AND an.reason = $%d", param) + countQuery += fmt.Sprintf(" AND reason = $%d", param) + } else { + baseQuery += fmt.Sprintf(" WHERE an.reason = $%d", param) + countQuery += fmt.Sprintf(" WHERE reason = $%d", param) + } args = append(args, reasonFilter) countArgs = append(countArgs, reasonFilter) param++ } - // ORDER & pagination - baseQuery += " ORDER BY created_at DESC" + if includeAcknowledged { + baseQuery += " ORDER BY an.created_at DESC" + } else { + baseQuery += ` ORDER BY CASE an.reason + WHEN 'pending_booking' THEN 1 + WHEN 'cancelled_booking' THEN 2 + WHEN 'late_cancellation' THEN 3 + WHEN 'no_deposit' THEN 4 + WHEN 'deposit_paid' THEN 5 + WHEN 'affiliate_claim' THEN 6 + WHEN 'edit_requested' THEN 7 + WHEN 'new_booking' THEN 8 + WHEN '1_month_no_pay' THEN 9 + WHEN '1_week_no_pay' THEN 10 + ELSE 11 + END, an.created_at ASC` + } baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", param, param+1) args = append(args, perPage, (page-1)*perPage) @@ -95,15 +130,21 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) { notifications := []AdminNotification{} for rows.Next() { - var n AdminNotification + var n AdminNotification var bookingID sql.NullString var userID sql.NullString + var userName sql.NullString + var bookingStartTime sql.NullTime + var acknowledgedAt sql.NullTime err := rows.Scan( &n.ID, &n.Reason, &bookingID, &userID, + &userName, + &bookingStartTime, + &acknowledgedAt, &n.CreatedAt, ) if err != nil { @@ -118,6 +159,15 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) { if userID.Valid { n.UserID = &userID.String } + if userName.Valid { + n.UserName = &userName.String + } + if bookingStartTime.Valid { + n.BookingStartTime = &bookingStartTime.Time + } + if acknowledgedAt.Valid { + n.AcknowledgedAt = &acknowledgedAt.Time + } notifications = append(notifications, n) } @@ -137,6 +187,27 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) { } } +// GET /api/admin/notifications/unread-count +// Returns the count of unacknowledged notifications for the bell icon. +func GetUnreadCount(w http.ResponseWriter, r *http.Request) { + var count int + err := db.DB.QueryRow(r.Context(), + `SELECT COUNT(*) FROM admin_notifications WHERE acknowledged_at IS NULL`, + ).Scan(&count) + if err != nil { + log.Printf("Failed to count unread notifications: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]int{"count": count}); err != nil { + log.Printf("Failed to encode response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } +} + func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") if idStr == "" { diff --git a/backend/handlers/notifications/notifications_extended_test.go b/backend/handlers/notifications/notifications_extended_test.go new file mode 100644 index 0000000..9ebbb28 --- /dev/null +++ b/backend/handlers/notifications/notifications_extended_test.go @@ -0,0 +1,559 @@ +//go:build test +// +build test + +package notifications + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "crussell/db" + "crussell/mw" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func makeExtendedAdminRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + bodyBytes, _ := json.Marshal(body) + req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + + rctx := chi.NewRouteContext() + if id, _ := extractIDFromPath(path); id != "" { + rctx.URLParams.Add("id", id) + } + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, mw.UserIDKey, "admin001") + ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") + req = req.WithContext(ctx) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +func createTestUser(t *testing.T) string { + t.Helper() + var userID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) + VALUES ('Test', 'User', 'test@test.com', '+447700900000', '1990-01-01', 'hash', 'verified_email', 'email') + RETURNING id + `).Scan(&userID) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + return userID +} + +func createNotification(t *testing.T, reason, userID string, acknowledged bool) int { + t.Helper() + var notificationID int + if userID == "" { + query := `INSERT INTO admin_notifications (reason) VALUES ($1) RETURNING id` + if acknowledged { + query = `INSERT INTO admin_notifications (reason, acknowledged_at) VALUES ($1, NOW()) RETURNING id` + } + err := db.DB.QueryRow(context.Background(), query, reason).Scan(¬ificationID) + if err != nil { + t.Fatalf("failed to create notification: %v", err) + } + } else { + query := `INSERT INTO admin_notifications (reason, user_id) VALUES ($1, $2) RETURNING id` + if acknowledged { + query = `INSERT INTO admin_notifications (reason, user_id, acknowledged_at) VALUES ($1, $2, NOW()) RETURNING id` + } + err := db.DB.QueryRow(context.Background(), query, reason, userID).Scan(¬ificationID) + if err != nil { + t.Fatalf("failed to create notification: %v", err) + } + } + return notificationID +} + +// ============================================================================= +// include_acknowledged param tests +// ============================================================================= + +func TestNotifications_IncludeAcknowledged_Default(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + createNotification(t, "pending_booking", userID, false) + createNotification(t, "cancelled_booking", userID, true) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 1 { + t.Errorf("expected 1 unacknowledged notification, got %d", len(resp.Notifications)) + } +} + +func TestNotifications_IncludeAcknowledged_True(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + createNotification(t, "pending_booking", userID, false) + createNotification(t, "cancelled_booking", userID, true) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 2 { + t.Errorf("expected 2 notifications with include_acknowledged=true, got %d", len(resp.Notifications)) + } +} + +func TestNotifications_IncludeAcknowledged_ResponseHasAcknowledgedAt(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + createNotification(t, "pending_booking", userID, true) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) + } + + if resp.Notifications[0].AcknowledgedAt == nil { + t.Error("expected acknowledged_at to be set for acknowledged notification") + } +} + +// ============================================================================= +// Priority ordering tests +// ============================================================================= + +func TestNotifications_PriorityOrdering(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + // Create notifications in reverse priority order + reasons := []string{ + "1_week_no_pay", + "affiliate_claim", + "pending_booking", + "cancelled_booking", + } + for _, reason := range reasons { + createNotification(t, reason, userID, false) + } + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 4 { + t.Fatalf("expected 4 notifications, got %d", len(resp.Notifications)) + } + + // Verify priority order: pending_booking(1) > cancelled_booking(3) > affiliate_claim(6) > 1_week_no_pay(10) + expectedOrder := []string{"pending_booking", "cancelled_booking", "affiliate_claim", "1_week_no_pay"} + for i, expected := range expectedOrder { + if resp.Notifications[i].Reason != expected { + t.Errorf("position %d: expected %s, got %s", i, expected, resp.Notifications[i].Reason) + } + } +} + +func TestNotifications_PriorityOrdering_OldestFirstWithinPriority(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + // Create two pending_booking notifications with a time gap + createNotification(t, "pending_booking", userID, false) + time.Sleep(10 * time.Millisecond) + createNotification(t, "pending_booking", userID, false) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 2 { + t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications)) + } + + // Oldest first within same priority + if resp.Notifications[0].CreatedAt.After(resp.Notifications[1].CreatedAt) { + t.Error("expected oldest notification first within same priority level") + } +} + +func TestNotifications_AllNotifications_NewestFirst(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + createNotification(t, "pending_booking", userID, false) + time.Sleep(10 * time.Millisecond) + createNotification(t, "cancelled_booking", userID, true) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 2 { + t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications)) + } + + // Newest first when include_acknowledged=true + if resp.Notifications[0].CreatedAt.Before(resp.Notifications[1].CreatedAt) { + t.Error("expected newest notification first when include_acknowledged=true") + } +} + +// ============================================================================= +// GetUnreadCount tests +// ============================================================================= + +func TestNotifications_UnreadCount(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + createNotification(t, "pending_booking", userID, false) + createNotification(t, "cancelled_booking", userID, false) + createNotification(t, "affiliate_claim", userID, true) + + handler := http.HandlerFunc(GetUnreadCount) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + var resp map[string]int + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if resp["count"] != 2 { + t.Errorf("expected count 2, got %d", resp["count"]) + } +} + +func TestNotifications_UnreadCount_Zero(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + createNotification(t, "pending_booking", userID, true) + + handler := http.HandlerFunc(GetUnreadCount) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil) + + var resp map[string]int + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if resp["count"] != 0 { + t.Errorf("expected count 0, got %d", resp["count"]) + } +} + +func TestNotifications_UnreadCount_Empty(t *testing.T) { + resetTestData(t) + + handler := http.HandlerFunc(GetUnreadCount) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil) + + var resp map[string]int + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if resp["count"] != 0 { + t.Errorf("expected count 0, got %d", resp["count"]) + } +} + +// ============================================================================= +// New notification reason tests +// ============================================================================= + +func TestNotifications_NewBookingReason(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + createNotification(t, "new_booking", userID, false) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) + } + + if resp.Notifications[0].Reason != "new_booking" { + t.Errorf("expected reason new_booking, got %s", resp.Notifications[0].Reason) + } +} + +func TestNotifications_EditRequestedReason(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + createNotification(t, "edit_requested", userID, false) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) + } + + if resp.Notifications[0].Reason != "edit_requested" { + t.Errorf("expected reason edit_requested, got %s", resp.Notifications[0].Reason) + } +} + +func TestNotifications_Priority_NewBookingBelowPendingBooking(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + createNotification(t, "new_booking", userID, false) + createNotification(t, "pending_booking", userID, false) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 2 { + t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications)) + } + + if resp.Notifications[0].Reason != "pending_booking" { + t.Errorf("expected pending_booking first, got %s", resp.Notifications[0].Reason) + } +} + +// ============================================================================= +// Combined param tests +// ============================================================================= + +func TestNotifications_IncludeAcknowledgedWithReasonFilter(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + createNotification(t, "pending_booking", userID, false) + createNotification(t, "pending_booking", userID, true) + createNotification(t, "cancelled_booking", userID, false) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true&reason=pending_booking", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 2 { + t.Errorf("expected 2 pending_booking notifications, got %d", len(resp.Notifications)) + } +} + +// ============================================================================= +// Response enrichment tests (user_name, booking_start_time) +// ============================================================================= + +func TestNotifications_ResponseEnriched_WithUserAndBooking(t *testing.T) { + resetTestData(t) + + // Create a service + var serviceID string + err := db.DB.QueryRow(context.Background(), ` + INSERT INTO services (name, description, price, duration_minutes, is_active) + VALUES ('Manicure', 'Test service', 25.00, 30, true) + RETURNING id + `).Scan(&serviceID) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + + // Create a user + var userID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) + VALUES ('Alice', 'Smith', 'alice@test.com', '+447700900001', '1990-01-01', 'hash', 'verified_email', 'email') + RETURNING id + `).Scan(&userID) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // Create a booking + var bookingID string + err = db.DB.QueryRow(context.Background(), ` + INSERT INTO bookings (user_id, start_time, status) + VALUES ($1, NOW() + INTERVAL '3 days', 'pending') + RETURNING id + `, userID).Scan(&bookingID) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + + // Create notification with both user_id and booking_id + createNotificationWithBooking(t, "pending_booking", userID, bookingID, false) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) + } + + n := resp.Notifications[0] + if n.UserName == nil || *n.UserName != "Alice Smith" { + t.Errorf("expected user_name 'Alice Smith', got %v", n.UserName) + } + if n.BookingStartTime == nil { + t.Error("expected booking_start_time to be set") + } +} + +func TestNotifications_ResponseEnriched_NoUserOrBooking(t *testing.T) { + resetTestData(t) + + // Create notification without user_id or booking_id + createNotification(t, "1_week_no_pay", "", false) + + handler := http.HandlerFunc(GetNotifications) + w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil) + + var resp AdminNotificationListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Notifications) != 1 { + t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) + } + + n := resp.Notifications[0] + if n.UserName != nil { + t.Errorf("expected user_name to be nil, got %v", n.UserName) + } + if n.BookingStartTime != nil { + t.Errorf("expected booking_start_time to be nil, got %v", n.BookingStartTime) + } +} + +// ============================================================================= +// Acknowledge notification tests (extended) +// ============================================================================= + +func TestNotifications_Acknowledge_ViaExtendedHandler(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + notifID := createNotification(t, "pending_booking", userID, false) + + handler := http.HandlerFunc(AcknowledgeNotification) + w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%d/acknowledge", notifID), nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + // Verify acknowledged + var ackTime *time.Time + err := db.DB.QueryRow(context.Background(), + "SELECT acknowledged_at FROM admin_notifications WHERE id = $1", notifID).Scan(&ackTime) + if err != nil { + t.Fatalf("failed to query notification: %v", err) + } + if ackTime == nil { + t.Error("expected acknowledged_at to be set") + } +} + +func TestNotifications_Acknowledge_AlreadyAcknowledged_Extended(t *testing.T) { + resetTestData(t) + userID := createTestUser(t) + + notifID := createNotification(t, "pending_booking", userID, true) + + handler := http.HandlerFunc(AcknowledgeNotification) + w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%d/acknowledge", notifID), nil) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404 for already acknowledged, got %d", w.Code) + } +} + +// createNotificationWithBooking creates a notification with both user_id and booking_id +func createNotificationWithBooking(t *testing.T, reason, userID, bookingID string, acknowledged bool) int { + t.Helper() + var notificationID int + query := `INSERT INTO admin_notifications (reason, user_id, booking_id) VALUES ($1, $2, $3) RETURNING id` + if acknowledged { + query = `INSERT INTO admin_notifications (reason, user_id, booking_id, acknowledged_at) VALUES ($1, $2, $3, NOW()) RETURNING id` + } + err := db.DB.QueryRow(context.Background(), query, reason, userID, bookingID).Scan(¬ificationID) + if err != nil { + t.Fatalf("failed to create notification: %v", err) + } + return notificationID +} + +// Ensure test compilation +var _ = func() *pgxpool.Pool { return nil } diff --git a/backend/handlers/notifications/notifications_test.go b/backend/handlers/notifications/notifications_test.go index 09c504e..5ca7320 100644 --- a/backend/handlers/notifications/notifications_test.go +++ b/backend/handlers/notifications/notifications_test.go @@ -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) diff --git a/backend/handlers/user/account.go b/backend/handlers/user/account.go index 32ce9a7..7decf3d 100644 --- a/backend/handlers/user/account.go +++ b/backend/handlers/user/account.go @@ -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) diff --git a/backend/main.go b/backend/main.go index c1e086e..3bd6767 100644 --- a/backend/main.go +++ b/backend/main.go @@ -262,10 +262,11 @@ r.Route("/admin/users", func(r chi.Router) { r.Get("/pending-approvals", today.GetPendingApprovalsHandler) }) - r.Route("/admin/notifications", func(r chi.Router) { - r.Get("/", notifications.GetNotifications) - r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification) - }) + r.Route("/admin/notifications", func(r chi.Router) { + r.Get("/", notifications.GetNotifications) + r.Get("/unread-count", notifications.GetUnreadCount) + r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification) + }) r.Route("/admin/time-blockers", func(r chi.Router) { r.Get("/", scheduling.ListTimeBlockers) diff --git a/frontend/src/lib/components/admin/ApprovalModal.svelte b/frontend/src/lib/components/admin/ApprovalModal.svelte index 994e17c..b069a4b 100644 --- a/frontend/src/lib/components/admin/ApprovalModal.svelte +++ b/frontend/src/lib/components/admin/ApprovalModal.svelte @@ -62,6 +62,51 @@ let overlappingBookings = $state([]); 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,29 +446,36 @@ {/if} - - -
-

- Customer Contact -

-
+ +
+

+ Customer Contact +

+
+
+
Name
+
{booking.user?.full_name || '—'}
+
+
-
Name
-
{booking.user?.full_name || '—'}
+
Phone
+
{booking.user?.phone || '—'}
-
-
-
Phone
-
{booking.user?.phone || '—'}
-
-
-
Email
-
{booking.user?.email || '—'}
-
+
+
Email
+
{booking.user?.email || '—'}
+
+ + +
+

+ Booking Date & Time +

+
{getBookingDateTime()}
+
@@ -546,14 +598,19 @@
- -
+
+ Total: £{getTotalCost().toFixed(2)} + | + {getTotalDuration()} min +
+
+ - {/if} - {#if isLoading} - - {/if} -
+
@@ -149,6 +204,20 @@ {/if} {/each} + {#if isAuthenticated} + + Notifications + {#if unreadCount > 0} + + {unreadCount > 9 ? '9+' : unreadCount} + + {/if} + + {/if} + {#if isLoading} {:else if !isAuthenticated} diff --git a/frontend/src/routes/notifications/+page.svelte b/frontend/src/routes/notifications/+page.svelte new file mode 100644 index 0000000..1a49f77 --- /dev/null +++ b/frontend/src/routes/notifications/+page.svelte @@ -0,0 +1,428 @@ + + +{#if pageState === 'loading'} +
+ + {#each Array(5) as _, i (i)} + + {/each} +
+{:else if pageState === 'unauthorized'} +
+
+ + + +

Coming Soon

+

+ Notifications are available for admin accounts. This feature will be enabled for all users + before launch. +

+
+
+{:else if error} +
+
+

Unable to Load Notifications

+

Something went wrong. Please try again.

+ +
+
+{:else} +
+
+

Notifications

+ +
+ + {#if notifications.length === 0} +
+ + + +

+ {includeAcknowledged ? 'No notifications yet' : 'No unread notifications'} +

+
+ {:else} +
+ {#each notifications as n (n.id)} +
+
+
+
+ {#if !n.acknowledged_at} + + {/if} +

{getNotificationTitle(n)}

+
+

{getNotificationSubtitle(n)}

+
+
+ {#if hasAction(n.reason)} + + {/if} + {#if !n.acknowledged_at} + + {/if} +
+
+
+ {/each} +
+ + {#if totalPages > 1} +
+

+ {(page - 1) * perPage + 1}–{Math.min(page * perPage, total)} of {total} +

+
+ + +
+
+ {/if} + {/if} +
+{/if} + +{#if showApprovalModal && selectedBooking} + +{/if} + +{#if showBookingModal && selectedBooking} + +{/if} + +{#if showUserModal && selectedUserId} + +{/if} diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index f614970..390001a 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -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, diff --git a/local-dev-2.sh b/local-dev-2.sh index 1cd20fd..52ffc7a 100755 --- a/local-dev-2.sh +++ b/local-dev-2.sh @@ -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" diff --git a/obsidian/.obsidian/workspace.json b/obsidian/.obsidian/workspace.json index 7729a76..c7152c6 100644 --- a/obsidian/.obsidian/workspace.json +++ b/obsidian/.obsidian/workspace.json @@ -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", diff --git a/obsidian/Crussell/Admin Manual.md b/obsidian/Crussell/Admin Manual.md index 7c2e9b9..b241697 100644 --- a/obsidian/Crussell/Admin Manual.md +++ b/obsidian/Crussell/Admin Manual.md @@ -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. diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index ba76afc..678a806 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -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) diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index 41cac64..af73075 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -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:**