Fix booking count filters, add admin notifications, and improve bookings

UI
This commit is contained in:
2026-01-06 16:45:17 +00:00
parent 08e4e12a73
commit f536a7fd04
10 changed files with 436 additions and 177 deletions
+3 -5
View File
@@ -161,7 +161,6 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
tx, err := db.DB.Begin(r.Context())
if err != nil {
fmt.Println(err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
@@ -173,8 +172,8 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
var userID string
err = tx.QueryRow(r.Context(), `
INSERT INTO users
(n_first_name, n_last_name, phone, date_of_birth, email, password_hash,
account_type, privacy_policy_and_terms_consent, policy_consent_updated_at,
(n_first_name, n_last_name, phone, date_of_birth, email, password_hash,
account_type, privacy_policy_and_terms_consent, policy_consent_updated_at,
created_at, updated_at)
VALUES
($1, $2, $3, $4, $5, $6, 'email', $7, $8, $8, $8)
@@ -182,7 +181,6 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
`, req.FirstName, req.LastName, req.Phone, dob, req.Email, string(hash), req.AgreedToPolicy, now).Scan(&userID)
if err != nil {
fmt.Println(err)
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "an account with this email already exists", http.StatusConflict)
} else {
@@ -246,7 +244,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
var userID, passwordHash, role string
ctx := context.Background()
err := db.DB.QueryRow(ctx, `
SELECT id, password_hash, account_role
SELECT id, password_hash, account_role
FROM users
WHERE email = $1 AND account_type = 'email'
`, req.Email).Scan(&userID, &passwordHash, &role)
+106 -36
View File
@@ -264,7 +264,9 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
var args []interface{}
var countArgs []interface{}
args = append(args, userID)
countArgs = append(countArgs, userID)
paramCount := 2
// Add filters
@@ -272,6 +274,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
baseQuery += fmt.Sprintf(" AND status = $%d", paramCount)
countQuery += fmt.Sprintf(" AND status = $%d", paramCount)
args = append(args, *req.Status)
countArgs = append(countArgs, *req.Status)
paramCount++
}
@@ -286,6 +289,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Ensure it's at start of day in London time
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation)
args = append(args, startTime)
countArgs = append(countArgs, startTime)
paramCount++
}
@@ -300,6 +304,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Add end of day
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
args = append(args, endTime)
countArgs = append(countArgs, endTime)
paramCount++
}
@@ -312,7 +317,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Get total count
var total int
err := db.DB.QueryRow(r.Context(), countQuery, args[:1]...).Scan(&total)
err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total)
if err != nil {
log.Printf("Failed to get booking count for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -367,7 +372,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Build base query - only what the component needs
baseQuery := `
WITH booking_totals AS (
SELECT
SELECT
bs.booking_id,
SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) as total_duration,
SUM(COALESCE(bs.override_price, s.price)) as total_amount
@@ -376,16 +381,16 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
GROUP BY bs.booking_id
),
payment_totals AS (
SELECT
SELECT
booking_id,
SUM(amount) as total_paid
FROM payments
FROM payments
WHERE status = 'completed'
GROUP BY booking_id
)
SELECT
b.id,
b.start_time,
SELECT
b.id,
b.start_time,
b.status,
u.fn,
COALESCE(bt.total_duration, 0) as duration_minutes,
@@ -707,10 +712,10 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.User = &UserSummary{}
err := db.DB.QueryRow(r.Context(), `
SELECT
SELECT
b.id, b.user_id, b.start_time, b.status, b.notes,
b.created_at, b.updated_at, b.created_by,
u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps,
u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps,
u.referral_code, u.notes
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
@@ -736,8 +741,8 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
// ----------------------------
var referralCodeUses int
err = db.DB.QueryRow(r.Context(), `
SELECT COUNT(*)
FROM user_referrals
SELECT COUNT(*)
FROM user_referrals
WHERE referrer_id = $1
`, booking.User.ID).Scan(&referralCodeUses)
if err != nil {
@@ -751,7 +756,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
// 2. Fetch services and calculate totals
// ----------------------------
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
SELECT
s.name,
coalesce(bs.override_price, s.price) as price,
coalesce(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
@@ -894,7 +899,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Build the main search query using CTEs to match your actual schema
searchQuery := `
WITH booking_totals AS (
SELECT
SELECT
bs.booking_id,
SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) as total_duration,
SUM(COALESCE(bs.override_price, s.price)) as total_amount
@@ -903,16 +908,16 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
GROUP BY bs.booking_id
),
payment_totals AS (
SELECT
SELECT
booking_id,
SUM(amount) as total_paid
FROM payments
FROM payments
WHERE status = 'completed'
GROUP BY booking_id
)
SELECT
b.id,
b.start_time,
SELECT
b.id,
b.start_time,
b.status,
u.fn as full_name,
COALESCE(bt.total_duration, 0) as duration_minutes,
@@ -921,7 +926,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN booking_totals bt ON b.id = bt.booking_id
LEFT JOIN payment_totals pt ON b.id = pt.booking_id
WHERE
WHERE
b.id ILIKE $1 ESCAPE '\' OR
b.notes ILIKE $1 ESCAPE '\' OR
b.status::text ILIKE $1 ESCAPE '\' OR
@@ -931,8 +936,8 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
u.email ILIKE $1 ESCAPE '\' OR
u.phone ILIKE $1 ESCAPE '\' OR
EXISTS (
SELECT 1 FROM booking_services bs
JOIN services s ON bs.service_id = s.id
SELECT 1 FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\'
)
ORDER BY b.start_time ASC
@@ -945,7 +950,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
WHERE
WHERE
b.id ILIKE $1 ESCAPE '\' OR
b.notes ILIKE $1 ESCAPE '\' OR
b.status::text ILIKE $1 ESCAPE '\' OR
@@ -1089,10 +1094,11 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
if req.StartTime.Before(time.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return
}
// TODO: reenable start time validation before going live, disabled for testing
// if req.StartTime.Before(time.Now()) {
// http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
// return
// }
// Get created by from context (if available)
var createdBy *string
@@ -1153,6 +1159,18 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Create admin notification for pending booking
notificationQuery := `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`
_, err = tx.Exec(r.Context(), notificationQuery, "pending_booking", booking.ID, userID)
if 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 err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -1202,7 +1220,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
// Update booking start time (only for user's own bookings)
query := `
UPDATE bookings
UPDATE bookings
SET start_time = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
@@ -1273,7 +1291,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
// Update booking status
query := `
UPDATE bookings
UPDATE bookings
SET status = $1, updated_at = NOW()
WHERE id = $2
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
@@ -1354,7 +1372,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
// Update booking status and notes
bookingQuery := `
UPDATE bookings
UPDATE bookings
SET status = 'confirmed', notes = COALESCE($1, notes), updated_at = NOW()
WHERE id = $2 AND status = 'pending'
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
@@ -1391,7 +1409,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
if len(req.ServiceOverrides) > 0 {
// First, verify all service IDs belong to this booking
serviceCheckQuery := `
SELECT COUNT(*) FROM booking_services
SELECT COUNT(*) FROM booking_services
WHERE booking_id = $1 AND service_id = ANY($2)
`
serviceIDs := make([]string, len(req.ServiceOverrides))
@@ -1413,7 +1431,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
// Update each service override
serviceUpdateQuery := `
UPDATE booking_services
UPDATE booking_services
SET override_price = $1,
override_duration_minutes = $2
WHERE booking_id = $3 AND service_id = $4
@@ -1493,13 +1511,22 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Start transaction for cancellation and notification
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Update booking status instead of deleting
query := `
UPDATE bookings
UPDATE bookings
SET status = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3
`
result, err := db.DB.Exec(r.Context(), query, req.Reason, bookingID, userID)
result, err := tx.Exec(r.Context(), query, req.Reason, bookingID, userID)
if err != nil {
log.Printf("Failed to cancel booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1511,6 +1538,23 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Create admin notification for cancelled booking
notificationQuery := `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`
_, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
if err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
@@ -1521,9 +1565,30 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Hard delete if no payments exist
// Hard delete if no payments exist - use transaction for notification
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Create admin notification BEFORE deleting the booking
notificationQuery := `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`
_, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
if err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Now delete the booking
query := "DELETE FROM bookings WHERE id = $1 AND user_id = $2"
result, err := db.DB.Exec(r.Context(), query, bookingID, userID)
result, err := tx.Exec(r.Context(), query, bookingID, userID)
if err != nil {
log.Printf("Failed to delete booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Failed to delete booking", http.StatusInternalServerError)
@@ -1535,6 +1600,11 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
@@ -1592,7 +1662,7 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
var durationMinutes int
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
SELECT
bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
FROM booking_services bs
+6 -6
View File
@@ -318,13 +318,13 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
// Load bookings
bookingRows, _ := db.DB.Query(r.Context(), `
SELECT
b.start_time,
SELECT
b.start_time,
COALESCE(SUM(
CASE
WHEN bs.override_duration_minutes IS NOT NULL AND bs.override_duration_minutes > 0
THEN bs.override_duration_minutes
ELSE s.duration_minutes
CASE
WHEN bs.override_duration_minutes IS NOT NULL AND bs.override_duration_minutes > 0
THEN bs.override_duration_minutes
ELSE s.duration_minutes
END
), 0) AS total_duration
FROM bookings b
+10
View File
@@ -17,6 +17,7 @@ import (
authHandlers "crussell/handlers/auth"
"crussell/handlers/bookings"
"crussell/handlers/notifications"
"crussell/handlers/scheduling"
"crussell/handlers/services"
"crussell/handlers/user"
@@ -132,6 +133,15 @@ func main() {
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
})
// --- Admin Notifications ---
r.Route("/admin/notifications", func(r chi.Router) {
// List all unacknowledged notifications
r.Get("/", notifications.GetNotifications)
// Acknowledge a single notification
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
})
})
})