Files
Crussell/backend/handlers/today/today.go
T
popertotsandSisyphus b4f70ada49 fix(today): use date string instead of time.Date for exceptional hours query
Replace time.Date with formatted date string for exceptional hours query parameters to ensure correct PostgreSQL type inference.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-22 12:55:16 +01:00

998 lines
30 KiB
Go

package today
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"crussell/db"
"github.com/jackc/pgx/v5"
)
type ServiceInfo struct {
ServiceName *string `json:"service_name,omitempty"`
ServiceDescription *string `json:"service_description,omitempty"`
Price *float64 `json:"price,omitempty"`
DurationMinutes *int `json:"duration_minutes,omitempty"`
}
type UserInfo struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Phone *string `json:"phone,omitempty"`
Email *string `json:"email,omitempty"`
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"`
}
type AppointmentInfo struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
User *UserInfo `json:"user,omitempty"`
Services []ServiceInfo `json:"services"`
DurationMinutes int `json:"duration_minutes"`
TotalAmount float64 `json:"total_amount"`
}
type ServiceBookingCount struct {
ServiceName string `json:"service_name"`
Count int `json:"count"`
}
type DailySummary struct {
TotalPaymentsToday float64 `json:"total_payments_today"`
TotalTipsToday float64 `json:"total_tips_today"`
AmountDueToday float64 `json:"amount_due_today"`
TotalDurationSpent int `json:"total_duration_spent"`
CustomersServed int `json:"customers_served"`
TotalBookings int `json:"total_bookings"`
NewCustomers int `json:"new_customers"`
ReturningCustomers int `json:"returning_customers"`
GuestCustomers int `json:"guest_customers"`
GiftCardsSold int `json:"gift_cards_sold"`
LastCustomerName string `json:"last_customer_name,omitempty"`
LastCustomerVisits int `json:"last_customer_visits,omitempty"`
NewBookingServices []ServiceBookingCount `json:"new_booking_services,omitempty"`
SummaryScope string `json:"summary_scope"`
SummaryStartDate string `json:"summary_start_date"`
SummaryEndDate string `json:"summary_end_date"`
}
type CurrentNextResponse struct {
Current *AppointmentInfo `json:"current"`
Next *AppointmentInfo `json:"next"`
ClosingTime *string `json:"closing_time,omitempty"`
DoneForDay *bool `json:"done_for_day,omitempty"`
Summary *DailySummary `json:"summary,omitempty"`
WeekSummary *DailySummary `json:"week_summary,omitempty"`
}
// GET /api/admin/today/current-next
func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayEnd := todayStart.Add(24 * time.Hour)
// Auto-transition confirmed bookings that have started but not ended to in_progress
_, err := db.Conn.Exec(r.Context(), `
UPDATE bookings
SET status = 'in_progress'
WHERE status = 'confirmed'
AND start_time <= $1
AND end_time > $1
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to in_progress: %v", err)
}
// Auto-transition in_progress bookings that have ended to completed
_, err = db.Conn.Exec(r.Context(), `
UPDATE bookings
SET status = 'completed'
WHERE status = 'in_progress'
AND end_time <= $1
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to completed: %v", err)
}
var current *AppointmentInfo
var next *AppointmentInfo
// Step 1: Try to find current in-progress appointment
currentBooking, err := fetchAppointment(r, `
SELECT b.id, b.start_time, b.status, b.notes, b.user_id
FROM bookings b
WHERE b.status = 'in_progress'
AND b.start_time >= $1
AND b.start_time < $2
ORDER BY b.start_time ASC
LIMIT 1
`, todayStart, todayEnd)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Error querying current appointment: %v", err)
}
if err == nil && currentBooking != nil {
current = currentBooking
// Also fetch the appointment AFTER this one within today
nextBooking, err := fetchAppointment(r, `
SELECT b.id, b.start_time, b.status, b.notes, b.user_id
FROM bookings b
WHERE b.start_time > $1
AND b.start_time < $2
AND (b.status = 'confirmed' OR b.status = 'pending' OR b.status = 'in_progress')
ORDER BY b.start_time ASC
LIMIT 1
`, currentBooking.StartTime, todayEnd)
if err == nil && nextBooking != nil {
next = nextBooking
}
} else {
// No current in-progress appointment, find the next upcoming one within today
nextBooking, err := fetchAppointment(r, `
SELECT b.id, b.start_time, b.status, b.notes, b.user_id
FROM bookings b
WHERE b.start_time >= $1
AND b.start_time < $2
AND (b.status = 'confirmed' OR b.status = 'pending')
ORDER BY b.start_time ASC
LIMIT 1
`, now, todayEnd)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Error querying next appointment: %v", err)
}
if err == nil && nextBooking != nil {
current = nextBooking
// Fetch the one after for free time calculation
afterNext, err := fetchAppointment(r, `
SELECT b.id, b.start_time, b.status, b.notes, b.user_id
FROM bookings b
WHERE b.start_time > $1
AND b.start_time < $2
AND (b.status = 'confirmed' OR b.status = 'pending' OR b.status = 'in_progress')
ORDER BY b.start_time ASC
LIMIT 1
`, nextBooking.StartTime, todayEnd)
if err == nil && afterNext != nil {
next = afterNext
}
}
}
response := CurrentNextResponse{
Current: current,
Next: next,
}
// Get closing time respecting exceptional hours
todayOpen := isDayOpen(r, now)
if todayOpen {
closeTime := getClosingTime(r, now)
if closeTime != "" {
response.ClosingTime = &closeTime
}
}
// Determine if we're done for the day: no current, no next
if current == nil && next == nil {
done := true
response.DoneForDay = &done
if todayOpen {
// Regular done-for-the-day: show daily summary
summary := computeAggregateSummary(r, todayStart, todayEnd)
summary.SummaryScope = "day"
summary.SummaryStartDate = todayStart.Format("2006-01-02")
summary.SummaryEndDate = todayEnd.Format("2006-01-02")
response.Summary = summary
// If tomorrow is closed, also compute a week summary
tomorrow := now.AddDate(0, 0, 1)
if !isDayOpen(r, tomorrow) {
weekStart, _ := findWeekSummaryRange(r, tomorrow)
ws := computeAggregateSummary(r, weekStart, todayEnd)
ws.SummaryScope = "week"
ws.SummaryStartDate = weekStart.Format("2006-01-02")
ws.SummaryEndDate = todayStart.Format("2006-01-02")
response.WeekSummary = ws
}
} else {
// Closed day: show week summary — from start of last work period to now
weekStart, _ := findWeekSummaryRange(r, now)
summary := computeAggregateSummary(r, weekStart, todayEnd)
summary.SummaryScope = "week"
summary.SummaryStartDate = weekStart.Format("2006-01-02")
summary.SummaryEndDate = todayStart.Format("2006-01-02")
response.Summary = summary
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode current/next response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
func computeAggregateSummary(r *http.Request, rangeStart, rangeEnd time.Time) *DailySummary {
summary := &DailySummary{}
// Single combined query replacing 9 separate round-trips
query := `
SELECT
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type != 'tip'), 0) AS total_payments,
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type = 'tip'), 0) AS total_tips,
COALESCE(SUM(sub.amount_due) FILTER (WHERE sub.amount_due > 0), 0) AS amount_due,
COALESCE(SUM(sub.duration_minutes), 0) AS total_duration,
COUNT(DISTINCT b.id) FILTER (WHERE b.status NOT IN ('client_cancelled','we_cancelled','no_show','deposit_lapsed')) AS total_bookings,
COUNT(DISTINCT b.user_id) FILTER (
WHERE b.status NOT IN ('client_cancelled','we_cancelled','no_show')
AND b.user_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM bookings b2 WHERE b2.user_id = b.user_id AND b2.start_time < $1)
) AS new_customers,
COUNT(DISTINCT b.user_id) FILTER (
WHERE b.status NOT IN ('client_cancelled','we_cancelled','no_show')
AND b.user_id IS NOT NULL
AND EXISTS (SELECT 1 FROM bookings b2 WHERE b2.user_id = b.user_id AND b2.start_time < $1)
) AS returning_customers,
(SELECT COUNT(*) FROM gift_cards WHERE created_at >= $1 AND created_at < $2 AND is_inventory = false) AS gift_cards_sold,
COALESCE((SELECT u.fn FROM bookings b2
JOIN users u ON u.id = b2.user_id
WHERE b2.start_time >= $1 AND b2.start_time < $2
AND b2.status = 'completed' AND b2.user_id IS NOT NULL
ORDER BY b2.start_time DESC LIMIT 1), '') AS last_customer_name,
COALESCE((WITH last_customer AS (
SELECT b2.user_id FROM bookings b2
WHERE b2.start_time >= $1 AND b2.start_time < $2
AND b2.status = 'completed' AND b2.user_id IS NOT NULL
ORDER BY b2.start_time DESC LIMIT 1
)
SELECT COUNT(b3.id)
FROM last_customer lc
LEFT JOIN bookings b3 ON b3.user_id = lc.user_id
AND b3.status NOT IN ('client_cancelled','we_cancelled')), 0) AS last_customer_visits,
COUNT(DISTINCT b.id) FILTER (WHERE b.status NOT IN ('client_cancelled','we_cancelled','no_show') AND b.user_id IS NULL) AS guest_customers
FROM bookings b
LEFT JOIN payments p ON p.booking_id = b.id AND p.status = 'completed'
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0)
+ COALESCE(SUM(COALESCE(bcs.override_price, cs.price)), 0)
- COALESCE((SELECT SUM(amount) FROM payments WHERE booking_id = b.id AND status = 'completed'), 0) AS amount_due,
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_inner
LEFT JOIN booking_services bs ON bs.booking_id = b_inner.id
LEFT JOIN services s ON s.id = bs.service_id
LEFT JOIN booking_custom_services bcs ON bcs.booking_id = b_inner.id
LEFT JOIN custom_services cs ON cs.id = bcs.custom_service_id
WHERE b_inner.id = b.id
GROUP BY b_inner.id
) sub ON true
WHERE b.start_time >= $1 AND b.start_time < $2
`
_ = db.Conn.QueryRow(r.Context(), query, 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
// 10. New bookings since last working day close
summary.NewBookingServices = findNewBookingServices(r, rangeStart)
return summary
}
func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBookingCount {
now := time.Now()
// Single query to find the last working day's closing time
var lastClose time.Time
var dayDate time.Time
var closeTimeStr string
err := db.Conn.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 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 = 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
) eh ON true
LEFT JOIN LATERAL (
SELECT wh.end_time 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 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(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())
}
// Query services across all new bookings, aggregated by service name
rows, err := db.Conn.Query(r.Context(), `
SELECT name, COUNT(*) as count
FROM (
SELECT s.name FROM booking_services bs2
JOIN services s ON s.id = bs2.service_id
JOIN bookings b ON b.id = bs2.booking_id
WHERE b.created_at > $1 AND b.created_at <= $2
AND b.status != 'pending'
UNION ALL
SELECT cs.name FROM booking_custom_services bcs2
JOIN custom_services cs ON cs.id = bcs2.custom_service_id
JOIN bookings b ON b.id = bcs2.booking_id
WHERE b.created_at > $1 AND b.created_at <= $2
AND b.status != 'pending'
) all_services
GROUP BY name
ORDER BY count DESC, name ASC
`, lastClose, now)
if err != nil {
log.Printf("Failed to fetch new booking services since close: %v", err)
return nil
}
defer rows.Close()
var items []ServiceBookingCount
for rows.Next() {
var item ServiceBookingCount
if err := rows.Scan(&item.ServiceName, &item.Count); err != nil {
log.Printf("Failed to scan new booking service row: %v", err)
continue
}
items = append(items, item)
}
return items
}
func isDayOpen(r *http.Request, date time.Time) bool {
weekday := int(date.Weekday())
if weekday == 0 {
weekday = 6
} else {
weekday -= 1
}
dateStr := date.Format("2006-01-02")
// Try exceptional hours first
var exceptionalOpen sql.NullBool
err := db.Conn.QueryRow(r.Context(), `
SELECT ewh.is_open
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::date
AND ega.week_start + INTERVAL '7 days' > $2::date
LIMIT 1
`, weekday, dateStr).Scan(&exceptionalOpen)
if err == nil {
return exceptionalOpen.Bool
}
// Fall back to default working hours
var isOpen bool
err = db.Conn.QueryRow(r.Context(), `
SELECT is_open FROM working_hours WHERE weekday = $1
`, weekday).Scan(&isOpen)
return err == nil && isOpen
}
// findWeekSummaryRange returns the start and end of the working period
// before the current run of consecutive closed days.
// For adjacent closed days (e.g. Sat+Sun), both days show stats for the
// same working period (e.g. Mon-Fri).
func findWeekSummaryRange(r *http.Request, today time.Time) (time.Time, time.Time) {
// Find the start of this run of consecutive closed days
closedRunStart := today
for i := 0; i <= 14; i++ {
d := today.AddDate(0, 0, -i)
if !isDayOpen(r, d) {
closedRunStart = d
} else {
break
}
}
// The working period ends the day before the closed run starts
workingEnd := closedRunStart.AddDate(0, 0, -1)
workingEndStart := time.Date(workingEnd.Year(), workingEnd.Month(), workingEnd.Day(), 0, 0, 0, 0, workingEnd.Location())
// Walk back from workingEnd to find where the previous closed run ended
workingStart := workingEndStart
for i := 0; i <= 14; i++ {
d := workingEnd.AddDate(0, 0, -i)
if !isDayOpen(r, d) {
workingStart = d.AddDate(0, 0, 1) // day after the closed day
workingStart = time.Date(workingStart.Year(), workingStart.Month(), workingStart.Day(), 0, 0, 0, 0, workingStart.Location())
break
}
}
// workingEndEnd is the end boundary (exclusive)
workingEndEnd := workingEndStart.Add(24 * time.Hour)
return workingStart, workingEndEnd
}
func getClosingTime(r *http.Request, date time.Time) string {
weekday := int(date.Weekday())
if weekday == 0 {
weekday = 6
} else {
weekday -= 1
}
dateStr := date.Format("2006-01-02")
// Check exceptional hours first
var exceptionalClose sql.NullString
err := db.Conn.QueryRow(r.Context(), `
SELECT ewh.end_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::date
AND ega.week_start + INTERVAL '7 days' > $2::date
AND ewh.is_open = true
LIMIT 1
`, weekday, dateStr).Scan(&exceptionalClose)
if err == nil && exceptionalClose.Valid {
return exceptionalClose.String
}
// Fall back to default working hours
var defaultClose sql.NullString
err = db.Conn.QueryRow(r.Context(), `
SELECT end_time FROM working_hours WHERE weekday = $1 AND is_open = true
`, weekday).Scan(&defaultClose)
if err == nil && defaultClose.Valid {
return defaultClose.String
}
return ""
}
// Helper function to fetch a single appointment with all details
func fetchAppointment(r *http.Request, query string, args ...interface{}) (*AppointmentInfo, error) {
var bookingID string
var startTime time.Time
var status string
var notes sql.NullString
var userID string
err := db.Conn.QueryRow(r.Context(), query, args...).Scan(
&bookingID, &startTime, &status, &notes, &userID,
)
if err != nil {
return nil, err
}
appointment := &AppointmentInfo{
ID: bookingID,
StartTime: startTime,
Status: status,
}
if notes.Valid {
appointment.Notes = &notes.String
}
// Fetch user info
var user UserInfo
var phone sql.NullString
var email sql.NullString
var profilePicURL sql.NullString
err = db.Conn.QueryRow(r.Context(), `
SELECT id, fn, phone, email, profile_pic_url
FROM users
WHERE id = $1
`, userID).Scan(&user.ID, &user.FullName, &phone, &email, &profilePicURL)
if err == nil {
if phone.Valid {
user.Phone = &phone.String
}
if profilePicURL.Valid {
user.ProfilePicURL = &profilePicURL.String
}
if email.Valid {
user.Email = &email.String
}
// Fetch unconsumed name history for former name display
var prevFirstName, prevLastName sql.NullString
err = db.Conn.QueryRow(r.Context(), `
SELECT nh.previous_first_name, nh.previous_last_name
FROM name_history nh
WHERE nh.user_id = $1 AND nh.booking_id IS NULL
ORDER BY nh.changed_at ASC
LIMIT 1
`, userID).Scan(&prevFirstName, &prevLastName)
if err == nil && prevFirstName.Valid && prevLastName.Valid {
user.PreviousFirstName = &prevFirstName.String
user.PreviousLastName = &prevLastName.String
}
appointment.User = &user
} else {
log.Printf("Failed to fetch user %s: %v", userID, err)
}
// Fetch services
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT
s.name,
s.description,
COALESCE(bs.override_price, s.price) as price,
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT
cs.name,
cs.description,
COALESCE(bcs.override_price, cs.price),
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
ORDER BY name
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
return appointment, nil
}
defer serviceRows.Close()
var totalAmount float64
var totalDuration int
for serviceRows.Next() {
var service ServiceInfo
var name string
var description sql.NullString
var price float64
var duration int
if err := serviceRows.Scan(&name, &description, &price, &duration); err != nil {
log.Printf("Failed to scan service: %v", err)
continue
}
service.ServiceName = &name
if description.Valid {
service.ServiceDescription = &description.String
}
service.Price = &price
service.DurationMinutes = &duration
totalAmount += price
totalDuration += duration
appointment.Services = append(appointment.Services, service)
}
appointment.TotalAmount = totalAmount
appointment.DurationMinutes = totalDuration
return appointment, nil
}
type TodayAppointment struct {
ID string `json:"id"`
StartTime string `json:"start_time"`
Status string `json:"status"`
UserName string `json:"user_name"`
UserID string `json:"user_id"`
Services []string `json:"services"`
DurationMinutes int `json:"duration_minutes"`
PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"`
}
type TodayAppointmentsResponse struct {
Appointments []TodayAppointment `json:"appointments"`
}
// GET /api/admin/today/appointments
func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
now := time.Now()
// Auto-transition confirmed bookings that have started but not ended to in_progress
_, err := db.Conn.Exec(r.Context(), `
UPDATE bookings
SET status = 'in_progress'
WHERE status = 'confirmed'
AND start_time <= $1
AND end_time > $1
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to in_progress: %v", err)
}
// Auto-transition in_progress bookings that have ended to completed
_, err = db.Conn.Exec(r.Context(), `
UPDATE bookings
SET status = 'completed'
WHERE status = 'in_progress'
AND end_time <= $1
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to completed: %v", err)
}
rangeStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayEnd := rangeStart.Add(24 * time.Hour)
// Fetch all bookings for today
rows, err := db.Conn.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
u.fn as user_name,
u.id as user_id
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.start_time >= $1
AND b.start_time < $2
ORDER BY b.start_time ASC
`, rangeStart, todayEnd)
if err != nil {
log.Printf("Failed to fetch today's appointments: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
type rawAppt struct {
ID string
StartTime time.Time
Status string
UserName string
UserID string
}
var raw []rawAppt
for rows.Next() {
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()
if len(raw) == 0 {
json.NewEncoder(w).Encode(TodayAppointmentsResponse{Appointments: []TodayAppointment{}})
return
}
// 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.Conn.Query(r.Context(), `
SELECT bs.booking_id, s.name, COALESCE(bs.override_duration_minutes, s.duration_minutes)
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1)
UNION ALL
SELECT bcs.booking_id, cs.name, COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = ANY($1)
ORDER BY name
`, batchIDs)
if err != nil {
log.Printf("Failed to batch-fetch services: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
svcMap := make(map[string][]string, len(raw))
durMap := make(map[string]int, len(raw))
for svcRows.Next() {
var bid, name string
var dur int
if err := svcRows.Scan(&bid, &name, &dur); err != nil {
log.Printf("Failed to scan service row: %v", err)
continue
}
svcMap[bid] = append(svcMap[bid], name)
durMap[bid] += dur
}
svcRows.Close()
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],
})
}
{
seenUserIDs := make(map[string]struct{})
var userIDs []string
for _, a := range appointments {
if a.UserID != "" {
if _, seen := seenUserIDs[a.UserID]; !seen {
seenUserIDs[a.UserID] = struct{}{}
userIDs = append(userIDs, a.UserID)
}
}
}
if len(userIDs) > 0 {
nhRows, err := db.Conn.Query(r.Context(), `
SELECT DISTINCT ON (user_id) user_id, previous_first_name, previous_last_name
FROM name_history
WHERE user_id = ANY($1) AND booking_id IS NULL
ORDER BY user_id, changed_at ASC
`, userIDs)
if err == nil {
prevByUser := make(map[string][2]string)
for nhRows.Next() {
var uid, pfn, pln string
if err := nhRows.Scan(&uid, &pfn, &pln); err == nil {
prevByUser[uid] = [2]string{pfn, pln}
}
}
nhRows.Close()
for i := range appointments {
if prev, ok := prevByUser[appointments[i].UserID]; ok {
appointments[i].PreviousFirstName = &prev[0]
appointments[i].PreviousLastName = &prev[1]
}
}
}
}
}
response := TodayAppointmentsResponse{
Appointments: appointments,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode today's appointments response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
type PendingApproval struct {
ID string `json:"id"`
StartTime string `json:"start_time"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"`
Services []string `json:"services"`
DurationMinutes int `json:"duration_minutes"`
CreatedAt string `json:"created_at"`
}
type PendingApprovalsResponse struct {
Approvals []PendingApproval `json:"approvals"`
}
// GET /api/admin/today/pending-approvals
func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
rows, err := db.Conn.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.created_at,
u.id as user_id,
u.fn as user_name
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.status = 'pending'
ORDER BY b.created_at ASC
`)
if err != nil {
log.Printf("Failed to fetch pending approvals: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
type rawApproval struct {
id, userID, userName string
startTime, createdAt time.Time
}
var raw []rawApproval
var bookingIDs []string
var userIDs []string
seenUsers := make(map[string]struct{})
for rows.Next() {
var a rawApproval
if err := rows.Scan(&a.id, &a.startTime, &a.createdAt, &a.userID, &a.userName); err != nil {
log.Printf("Failed to scan pending approval row: %v", err)
continue
}
raw = append(raw, a)
bookingIDs = append(bookingIDs, a.id)
if _, seen := seenUsers[a.userID]; !seen && a.userID != "" {
seenUsers[a.userID] = struct{}{}
userIDs = append(userIDs, a.userID)
}
}
// Batch-fetch name_history for all users.
prevByUser := make(map[string][2]string)
if len(userIDs) > 0 {
nhRows, err := db.Conn.Query(r.Context(), `
SELECT DISTINCT ON (user_id) user_id, previous_first_name, previous_last_name
FROM name_history
WHERE user_id = ANY($1) AND booking_id IS NULL
ORDER BY user_id, changed_at ASC
`, userIDs)
if err == nil {
for nhRows.Next() {
var uid, pfn, pln string
if err := nhRows.Scan(&uid, &pfn, &pln); err == nil {
prevByUser[uid] = [2]string{pfn, pln}
}
}
nhRows.Close()
}
}
// Batch-fetch services for all bookings.
svcMap := make(map[string][]string)
durMap := make(map[string]int)
if len(bookingIDs) > 0 {
svcRows, err := db.Conn.Query(r.Context(), `
SELECT booking_id, name, duration_minutes FROM (
SELECT bs.booking_id, s.name, COALESCE(bs.override_duration_minutes, s.duration_minutes) AS duration_minutes
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1)
UNION ALL
SELECT bcs.booking_id, cs.name, COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = ANY($1)
) sub ORDER BY name
`, bookingIDs)
if err == nil {
for svcRows.Next() {
var bid, name string
var dur int
if err := svcRows.Scan(&bid, &name, &dur); err == nil {
svcMap[bid] = append(svcMap[bid], name)
durMap[bid] += dur
}
}
svcRows.Close()
}
}
approvals := make([]PendingApproval, 0, len(raw))
for _, a := range raw {
prevFirstName, prevLastName := "", ""
if p, ok := prevByUser[a.userID]; ok {
prevFirstName = p[0]
prevLastName = p[1]
}
apt := PendingApproval{
ID: a.id,
StartTime: a.startTime.Format(time.RFC3339),
CreatedAt: a.createdAt.Format(time.RFC3339),
UserID: a.userID,
UserName: a.userName,
Services: svcMap[a.id],
DurationMinutes: durMap[a.id],
}
if prevFirstName != "" {
apt.PreviousFirstName = &prevFirstName
}
if prevLastName != "" {
apt.PreviousLastName = &prevLastName
}
approvals = append(approvals, apt)
}
if approvals == nil {
approvals = []PendingApproval{}
}
response := PendingApprovalsResponse{
Approvals: approvals,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode pending approvals response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}