Files
Crussell/backend/handlers/bookings/manage.go
T
popertotsandSisyphus c1a9f35ea5 feat: prevent reservation self-block in booking handlers
Pass excludeUserID to CheckTimeBlockerOverlap in AdminReserveSlotHandler, CreateBookingHandler, EditBookingHandler, AdminRescheduleBookingHandler, AdminCreateBookingForUserHandler, AdminApproveEditRequestHandler, and ReserveSlotHandler. Also clean up stale reservations before overlap check in AdminReserveSlotHandler and ReserveSlotHandler via db.Conn.Exec for cross-connection visibility. Improve error handling in RequestEditHandler's service update block.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-05 11:45:02 +01:00

2390 lines
79 KiB
Go

package bookings
import (
"context"
"crussell/db"
"crussell/clock"
"github.com/jackc/pgx/v5"
"crussell/handlers/notifications"
"crussell/handlers/payments"
"crussell/handlers/scheduling"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
// UserCancelBookingHandler allows an authenticated user to cancel a booking they own.
// The update is performed in a transaction with notification handling.
func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
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())
// Get current status before updating
var originalStatus string
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not cancellable", http.StatusNotFound)
return
}
log.Printf("Failed to get booking status %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
res, err := tx.Exec(r.Context(), `
UPDATE bookings
SET status = 'client_cancelled', updated_at = $1
WHERE id = $2 AND user_id = $3 AND status IN ('pending', 'confirmed', 'in_progress')
`, clock.Now(), bookingID, userID)
if err != nil {
log.Printf("Failed to cancel booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rowsAffected := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Booking not cancellable", http.StatusNotFound)
return
}
// Acknowledge pending notification if exists
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(), `
DELETE FROM booking_edit_requests WHERE booking_id = $1
`, bookingID); err != nil {
log.Printf("ALERT: failed to delete edit requests: %v", err)
}
if _, err := tx.Exec(r.Context(), `
DELETE FROM time_blockers WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
log.Printf("ALERT: failed to delete time_blocker: %v", err)
}
if _, err := tx.Exec(r.Context(), `
DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'
`, bookingID); err != nil {
log.Printf("ALERT: failed to delete edit_requested notification: %v", err)
}
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
if originalStatus != "pending" {
notificationQuery := `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`
_, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
if err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit user cancel: %v, %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
type AdminCancelBookingRequest struct {
ForgiveFees *bool `json:"forgive_fees,omitempty"`
ForgiveNoShow *bool `json:"forgive_noshow,omitempty"`
}
func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
var req AdminCancelBookingRequest
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("failed to decode admin cancel request body: %v", err)
}
}
forgiveFees := req.ForgiveFees != nil && *req.ForgiveFees
forgiveNoShow := req.ForgiveNoShow != nil && *req.ForgiveNoShow
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
// Fetch payment info before the transaction (read-only, no side effects).
var refundResult *payments.RefundCalculationResult
var refundFailed bool
paySvc := payments.NewPaymentService()
payInfo, payErr := paySvc.GetBookingPaymentInfo(r.Context(), bookingID)
if payErr != nil {
log.Printf("Failed to get booking payment info for %s: %v", bookingID, payErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
totalAmount := payInfo.TotalAmount
totalPaid := payInfo.TotalPaid
calculatedRefund := totalPaid > 0 && !forgiveFees
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())
// Get current status and user ID — use FOR UPDATE to lock the row so
// the refund and status change are atomic.
var originalStatus string
var bookingUserID string
if err := tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1 FOR UPDATE", bookingID).Scan(&originalStatus, &bookingUserID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not cancellable", http.StatusNotFound)
return
}
log.Printf("Failed to get booking status %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
res, err := tx.Exec(r.Context(), `
UPDATE bookings
SET status = 'we_cancelled', updated_at = $1
WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress')
`, clock.Now(), bookingID)
if err != nil {
log.Printf("Failed to admin cancel booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rowsAffected := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Booking not cancellable", http.StatusNotFound)
return
}
// Status update succeeded — now process the refund in the SAME transaction
// so that a commit failure rolls back both the status change and the refund.
if forgiveFees && totalPaid > 0 {
refundResult = &payments.RefundCalculationResult{
TotalPrePaid: totalPaid,
RefundableAmount: totalPaid,
KeptAmount: 0,
Tier: "admin_full_refund",
}
}
if calculatedRefund {
var calc *payments.RefundCalculationResult
calc, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), "admin_cancelled", &adminID)
if err == nil {
refundResult = calc
} else {
refundFailed = true
log.Printf("ALERT: AdminCancelBookingHandler — ProcessCancellationRefundTx failed for booking %s after status was updated to we_cancelled. Refund was NOT processed. The transaction WILL be committed (cancellation stands, no refund). Error: %v", bookingID, err)
}
}
if forgiveNoShow && bookingUserID != "" {
if _, err := tx.Exec(r.Context(), `
INSERT INTO forgiven_no_shows (booking_id, forgiven_by)
VALUES ($1, $2)
ON CONFLICT (booking_id) DO NOTHING
`, bookingID, adminID); err != nil {
log.Printf("Failed to insert forgiven_no_show for booking %s: %v", bookingID, err)
}
}
// Acknowledge pending notification if exists
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
if originalStatus != "pending" {
notificationQuery := `
INSERT INTO admin_notifications (reason, booking_id, user_id)
SELECT 'cancelled_booking', $1, user_id FROM bookings WHERE id = $1
`
_, err = tx.Exec(r.Context(), notificationQuery, bookingID)
if err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Clean up any pending edit requests for this booking.
if _, err := tx.Exec(r.Context(), `
DELETE FROM booking_edit_requests WHERE booking_id = $1
`, bookingID); err != nil {
log.Printf("ALERT: failed to delete edit requests on admin cancel: %v", err)
}
if _, err := tx.Exec(r.Context(), `
DELETE FROM time_blockers WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
log.Printf("ALERT: failed to delete edit request time_blocker on admin cancel: %v", err)
}
if _, err := tx.Exec(r.Context(), `
DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'
`, bookingID); err != nil {
log.Printf("ALERT: failed to delete edit_requested notification on admin cancel: %v", err)
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit admin cancel: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Process pending Square refunds after the transaction commits successfully.
// This ensures Square API calls only happen if the DB records persist.
if calculatedRefund {
payments.ProcessPendingSquareRefunds(r.Context(), bookingID, "admin_cancelled")
}
if refundFailed || (refundResult != nil && refundResult.RefundableAmount > 0) {
resp := map[string]interface{}{
"message": "Booking cancelled",
}
if refundResult != nil && refundResult.RefundableAmount > 0 {
resp["refund_calculation"] = refundResult
}
if refundFailed {
resp["refund_failed"] = true
resp["warning"] = "Booking was cancelled but refund processing failed — please process refund manually or retry"
}
json.NewEncoder(w).Encode(resp)
return
}
w.WriteHeader(http.StatusNoContent)
}
// AdminListPendingBookingsHandler returns all bookings with status `pending` by delegating to the existing admin list handler.
func AdminListPendingBookingsHandler(w http.ResponseWriter, r *http.Request) {
r = r.Clone(r.Context())
q := r.URL.Query()
q.Set("status", "pending")
r.URL.RawQuery = q.Encode()
GetAllAdminBookingsHandler(w, r)
}
// AdminGetInProgressBookingHandler returns the booking that is currently in progress.
// It joins the bookings table with users to populate the UserSummary in the returned Booking.
func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
var b Booking
var userID, fullName string
err := db.Conn.QueryRow(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
b.notes,
b.created_at,
b.updated_at,
b.created_by,
u.id,
u.fn
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.status = 'in_progress'
ORDER BY b.start_time
LIMIT 1
`).Scan(
&b.ID,
&b.StartTime,
&b.Status,
&b.Notes,
&b.CreatedAt,
&b.UpdatedAt,
&b.CreatedBy,
&userID,
&fullName,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "No in-progress booking found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch in-progress booking: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Populate the UserSummary field
b.User = &UserSummary{
ID: userID,
FullName: fullName,
FirstName: "", // not available here
LastName: "", // not available here
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(b); err != nil {
log.Printf("Failed to encode booking response: %v", err)
}
}
type AdminCreateBookingForUserRequest struct {
UserID string `json:"user_id" validate:"required"`
StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids,omitempty"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
CustomOverrides []ServiceOverride `json:"custom_service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
EnforceDeposits *bool `json:"enforce_deposits,omitempty"`
OutOfHours bool `json:"out_of_hours"`
}
func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// Admin identity (creator)
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req AdminCreateBookingForUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Failed to decode request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Extract idempotency key from header
idempotencyKey := r.Header.Get("Idempotency-Key")
// If idempotency key provided, check for existing booking
if idempotencyKey != "" {
var existingID string
err := db.Conn.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID)
if err == nil {
// Booking already exists with this key — fetch and return it
var existingBooking Booking
existingBooking.User = &UserSummary{}
err := db.Conn.QueryRow(r.Context(), `
SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required
FROM bookings b WHERE b.id = $1
`, existingID).Scan(
&existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status,
&existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy,
&existingBooking.DepositRequired,
)
if err == nil {
// Fetch services for the response
rows, err := db.Conn.Query(r.Context(), `
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT bcs.booking_id, bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
cs.name, cs.description, cs.price, cs.duration_minutes
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
`, existingID)
if err == nil {
defer rows.Close()
for rows.Next() {
var bs BookingService
if err := rows.Scan(
&bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes,
&bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes,
); err != nil {
break
}
existingBooking.Services = append(existingBooking.Services, bs)
}
}
// Get deposit info
var depositRequired bool
var preStartPaid float64
db.Conn.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
populateDepositFields(&existingBooking, depositRequired, preStartPaid)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(existingBooking)
return
}
}
}
// Basic validation
if req.UserID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
if req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
http.Error(w, "At least one service or custom service is required", http.StatusBadRequest)
return
}
// Check patch test requirements for all regular services (custom services skip patch tests)
if len(req.ServiceIDs) > 0 {
patchTestRows, err := db.Conn.Query(r.Context(), `
SELECT id, service_ids, notice_duration_hours, expiry_months
FROM patch_tests
WHERE service_ids::text[] && $1::text[]
`, req.ServiceIDs)
if err != nil {
log.Printf("Failed to query patch tests: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
type ptInfo struct {
id string
noticeHours int
expiryMonths int
}
patchTestsByService := make(map[string]ptInfo)
var allPtIDs []string
for patchTestRows.Next() {
var id string
var serviceIDs []string
var noticeHours, expiryMonths int
if err := patchTestRows.Scan(&id, &serviceIDs, &noticeHours, &expiryMonths); err != nil {
log.Printf("Failed to scan patch test: %v", err)
continue
}
allPtIDs = append(allPtIDs, id)
for _, sid := range serviceIDs {
patchTestsByService[sid] = ptInfo{id, noticeHours, expiryMonths}
}
}
patchTestRows.Close()
userPatchTests := make(map[string]time.Time)
if len(allPtIDs) > 0 {
uptRows, err := db.Conn.Query(r.Context(), `
SELECT patch_test_id, tested_at
FROM user_patch_tests
WHERE user_id = $1 AND patch_test_id = ANY($2)
`, req.UserID, allPtIDs)
if err != nil {
log.Printf("Failed to query user patch tests: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for uptRows.Next() {
var ptID string
var testedAt time.Time
if err := uptRows.Scan(&ptID, &testedAt); err != nil {
log.Printf("Failed to scan user patch test: %v", err)
continue
}
userPatchTests[ptID] = testedAt
}
uptRows.Close()
}
for _, serviceID := range req.ServiceIDs {
pt, needsPatch := patchTestsByService[serviceID]
if !needsPatch {
continue
}
testedAt, hasTest := userPatchTests[pt.id]
if !hasTest {
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
return
}
eligibleFrom := testedAt.Add(time.Duration(pt.noticeHours) * time.Hour)
if req.StartTime.Before(eligibleFrom) {
hoursNeeded := time.Until(eligibleFrom).Hours()
http.Error(w, fmt.Sprintf("Booking time is before the %.0f hour notice period after patch test. Earliest booking: %s", hoursNeeded, eligibleFrom.Format("2006-01-02 15:04")), http.StatusBadRequest)
return
}
if pt.expiryMonths > 0 {
expiresAt := testedAt.AddDate(0, pt.expiryMonths, 0)
if req.StartTime.After(expiresAt) {
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
return
}
}
}
}
// Enforce deposit checks if requested (default: true if not specified)
enforceDeposits := true
if req.EnforceDeposits != nil {
enforceDeposits = *req.EnforceDeposits
}
if enforceDeposits {
// Read live deposits_required from user
var depositsRequired int
if err := db.Conn.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, req.UserID).Scan(&depositsRequired); err != nil {
log.Printf("Failed to fetch deposits_required for user %s: %v", req.UserID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Check one-active-booking limit when deposits are outstanding
if depositsRequired > 0 {
var activeCount int
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE user_id = $1 AND status IN ('pending', 'confirmed')
`, req.UserID).Scan(&activeCount); err != nil {
log.Printf("Failed to check active bookings for user %s: %v", req.UserID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if activeCount > 0 {
http.Error(w, "User already has an active booking. Cannot create another until deposit requirements are cleared.", http.StatusConflict)
return
}
}
}
// Validate overrides
if req.UserID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
if req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
http.Error(w, "At least one service or custom service is required", http.StatusBadRequest)
return
}
// Validate overrides
for _, override := range req.ServiceOverrides {
if override.ServiceID == "" {
http.Error(w, "Service ID is required for overrides", http.StatusBadRequest)
return
}
if override.OverridePrice != nil && *override.OverridePrice < 0 {
http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
return
}
if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
http.Error(w, "Override duration must be positive", http.StatusBadRequest)
return
}
}
// Check if booking time falls within a closed exceptional hours period
// Calculate the Monday of the week containing the booking date
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
localStart := req.StartTime.In(londonLocation)
weekday := int((localStart.Weekday() + 6) % 7)
daysToMonday := int(localStart.Weekday())
if daysToMonday == 0 {
daysToMonday = 7 // Sunday -> next Monday
}
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)
bookingTime := localStart.Format("15:04:05")
if !req.OutOfHours {
// Check if there's an exceptional hours entry that makes this time unavailable
var isClosed bool
var checkErr error
checkErr = db.Conn.QueryRow(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM exceptional_working_hours ewh
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
WHERE ega.week_start = $1
AND ewh.weekday = $2
AND ewh.is_open = false
AND ewh.start_time <= $3
AND ewh.end_time >= $3
)
`, weekStart, weekday, bookingTime).Scan(&isClosed)
if checkErr != nil {
log.Printf("Failed to check exceptional hours: %v", checkErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if isClosed {
http.Error(w, "Cannot book during holiday hours when the salon is closed", http.StatusConflict)
return
}
}
// Check for overlapping confirmed/in_progress/completed bookings
allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
var dur int
err := db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 0) FROM (
SELECT duration_minutes AS dur FROM services WHERE id = ANY($1)
UNION ALL
SELECT duration_minutes FROM custom_services WHERE id = ANY($1)
) combined
`, allIDs).Scan(&dur)
if err != nil {
log.Printf("Failed to calculate duration: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute)
// Check for time blocker overlap - admin can proceed with warning
// Pass adminID as excludeUserID so the admin's own reservation doesn't self-block
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEnd, &adminID)
if err != nil {
log.Printf("Failed to check time blocker overlap: %v", err)
}
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, req.StartTime, newEnd); evictErr != nil {
log.Printf("Failed to evict pending_release bookings for slot %s: %v", req.StartTime, evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Check for overlapping bookings (inside transaction)
overlapRows, err := tx.Query(r.Context(), `
SELECT 1 FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND end_time > $1
FOR UPDATE
`, req.StartTime, newEnd)
if err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var overlapCount int
for overlapRows.Next() {
overlapCount++
}
overlapRows.Close()
if err := overlapRows.Err(); err != nil {
log.Printf("Overlap row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if overlapCount > 0 {
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
return
}
// Create booking directly as confirmed
bookingQuery := `
INSERT INTO bookings (
user_id,
start_time,
status,
notes,
created_by,
idempotency_key,
out_of_hours
)
VALUES ($1, $2, 'confirmed', $3, $4, $5, $6)
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, out_of_hours
`
var booking Booking
booking.User = &UserSummary{}
err = tx.QueryRow(
r.Context(),
bookingQuery,
req.UserID,
req.StartTime,
req.Notes,
adminID,
sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""},
req.OutOfHours,
).Scan(
&booking.ID,
&booking.User.ID,
&booking.StartTime,
&booking.Status,
&booking.Notes,
&booking.CreatedAt,
&booking.UpdatedAt,
&booking.CreatedBy,
&booking.OutOfHours,
)
if err != nil {
log.Printf("Failed to create admin booking: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Insert booking services
if len(req.ServiceIDs) > 0 {
_, err = tx.Exec(r.Context(), `
INSERT INTO booking_services (booking_id, service_id)
SELECT $1, unnest($2::text[])
`, booking.ID, req.ServiceIDs)
if err != nil {
log.Printf("Failed to insert booking services: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if len(req.CustomServiceIDs) > 0 {
_, err = tx.Exec(r.Context(), `
INSERT INTO booking_custom_services (booking_id, custom_service_id)
SELECT $1, unnest($2::text[])
`, booking.ID, req.CustomServiceIDs)
if err != nil {
log.Printf("Failed to insert custom booking services: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(), `
UPDATE custom_services SET usage_count = usage_count + 1, last_used_at = NOW() WHERE id = ANY($1)
`, req.CustomServiceIDs); err != nil {
log.Printf("ALERT: failed to update custom service usage: %v", err)
}
}
// Apply overrides (optional)
if len(req.ServiceOverrides) > 0 {
// Ensure overrides only reference services in this booking
serviceCheckQuery := `
SELECT COUNT(*) FROM booking_services
WHERE booking_id = $1 AND service_id = ANY($2)
`
overrideServiceIDs := make([]string, len(req.ServiceOverrides))
for i, o := range req.ServiceOverrides {
overrideServiceIDs[i] = o.ServiceID
}
var count int
err = tx.QueryRow(
r.Context(),
serviceCheckQuery,
booking.ID,
overrideServiceIDs,
).Scan(&count)
if err != nil {
log.Printf("Failed to verify service overrides: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if count != len(req.ServiceOverrides) {
http.Error(w, "One or more service overrides do not belong to this booking", http.StatusBadRequest)
return
}
overrideUpdateQuery := `
UPDATE booking_services
SET override_price = $1,
override_duration_minutes = $2
WHERE booking_id = $3 AND service_id = $4
`
for _, override := range req.ServiceOverrides {
_, err := tx.Exec(
r.Context(),
overrideUpdateQuery,
override.OverridePrice,
override.OverrideDurationMinutes,
booking.ID,
override.ServiceID,
)
if err != nil {
log.Printf(
"Failed to apply override (booking %s, service %s): %v",
booking.ID,
override.ServiceID,
err,
)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
}
if len(req.CustomOverrides) > 0 {
customOverrideServiceIDs := make([]string, len(req.CustomOverrides))
for i, o := range req.CustomOverrides {
customOverrideServiceIDs[i] = o.ServiceID
}
var customCount int
err = tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM booking_custom_services
WHERE booking_id = $1 AND custom_service_id = ANY($2)
`, booking.ID, customOverrideServiceIDs).Scan(&customCount)
if err != nil {
log.Printf("Failed to verify custom service overrides: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if customCount != len(req.CustomOverrides) {
http.Error(w, "One or more custom service overrides do not belong to this booking", http.StatusBadRequest)
return
}
customOverrideUpdateQuery := `
UPDATE booking_custom_services
SET override_price = $1,
override_duration_minutes = $2
WHERE booking_id = $3 AND custom_service_id = $4
`
for _, override := range req.CustomOverrides {
_, err := tx.Exec(
r.Context(),
customOverrideUpdateQuery,
override.OverridePrice,
override.OverrideDurationMinutes,
booking.ID,
override.ServiceID,
)
if err != nil {
log.Printf(
"Failed to apply custom service override (booking %s, custom service %s): %v",
booking.ID,
override.ServiceID,
err,
)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit admin booking creation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Build response with warnings if any
warnings := []string{}
if blockerOverlap {
warnings = append(warnings, fmt.Sprintf("Warning: This booking overlaps with a time blocker: %s", blockerDesc))
}
response := map[string]interface{}{
"booking": booking,
"warnings": warnings,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// =============================================================================
// Booking Edit Request Handlers
// =============================================================================
// BookingEditRequest represents a user's request to edit a booking
type BookingEditRequest struct {
ID string `json:"id"`
BookingID string `json:"booking_id"`
RequestedBy string `json:"requested_by"`
NewStartTime *time.Time `json:"new_start_time,omitempty"`
NewServices []string `json:"new_services"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
HasOverrides bool `json:"has_overrides"`
UpdatedAt time.Time `json:"updated_at"`
// Joined fields
Booking *Booking `json:"booking,omitempty"`
User *UserSummary `json:"user,omitempty"`
}
// Enriched response types for edit request detail views
type EditServiceDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
DurationMinutes int `json:"duration_minutes"`
}
type EditSnapshot struct {
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
Services []EditServiceDetail `json:"services"`
Notes *string `json:"notes" validate:"omitempty,max=1000000"`
}
type EditUserSummary struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"`
}
type EnrichedEditRequest struct {
ID string `json:"id"`
BookingID string `json:"booking_id"`
RequestedBy string `json:"requested_by"`
RequestedAt time.Time `json:"requested_at"`
Notes *string `json:"notes" validate:"omitempty,max=1000000"`
Original *EditSnapshot `json:"original"`
Proposed *EditSnapshot `json:"proposed"`
User *EditUserSummary `json:"user,omitempty"`
}
// buildEnrichedEditRequest builds a full enriched response from a pending edit request.
// It queries the database for original booking details, services, and user info.
func buildEnrichedEditRequest(ctx context.Context, editReq *BookingEditRequest) (*EnrichedEditRequest, error) {
var bStartTime time.Time
var bNotes *string
err := db.Conn.QueryRow(ctx, `
SELECT start_time, notes FROM bookings WHERE id = $1
`, editReq.BookingID).Scan(&bStartTime, &bNotes)
if err != nil {
return nil, fmt.Errorf("failed to get booking %s: %w", editReq.BookingID, err)
}
origServices, err := queryBookingServicesWithDetails(ctx, editReq.BookingID)
if err != nil {
return nil, fmt.Errorf("failed to get booking services for %s: %w", editReq.BookingID, err)
}
var proposedServices []EditServiceDetail
if len(editReq.NewServices) > 0 && !editReq.HasOverrides {
proposedServices, err = queryServiceDetailsByIDs(ctx, editReq.NewServices)
if err != nil {
return nil, fmt.Errorf("failed to get service details: %w", err)
}
} else {
proposedServices = origServices
}
var proposedStartTime *time.Time
if editReq.NewStartTime != nil {
proposedStartTime = editReq.NewStartTime
} else {
proposedStartTime = &bStartTime
}
var proposedNotes *string
if editReq.Notes != nil {
proposedNotes = editReq.Notes
} else {
proposedNotes = bNotes
}
origDuration := sumServiceDurations(origServices)
proposedDuration := sumServiceDurations(proposedServices)
origEndTime := bStartTime.Add(time.Duration(origDuration) * time.Minute)
var proposedEndTime *time.Time
if editReq.NewStartTime != nil {
et := editReq.NewStartTime.Add(time.Duration(proposedDuration) * time.Minute)
proposedEndTime = &et
} else {
proposedEndTime = &origEndTime
}
// Non-fatal: still return the request without user details
userSummary, err := queryUserSummary(ctx, editReq.RequestedBy)
if err != nil {
// Non-fatal: still return the request without user details
log.Printf("Failed to get user summary for %s: %v", editReq.RequestedBy, err)
}
result := &EnrichedEditRequest{
ID: editReq.ID,
BookingID: editReq.BookingID,
RequestedBy: editReq.RequestedBy,
RequestedAt: editReq.UpdatedAt,
Notes: editReq.Notes,
Original: &EditSnapshot{
StartTime: &bStartTime,
EndTime: &origEndTime,
Services: origServices,
Notes: bNotes,
},
Proposed: &EditSnapshot{
StartTime: proposedStartTime,
EndTime: proposedEndTime,
Services: proposedServices,
Notes: proposedNotes,
},
User: userSummary,
}
return result, nil
}
// queryBookingServicesWithDetails returns service details for a booking, respecting overrides.
func queryBookingServicesWithDetails(ctx context.Context, bookingID string) ([]EditServiceDetail, error) {
rows, err := db.Conn.Query(ctx, `
SELECT s.id, s.name,
COALESCE(bs.override_price, s.price) as price,
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT cs.id, cs.name,
COALESCE(bcs.override_price, cs.price),
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
ORDER BY name
`, bookingID)
if err != nil {
return nil, err
}
defer rows.Close()
var services []EditServiceDetail
for rows.Next() {
var svc EditServiceDetail
if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil {
return nil, err
}
services = append(services, svc)
}
if services == nil {
services = []EditServiceDetail{}
}
return services, rows.Err()
}
// queryServiceDetailsByIDs returns service details for the given service IDs.
func queryServiceDetailsByIDs(ctx context.Context, serviceIDs []string) ([]EditServiceDetail, error) {
if len(serviceIDs) == 0 {
return []EditServiceDetail{}, nil
}
rows, err := db.Conn.Query(ctx, `
SELECT id, name, price, duration_minutes
FROM services
WHERE id = ANY($1)
ORDER BY name
`, serviceIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var services []EditServiceDetail
for rows.Next() {
var svc EditServiceDetail
if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil {
return nil, err
}
services = append(services, svc)
}
if services == nil {
services = []EditServiceDetail{}
}
return services, rows.Err()
}
// sumServiceDurations returns the total duration in minutes from a slice of EditServiceDetail.
func sumServiceDurations(services []EditServiceDetail) int {
total := 0
for _, s := range services {
total += s.DurationMinutes
}
if total == 0 {
return 60 // fallback
}
return total
}
// queryUserSummary fetches user details for the enriched edit request response.
func queryUserSummary(ctx context.Context, userID string) (*EditUserSummary, error) {
var summary EditUserSummary
var prevFirstName, prevLastName sql.NullString
err := db.Conn.QueryRow(ctx, `
SELECT u.id, u.fn, u.email, u.phone,
nh.previous_first_name, nh.previous_last_name
FROM users u
LEFT JOIN LATERAL (
SELECT previous_first_name, previous_last_name
FROM name_history
WHERE user_id = u.id
ORDER BY changed_at DESC
LIMIT 1
) nh ON true
WHERE u.id = $1
`, userID).Scan(&summary.ID, &summary.FullName, &summary.Email, &summary.Phone,
&prevFirstName, &prevLastName)
if err != nil {
return nil, err
}
if prevFirstName.Valid {
summary.PreviousFirstName = &prevFirstName.String
}
if prevLastName.Valid {
summary.PreviousLastName = &prevLastName.String
}
return &summary, nil
}
// DeleteEditRequestHandler allows a user to delete/cancel their pending edit request
func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
// Verify user owns this booking
var ownerID string
err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if ownerID != userID {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Use transaction to delete edit request and associated admin notification
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())
// Delete the edit request for this booking
res, err := tx.Exec(r.Context(), `
DELETE FROM booking_edit_requests
WHERE booking_id = $1 AND requested_by = $2
`, bookingID, userID)
if err != nil {
log.Printf("Failed to delete edit request for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rowsAffected := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "No edit request found", http.StatusNotFound)
return
}
if _, err := tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
log.Printf("ALERT: failed to delete time_blocker: %v", err)
}
// Delete the admin notification for this edit request
_, err = tx.Exec(r.Context(), `
DELETE FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_requested' AND user_id = $2
`, bookingID, userID)
if err != nil {
log.Printf("Failed to delete admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit delete edit request: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// RequestEditHandler allows a user to request an edit to their booking
func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req struct {
NewStartTime *time.Time `json:"new_start_time,omitempty"`
NewServices []string `json:"new_services"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
// Validate: at least one of new_start_time, new_services, or notes must be provided
if req.NewStartTime == nil && len(req.NewServices) == 0 && req.Notes == nil {
http.Error(w, "At least one of new_start_time, new_services, or notes is required", http.StatusBadRequest)
return
}
// Verify user owns this booking
var ownerID string
err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if ownerID != userID {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Check booking is not already completed/cancelled
var currentStatus string
var currentStartTime time.Time
var depositRequired bool
err = db.Conn.QueryRow(r.Context(), "SELECT status, start_time, deposit_required FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus, &currentStartTime, &depositRequired)
if err != nil {
log.Printf("Failed to get booking status %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
return
}
// Query payment and timing info (used for validation AND auto-approval later)
var hasPayments bool
hoursUntilCurrent := currentStartTime.Sub(clock.Now()).Hours()
db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND status = 'completed')", bookingID).Scan(&hasPayments)
// Check if booking has discounts (affects auto-approval decisions)
var hasDiscounts bool
db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)", bookingID).Scan(&hasDiscounts)
if req.NewStartTime != nil && !req.NewStartTime.Equal(currentStartTime) {
if hasPayments && hoursUntilCurrent < 72 {
http.Error(w, "This booking is too close to the appointment time to reschedule online. Please contact us to discuss options, or cancel and rebook (note: cancellation fees may apply based on our deposit policy).", http.StatusForbidden)
return
}
if !hasPayments && hoursUntilCurrent < 24 {
http.Error(w, "This booking is too close to the appointment time to reschedule online. Please contact us to discuss options, or cancel and rebook.", http.StatusForbidden)
return
}
// Warn when within 72h with no payments (24-72h window — close enough
// to reschedule but counts toward no-show history).
if !hasPayments && hoursUntilCurrent < 72 {
w.Header().Set("X-No-Show-Warning", "Rescheduling within 72h counts as a no-show towards your deposit obligations. Two no-shows within 6 months will require deposits on future bookings.")
}
}
if len(req.NewServices) > 0 {
var overrideCount int
err = db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM (
SELECT 1 FROM booking_services
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
UNION ALL
SELECT 1 FROM booking_custom_services
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
) overrides
`, bookingID).Scan(&overrideCount)
if err != nil {
log.Printf("Failed to check overrides for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if overrideCount > 0 {
http.Error(w, "Cannot change services on a booking that has overrides. Please contact the salon.", http.StatusForbidden)
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())
// Delete any existing edit request for this booking (upsert behavior)
_, err = tx.Exec(r.Context(), `
DELETE FROM booking_edit_requests
WHERE booking_id = $1 AND requested_by = $2
`, bookingID, userID)
if err != nil {
log.Printf("Failed to delete existing edit request for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Create edit request - has_overrides is false since user can't override
var editReq BookingEditRequest
err = tx.QueryRow(r.Context(), `
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
`, bookingID, userID, req.NewStartTime, req.NewServices, req.Notes, false).Scan(
&editReq.ID,
&editReq.BookingID,
&editReq.RequestedBy,
&editReq.NewStartTime,
&editReq.NewServices,
&editReq.Notes,
&editReq.HasOverrides,
&editReq.UpdatedAt,
)
if err != nil {
log.Printf("Failed to create edit request for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Auto-approve if no payments exist, the booking is >48h away,
// and no discounts+time-change combo (requires admin review)
if !hasPayments && hoursUntilCurrent > 48 && !(hasDiscounts && req.NewStartTime != nil) {
if req.NewStartTime != nil {
// Calculate duration for the new time
var durMinutes int
if len(req.NewServices) > 0 {
if err := tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM services s WHERE s.id = ANY($1)
`, req.NewServices).Scan(&durMinutes); err != nil {
log.Printf("Failed to get duration for new services: %v", err)
durMinutes = 60
}
} else {
if err := tx.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&durMinutes); err != nil {
log.Printf("Failed to get duration for existing services: %v", err)
durMinutes = 60
}
}
if durMinutes <= 0 {
durMinutes = 60
}
// Quick overlap check — block if slot is taken
newEnd := req.NewStartTime.Add(time.Duration(durMinutes) * time.Minute)
// Evict any pending_release bookings that overlap this slot.
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, *req.NewStartTime, newEnd); evictErr != nil {
log.Printf("Failed to evict pending_release bookings for slot %s: %v", *req.NewStartTime, evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var overlapCount int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE id != $1
AND status NOT IN ('completed','client_cancelled','we_cancelled','no_show','deposit_lapsed')
AND start_time < $3
AND end_time > $2
`, bookingID, *req.NewStartTime, newEnd).Scan(&overlapCount); err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if overlapCount > 0 {
http.Error(w, "The requested time slot has been taken. Please choose a different time.", http.StatusConflict)
return
}
}
// Update booking start_time, notes, and services directly
if req.NewStartTime != nil || req.Notes != nil {
var setClauses []string
var args []interface{}
argNum := 1
if req.NewStartTime != nil {
setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum))
args = append(args, *req.NewStartTime)
argNum++
}
if req.Notes != nil {
setClauses = append(setClauses, fmt.Sprintf("notes = $%d", argNum))
args = append(args, *req.Notes)
argNum++
}
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
args = append(args, clock.Now())
argNum++
args = append(args, bookingID)
query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum)
if _, err := tx.Exec(r.Context(), query, args...); err != nil {
log.Printf("Failed to auto-approve booking update %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Update services if requested
if len(req.NewServices) > 0 {
if _, err := tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID); err != nil {
log.Printf("Failed to delete services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID); err != nil {
log.Printf("Failed to delete custom services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for _, sid := range req.NewServices {
if _, err := tx.Exec(r.Context(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", bookingID, sid); err != nil {
log.Printf("Failed to insert auto-approve service %s: %v", sid, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
}
// Clean up the edit request and reservations
if _, err := tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", editReq.ID); err != nil {
log.Printf("Failed to delete edit request %s: %v", editReq.ID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(), `DELETE FROM time_blockers WHERE description = $1`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
log.Printf("Failed to delete time blocker for edit request %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(), `UPDATE admin_notifications SET acknowledged_at = NOW() WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL`, bookingID); err != nil {
log.Printf("Failed to acknowledge notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit auto-approve edit request: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"auto_approved": true,
"edit_request": editReq,
})
return
}
if req.NewStartTime != nil {
if _, err := tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
log.Printf("ALERT: failed to delete time_blocker: %v", err)
}
var durationMinutes int
if len(req.NewServices) > 0 {
_ = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT s.duration_minutes AS dur
FROM services s
WHERE s.id = ANY($1)
UNION ALL
SELECT cs.duration_minutes
FROM custom_services cs
WHERE cs.id = ANY($1)
) sub
`, req.NewServices).Scan(&durationMinutes)
} else {
_ = tx.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&durationMinutes)
}
_, err = tx.Exec(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, $4)
`, *req.NewStartTime, durationMinutes, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), userID)
if err != nil {
log.Printf("Failed to create time_blocker reservation for edit request %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Always create low-priority notification for edit requests
_, err = tx.Exec(r.Context(), `
DELETE FROM admin_notifications
WHERE booking_id = $1 AND reason = 'edit_requested'
`, bookingID)
if err != nil {
log.Printf("Failed to delete old admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('edit_requested', $1, $2)
`, bookingID, userID)
if err != nil {
log.Printf("Failed to create admin notification for edit request %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// If booking is pending, also create high-priority approval notification
if currentStatus == "pending" {
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
SET acknowledged_at = NOW()
WHERE booking_id = $1 AND reason = 'pending_booking' AND acknowledged_at IS NULL
`, bookingID)
if err != nil {
log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err)
}
_, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('pending_booking', $1, $2)
`, bookingID, userID)
if err != nil {
log.Printf("Failed to create pending booking notification for %s: %v", bookingID, err)
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit edit request: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(editReq)
}
// AdminListEditRequestsHandler returns all edit requests
func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
baseQuery := `
SELECT
ber.id, ber.booking_id, ber.requested_by, ber.new_start_time,
ber.new_services, ber.notes, ber.has_overrides, ber.updated_at,
b.start_time as original_start_time, b.status as booking_status,
u.fn as user_name
FROM booking_edit_requests ber
JOIN bookings b ON ber.booking_id = b.id
JOIN users u ON ber.requested_by = u.id
`
var args []interface{}
baseQuery += " ORDER BY ber.updated_at DESC"
var total int
// Count query (no ORDER BY needed).
db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total)
rows, err := db.Conn.Query(r.Context(), baseQuery, args...)
if err != nil {
log.Printf("Failed to fetch edit requests: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var requests []BookingEditRequest
for rows.Next() {
var req BookingEditRequest
var origStartTime time.Time
var bookingStatus string
var userName string
var newServices []string
err := rows.Scan(
&req.ID,
&req.BookingID,
&req.RequestedBy,
&req.NewStartTime,
&newServices,
&req.Notes,
&req.HasOverrides,
&req.UpdatedAt,
&origStartTime,
&bookingStatus,
&userName,
)
if err != nil {
log.Printf("Failed to scan edit request: %v", err)
continue
}
req.NewServices = newServices
req.Booking = &Booking{
ID: req.BookingID,
StartTime: origStartTime,
Status: bookingStatus,
}
req.User = &UserSummary{
ID: req.RequestedBy,
FullName: userName,
}
requests = append(requests, req)
}
if requests == nil {
requests = []BookingEditRequest{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"requests": requests,
"total": total,
})
}
// AdminApproveEditRequestHandler approves an edit request and updates the booking
func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
requestID := chi.URLParam(r, "request_id")
if requestID == "" || !validators.IsValidID(requestID) {
http.Error(w, "Edit request not found", http.StatusNotFound)
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())
// Get the edit request
var bookingID string
var newStartTime *time.Time
var newServices []string
var notes *string
var hasOverrides bool
err = tx.QueryRow(r.Context(), `
SELECT booking_id, new_start_time, new_services, notes, has_overrides
FROM booking_edit_requests
WHERE id = $1
`, requestID).Scan(&bookingID, &newStartTime, &newServices, &notes, &hasOverrides)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Edit request not found", http.StatusNotFound)
return
}
log.Printf("Failed to get edit request %s: %v", requestID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// If new_services provided and has_overrides is true, block with error
if len(newServices) > 0 && hasOverrides {
http.Error(w, "Cannot change services on a booking that has overrides. Please update services manually.", http.StatusForbidden)
return
}
// Check for applied discounts (admin warning only — discounts remain locked in)
var discountCount int
if err := tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount); err != nil {
log.Printf("ADMIN APPROVE EDIT: Failed to check discounts: %v", err)
}
if discountCount > 0 {
log.Printf("ADMIN APPROVE EDIT: Booking %s has %d discount(s) applied — discounts remain locked in after reschedule", bookingID, discountCount)
}
// Calculate duration for overlap check - use overrides if has_overrides is true
var durationMinutes int
if hasOverrides {
// Use the existing booking_services with overrides
err = tx.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&durationMinutes)
} else {
// Use standard durations or new_services if provided
if len(newServices) > 0 {
// Use new services to calculate duration
err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM services s
WHERE s.id = ANY($1)
`, newServices).Scan(&durationMinutes)
} else {
// Use existing booking services
err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT s.duration_minutes AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
UNION ALL
SELECT cs.duration_minutes
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes)
}
}
if err != nil {
log.Printf("Failed to calculate duration: %v", err)
durationMinutes = 60 // fallback
}
// Check for overlapping bookings if start time is being changed
if newStartTime != nil {
newEndTime := newStartTime.Add(time.Duration(durationMinutes) * time.Minute)
// Evict any pending_release bookings that overlap this slot.
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, *newStartTime, newEndTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings for slot %s: %v", *newStartTime, evictErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var overlapCount int
err = tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE id != $1
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
AND start_time < $3
AND end_time > $2
`, bookingID, *newStartTime, newEndTime).Scan(&overlapCount)
if err != nil {
log.Printf("Failed to check overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if overlapCount > 0 {
http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict)
return
}
// Delete the edit request's reservation BEFORE checking time blockers.
// The reservation (RESERVATION:edit_request:*) was created by RequestEditHandler
// to temporarily hold the slot. If not removed first, it would show up as a
// blocker and prevent the approve from succeeding.
if _, delErr := tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); delErr != nil {
log.Printf("ALERT: failed to delete time_blocker: %v", delErr)
}
blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime, &adminID)
if err != nil {
log.Printf("Failed to check time blocker overlap: %v", err)
}
if blockerOverlap {
http.Error(w, "This edit would overlap with a time blocker", http.StatusConflict)
return
}
// Check working hours (admin gets warning)
// newStartTime from the DB is UTC; convert to London for weekday/daysToMonday
// so BST dates (e.g. 00:30 BST = 23:30 UTC previous day) compute correctly.
localStart := newStartTime.In(londonLocation)
weekday := int((localStart.Weekday() + 6) % 7)
bookingTime := localStart.Format("15:04:05")
daysToMonday := int(localStart.Weekday())
if daysToMonday == 0 {
daysToMonday = 7 // Sunday -> next Monday
}
tm := localStart.AddDate(0, 0, -daysToMonday+1)
// Use UTC midnight for weekStart so PostgreSQL DATE comparison works
// correctly with TIMESTAMPTZ. London-midnight during BST = 23:00 UTC
// previous day, which would shift the DATE comparison by -1 day.
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
var isClosed bool
err = tx.QueryRow(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM exceptional_working_hours ewh
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
WHERE ega.week_start = $1
AND ewh.weekday = $2
AND ewh.is_open = false
AND ewh.start_time <= $3
AND ewh.end_time >= $3
)
`, weekStart, weekday, bookingTime).Scan(&isClosed)
if err != nil {
log.Printf("Failed to check exceptional hours: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if isClosed {
http.Error(w, "Cannot approve: the proposed time falls during a period when the salon is closed", http.StatusConflict)
return
}
}
// Build update query for bookings table
if newStartTime != nil || notes != nil {
// setClauses contains only hardcoded column name assignments ("start_time = $N", "notes = $N").
// Column names are never derived from user input. User values are in args and always parameterised.
var setClauses []string
var args []interface{}
argNum := 1
if newStartTime != nil {
setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum))
args = append(args, *newStartTime)
argNum++
}
if notes != nil {
setClauses = append(setClauses, fmt.Sprintf("notes = $%d", argNum))
args = append(args, *notes)
argNum++
}
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
args = append(args, clock.Now())
argNum++
args = append(args, bookingID)
query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum)
_, err = tx.Exec(r.Context(), query, args...)
if err != nil {
log.Printf("Failed to update booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Handle new_services (only if has_overrides is false)
if len(newServices) > 0 && !hasOverrides {
// Delete existing booking_services
_, err = tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID)
if err != nil {
log.Printf("Failed to delete existing services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, err = tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID)
if err != nil {
log.Printf("Failed to delete existing custom services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Insert new services
for _, serviceID := range newServices {
_, err = tx.Exec(r.Context(), `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
log.Printf("Failed to insert booking service %s: %v", serviceID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
}
// Delete the edit request row (not update status)
_, err = tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", requestID)
if err != nil {
log.Printf("Failed to delete edit request %s: %v", requestID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Acknowledge the admin notification for this edit request
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
SET acknowledged_at = NOW()
WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL
`, bookingID)
if err != nil {
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// TODO: Notify user that their edit request was approved (blocked on E5 SMTP)
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// AdminRejectEditRequestHandler rejects an edit request by deleting it and acknowledging the admin notification
func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
requestID := chi.URLParam(r, "request_id")
if requestID == "" || !validators.IsValidID(requestID) {
http.Error(w, "Edit request not found", http.StatusNotFound)
return
}
// First get the booking_id from the edit request before deleting
var bookingID string
err := db.Conn.QueryRow(r.Context(), `
SELECT booking_id FROM booking_edit_requests WHERE id = $1
`, requestID).Scan(&bookingID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Edit request not found", http.StatusNotFound)
return
}
log.Printf("Failed to get edit request %s: %v", requestID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Use transaction to delete edit request and acknowledge associated admin notification
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())
// Delete the edit request
_, err = tx.Exec(r.Context(), `
DELETE FROM booking_edit_requests
WHERE id = $1
`, requestID)
if err != nil {
log.Printf("Failed to reject edit request %s: %v", requestID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
log.Printf("ALERT: failed to delete time_blocker: %v", err)
}
// Acknowledge the admin notification for this edit request
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
SET acknowledged_at = NOW()
WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL
`, bookingID)
if err != nil {
log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// TODO: Notify user that their edit request was denied with option to cancel (blocked on E5 SMTP)
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit reject edit request: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetMyEditRequestHandler returns the pending edit request for a specific booking the user owns.
// GET /api/bookings/{id}/edit-request
func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var ownerID string
err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if ownerID != userID {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
var editReq BookingEditRequest
var newServices []string
err = db.Conn.QueryRow(r.Context(), `
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
FROM booking_edit_requests
WHERE booking_id = $1 AND requested_by = $2
`, bookingID, userID).Scan(
&editReq.ID,
&editReq.BookingID,
&editReq.RequestedBy,
&editReq.NewStartTime,
&newServices,
&editReq.Notes,
&editReq.HasOverrides,
&editReq.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"edit_request": nil,
})
return
}
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
editReq.NewServices = newServices
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
if err != nil {
log.Printf("Failed to build enriched edit request: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"edit_request": enriched,
})
}
// GetMyEditRequestsHandler returns all pending edit requests for the current user across all bookings.
// GET /api/bookings/edit-requests
func GetMyEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
rows, err := db.Conn.Query(r.Context(), `
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
FROM booking_edit_requests
WHERE requested_by = $1
ORDER BY updated_at DESC
`, userID)
if err != nil {
log.Printf("Failed to fetch edit requests for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
// Collect all edit requests first to avoid nested queries on the same tx connection.
var editReqs []BookingEditRequest
for rows.Next() {
var editReq BookingEditRequest
var newServices []string
if err := rows.Scan(
&editReq.ID,
&editReq.BookingID,
&editReq.RequestedBy,
&editReq.NewStartTime,
&newServices,
&editReq.Notes,
&editReq.HasOverrides,
&editReq.UpdatedAt,
); err != nil {
log.Printf("Failed to scan edit request: %v", err)
continue
}
editReq.NewServices = newServices
editReqs = append(editReqs, editReq)
}
var enrichedRequests []*EnrichedEditRequest
for _, editReq := range editReqs {
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
if err != nil {
log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
continue
}
enrichedRequests = append(enrichedRequests, enriched)
}
if enrichedRequests == nil {
enrichedRequests = []*EnrichedEditRequest{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"edit_requests": enrichedRequests,
})
}
// AdminListAllEditRequestsHandler returns ALL pending edit requests across all bookings.
// GET /api/admin/bookings/edit-requests
func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
rows, err := db.Conn.Query(r.Context(), `
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
FROM booking_edit_requests
ORDER BY updated_at DESC
`)
if err != nil {
log.Printf("Failed to fetch all edit requests: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
// Collect all edit requests first to avoid nested queries on the same tx connection.
var editReqs []BookingEditRequest
for rows.Next() {
var editReq BookingEditRequest
var newServices []string
if err := rows.Scan(
&editReq.ID,
&editReq.BookingID,
&editReq.RequestedBy,
&editReq.NewStartTime,
&newServices,
&editReq.Notes,
&editReq.HasOverrides,
&editReq.UpdatedAt,
); err != nil {
log.Printf("Failed to scan edit request: %v", err)
continue
}
editReq.NewServices = newServices
editReqs = append(editReqs, editReq)
}
var enrichedRequests []*EnrichedEditRequest
for _, editReq := range editReqs {
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
if err != nil {
log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
continue
}
enrichedRequests = append(enrichedRequests, enriched)
}
if enrichedRequests == nil {
enrichedRequests = []*EnrichedEditRequest{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"edit_requests": enrichedRequests,
})
}
// AdminGetBookingEditRequestHandler returns the pending edit request for a specific booking.
// GET /api/admin/bookings/{id}/edit-request
func AdminGetBookingEditRequestHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
var editReq BookingEditRequest
var newServices []string
err := db.Conn.QueryRow(r.Context(), `
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
FROM booking_edit_requests
WHERE booking_id = $1
`, bookingID).Scan(
&editReq.ID,
&editReq.BookingID,
&editReq.RequestedBy,
&editReq.NewStartTime,
&newServices,
&editReq.Notes,
&editReq.HasOverrides,
&editReq.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "No edit request pending for this booking", http.StatusNotFound)
return
}
log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
editReq.NewServices = newServices
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
if err != nil {
log.Printf("Failed to build enriched edit request: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"edit_request": enriched,
})
}
// ========================================
// NO-SHOW HELPER FUNCTIONS
// ========================================
// CountUnforgivenNoShows counts the number of no-shows in the last 6 months
// that have not been forgiven (not in forgiven_no_shows table)
func CountUnforgivenNoShows(ctx context.Context, userID string) (int, error) {
var count int
err := db.Conn.QueryRow(ctx, `
SELECT COUNT(*)
FROM bookings b
WHERE b.user_id = $1
AND b.status = 'no_show'
AND b.start_time >= NOW() - INTERVAL '6 months'
AND NOT EXISTS (
SELECT 1 FROM forgiven_no_shows WHERE booking_id = b.id
)
`, userID).Scan(&count)
return count, err
}
// ApplyDepositsIfNeeded checks if user has 2+ unforgiven no-shows
// and applies 3 deposits if so. Returns true if deposits were applied.
func ApplyDepositsIfNeeded(ctx context.Context, q db.Querier, userID string) (bool, error) {
count, err := CountUnforgivenNoShows(ctx, userID)
if err != nil {
return false, err
}
if count >= 2 {
// Apply 3 deposits
_, err := q.Exec(ctx, `
UPDATE users SET deposits_required = 3 WHERE id = $1
`, userID)
if err != nil {
return false, err
}
return true, nil
}
return false, nil
}