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:
@@ -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(¤tStatus); 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(), `
|
||||
|
||||
Reference in New Issue
Block a user