Files
Crussell/backend/handlers/today/today.go
T
popertotsandSisyphus 8efb8d93bf feat(backend): add name history tracking, booking pagination, and CardDAV improvements
Name history system tracks user name changes and displays former names on bookings, appointments, and admin views until consumed by the first completed booking post-change.

- Add name_history queries to today.go, bookings.go, manage.go, profile.go
- Show previous first/last name on today appointments, pending approvals,
  booking details, edit requests, and admin user views
- Paginate bookings by start_time instead of created_at (more intuitive ordering)
- Add name change detection in UpdateProfileHandler with history insert
- Support DAV_BASE_URL env var for configurable CardDAV endpoint
- Add referral_savings to profile response
- Add ParseCursor3 validator for user list cursor pagination
- Consume name_history entries when a booking is completed (ProgressBookingHandler)

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-20 16:58:29 +01:00

1112 lines
34 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.DB.Exec(r.Context(), `
UPDATE bookings
SET status = 'in_progress'
WHERE status = 'confirmed'
AND start_time <= $1
AND (
start_time + (
COALESCE(
(SELECT SUM(dur) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub),
0
) || ' minutes'
)::interval
) > $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.DB.Exec(r.Context(), `
UPDATE bookings
SET status = 'completed'
WHERE status = 'in_progress'
AND (
start_time + (
COALESCE(
(SELECT SUM(dur) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub),
0
) || ' minutes'
)::interval
) <= $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
_ = db.DB.QueryRow(r.Context(), `
SELECT
COALESCE((SELECT SUM(p.amount)
FROM bookings b
JOIN payments p ON p.booking_id = b.id
WHERE b.start_time >= $1 AND b.start_time < $2
AND p.status = 'completed'
AND p.payment_type != 'tip'), 0),
COALESCE((SELECT SUM(p.amount)
FROM bookings b
JOIN payments p ON p.booking_id = b.id
WHERE b.start_time >= $1 AND b.start_time < $2
AND p.status = 'completed'
AND p.payment_type = 'tip'), 0),
COALESCE((SELECT COALESCE(SUM(sub.amount_due), 0)
FROM (
SELECT b.id,
COALESCE(s.service_total, 0) + COALESCE(cs.service_total, 0) - COALESCE(p.payment_total, 0) AS amount_due
FROM bookings b
LEFT JOIN (
SELECT booking_id, SUM(amount) AS payment_total
FROM payments WHERE status = 'completed'
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', 'deposit_lapsed')
) sub
WHERE sub.amount_due > 0), 0),
COALESCE((SELECT SUM(duration_minutes)
FROM (
SELECT b.id,
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 0)
+ COALESCE(SUM(COALESCE(bcs.override_duration_minutes, cs.duration_minutes)), 0) AS duration_minutes
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), 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)
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.DB.QueryRow(r.Context(), `
WITH days AS (
SELECT
(NOW() - make_interval(days => i))::date AS day_date,
CASE WHEN EXTRACT(DOW FROM NOW() - make_interval(days => i)) = 0 THEN 6
ELSE EXTRACT(DOW FROM NOW() - make_interval(days => i))::integer - 1
END AS weekday
FROM generate_series(1, 14) AS i
)
SELECT
d.day_date,
COALESCE(eh.close_time, wh.close_time) AS close_time
FROM days d
LEFT JOIN LATERAL (
SELECT (ewh.end_time)::text AS close_time
FROM exceptional_working_hours ewh
JOIN exceptional_working_hours_groups ewhg ON ewhg.id = ewh.group_id
JOIN exceptional_group_applications ega ON ega.group_id = ewhg.id
WHERE ewh.weekday = 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::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 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.DB.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
}
dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
var exceptionalOpen sql.NullBool
err := db.DB.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
AND ega.week_start + INTERVAL '7 days' > $2
LIMIT 1
`, weekday, dateStart).Scan(&exceptionalOpen)
if err == nil {
return exceptionalOpen.Bool
}
var isOpen bool
err = db.DB.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
}
dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
// Check exceptional hours first
var exceptionalClose sql.NullString
err := db.DB.QueryRow(r.Context(), `
SELECT ewh.end_time::text
FROM exceptional_working_hours ewh
JOIN exceptional_working_hours_groups ewhg ON ewhg.id = ewh.group_id
JOIN exceptional_group_applications ega ON ega.group_id = ewhg.id
WHERE ewh.weekday = $1
AND ega.week_start <= $2
AND ega.week_start + INTERVAL '7 days' > $2
AND ewh.is_open = true
LIMIT 1
`, weekday, dateStart).Scan(&exceptionalClose)
if err == nil && exceptionalClose.Valid {
return exceptionalClose.String
}
// Fall back to default working hours
var defaultClose sql.NullString
err = db.DB.QueryRow(r.Context(), `
SELECT end_time::text 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.DB.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.DB.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.DB.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.DB.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.DB.Exec(r.Context(), `
UPDATE bookings
SET status = 'in_progress'
WHERE status = 'confirmed'
AND start_time <= $1
AND (
start_time + (
COALESCE(
(SELECT SUM(dur) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub),
0
) || ' minutes'
)::interval
) > $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.DB.Exec(r.Context(), `
UPDATE bookings
SET status = 'completed'
WHERE status = 'in_progress'
AND (
start_time + (
COALESCE(
(SELECT SUM(dur) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub),
0
) || ' minutes'
)::interval
) <= $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.DB.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.DB.Query(r.Context(), `
SELECT bs.booking_id, s.name, COALESCE(bs.override_duration_minutes, s.duration_minutes)
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = 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.DB.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.DB.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.DB.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.DB.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
}
}