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"
"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, &noticeDurationHours, &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
View File
@@ -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 {