diff --git a/backend/handlers/services/services.go b/backend/handlers/services/services.go index b42572e..9a66e11 100644 --- a/backend/handlers/services/services.go +++ b/backend/handlers/services/services.go @@ -14,7 +14,6 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5" ) // Service represents a service in the system @@ -337,6 +336,9 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { var services []ServiceResponse var ineligibleServices []ServiceResponse + // Preload patch test data once to avoid N+1 queries + patchTests := loadPatchTests(r.Context(), userID) + for rows.Next() { var service ServiceResponse @@ -360,7 +362,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) { } // Check patch test requirement using new schema - patchTestStatus := checkPatchTestStatus(r.Context(), userID, service.ID) + patchTestStatus := checkPatchTestStatus(r.Context(), userID, service.ID, patchTests) if patchTestStatus != nil { // Patch test required - add status and put in ineligible list service.PatchTestStatus = patchTestStatus @@ -441,6 +443,9 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { var services []ServiceResponse var grayedOutServices []ServiceResponse + // Preload patch test data once to avoid N+1 queries + patchTests := loadPatchTests(r.Context(), userID) + for rows.Next() { var service ServiceResponse @@ -464,7 +469,7 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { } // Check patch test requirement using new schema - patchTestStatus := checkPatchTestStatus(r.Context(), userID, service.ID) + patchTestStatus := checkPatchTestStatus(r.Context(), userID, service.ID, patchTests) if patchTestStatus != nil { // Patch test required - add status and put in grayed out list service.PatchTestStatus = patchTestStatus @@ -496,52 +501,105 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { } } -// checkPatchTestStatus checks if a service requires a patch test and if the user has a valid one -// Returns: nil = no patch test required, "required" = no record, "expired" = record too old -func checkPatchTestStatus(ctx context.Context, userID, serviceID string) *string { - // Find patch tests that include this service - var patchTestID string - var noticeDurationHours int - var expiryMonths int +// patchTestInfo holds preloaded patch test data for a service +type patchTestInfo struct { + patchTestID string + noticeDurationHours int + expiryMonths int + testedAt *time.Time // nil if no user_patch_test record +} - err := db.DB.QueryRow(ctx, ` - SELECT id, notice_duration_hours, expiry_months +// loadPatchTests preloads all patch test data for a user into a serviceID-keyed map. +// Performs exactly 2 queries total regardless of the number of services. +func loadPatchTests(ctx context.Context, userID string) map[string]*patchTestInfo { + result := make(map[string]*patchTestInfo) + + // Query 1: load all patch_test records + rows, err := db.DB.Query(ctx, ` + SELECT id, notice_duration_hours, expiry_months, service_ids FROM patch_tests - WHERE $1 = ANY(service_ids) - LIMIT 1 - `, serviceID).Scan(&patchTestID, ¬iceDurationHours, &expiryMonths) + `) + if err != nil { + return result + } + defer rows.Close() - if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { + type ptRow struct { + id string + noticeDurationHours int + expiryMonths int + serviceIDs []string + } + + var patchTests []ptRow + for rows.Next() { + var pt ptRow + if err := rows.Scan(&pt.id, &pt.noticeDurationHours, &pt.expiryMonths, &pt.serviceIDs); err != nil { + continue + } + patchTests = append(patchTests, pt) + } + if err = rows.Err(); err != nil { + return result + } + + // Query 2: load user_patch_test records for this user + testedAtMap := make(map[string]time.Time) + if len(patchTests) > 0 { + uRows, err := db.DB.Query(ctx, ` + SELECT patch_test_id, tested_at + FROM user_patch_tests + WHERE user_id = $1 + `, userID) + if err != nil { + return result + } + defer uRows.Close() + for uRows.Next() { + var ptID string + var testedAt time.Time + if err := uRows.Scan(&ptID, &testedAt); err == nil { + testedAtMap[ptID] = testedAt + } + } + } + + // Build serviceID → patchTestInfo map + for _, pt := range patchTests { + info := &patchTestInfo{ + patchTestID: pt.id, + noticeDurationHours: pt.noticeDurationHours, + expiryMonths: pt.expiryMonths, + } + if testedAt, ok := testedAtMap[pt.id]; ok { + info.testedAt = &testedAt + } + for _, sid := range pt.serviceIDs { + result[sid] = info + } + } + + return result +} + +// checkPatchTestStatus checks if a service requires a patch test and if the user has a valid one. +// Uses preloaded patch test data to avoid per-service DB queries. +// Returns: nil = no patch test required, "required" = no record, "expired" = record too old +func checkPatchTestStatus(ctx context.Context, userID, serviceID string, patchTests map[string]*patchTestInfo) *string { + info, ok := patchTests[serviceID] + if !ok { // No patch test required for this service return nil } - if err != nil { - // Database error - don't fail the whole request, just assume patch test required - status := "required" - return &status - } - // Check if user has a valid patch test record - var testedAt time.Time - err = db.DB.QueryRow(ctx, ` - SELECT tested_at - FROM user_patch_tests - WHERE user_id = $1 AND patch_test_id = $2 - `, userID, patchTestID).Scan(&testedAt) - - if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { + if info.testedAt == nil { // No patch test record - required status := "required" return &status } - if err != nil { - // Database error - status := "required" - return &status - } // Check if notice period has passed (can only book after this time) - eligibleFrom := testedAt.Add(time.Duration(noticeDurationHours) * time.Hour) + eligibleFrom := info.testedAt.Add(time.Duration(info.noticeDurationHours) * time.Hour) if time.Now().Before(eligibleFrom) { // Not yet eligible (within notice period) status := "required" @@ -549,7 +607,7 @@ func checkPatchTestStatus(ctx context.Context, userID, serviceID string) *string } // Check if patch test has expired - expiresAt := testedAt.AddDate(0, expiryMonths, 0) + expiresAt := info.testedAt.AddDate(0, info.expiryMonths, 0) if time.Now().After(expiresAt) { // Patch test expired status := "expired" diff --git a/backend/handlers/today/today.go b/backend/handlers/today/today.go index f8abea2..bd6d542 100644 --- a/backend/handlers/today/today.go +++ b/backend/handlers/today/today.go @@ -44,22 +44,22 @@ type ServiceBookingCount struct { } type DailySummary struct { - TotalPaymentsToday float64 `json:"total_payments_today"` - TotalTipsToday float64 `json:"total_tips_today"` - AmountDueToday float64 `json:"amount_due_today"` - TotalDurationSpent int `json:"total_duration_spent"` - CustomersServed int `json:"customers_served"` - TotalBookings int `json:"total_bookings"` - NewCustomers int `json:"new_customers"` - ReturningCustomers int `json:"returning_customers"` - GuestCustomers int `json:"guest_customers"` - GiftCardsSold int `json:"gift_cards_sold"` - LastCustomerName string `json:"last_customer_name,omitempty"` - LastCustomerVisits int `json:"last_customer_visits,omitempty"` + TotalPaymentsToday float64 `json:"total_payments_today"` + TotalTipsToday float64 `json:"total_tips_today"` + AmountDueToday float64 `json:"amount_due_today"` + TotalDurationSpent int `json:"total_duration_spent"` + CustomersServed int `json:"customers_served"` + TotalBookings int `json:"total_bookings"` + NewCustomers int `json:"new_customers"` + ReturningCustomers int `json:"returning_customers"` + GuestCustomers int `json:"guest_customers"` + GiftCardsSold int `json:"gift_cards_sold"` + LastCustomerName string `json:"last_customer_name,omitempty"` + LastCustomerVisits int `json:"last_customer_visits,omitempty"` NewBookingServices []ServiceBookingCount `json:"new_booking_services,omitempty"` - SummaryScope string `json:"summary_scope"` - SummaryStartDate string `json:"summary_start_date"` - SummaryEndDate string `json:"summary_end_date"` + SummaryScope string `json:"summary_scope"` + SummaryStartDate string `json:"summary_start_date"` + SummaryEndDate string `json:"summary_end_date"` } type CurrentNextResponse struct { @@ -261,138 +261,129 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) { func computeAggregateSummary(r *http.Request, rangeStart, rangeEnd time.Time) *DailySummary { summary := &DailySummary{} - // 1. Total payments today (non-tip, completed) + // Single combined query replacing 9 separate round-trips _ = db.DB.QueryRow(r.Context(), ` - SELECT COALESCE(SUM(p.amount), 0) - FROM bookings b - JOIN payments p ON p.booking_id = b.id - WHERE b.start_time >= $1 AND b.start_time < $2 - AND p.status = 'completed' - AND p.payment_type != 'tip' - `, rangeStart, rangeEnd).Scan(&summary.TotalPaymentsToday) - - // 2. Total tips today (completed) - _ = db.DB.QueryRow(r.Context(), ` - SELECT COALESCE(SUM(p.amount), 0) - FROM bookings b - JOIN payments p ON p.booking_id = b.id - WHERE b.start_time >= $1 AND b.start_time < $2 - AND p.status = 'completed' - AND p.payment_type = 'tip' - `, rangeStart, rangeEnd).Scan(&summary.TotalTipsToday) - - // 3. Amount still due from today's non-cancelled bookings - _ = db.DB.QueryRow(r.Context(), ` - SELECT COALESCE(SUM(sub.amount_due), 0) - FROM ( - SELECT - b.id, - COALESCE(service_total, 0) - COALESCE(payment_total, 0) AS amount_due - FROM bookings b - LEFT JOIN ( - SELECT booking_id, SUM(amount) AS payment_total - FROM payments - WHERE status = 'completed' - GROUP BY booking_id - ) p ON p.booking_id = b.id - LEFT JOIN ( - SELECT bs.booking_id, SUM(COALESCE(bs.override_price, s.price)) AS service_total - FROM booking_services bs - JOIN services s ON bs.service_id = s.id - GROUP BY bs.booking_id - ) s ON s.booking_id = b.id - LEFT JOIN ( - SELECT bcs.booking_id, SUM(COALESCE(bcs.override_price, cs.price)) AS service_total - FROM booking_custom_services bcs - JOIN custom_services cs ON bcs.custom_service_id = cs.id - GROUP BY bcs.booking_id - ) cs ON cs.booking_id = b.id - WHERE b.start_time >= $1 AND b.start_time < $2 - AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') - ) sub - WHERE sub.amount_due > 0 - `, rangeStart, rangeEnd).Scan(&summary.AmountDueToday) - - // 4. Total duration spent in completed + in_progress appointments - _ = db.DB.QueryRow(r.Context(), ` - SELECT COALESCE(SUM(duration_minutes), 0) FROM ( - SELECT - b.id, - COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 0) - + COALESCE(SUM(COALESCE(bcs.override_duration_minutes, cs.duration_minutes)), 0) - AS duration_minutes - FROM bookings b - LEFT JOIN booking_services bs ON bs.booking_id = b.id - LEFT JOIN services s ON s.id = bs.service_id - LEFT JOIN booking_custom_services bcs ON bcs.booking_id = b.id - LEFT JOIN custom_services cs ON cs.id = bcs.custom_service_id - WHERE b.start_time >= $1 AND b.start_time < $2 - AND b.status IN ('completed', 'in_progress') - GROUP BY b.id - ) sub - `, rangeStart, rangeEnd).Scan(&summary.TotalDurationSpent) - - // 5. Total bookings (completed + in-progress + confirmed, excluding cancelled/no-show) - _ = db.DB.QueryRow(r.Context(), ` - SELECT COUNT(DISTINCT b.id) - FROM bookings b - WHERE b.start_time >= $1 AND b.start_time < $2 - AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') - `, rangeStart, rangeEnd).Scan(&summary.TotalBookings) - - // 6. New vs returning customers - _ = db.DB.QueryRow(r.Context(), ` - WITH range_customers AS ( - SELECT DISTINCT b.user_id - FROM bookings b - WHERE b.start_time >= $1 AND b.start_time < $2 - AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show') - AND b.user_id IS NOT NULL - ) SELECT - COALESCE(COUNT(*) FILTER (WHERE NOT EXISTS ( - SELECT 1 FROM bookings b2 - WHERE b2.user_id = rc.user_id AND b2.start_time < $1 - LIMIT 1 - )), 0) AS new_customers, - COALESCE(COUNT(*) FILTER (WHERE EXISTS ( - SELECT 1 FROM bookings b2 - WHERE b2.user_id = rc.user_id AND b2.start_time < $1 - LIMIT 1 - )), 0) AS returning_customers - FROM range_customers rc - `, rangeStart, rangeEnd).Scan(&summary.NewCustomers, &summary.ReturningCustomers) - - // 7. Gift cards sold today (non-inventory) - _ = db.DB.QueryRow(r.Context(), ` - SELECT COUNT(*) - FROM gift_cards - WHERE created_at >= $1 AND created_at < $2 - AND is_inventory = false - `, rangeStart, rangeEnd).Scan(&summary.GiftCardsSold) - - // 8. Last completed customer and their total visit count - _ = db.DB.QueryRow(r.Context(), ` - SELECT u.fn, COUNT(b2.id) - FROM bookings b - JOIN users u ON u.id = b.user_id - LEFT JOIN bookings b2 ON b2.user_id = b.user_id AND b2.status NOT IN ('client_cancelled', 'we_cancelled') - WHERE b.start_time >= $1 AND b.start_time < $2 - AND b.status = 'completed' - AND b.user_id IS NOT NULL - GROUP BY u.id, u.fn, b.start_time - ORDER BY b.start_time DESC - LIMIT 1 - `, rangeStart, rangeEnd).Scan(&summary.LastCustomerName, &summary.LastCustomerVisits) - - // 9. Guest customers (user_id IS NULL) - _ = db.DB.QueryRow(r.Context(), ` - SELECT COUNT(DISTINCT b.id) - FROM bookings b - WHERE b.start_time >= $1 AND b.start_time < $2 - AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show') - AND b.user_id IS NULL - `, rangeStart, rangeEnd).Scan(&summary.GuestCustomers) + COALESCE((SELECT SUM(p.amount) + FROM bookings b + JOIN payments p ON p.booking_id = b.id + WHERE b.start_time >= $1 AND b.start_time < $2 + AND p.status = 'completed' + AND p.payment_type != 'tip'), 0), + COALESCE((SELECT SUM(p.amount) + FROM bookings b + JOIN payments p ON p.booking_id = b.id + WHERE b.start_time >= $1 AND b.start_time < $2 + AND p.status = 'completed' + AND p.payment_type = 'tip'), 0), + COALESCE((SELECT COALESCE(SUM(sub.amount_due), 0) + FROM ( + SELECT b.id, + COALESCE(s.service_total, 0) + COALESCE(cs.service_total, 0) - COALESCE(p.payment_total, 0) AS amount_due + FROM bookings b + LEFT JOIN ( + SELECT booking_id, SUM(amount) AS payment_total + FROM payments WHERE status = 'completed' + GROUP BY booking_id + ) p ON p.booking_id = b.id + LEFT JOIN ( + SELECT bs.booking_id, SUM(COALESCE(bs.override_price, s.price)) AS service_total + FROM booking_services bs JOIN services s ON bs.service_id = s.id + GROUP BY bs.booking_id + ) s ON s.booking_id = b.id + LEFT JOIN ( + SELECT bcs.booking_id, SUM(COALESCE(bcs.override_price, cs.price)) AS service_total + FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id + GROUP BY bcs.booking_id + ) cs ON cs.booking_id = b.id + WHERE b.start_time >= $1 AND b.start_time < $2 + AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') + ) sub + WHERE sub.amount_due > 0), 0), + COALESCE((SELECT SUM(duration_minutes) + FROM ( + SELECT b.id, + COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 0) + + COALESCE(SUM(COALESCE(bcs.override_duration_minutes, cs.duration_minutes)), 0) AS duration_minutes + FROM bookings b + LEFT JOIN booking_services bs ON bs.booking_id = b.id + LEFT JOIN services s ON s.id = bs.service_id + LEFT JOIN booking_custom_services bcs ON bcs.booking_id = b.id + LEFT JOIN custom_services cs ON cs.id = bcs.custom_service_id + WHERE b.start_time >= $1 AND b.start_time < $2 + AND b.status IN ('completed', 'in_progress') + GROUP BY b.id + ) sub), 0), + COALESCE((SELECT COUNT(DISTINCT b.id) + FROM bookings b + WHERE b.start_time >= $1 AND b.start_time < $2 + AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')), 0), + COALESCE((SELECT COUNT(*) + FROM ( + SELECT DISTINCT b.user_id + FROM bookings b + WHERE b.start_time >= $1 AND b.start_time < $2 + AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show') + AND b.user_id IS NOT NULL + ) rc + WHERE NOT EXISTS ( + SELECT 1 FROM bookings b2 + WHERE b2.user_id = rc.user_id AND b2.start_time < $1 + LIMIT 1 + )), 0), + COALESCE((SELECT COUNT(*) + FROM ( + SELECT DISTINCT b.user_id + FROM bookings b + WHERE b.start_time >= $1 AND b.start_time < $2 + AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show') + AND b.user_id IS NOT NULL + ) rc + WHERE EXISTS ( + SELECT 1 FROM bookings b2 + WHERE b2.user_id = rc.user_id AND b2.start_time < $1 + LIMIT 1 + )), 0), + COALESCE((SELECT COUNT(*) + FROM gift_cards + WHERE created_at >= $1 AND created_at < $2 + AND is_inventory = false), 0), + COALESCE((SELECT u.fn + FROM bookings b + JOIN users u ON u.id = b.user_id + WHERE b.start_time >= $1 AND b.start_time < $2 + AND b.status = 'completed' + AND b.user_id IS NOT NULL + ORDER BY b.start_time DESC + LIMIT 1), ''), + COALESCE((SELECT COUNT(b2.id) + FROM bookings b + JOIN users u ON u.id = b.user_id + LEFT JOIN bookings b2 ON b2.user_id = b.user_id AND b2.status NOT IN ('client_cancelled', 'we_cancelled') + WHERE b.start_time >= $1 AND b.start_time < $2 + AND b.status = 'completed' + AND b.user_id IS NOT NULL + GROUP BY u.id, u.fn, b.start_time + ORDER BY b.start_time DESC + LIMIT 1), 0), + COALESCE((SELECT COUNT(DISTINCT b.id) + FROM bookings b + WHERE b.start_time >= $1 AND b.start_time < $2 + AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show') + AND b.user_id IS NULL), 0) + `, rangeStart, rangeEnd).Scan( + &summary.TotalPaymentsToday, + &summary.TotalTipsToday, + &summary.AmountDueToday, + &summary.TotalDurationSpent, + &summary.TotalBookings, + &summary.NewCustomers, + &summary.ReturningCustomers, + &summary.GiftCardsSold, + &summary.LastCustomerName, + &summary.LastCustomerVisits, + &summary.GuestCustomers, + ) // Customers served = new + returning + guest (distinct people, not bookings) summary.CustomersServed = summary.NewCustomers + summary.ReturningCustomers + summary.GuestCustomers @@ -406,70 +397,54 @@ func computeAggregateSummary(r *http.Request, rangeStart, rangeEnd time.Time) *D func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBookingCount { now := time.Now() - // Walk back up to 14 days to find the last working day's closing time + // Single query to find the last working day's closing time var lastClose time.Time - found := false - - for i := 1; i <= 14; i++ { - d := now.AddDate(0, 0, -i) - dateStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) - weekday := int(d.Weekday()) - if weekday == 0 { - weekday = 6 - } else { - weekday -= 1 - } - - // Check if this day has exceptional hours making it open - var exceptionalClose sql.NullString - _ = db.DB.QueryRow(r.Context(), ` - SELECT ewh.end_time::text + var dayDate time.Time + var closeTimeStr string + err := db.DB.QueryRow(r.Context(), ` + WITH days AS ( + SELECT + (NOW() - make_interval(days => i))::date AS day_date, + CASE WHEN EXTRACT(DOW FROM NOW() - make_interval(days => i)) = 0 THEN 6 + ELSE EXTRACT(DOW FROM NOW() - make_interval(days => i))::integer - 1 + END AS weekday + FROM generate_series(1, 14) AS i + ) + SELECT + d.day_date, + COALESCE(eh.close_time, wh.close_time) AS close_time + FROM days d + LEFT JOIN LATERAL ( + SELECT (ewh.end_time)::text AS close_time FROM exceptional_working_hours ewh JOIN exceptional_working_hours_groups ewhg ON ewhg.id = ewh.group_id JOIN exceptional_group_applications ega ON ega.group_id = ewhg.id - WHERE ewh.weekday = $1 - AND ega.week_start <= $2 - AND ega.week_start + INTERVAL '7 days' > $2 - AND ewh.is_open = true - LIMIT 1 - `, weekday, dateStart).Scan(&exceptionalClose) - - if exceptionalClose.Valid { - closeTime := exceptionalClose.String - parts := strings.Split(closeTime, ":") - h, m := 0, 0 - fmt.Sscanf(parts[0], "%d", &h) - if len(parts) > 1 { - fmt.Sscanf(parts[1], "%d", &m) - } - lastClose = time.Date(d.Year(), d.Month(), d.Day(), h, m, 0, 0, d.Location()) - found = true - break - } - - // Check default working hours - var defaultClose sql.NullString - _ = db.DB.QueryRow(r.Context(), ` - SELECT end_time::text FROM working_hours - WHERE weekday = $1 AND is_open = true + WHERE ewh.weekday = d.weekday + AND ega.week_start <= d.day_date + AND ega.week_start + INTERVAL '7 days' > d.day_date + AND ewh.is_open = true LIMIT 1 - `, weekday).Scan(&defaultClose) + ) eh ON true + LEFT JOIN LATERAL ( + SELECT wh.end_time::text AS close_time + FROM working_hours wh + WHERE wh.weekday = d.weekday AND wh.is_open = true + LIMIT 1 + ) wh ON true + WHERE COALESCE(eh.close_time, wh.close_time) IS NOT NULL + ORDER BY d.day_date DESC + LIMIT 1 + `).Scan(&dayDate, &closeTimeStr) - if defaultClose.Valid { - closeTime := defaultClose.String - parts := strings.Split(closeTime, ":") - h, m := 0, 0 - fmt.Sscanf(parts[0], "%d", &h) - if len(parts) > 1 { - fmt.Sscanf(parts[1], "%d", &m) - } - lastClose = time.Date(d.Year(), d.Month(), d.Day(), h, m, 0, 0, d.Location()) - found = true - break + if err == nil { + parts := strings.Split(closeTimeStr, ":") + h, m := 0, 0 + fmt.Sscanf(parts[0], "%d", &h) + if len(parts) > 1 { + fmt.Sscanf(parts[1], "%d", &m) } - } - - if !found { + lastClose = time.Date(dayDate.Year(), dayDate.Month(), dayDate.Day(), h, m, 0, 0, dayDate.Location()) + } else { // Fallback: 5pm yesterday lastClose = time.Date(now.Year(), now.Month(), now.Day()-1, 17, 0, 0, 0, now.Location()) } @@ -833,70 +808,82 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) { } defer rows.Close() - var appointments []TodayAppointment + type rawAppt struct { + ID string + StartTime time.Time + Status string + UserName string + UserID string + } + var raw []rawAppt for rows.Next() { - var apt TodayAppointment - var startTime time.Time - - err := rows.Scan( - &apt.ID, - &startTime, - &apt.Status, - &apt.UserName, - &apt.UserID, - ) - if err != nil { + var a rawAppt + if err := rows.Scan(&a.ID, &a.StartTime, &a.Status, &a.UserName, &a.UserID); err != nil { log.Printf("Failed to scan appointment row: %v", err) continue } + raw = append(raw, a) + } + rows.Close() - apt.StartTime = startTime.Format(time.RFC3339) + if len(raw) == 0 { + json.NewEncoder(w).Encode(TodayAppointmentsResponse{Appointments: []TodayAppointment{}}) + return + } - // Fetch services for this booking - serviceRows, err := db.DB.Query(r.Context(), ` - SELECT - s.name, - COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes - FROM booking_services bs - LEFT JOIN services s ON bs.service_id = s.id - WHERE bs.booking_id = $1 - UNION ALL - SELECT - cs.name, - COALESCE(bcs.override_duration_minutes, cs.duration_minutes) - FROM booking_custom_services bcs - LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id - WHERE bcs.booking_id = $1 - ORDER BY name - `, apt.ID) - - if err != nil { - log.Printf("Failed to fetch services for booking %s: %v", apt.ID, err) + // Batch-fetch services for ALL appointments in a single query. + batchIDs := make([]string, len(raw)) + for i, a := range raw { + batchIDs[i] = a.ID + } + type svcRow struct { + BookingID string + Name string + Duration int + } + svcRows, err := db.DB.Query(r.Context(), ` + SELECT bs.booking_id, s.name, COALESCE(bs.override_duration_minutes, s.duration_minutes) + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = ANY($1) + UNION ALL + SELECT bcs.booking_id, cs.name, COALESCE(bcs.override_duration_minutes, cs.duration_minutes) + FROM booking_custom_services bcs + LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id + WHERE bcs.booking_id = ANY($1) + ORDER BY name + `, batchIDs) + if err != nil { + log.Printf("Failed to batch-fetch services: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + svcMap := make(map[string][]string, len(raw)) + durMap := make(map[string]int, len(raw)) + for svcRows.Next() { + var bid, name string + var dur int + if err := svcRows.Scan(&bid, &name, &dur); err != nil { + log.Printf("Failed to scan service row: %v", err) continue } + svcMap[bid] = append(svcMap[bid], name) + durMap[bid] += dur + } + svcRows.Close() - var services []string - var totalDuration int - - for serviceRows.Next() { - var serviceName string - var duration int - - if err := serviceRows.Scan(&serviceName, &duration); err != nil { - log.Printf("Failed to scan service: %v", err) - continue - } - - services = append(services, serviceName) - totalDuration += duration - } - serviceRows.Close() - - apt.Services = services - apt.DurationMinutes = totalDuration - - appointments = append(appointments, apt) + appointments := make([]TodayAppointment, 0, len(raw)) + for _, a := range raw { + appointments = append(appointments, TodayAppointment{ + ID: a.ID, + StartTime: a.StartTime.Format(time.RFC3339), + Status: a.Status, + UserName: a.UserName, + UserID: a.UserID, + Services: svcMap[a.ID], + DurationMinutes: durMap[a.ID], + }) } if appointments == nil {