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:
@@ -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"
|
||||
|
||||
+164
-177
@@ -261,66 +261,50 @@ 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
|
||||
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'
|
||||
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
|
||||
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
|
||||
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')
|
||||
AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
|
||||
) 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,
|
||||
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
|
||||
+ 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
|
||||
@@ -329,51 +313,50 @@ func computeAggregateSummary(r *http.Request, rangeStart, rangeEnd time.Time) *D
|
||||
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)
|
||||
) 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', 'no_deposit')
|
||||
`, rangeStart, rangeEnd).Scan(&summary.TotalBookings)
|
||||
|
||||
// 6. New vs returning customers
|
||||
_ = db.DB.QueryRow(r.Context(), `
|
||||
WITH range_customers AS (
|
||||
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
|
||||
)
|
||||
SELECT
|
||||
COALESCE(COUNT(*) FILTER (WHERE NOT EXISTS (
|
||||
) rc
|
||||
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 (
|
||||
)), 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) 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(*)
|
||||
)), 0),
|
||||
COALESCE((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)
|
||||
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')
|
||||
@@ -382,17 +365,25 @@ func computeAggregateSummary(r *http.Request, rangeStart, rangeEnd time.Time) *D
|
||||
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)
|
||||
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
|
||||
`, rangeStart, rangeEnd).Scan(&summary.GuestCustomers)
|
||||
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
|
||||
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, 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
|
||||
) 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
|
||||
`, weekday).Scan(&defaultClose)
|
||||
) 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, ":")
|
||||
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)
|
||||
}
|
||||
lastClose = time.Date(d.Year(), d.Month(), d.Day(), h, m, 0, 0, d.Location())
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// 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 = $1
|
||||
WHERE bs.booking_id = ANY($1)
|
||||
UNION ALL
|
||||
SELECT
|
||||
cs.name,
|
||||
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
||||
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 = $1
|
||||
WHERE bcs.booking_id = ANY($1)
|
||||
ORDER BY name
|
||||
`, apt.ID)
|
||||
|
||||
`, batchIDs)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch services for booking %s: %v", apt.ID, err)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
svcMap[bid] = append(svcMap[bid], name)
|
||||
durMap[bid] += dur
|
||||
}
|
||||
svcRows.Close()
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user