feat(backend): update services and today handlers

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-18 16:26:40 +01:00
co-authored by Sisyphus
parent 6c89fea118
commit 5e795b6291
2 changed files with 338 additions and 293 deletions
+95 -37
View File
@@ -14,7 +14,6 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
) )
// Service represents a service in the system // Service represents a service in the system
@@ -337,6 +336,9 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
var services []ServiceResponse var services []ServiceResponse
var ineligibleServices []ServiceResponse var ineligibleServices []ServiceResponse
// Preload patch test data once to avoid N+1 queries
patchTests := loadPatchTests(r.Context(), userID)
for rows.Next() { for rows.Next() {
var service ServiceResponse var service ServiceResponse
@@ -360,7 +362,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
} }
// Check patch test requirement using new schema // 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 { if patchTestStatus != nil {
// Patch test required - add status and put in ineligible list // Patch test required - add status and put in ineligible list
service.PatchTestStatus = patchTestStatus service.PatchTestStatus = patchTestStatus
@@ -441,6 +443,9 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
var services []ServiceResponse var services []ServiceResponse
var grayedOutServices []ServiceResponse var grayedOutServices []ServiceResponse
// Preload patch test data once to avoid N+1 queries
patchTests := loadPatchTests(r.Context(), userID)
for rows.Next() { for rows.Next() {
var service ServiceResponse var service ServiceResponse
@@ -464,7 +469,7 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
} }
// Check patch test requirement using new schema // 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 { if patchTestStatus != nil {
// Patch test required - add status and put in grayed out list // Patch test required - add status and put in grayed out list
service.PatchTestStatus = patchTestStatus 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 // patchTestInfo holds preloaded patch test data for a service
// Returns: nil = no patch test required, "required" = no record, "expired" = record too old type patchTestInfo struct {
func checkPatchTestStatus(ctx context.Context, userID, serviceID string) *string { patchTestID string
// Find patch tests that include this service noticeDurationHours int
var patchTestID string expiryMonths int
var noticeDurationHours int testedAt *time.Time // nil if no user_patch_test record
var expiryMonths int }
err := db.DB.QueryRow(ctx, ` // loadPatchTests preloads all patch test data for a user into a serviceID-keyed map.
SELECT id, notice_duration_hours, expiry_months // 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 FROM patch_tests
WHERE $1 = ANY(service_ids) `)
LIMIT 1 if err != nil {
`, serviceID).Scan(&patchTestID, &noticeDurationHours, &expiryMonths) 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 // No patch test required for this service
return nil 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 if info.testedAt == nil {
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) {
// No patch test record - required // No patch test record - required
status := "required" status := "required"
return &status return &status
} }
if err != nil {
// Database error
status := "required"
return &status
}
// Check if notice period has passed (can only book after this time) // 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) { if time.Now().Before(eligibleFrom) {
// Not yet eligible (within notice period) // Not yet eligible (within notice period)
status := "required" status := "required"
@@ -549,7 +607,7 @@ func checkPatchTestStatus(ctx context.Context, userID, serviceID string) *string
} }
// Check if patch test has expired // 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) { if time.Now().After(expiresAt) {
// Patch test expired // Patch test expired
status := "expired" status := "expired"
+243 -256
View File
@@ -44,22 +44,22 @@ type ServiceBookingCount struct {
} }
type DailySummary struct { type DailySummary struct {
TotalPaymentsToday float64 `json:"total_payments_today"` TotalPaymentsToday float64 `json:"total_payments_today"`
TotalTipsToday float64 `json:"total_tips_today"` TotalTipsToday float64 `json:"total_tips_today"`
AmountDueToday float64 `json:"amount_due_today"` AmountDueToday float64 `json:"amount_due_today"`
TotalDurationSpent int `json:"total_duration_spent"` TotalDurationSpent int `json:"total_duration_spent"`
CustomersServed int `json:"customers_served"` CustomersServed int `json:"customers_served"`
TotalBookings int `json:"total_bookings"` TotalBookings int `json:"total_bookings"`
NewCustomers int `json:"new_customers"` NewCustomers int `json:"new_customers"`
ReturningCustomers int `json:"returning_customers"` ReturningCustomers int `json:"returning_customers"`
GuestCustomers int `json:"guest_customers"` GuestCustomers int `json:"guest_customers"`
GiftCardsSold int `json:"gift_cards_sold"` GiftCardsSold int `json:"gift_cards_sold"`
LastCustomerName string `json:"last_customer_name,omitempty"` LastCustomerName string `json:"last_customer_name,omitempty"`
LastCustomerVisits int `json:"last_customer_visits,omitempty"` LastCustomerVisits int `json:"last_customer_visits,omitempty"`
NewBookingServices []ServiceBookingCount `json:"new_booking_services,omitempty"` NewBookingServices []ServiceBookingCount `json:"new_booking_services,omitempty"`
SummaryScope string `json:"summary_scope"` SummaryScope string `json:"summary_scope"`
SummaryStartDate string `json:"summary_start_date"` SummaryStartDate string `json:"summary_start_date"`
SummaryEndDate string `json:"summary_end_date"` SummaryEndDate string `json:"summary_end_date"`
} }
type CurrentNextResponse struct { 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 { func computeAggregateSummary(r *http.Request, rangeStart, rangeEnd time.Time) *DailySummary {
summary := &DailySummary{} summary := &DailySummary{}
// 1. Total payments today (non-tip, completed) // Single combined query replacing 9 separate round-trips
_ = db.DB.QueryRow(r.Context(), ` _ = 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 SELECT
COALESCE(COUNT(*) FILTER (WHERE NOT EXISTS ( COALESCE((SELECT SUM(p.amount)
SELECT 1 FROM bookings b2 FROM bookings b
WHERE b2.user_id = rc.user_id AND b2.start_time < $1 JOIN payments p ON p.booking_id = b.id
LIMIT 1 WHERE b.start_time >= $1 AND b.start_time < $2
)), 0) AS new_customers, AND p.status = 'completed'
COALESCE(COUNT(*) FILTER (WHERE EXISTS ( AND p.payment_type != 'tip'), 0),
SELECT 1 FROM bookings b2 COALESCE((SELECT SUM(p.amount)
WHERE b2.user_id = rc.user_id AND b2.start_time < $1 FROM bookings b
LIMIT 1 JOIN payments p ON p.booking_id = b.id
)), 0) AS returning_customers WHERE b.start_time >= $1 AND b.start_time < $2
FROM range_customers rc AND p.status = 'completed'
`, rangeStart, rangeEnd).Scan(&summary.NewCustomers, &summary.ReturningCustomers) AND p.payment_type = 'tip'), 0),
COALESCE((SELECT COALESCE(SUM(sub.amount_due), 0)
// 7. Gift cards sold today (non-inventory) FROM (
_ = db.DB.QueryRow(r.Context(), ` SELECT b.id,
SELECT COUNT(*) COALESCE(s.service_total, 0) + COALESCE(cs.service_total, 0) - COALESCE(p.payment_total, 0) AS amount_due
FROM gift_cards FROM bookings b
WHERE created_at >= $1 AND created_at < $2 LEFT JOIN (
AND is_inventory = false SELECT booking_id, SUM(amount) AS payment_total
`, rangeStart, rangeEnd).Scan(&summary.GiftCardsSold) FROM payments WHERE status = 'completed'
GROUP BY booking_id
// 8. Last completed customer and their total visit count ) p ON p.booking_id = b.id
_ = db.DB.QueryRow(r.Context(), ` LEFT JOIN (
SELECT u.fn, COUNT(b2.id) SELECT bs.booking_id, SUM(COALESCE(bs.override_price, s.price)) AS service_total
FROM bookings b FROM booking_services bs JOIN services s ON bs.service_id = s.id
JOIN users u ON u.id = b.user_id GROUP BY bs.booking_id
LEFT JOIN bookings b2 ON b2.user_id = b.user_id AND b2.status NOT IN ('client_cancelled', 'we_cancelled') ) s ON s.booking_id = b.id
WHERE b.start_time >= $1 AND b.start_time < $2 LEFT JOIN (
AND b.status = 'completed' SELECT bcs.booking_id, SUM(COALESCE(bcs.override_price, cs.price)) AS service_total
AND b.user_id IS NOT NULL FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
GROUP BY u.id, u.fn, b.start_time GROUP BY bcs.booking_id
ORDER BY b.start_time DESC ) cs ON cs.booking_id = b.id
LIMIT 1 WHERE b.start_time >= $1 AND b.start_time < $2
`, rangeStart, rangeEnd).Scan(&summary.LastCustomerName, &summary.LastCustomerVisits) AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
) sub
// 9. Guest customers (user_id IS NULL) WHERE sub.amount_due > 0), 0),
_ = db.DB.QueryRow(r.Context(), ` COALESCE((SELECT SUM(duration_minutes)
SELECT COUNT(DISTINCT b.id) FROM (
FROM bookings b SELECT b.id,
WHERE b.start_time >= $1 AND b.start_time < $2 COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 0)
AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show') + COALESCE(SUM(COALESCE(bcs.override_duration_minutes, cs.duration_minutes)), 0) AS duration_minutes
AND b.user_id IS NULL FROM bookings b
`, rangeStart, rangeEnd).Scan(&summary.GuestCustomers) 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) // Customers served = new + returning + guest (distinct people, not bookings)
summary.CustomersServed = summary.NewCustomers + summary.ReturningCustomers + summary.GuestCustomers 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 { func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBookingCount {
now := time.Now() 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 var lastClose time.Time
found := false var dayDate time.Time
var closeTimeStr string
for i := 1; i <= 14; i++ { err := db.DB.QueryRow(r.Context(), `
d := now.AddDate(0, 0, -i) WITH days AS (
dateStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) SELECT
weekday := int(d.Weekday()) (NOW() - make_interval(days => i))::date AS day_date,
if weekday == 0 { CASE WHEN EXTRACT(DOW FROM NOW() - make_interval(days => i)) = 0 THEN 6
weekday = 6 ELSE EXTRACT(DOW FROM NOW() - make_interval(days => i))::integer - 1
} else { END AS weekday
weekday -= 1 FROM generate_series(1, 14) AS i
} )
SELECT
// Check if this day has exceptional hours making it open d.day_date,
var exceptionalClose sql.NullString COALESCE(eh.close_time, wh.close_time) AS close_time
_ = db.DB.QueryRow(r.Context(), ` FROM days d
SELECT ewh.end_time::text LEFT JOIN LATERAL (
SELECT (ewh.end_time)::text AS close_time
FROM exceptional_working_hours ewh FROM exceptional_working_hours ewh
JOIN exceptional_working_hours_groups ewhg ON ewhg.id = ewh.group_id JOIN exceptional_working_hours_groups ewhg ON ewhg.id = ewh.group_id
JOIN exceptional_group_applications ega ON ega.group_id = ewhg.id JOIN exceptional_group_applications ega ON ega.group_id = ewhg.id
WHERE ewh.weekday = $1 WHERE ewh.weekday = d.weekday
AND ega.week_start <= $2 AND ega.week_start <= d.day_date
AND ega.week_start + INTERVAL '7 days' > $2 AND ega.week_start + INTERVAL '7 days' > d.day_date
AND ewh.is_open = true 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
LIMIT 1 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 { if err == nil {
closeTime := defaultClose.String parts := strings.Split(closeTimeStr, ":")
parts := strings.Split(closeTime, ":") h, m := 0, 0
h, m := 0, 0 fmt.Sscanf(parts[0], "%d", &h)
fmt.Sscanf(parts[0], "%d", &h) if len(parts) > 1 {
if len(parts) > 1 { fmt.Sscanf(parts[1], "%d", &m)
fmt.Sscanf(parts[1], "%d", &m)
}
lastClose = time.Date(d.Year(), d.Month(), d.Day(), h, m, 0, 0, d.Location())
found = true
break
} }
} lastClose = time.Date(dayDate.Year(), dayDate.Month(), dayDate.Day(), h, m, 0, 0, dayDate.Location())
} else {
if !found {
// Fallback: 5pm yesterday // Fallback: 5pm yesterday
lastClose = time.Date(now.Year(), now.Month(), now.Day()-1, 17, 0, 0, 0, now.Location()) 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() 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() { for rows.Next() {
var apt TodayAppointment var a rawAppt
var startTime time.Time if err := rows.Scan(&a.ID, &a.StartTime, &a.Status, &a.UserName, &a.UserID); err != nil {
err := rows.Scan(
&apt.ID,
&startTime,
&apt.Status,
&apt.UserName,
&apt.UserID,
)
if err != nil {
log.Printf("Failed to scan appointment row: %v", err) log.Printf("Failed to scan appointment row: %v", err)
continue 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 // Batch-fetch services for ALL appointments in a single query.
serviceRows, err := db.DB.Query(r.Context(), ` batchIDs := make([]string, len(raw))
SELECT for i, a := range raw {
s.name, batchIDs[i] = a.ID
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes }
FROM booking_services bs type svcRow struct {
LEFT JOIN services s ON bs.service_id = s.id BookingID string
WHERE bs.booking_id = $1 Name string
UNION ALL Duration int
SELECT }
cs.name, svcRows, err := db.DB.Query(r.Context(), `
COALESCE(bcs.override_duration_minutes, cs.duration_minutes) SELECT bs.booking_id, s.name, COALESCE(bs.override_duration_minutes, s.duration_minutes)
FROM booking_custom_services bcs FROM booking_services bs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id LEFT JOIN services s ON bs.service_id = s.id
WHERE bcs.booking_id = $1 WHERE bs.booking_id = ANY($1)
ORDER BY name UNION ALL
`, apt.ID) SELECT bcs.booking_id, cs.name, COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
if err != nil { LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
log.Printf("Failed to fetch services for booking %s: %v", apt.ID, err) 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 continue
} }
svcMap[bid] = append(svcMap[bid], name)
durMap[bid] += dur
}
svcRows.Close()
var services []string appointments := make([]TodayAppointment, 0, len(raw))
var totalDuration int for _, a := range raw {
appointments = append(appointments, TodayAppointment{
for serviceRows.Next() { ID: a.ID,
var serviceName string StartTime: a.StartTime.Format(time.RFC3339),
var duration int Status: a.Status,
UserName: a.UserName,
if err := serviceRows.Scan(&serviceName, &duration); err != nil { UserID: a.UserID,
log.Printf("Failed to scan service: %v", err) Services: svcMap[a.ID],
continue DurationMinutes: durMap[a.ID],
} })
services = append(services, serviceName)
totalDuration += duration
}
serviceRows.Close()
apt.Services = services
apt.DurationMinutes = totalDuration
appointments = append(appointments, apt)
} }
if appointments == nil { if appointments == nil {