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 ( 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.Conn.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.Conn.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.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)::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.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 } dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) 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 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.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 } 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.Conn.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.Conn.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.Conn.QueryRow(r.Context(), query, args...).Scan( &bookingID, &startTime, &status, ¬es, &userID, ) if err != nil { return nil, err } appointment := &AppointmentInfo{ ID: bookingID, StartTime: startTime, Status: status, } if notes.Valid { appointment.Notes = ¬es.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 ( 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.Conn.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.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 } }