From f536a7fd047c7d63488260caf3bf44bc73389535 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Tue, 6 Jan 2026 16:45:17 +0000 Subject: [PATCH] Fix booking count filters, add admin notifications, and improve bookings UI --- backend/handlers/auth/local.go | 8 +- backend/handlers/bookings/bookings.go | 142 ++++++-- backend/handlers/scheduling/default-hours.go | 12 +- backend/main.go | 10 + .../lib/components/layout/ContactCard.svelte | 2 +- frontend/src/routes/+page.svelte | 24 +- frontend/src/routes/account/+page.svelte | 319 ++++++++++++------ frontend/src/routes/book/+page.svelte | 2 +- init-scripts/init-script.sql | 11 + local-dev.sh | 83 ++++- 10 files changed, 436 insertions(+), 177 deletions(-) diff --git a/backend/handlers/auth/local.go b/backend/handlers/auth/local.go index 7eec7ee..7820a57 100644 --- a/backend/handlers/auth/local.go +++ b/backend/handlers/auth/local.go @@ -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) diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 2ecf5b6..72d1522 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -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 diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index 710bab7..c066128 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -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 diff --git a/backend/main.go b/backend/main.go index 8aaf22f..81efa2d 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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) + }) }) }) diff --git a/frontend/src/lib/components/layout/ContactCard.svelte b/frontend/src/lib/components/layout/ContactCard.svelte index bf6f827..60e357e 100644 --- a/frontend/src/lib/components/layout/ContactCard.svelte +++ b/frontend/src/lib/components/layout/ContactCard.svelte @@ -11,7 +11,7 @@
-
+
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index e5d7b9f..c633b72 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -76,17 +76,19 @@ -
-
-

Why Choose Us?

-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus - tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices - diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci - nec nonummy molestie, -

