Files
Crussell/backend/handlers/today/today.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

1065 lines
33 KiB
Go

package today
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"crussell/db"
"crussell/clock"
"github.com/jackc/pgx/v5"
)
var londonLocation = func() *time.Location {
loc, err := time.LoadLocation("Europe/London")
if err != nil {
panic("failed to load Europe/London timezone: " + err.Error())
}
return loc
}()
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"`
TotalVATCollected float64 `json:"total_vat_collected,omitempty"`
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 := clock.Now()
// Align the daily summary boundary with the UK business day (midnight local time)
// instead of UTC midnight. This prevents a 1-hour shift during BST.
londonNow := now.In(londonLocation)
todayStart := time.Date(londonNow.Year(), londonNow.Month(), londonNow.Day(), 0, 0, 0, 0, londonLocation).UTC()
todayEnd := todayStart.Add(24 * time.Hour)
// Auto-transition confirmed bookings that have started but not ended to in_progress
// Auto-transition in_progress bookings that have ended to completed
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
return
}
defer tx.Rollback(r.Context())
_, err = tx.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)
return
}
_, err = tx.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)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
return
}
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, londonNow)
if todayOpen {
closeTime := getClosingTime(r, londonNow)
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 := londonNow.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, londonNow)
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(p.vat_amount) FILTER (WHERE p.status = 'completed' AND p.payment_type != 'tip'), 0) AS total_vat,
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.TotalVATCollected,
&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 := clock.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
(($1::timestamptz AT TIME ZONE 'Europe/London')::date - i) AS day_date,
CASE WHEN EXTRACT(DOW FROM ($1::timestamptz AT TIME ZONE 'Europe/London')::date - i) = 0 THEN 6
ELSE EXTRACT(DOW FROM ($1::timestamptz AT TIME ZONE 'Europe/London')::date - 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
`, now).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, londonLocation).UTC()
} else {
// Fallback: 5pm yesterday
londonNow := now.In(londonLocation)
lastClose = time.Date(londonNow.Year(), londonNow.Month(), londonNow.Day()-1, 17, 0, 0, 0, londonLocation).UTC()
}
// 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)
}
if err := rows.Err(); err != nil {
log.Printf("Row iteration error in getNewBookingServiceCounts: %v", err)
}
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, londonLocation)
// 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, londonLocation)
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 := clock.Now()
// Auto-transition confirmed bookings that have started but not ended to in_progress
// Auto-transition in_progress bookings that have ended to completed
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
return
}
defer tx.Rollback(r.Context())
_, err = tx.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)
return
}
_, err = tx.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)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
return
}
londonNow := now.In(londonLocation)
rangeStart := time.Date(londonNow.Year(), londonNow.Month(), londonNow.Day(), 0, 0, 0, 0, londonLocation).UTC()
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)
}
if err := rows.Err(); err != nil {
log.Printf("Row iteration error in GetTodayAppointments: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
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
}
if err := svcRows.Err(); err != nil {
log.Printf("Row iteration error in GetTodayAppointments service fetch: %v", err)
}
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}
}
}
if err := nhRows.Err(); err != nil {
log.Printf("Row iteration error in GetTodayAppointments name_history: %v", err)
}
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)
}
}
if err := rows.Err(); err != nil {
log.Printf("Row iteration error in GetPendingApprovals: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// 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}
}
}
if err := nhRows.Err(); err != nil {
log.Printf("Row iteration error in GetPendingApprovals name_history: %v", err)
}
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
}
}