refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns

Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-24 23:43:50 +01:00
co-authored by Sisyphus
parent 7b24f8e484
commit e4b9003439
36 changed files with 1923 additions and 590 deletions
+87 -23
View File
@@ -11,9 +11,18 @@ import (
"time"
"crussell/db"
"crussell/clock"
"github.com/jackc/pgx/v5"
)
var londonLocation = func() *time.Location {
loc, err := time.LoadLocation("Europe/London")
if err != nil {
panic("failed to load Europe/London timezone: " + err.Error())
}
return loc
}()
type ServiceInfo struct {
ServiceName *string `json:"service_name,omitempty"`
ServiceDescription *string `json:"service_description,omitempty"`
@@ -78,12 +87,23 @@ type CurrentNextResponse struct {
// 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())
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
_, err := db.Conn.Exec(r.Context(), `
// 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'
@@ -92,10 +112,10 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to in_progress: %v", err)
return
}
// Auto-transition in_progress bookings that have ended to completed
_, err = db.Conn.Exec(r.Context(), `
_, err = tx.Exec(r.Context(), `
UPDATE bookings
SET status = 'completed'
WHERE status = 'in_progress'
@@ -103,6 +123,12 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
`, 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
@@ -182,9 +208,9 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
}
// Get closing time respecting exceptional hours
todayOpen := isDayOpen(r, now)
todayOpen := isDayOpen(r, londonNow)
if todayOpen {
closeTime := getClosingTime(r, now)
closeTime := getClosingTime(r, londonNow)
if closeTime != "" {
response.ClosingTime = &closeTime
}
@@ -204,7 +230,7 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
response.Summary = summary
// If tomorrow is closed, also compute a week summary
tomorrow := now.AddDate(0, 0, 1)
tomorrow := londonNow.AddDate(0, 0, 1)
if !isDayOpen(r, tomorrow) {
weekStart, _ := findWeekSummaryRange(r, tomorrow)
ws := computeAggregateSummary(r, weekStart, todayEnd)
@@ -215,7 +241,7 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
}
} else {
// Closed day: show week summary — from start of last work period to now
weekStart, _ := findWeekSummaryRange(r, now)
weekStart, _ := findWeekSummaryRange(r, londonNow)
summary := computeAggregateSummary(r, weekStart, todayEnd)
summary.SummaryScope = "week"
summary.SummaryStartDate = weekStart.Format("2006-01-02")
@@ -316,7 +342,7 @@ func computeAggregateSummary(r *http.Request, rangeStart, rangeEnd time.Time) *D
}
func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBookingCount {
now := time.Now()
now := clock.Now()
// Single query to find the last working day's closing time
var lastClose time.Time
@@ -325,9 +351,9 @@ func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBook
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
(($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
)
@@ -355,7 +381,7 @@ func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBook
WHERE COALESCE(eh.close_time, wh.close_time) IS NOT NULL
ORDER BY d.day_date DESC
LIMIT 1
`).Scan(&dayDate, &closeTimeStr)
`, now).Scan(&dayDate, &closeTimeStr)
if err == nil {
parts := strings.Split(closeTimeStr, ":")
@@ -364,10 +390,11 @@ func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBook
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())
lastClose = time.Date(dayDate.Year(), dayDate.Month(), dayDate.Day(), h, m, 0, 0, londonLocation).UTC()
} else {
// Fallback: 5pm yesterday
lastClose = time.Date(now.Year(), now.Month(), now.Day()-1, 17, 0, 0, 0, now.Location())
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
@@ -405,6 +432,9 @@ func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBook
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
log.Printf("Row iteration error in getNewBookingServiceCounts: %v", err)
}
return items
}
@@ -462,7 +492,7 @@ func findWeekSummaryRange(r *http.Request, today time.Time) (time.Time, time.Tim
// 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())
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
@@ -470,7 +500,7 @@ func findWeekSummaryRange(r *http.Request, today time.Time) (time.Time, time.Tim
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())
workingStart = time.Date(workingStart.Year(), workingStart.Month(), workingStart.Day(), 0, 0, 0, 0, londonLocation)
break
}
}
@@ -668,10 +698,18 @@ type TodayAppointmentsResponse struct {
// GET /api/admin/today/appointments
func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
now := time.Now()
now := clock.Now()
// Auto-transition confirmed bookings that have started but not ended to in_progress
_, err := db.Conn.Exec(r.Context(), `
// 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'
@@ -680,10 +718,10 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to in_progress: %v", err)
return
}
// Auto-transition in_progress bookings that have ended to completed
_, err = db.Conn.Exec(r.Context(), `
_, err = tx.Exec(r.Context(), `
UPDATE bookings
SET status = 'completed'
WHERE status = 'in_progress'
@@ -691,9 +729,16 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
`, now)
if err != nil {
log.Printf("Failed to auto-transition bookings to completed: %v", err)
return
}
rangeStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
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
@@ -735,6 +780,11 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
}
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 {
@@ -781,6 +831,9 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
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))
@@ -822,6 +875,9 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
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 {
@@ -906,6 +962,11 @@ func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
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)
@@ -923,6 +984,9 @@ func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
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()
}
}