-
-
+{#if !authStore.isAuthenticated} +
+
+

Why Choose Us?

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus + tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices + diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci + nec nonummy molestie, +

+
+
+{/if}

Ready to Treat Yourself?

diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 767f0b0..b47d217 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -62,9 +62,7 @@ }; let userData = $state(null); - let bookings = $state([]); let loadingUser = $state(true); - let loadingBookings = $state(true); let stamps = $state(0); // =============== Fetch User Data =============== @@ -97,12 +95,22 @@ } // =============== Fetch Bookings =============== - async function fetchBookings() { + let upcomingBookings = $state([]); + let pastBookings = $state([]); + let pastPage = $state(1); + let pastTotalPages = $state(1); + + let loadingUpcoming = $state(false); + let loadingPast = $state(false); + + // =============== Fetch Upcoming Bookings (next 3) =============== + async function fetchUpcomingBookings() { if (pageState !== 'authorized') return; - loadingBookings = true; + loadingUpcoming = true; try { - const response = await fetch('/api/user/bookings', { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const response = await fetch(`/api/bookings?start_date=${today}&per_page=3&page=1`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -110,24 +118,66 @@ } }); - if (response.ok) { - const data = await response.json(); - bookings = data.bookings || []; - } else { - // toast.error('Failed to load booking history'); + if (!response.ok) { + const text = await response.text(); + toast.error('Failed to load upcoming bookings: ' + text); + return; } + + const data = await response.json(); + upcomingBookings = data.bookings || []; } catch (err) { - console.error('Error fetching bookings:', err); - toast.error('Network error loading bookings'); + console.error('Error fetching upcoming bookings:', err); + toast.error('Network error loading upcoming bookings'); } finally { - loadingBookings = false; + loadingUpcoming = false; } } + // =============== Fetch Past Bookings (paginated 10 per page) =============== + async function fetchPastBookings(page = 1) { + if (pageState !== 'authorized') return; + + loadingPast = true; + try { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const response = await fetch(`/api/bookings?end_date=${today}&per_page=10&page=${page}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + } + }); + + if (!response.ok) { + const text = await response.text(); + toast.error('Failed to load past bookings: ' + text); + return; + } + + const data = await response.json(); + pastBookings = data.bookings || []; + pastPage = data.page || page; + pastTotalPages = Math.ceil((data.total || 0) / (data.per_page || 10)); + } catch (err) { + console.error('Error fetching past bookings:', err); + toast.error('Network error loading past bookings'); + } finally { + loadingPast = false; + } + } + + // =============== Pagination Helpers =============== + function goToPastPage(page: number) { + if (page < 1 || page > pastTotalPages) return; + fetchPastBookings(page); + } + $effect(() => { if (pageState === 'authorized') { fetchUserData(); - fetchBookings(); + fetchUpcomingBookings(); + fetchPastBookings(); } }); @@ -231,13 +281,29 @@ function formatDateTime(dateString: string): string { const date = new SvelteDate(dateString); - return date.toLocaleString('en-GB', { - day: 'numeric', - month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); + + // Get date parts + const day = date.getDate(); + const month = date.toLocaleString('en-GB', { month: 'short' }); + const year = date.getFullYear(); + + // Get time parts + const hours = date.getHours(); + const minutes = date.getMinutes(); + + // Special cases for midnight and noon + let timeStr; + if (hours === 12 && minutes === 0) { + timeStr = 'Noon'; + } else if (hours === 0 && minutes === 0) { + timeStr = 'Midnight'; + } else { + const period = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + timeStr = `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`; + } + + return `${day} ${month} ${year}, ${timeStr}`; } @@ -424,97 +490,152 @@ {:else if activeTab === 'history'} - - + + - Booking History - Your past and upcoming appointments + Upcoming Appointments + Next 3 upcoming bookings - - {#if loadingBookings} + + {#if loadingUpcoming} {#each Array(3) as _, i (i)} - + {/each} - {:else if bookings.length === 0} -
No bookings found
+ {:else if upcomingBookings.length === 0} +
No upcoming bookings
{:else} - {#each bookings as booking (booking.id)} -
-
-
-
{formatDateTime(booking.start_time)}
-
- {booking.duration_minutes} minutes -
-
- - {booking.status} - -
- - - {#if booking.services && booking.services.length > 0} -
-
Services:
- {#each booking.services as service (service.service_name)} -
- {service.service_name} - £{service.price.toFixed(2)} -
- {/each} -
- {/if} - - - - -
-
- Total: - £{booking.total_amount.toFixed(2)} -
-
- Paid: - £{booking.amount_paid.toFixed(2)} +
+
{formatDateTime(b.start_time)}
+
+ + + {b.status} + + + - {(() => { + const services = (b.services || []).map( + (s) => s.service_name || 'Unknown Service' + ); + if (services.length === 0) return 'No services'; + if (services.length === 1) return services[0]; + if (services.length === 2) return services.join(' and '); + return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`; + })()} +
- {#if booking.amount_due > 0} -
- Due: - £{booking.amount_due.toFixed(2)} -
- {/if}
- - - {#if booking.payments && booking.payments.length > 0} -
-
Payments:
- {#each booking.payments as payment (payment.id)} -
- {payment.payment_method.replace('_', ' ')} ({payment.payment_type}) - £{payment.amount.toFixed(2)} -
- {/each} -
- {/if} +
{/each} {/if} + + + + + Past Appointments + Previous bookings + + + {#if loadingPast} + {#each Array(5) as _, i (i)} + + {/each} + {:else if pastBookings.length === 0} +
No past bookings
+ {:else} + {#each pastBookings as b (b.id)} +
+
+
{formatDateTime(b.start_time)}
+
+ + + {b.status} + + + - {(() => { + const services = (b.services || []).map( + (s) => s.service_name || 'Unknown Service' + ); + if (services.length === 0) return 'No services'; + if (services.length === 1) return services[0]; + if (services.length === 2) return services.join(' and '); + return `${services[0]} and ${services.length - 1} other${services.length - 1 > 1 ? 's' : ''}`; + })()} + +
+
+ +
+ {/each} + {/if} + + + {#if pastTotalPages > 1} +
+ + {pastPage} / {pastTotalPages} + +
+ {/if} +
+
{:else if activeTab === 'referral'} diff --git a/frontend/src/routes/book/+page.svelte b/frontend/src/routes/book/+page.svelte index f1b4b54..23486c0 100644 --- a/frontend/src/routes/book/+page.svelte +++ b/frontend/src/routes/book/+page.svelte @@ -795,7 +795,7 @@ {#if selectedDate}
{getDayWithOrdinal(selectedDate)}
{/if} - +
{#each groupedTimeSlots as slot (slot.startTime)} {#if slot.type === 'available'} diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 115a633..8e16fad 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -339,6 +339,17 @@ INSERT INTO business_settings ( 'https://www.website.co.uk' ); +CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim'); + +CREATE TABLE admin_notifications ( + id SERIAL PRIMARY KEY, + reason admin_notification_reason NOT NULL, + booking_id CHAR(12) REFERENCES bookings(id), + user_id CHAR(12) REFERENCES users(id), + acknowledged_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status); CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status); CREATE INDEX idx_users_created_at ON users(created_at); diff --git a/local-dev.sh b/local-dev.sh index 50e21ef..6f7aa50 100755 --- a/local-dev.sh +++ b/local-dev.sh @@ -164,16 +164,16 @@ for SERVICE_JSON in "${SERVICES[@]}"; do echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Creating: $SERVICE_NAME" echo "Request JSON: $SERVICE_JSON" - + CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -d "$SERVICE_JSON" \ "$BASE_URL/admin/services") - + HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1) RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | sed '$d') - + if [ "$HTTP_CODE" = "201" ]; then SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) echo "✅ Created: $SERVICE_NAME" @@ -207,7 +207,7 @@ done # --- 7️⃣ Create Demo Bookings --- echo "" -echo "7️⃣ Creating 6 Demo Bookings..." +echo "7️⃣ Creating 41 Demo Bookings (35 past + 6 future)..." # Function to create a booking create_booking() { @@ -216,28 +216,28 @@ create_booking() { local SERVICE_IDS_JSON=$3 local NOTES=$4 local BOOKING_NAME=$5 - + local BOOKING_JSON="{\"start_time\":\"$START_TIME\",\"service_ids\":$SERVICE_IDS_JSON" if [ -n "$NOTES" ]; then BOOKING_JSON="$BOOKING_JSON,\"notes\":\"$NOTES\"" fi BOOKING_JSON="$BOOKING_JSON}" - + echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Creating: $BOOKING_NAME" echo "Time: $START_TIME" echo "Services: $SERVICE_IDS_JSON" - + CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d "$BOOKING_JSON" \ "$BASE_URL/bookings") - + HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1) RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | sed '$d') - + if [ "$HTTP_CODE" = "201" ]; then echo "✅ Created: $BOOKING_NAME" BOOKING_ID=$(echo "$RESPONSE_BODY" | grep -o '"id":"[^"]*' | cut -d'"' -f4) @@ -248,20 +248,13 @@ create_booking() { echo "Response body: $RESPONSE_BODY" fi fi - + sleep 0.2 } # Use TZ=Europe/London to generate London-local times with correct offset # Note: date command respects TZ for parsing and formatting -# Get tomorrow at 08:00 London time as base (ensures future) -TOMORROW_BASE=$(TZ=Europe/London date -d "tomorrow 08:00" +%Y-%m-%d) - -# Calculate future dates in London time -NEXT_WEEK_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +7 days" +%Y-%m-%d) -WEEK_AFTER_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +14 days" +%Y-%m-%d) - # Helper function to format a London time as RFC3339 with 'T' (required by Go backend) format_london_time() { local DATE_PART="$1" @@ -269,6 +262,60 @@ format_london_time() { TZ=Europe/London date -d "$DATE_PART $TIME_PART" +"%Y-%m-%dT%H:%M:%S%:z" } +# --- PAST BOOKINGS (35 bookings from October-November 2025) --- +echo "" +echo "Creating 35 past bookings..." + +# October 2025 bookings (15 bookings) +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-01" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 1 - Classic Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-03" "14:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Oct 3 - Gel Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-05" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "Treating myself" "Oct 5 - Luxury Pedicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-08" "15:45:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Oct 8 - Express Mani & Pedi" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-10" "09:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Simple nail art" "Oct 10 - Classic + Nail Art" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-12" "13:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Oct 12 - Gel Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-15" "16:00:00")" "[\"${SERVICE_IDS[4]}\"]" "" "Oct 15 - Gel Removal" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-17" "10:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Oct 17 - Luxury Pedicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-19" "14:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 19 - Classic Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-22" "11:30:00")" "[\"${SERVICE_IDS[3]}\"]" "Quick refresh" "Oct 22 - Express Mani & Pedi" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-24" "15:00:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "" "Oct 24 - Gel + Nail Art" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-26" "09:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Oct 26 - Classic Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-28" "13:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Oct 28 - Luxury Pedicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-29" "16:30:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[1]}\"]" "" "Oct 29 - Removal + New Gel" +create_booking "$USER_TOKEN" "$(format_london_time "2025-10-31" "12:00:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Halloween nails!" "Oct 31 - Classic + Nail Art" + +# November 2025 bookings (20 bookings) +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-02" "10:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 2 - Gel Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-04" "14:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 4 - Classic Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-05" "11:30:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 5 - Express Mani & Pedi" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-07" "15:30:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 7 - Luxury Pedicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-09" "09:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 9 - Classic + Nail Art" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-11" "13:00:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 11 - Gel Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-12" "16:00:00")" "[\"${SERVICE_IDS[4]}\"]" "" "Nov 12 - Gel Removal" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-14" "10:30:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 14 - Classic Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-16" "14:30:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 16 - Express Mani & Pedi" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-18" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 18 - Luxury Pedicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-19" "15:00:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 19 - Gel + Nail Art" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-21" "09:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 21 - Classic Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-22" "13:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 22 - Gel Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-23" "16:30:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[0]}\"]" "" "Nov 23 - Removal + Classic" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-25" "10:00:00")" "[\"${SERVICE_IDS[3]}\"]" "" "Nov 25 - Express Mani & Pedi" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-26" "14:00:00")" "[\"${SERVICE_IDS[2]}\"]" "" "Nov 26 - Luxury Pedicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-27" "11:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "" "Nov 27 - Classic + Nail Art" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-28" "15:30:00")" "[\"${SERVICE_IDS[1]}\"]" "" "Nov 28 - Gel Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-29" "09:30:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Nov 29 - Classic Manicure" +create_booking "$USER_TOKEN" "$(format_london_time "2025-11-29" "13:00:00")" "[\"${SERVICE_IDS[3]}\"]" "Last one before December!" "Nov 29 - Express Mani & Pedi" + +# --- FUTURE BOOKINGS (6 bookings) --- +echo "" +echo "Creating 6 future bookings..." + +# Get tomorrow at 08:00 London time as base (ensures future) +TOMORROW_BASE=$(TZ=Europe/London date -d "tomorrow 08:00" +%Y-%m-%d) + +# Calculate future dates in London time +NEXT_WEEK_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +7 days" +%Y-%m-%d) +WEEK_AFTER_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +14 days" +%Y-%m-%d) + # Create demo bookings using London time with proper offset create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Classic Manicure - Tomorrow 10:00" create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "14:30:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "Want French manicure with simple nail art on accent fingers" "Gel Manicure + Nail Art - Tomorrow 14:30" @@ -385,4 +432,4 @@ tmux select-pane -t $SESSION_NAME:0.0 trap 'rm -f /tmp/seed_data.sh' EXIT # Attach to session -tmux attach-session -t $SESSION_NAME \ No newline at end of file +tmux attach-session -t $SESSION_NAME