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