CI / Go vulnerabilities (push) Successful in 1m10s
CI / Build & Vet (push) Successful in 1m39s
CI / Frontend build (gate) (push) Successful in 1m42s
CI / Frontend QC (audit) (push) Successful in 56s
CI / Frontend QC (typecheck) (push) Successful in 1m36s
CI / Frontend QC (lint) (push) Successful in 1m51s
CI / Tests (prod) (push) Has been cancelled
CI / Tests (dev) (push) Has been cancelled
CI / Race (prod) (push) Has been cancelled
CI / Race (dev) (push) Has been cancelled
106 files: interface{}→any, strings.Split→SplitSeq, CutPrefix/Cut, strings.Builder, slices.Contains, remove redundant // +build directives, gofmt import ordering and indentation.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1065 lines
33 KiB
Go
1065 lines
33 KiB
Go
package today
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"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 ...any) (*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 := 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
|
|
}
|
|
}
|