refactor(bookings): migrate remaining handlers and tests to clock.Now()

Replace time.Now() with clock.Now() in bookings handlers and all test files. Includes deposit, discount, dedup, overlap, and edit request test updates.

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:32 +01:00
co-authored by Sisyphus
parent 0ea1bb64b4
commit 40bbd9ba49
10 changed files with 2057 additions and 494 deletions
+181 -82
View File
@@ -3,6 +3,7 @@ package bookings
import (
"context"
"crussell/db"
"crussell/clock"
"crussell/handlers/notifications"
"crussell/handlers/payments"
"crussell/handlers/scheduling"
@@ -16,6 +17,7 @@ import (
"log"
"math"
"net/http"
"sort"
"strconv"
"strings"
@@ -28,7 +30,7 @@ import (
var londonLocation = func() *time.Location {
loc, err := time.LoadLocation("Europe/London")
if err != nil {
panic("Europe/London timezone not available")
panic("failed to load Europe/London timezone: " + err.Error())
}
return loc
}()
@@ -395,12 +397,13 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
}
if req.StartDate != nil {
whereClause += fmt.Sprintf(" AND b.start_time >= $%d", paramCount)
startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation)
startTime, err := time.Parse("2006-01-02", *req.StartDate)
if err != nil {
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
}
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation)
londonDate := startTime.In(londonLocation)
startTime = time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC()
whereArgs = append(whereArgs, startTime)
paramCount++
}
@@ -411,7 +414,8 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
}
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
londonEnd := endTime.In(londonLocation)
endTime = time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC()
whereArgs = append(whereArgs, endTime)
paramCount++
}
@@ -702,7 +706,8 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
}
args = append(args, startTime)
londonDate := startTime.In(londonLocation)
args = append(args, time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC())
paramCount++
}
if req.EndDate != nil {
@@ -712,7 +717,8 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
}
args = append(args, endTime.Add(23*time.Hour+59*time.Minute+59*time.Second))
londonEnd := endTime.In(londonLocation)
args = append(args, time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC())
paramCount++
}
@@ -760,13 +766,15 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
}
if req.StartDate != nil {
addCountWhere(fmt.Sprintf("b.start_time >= $%d", cp))
st, _ := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation)
countArgs = append(countArgs, time.Date(st.Year(), st.Month(), st.Day(), 0, 0, 0, 0, londonLocation))
st, _ := time.Parse("2006-01-02", *req.StartDate)
londonDate := st.In(londonLocation)
countArgs = append(countArgs, time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC())
}
if req.EndDate != nil {
addCountWhere(fmt.Sprintf("b.start_time <= $%d", cp))
et, _ := time.Parse("2006-01-02", *req.EndDate)
countArgs = append(countArgs, et.Add(23*time.Hour+59*time.Minute+59*time.Second))
londonEnd := et.In(londonLocation)
countArgs = append(countArgs, time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC())
}
if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+countWhere, countArgs...).Scan(&total); err != nil {
log.Printf("Failed to count admin bookings: %v", err)
@@ -1433,15 +1441,29 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
newEndTime := startTime.Add(time.Duration(newTotalDuration) * time.Minute)
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Evict any pending_release bookings that overlap this slot.
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, startTime, newEndTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings for slot %s: %v", startTime, evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var overlapCount int
err := db.Conn.QueryRow(r.Context(), `
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE id != $1
AND status IN ('confirmed', 'pending', 'in_progress', 'completed')
AND start_time < $3
AND end_time > $2
`, bookingID, startTime, newEndTime).Scan(&overlapCount)
if err != nil {
`, bookingID, startTime, newEndTime).Scan(&overlapCount); err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -1451,14 +1473,6 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
if _, err := tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID); err != nil {
log.Printf("Failed to delete booking services for %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -2052,12 +2066,12 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
}
// Check 1h minimum advance for all users
if req.StartTime.Before(time.Now().Add(1 * time.Hour)) {
if req.StartTime.Before(clock.Now().Add(1 * time.Hour)) {
http.Error(w, "Bookings must be at least 1 hour in advance", http.StatusBadRequest)
return
}
if !isGuest && depositsRequired > 0 && req.StartTime.Before(time.Now().Add(payments.DepositAdvanceWindow)) {
if !isGuest && depositsRequired > 0 && req.StartTime.Before(clock.Now().Add(payments.DepositAdvanceWindow)) {
http.Error(w, fmt.Sprintf("When deposits are required, bookings must be made at least %.0f hours in advance to allow time for payment.", payments.DepositAdvanceWindow.Hours()), http.StatusBadRequest)
return
}
@@ -2154,7 +2168,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
if req.StartTime.Before(time.Now()) {
if req.StartTime.Before(clock.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return
}
@@ -2179,13 +2193,30 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
}
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
closeTime, _ := time.Parse("15:04:05", closeStr)
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
if err := checkClosingHours(localEndLondon, closeStr); err != nil {
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
return
}
// Clean up any existing reservation for this user BEFORE the transaction
// and time-blocker check. The reservation was created by the reserve step
// (POST /api/bookings/reserve) and is stored in the time_blockers table.
// If not deleted here, CheckTimeBlockerOverlap below would detect this
// reservation as a conflict and reject the booking — a self-blocking race.
// Using db.Conn.Exec (not tx.Exec) so the delete is visible to the
// separate connection used by CheckTimeBlockerOverlap.
if _, err := db.Conn.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description LIKE 'RESERVATION:%'
AND (created_by = $1
OR (description LIKE 'RESERVATION:anon:%' AND start_time = $2))
`, userID, req.StartTime); err != nil {
log.Printf("Failed to delete reservation time blocker for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
@@ -2284,10 +2315,9 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
if req.Notes != nil && *req.Notes != "" {
needsApproval = true
} else {
london, _ := time.LoadLocation("Europe/London")
now := time.Now().In(london)
bookingDay := req.StartTime.In(london)
if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.YearDay() {
londonNow := clock.Now().In(londonLocation)
londonBookingDay := req.StartTime.In(londonLocation)
if londonNow.Year() == londonBookingDay.Year() && londonNow.YearDay() == londonBookingDay.YearDay() {
needsApproval = true
}
}
@@ -2371,7 +2401,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if req.StartTime.Before(time.Now()) {
if req.StartTime.Before(clock.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return
}
@@ -2392,16 +2422,6 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
var durationMinutes int
if err := db.Conn.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&durationMinutes); err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60
}
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
@@ -2410,6 +2430,16 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback(r.Context())
var durationMinutes int
if err := tx.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&durationMinutes); err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60
}
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
// Evict any pending_release bookings that overlap this slot.
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings on edit: %v", evictErr)
@@ -2442,15 +2472,22 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
localStart := req.StartTime.In(londonLocation)
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
weekday := int((req.StartTime.Weekday() + 6) % 7)
bookingTime := req.StartTime.Format("15:04:05")
daysToMonday := int(req.StartTime.Weekday())
weekday := int((localStart.Weekday() + 6) % 7)
bookingTime := localStart.Format("15:04:05")
daysToMonday := int(localStart.Weekday())
if daysToMonday == 0 {
daysToMonday = 7
}
tm := req.StartTime.AddDate(0, 0, -daysToMonday+1)
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, tm.Location())
tm := localStart.AddDate(0, 0, -daysToMonday+1)
// Use UTC midnight so the time.Time has Location=UTC at the London calendar date.
// tm has Location=London (from .In(londonLocation) above), so tm.Year/Month/Day()
// return London calendar values. Creating a UTC midnight of those values produces
// a Location=UTC time at the correct London calendar Monday. pgx's DATE codec
// extracts the calendar date from the time's own location — so this maps correctly
// to ega.week_start (DATE column), regardless of BST/GMT.
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
var isClosed bool
if err := tx.QueryRow(r.Context(), `
@@ -2547,6 +2584,36 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback(r.Context())
// Read current status before updating to validate the transition
var currentStatus string
if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 FOR UPDATE", bookingID).Scan(&currentStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch current status for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
validTransitions := map[string]map[string]bool{
"pending": {"confirmed": true, "completed": true, "client_cancelled": true, "we_cancelled": true},
"confirmed": {"in_progress": true, "completed": true, "client_cancelled": true, "we_cancelled": true},
"in_progress": {"completed": true},
"pending_release": {"pending": true, "confirmed": true, "client_cancelled": true, "we_cancelled": true},
"no_show": {},
"deposit_lapsed": {},
}
if targets, ok := validTransitions[currentStatus]; ok {
if !targets[req.Status] {
http.Error(w, fmt.Sprintf("Cannot transition booking from '%s' to '%s'", currentStatus, req.Status), http.StatusBadRequest)
return
}
} else if currentStatus != req.Status {
http.Error(w, fmt.Sprintf("Cannot transition booking from '%s' to '%s'", currentStatus, req.Status), http.StatusBadRequest)
return
}
var booking Booking
booking.User = &UserSummary{}
if err := tx.QueryRow(r.Context(), `
@@ -2568,6 +2635,9 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
}
if req.Status == "completed" {
if currentStatus == "completed" {
log.Printf("Booking %s is already completed — skipping duplicate completion", bookingID)
} else {
// Collect patch test IDs first so the rows are consumed before INSERT operations.
var patchTestIDs []string
ptRows, err := tx.Query(r.Context(), `
@@ -2721,7 +2791,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
_ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
var hasInPersonPayment bool
db.Conn.QueryRow(r.Context(), `
tx.QueryRow(r.Context(), `
SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment)
if hasInPersonPayment {
@@ -2783,6 +2853,10 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
}
annRows.Close()
// Sort by milestone_value descending so we apply the longest anniversary only
sort.Slice(campaigns, func(i, j int) bool {
return campaigns[i].value > campaigns[j].value
})
for _, c := range campaigns {
var matches bool
elapsed := time.Since(firstVisitDate)
@@ -2813,7 +2887,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
`, c.id); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
break
break // apply longest matching only
}
}
}
@@ -2850,6 +2924,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
`, bookingID, booking.User.ID); err != nil {
log.Printf("Failed to consume name_history for user %s: %v", booking.User.ID, err)
}
} // close the else from alreadyCompleted check
}
if err := tx.Commit(r.Context()); err != nil {
@@ -2907,22 +2982,6 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
var bkStart time.Time
var dur int
if err := db.Conn.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil {
log.Printf("Failed to get start time: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := db.Conn.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&dur); err != nil {
log.Printf("Failed to calculate duration on confirm: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
endTime := bkStart.Add(time.Duration(dur) * time.Minute)
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
@@ -2931,6 +2990,22 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback(r.Context())
var bkStart time.Time
var dur int
if err := tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil {
log.Printf("Failed to get start time: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&dur); err != nil {
log.Printf("Failed to calculate duration on confirm: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
endTime := bkStart.Add(time.Duration(dur) * time.Minute)
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, bkStart, endTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings on confirm: %v", evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -3127,7 +3202,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
var calcErr error
refundResult, calcErr = payments.ProcessCancellationRefund(
r.Context(), bookingID, payInfo.TotalAmount, payInfo.TotalPaid,
startTime, time.Now(), "client_cancelled", nil,
startTime, clock.Now(), "client_cancelled", nil,
)
if calcErr != nil {
log.Printf("Refund processing failed for booking %s — cancellation aborted: %v", bookingID, calcErr)
@@ -3168,15 +3243,22 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
}
if originalStatus == "confirmed" {
noticeHours := startTime.Sub(time.Now()).Hours()
// Only apply no-show logic for future bookings — past bookings
// that happen to still be "confirmed" should not retroactively
// receive a no-show penalty when cancelled after the fact.
noticeHours := startTime.Sub(clock.Now()).Hours()
if noticeHours < 24 {
if startTime.After(clock.Now()) && noticeHours < 24 {
isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow
if !isForgiving {
tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID)
if _, err := tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID); err != nil {
log.Printf("Failed to update no-show status for booking %s: %v", bookingID, err)
}
} else {
tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID)
if _, err := tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID); err != nil {
log.Printf("Failed to update client_cancelled status for booking %s: %v", bookingID, err)
}
}
}
@@ -3195,8 +3277,10 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
}
// After a no-show is recorded, check if user now has 2+ no-shows in 6 months.
if originalStatus == "confirmed" && startTime.Sub(time.Now()).Hours() < 24 && !(req.ForgiveNoShow != nil && *req.ForgiveNoShow) {
if applied, err := ApplyDepositsIfNeeded(r.Context(), userID); err != nil {
// Only apply for future bookings — past confirmed bookings should not
// trigger deposit requirements when cancelled after the fact.
if originalStatus == "confirmed" && startTime.After(clock.Now()) && startTime.Sub(clock.Now()).Hours() < 24 && !(req.ForgiveNoShow != nil && *req.ForgiveNoShow) {
if applied, err := ApplyDepositsIfNeeded(r.Context(), db.Conn, userID); err != nil {
log.Printf("Failed to check deposits after no-show for user %s: %v", userID, err)
} else if applied {
log.Printf("Deposits required set to 3 for user %s due to 2+ no-shows in 6 months", userID)
@@ -3521,6 +3605,9 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
services = append(services, name)
totalPrice += price
}
if err := rows.Err(); err != nil {
log.Printf("Row iteration error in GetBookingICalHandler: %v", err)
}
serviceList := strings.Join(services, ", ")
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
@@ -3542,10 +3629,10 @@ func sanitizeICS(s string) string {
}
func generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string {
uid := fmt.Sprintf("booking-%d@crussell.com", time.Now().UnixNano())
dtstamp := time.Now().UTC().Format("20060102T150405Z")
dtstart := start.Format("20060102T150405")
dtend := end.Format("20060102T150405")
uid := fmt.Sprintf("booking-%d@crussell.com", clock.Now().UnixNano())
dtstamp := clock.Now().UTC().Format("20060102T150405Z")
dtstart := start.UTC().Format("20060102T150405Z")
dtend := end.UTC().Format("20060102T150405Z")
sanitizedServiceList := sanitizeICS(serviceList)
sanitizedStatus := sanitizeICS(status)
@@ -3812,7 +3899,12 @@ func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
return
}
endOfDay := endTime.Add(24 * time.Hour)
// Convert date-only params to London-aligned boundaries so bookings
// at BST midnight (23:xx UTC = 00:xx BST next day) are correctly included.
startLondon := startTime.In(londonLocation)
startTime = time.Date(startLondon.Year(), startLondon.Month(), startLondon.Day(), 0, 0, 0, 0, londonLocation).UTC()
endLondon := endTime.In(londonLocation)
endOfDay := time.Date(endLondon.Year(), endLondon.Month(), endLondon.Day(), 23, 59, 59, 999999999, londonLocation).UTC()
rows, err := db.Conn.Query(r.Context(), `
SELECT
@@ -4015,7 +4107,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if req.StartTime.Before(time.Now()) {
if req.StartTime.Before(clock.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return
}
@@ -4087,14 +4179,21 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
weekday := int((req.StartTime.Weekday() + 6) % 7)
bookingTime := req.StartTime.Format("15:04:05")
daysToMonday := int(req.StartTime.Weekday())
localStart := req.StartTime.In(londonLocation)
weekday := int((localStart.Weekday() + 6) % 7)
bookingTime := localStart.Format("15:04:05")
daysToMonday := int(localStart.Weekday())
if daysToMonday == 0 {
daysToMonday = 7
}
tm := req.StartTime.AddDate(0, 0, -daysToMonday+1)
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, tm.Location())
tm := localStart.AddDate(0, 0, -daysToMonday+1)
// Use UTC midnight so the time.Time has Location=UTC at the London calendar date.
// tm has Location=London (from .In(londonLocation) above), so tm.Year/Month/Day()
// return London calendar values. Creating a UTC midnight of those values produces
// a Location=UTC time at the correct London calendar Monday. pgx's DATE codec
// extracts the calendar date from the time's own location — so this maps correctly
// to ega.week_start (DATE column), regardless of BST/GMT.
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
var isClosed bool
if err := db.Conn.QueryRow(r.Context(), `