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"
|
"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, ¬iceDurationHours, &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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user