Files
Crussell/backend/handlers/bookings/manage.go
T
popertotsandSisyphus 8efb8d93bf feat(backend): add name history tracking, booking pagination, and CardDAV improvements
Name history system tracks user name changes and displays former names on bookings, appointments, and admin views until consumed by the first completed booking post-change.

- Add name_history queries to today.go, bookings.go, manage.go, profile.go
- Show previous first/last name on today appointments, pending approvals,
  booking details, edit requests, and admin user views
- Paginate bookings by start_time instead of created_at (more intuitive ordering)
- Add name change detection in UpdateProfileHandler with history insert
- Support DAV_BASE_URL env var for configurable CardDAV endpoint
- Add referral_savings to profile response
- Add ParseCursor3 validator for user list cursor pagination
- Consume name_history entries when a booking is completed (ProgressBookingHandler)

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-20 16:58:29 +01:00

2480 lines
81 KiB
Go

package bookings
import (
"context"
"crussell/db"
"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.DB.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')
`, time.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
}
// Process refund FIRST, before the cancel transaction. If the refund fails,
// the booking stays active and the admin can retry. This mirrors the
// DeleteBookingHandler pattern — the booking status change is independent
// of the refund execution.
var refundResult *payments.RefundCalculationResult
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
if totalPaid > 0 {
if forgiveFees {
refundResult = &payments.RefundCalculationResult{
TotalPrePaid: totalPaid,
RefundableAmount: totalPaid,
KeptAmount: 0,
Tier: "admin_full_refund",
}
} else {
calc, err := payments.ProcessCancellationRefund(r.Context(), bookingID, totalAmount, totalPaid, payInfo.StartTime, time.Now(), "admin_cancelled", &adminID)
if err == nil {
refundResult = calc
}
}
}
tx, err := db.DB.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 before updating
var originalStatus string
var bookingUserID string
err = tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus, &bookingUserID)
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 = 'we_cancelled', updated_at = $1
WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress')
`, time.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
}
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
}
if refundResult != nil && refundResult.RefundableAmount > 0 {
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Booking cancelled",
"refund_calculation": refundResult,
})
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.DB.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)
}
}
// AdminEditBookingHandler allows an admin to modify the start time of any booking.
// Admin can edit any booking EXCEPT completed or cancelled bookings.
// Admin can create/edit bookings outside working hours (with warning).
// Admin can create/edit bookings that overlap with existing bookings (with warning).
func AdminEditBookingHandler(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 EditBookingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Basic validation: ensure the new time is not in the past
if time.Now().After(req.StartTime) {
http.Error(w, "Start time must be in the future", http.StatusBadRequest)
return
}
// Check if booking exists and is not completed/cancelled
var currentStatus string
err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking status %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Block edits on completed or cancelled bookings
if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
return
}
// Get booking duration for overlap check
var durationMinutes int
err = db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, 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 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
) sub
`, bookingID).Scan(&durationMinutes)
if err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60 // fallback
}
// Check for overlapping bookings (excluding the current booking)
var overlapCount int
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
err = db.DB.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 start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT 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 = bookings.id
) sub
)) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount)
if err != nil {
log.Printf("Failed to check overlap %s: %v", bookingID, err)
}
// Check if salon is closed (exceptional hours) - admin gets warning but can proceed
// 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())
if daysToMonday == 0 {
daysToMonday = 7
}
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
// Check if salon is closed (exceptional hours)
var isClosed bool
err = db.DB.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)
}
isOutsideWorkingHours := isClosed
// Prevent overlap - block admin
if overlapCount > 0 {
http.Error(w, "This booking overlaps with an existing booking", http.StatusConflict)
return
}
// Build warning for outside working hours (admin can proceed with warning)
var warnings []string
if isOutsideWorkingHours {
warnings = append(warnings, "Warning: This booking is outside standard working hours")
}
// Check for time blocker overlap - admin can proceed with warning
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime)
if err != nil {
log.Printf("Failed to check time blocker overlap: %v", err)
}
if blockerOverlap {
warnings = append(warnings, fmt.Sprintf("Warning: This booking overlaps with a time blocker: %s", blockerDesc))
}
// Perform the update
tx, err := db.DB.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())
res, err := tx.Exec(r.Context(), `
UPDATE bookings
SET start_time = $1, updated_at = $2
WHERE id = $3
`, req.StartTime, time.Now(), bookingID)
if err != nil {
log.Printf("Failed to edit 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 found", http.StatusNotFound)
return
}
// Clear any pending edit requests for this booking (admin edit takes priority)
_, err = tx.Exec(r.Context(), `
DELETE FROM booking_edit_requests
WHERE booking_id = $1
`, bookingID)
if err != nil {
log.Printf("Failed to clear edit requests for booking %s: %v", bookingID, err)
// Don't fail the request, just log the error
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// TODO: Notify user that their edit request was superseded by admin direct edit (blocked on E5 SMTP)
// Return warnings if any
if len(warnings) > 0 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Booking updated",
"warnings": warnings,
})
return
}
w.WriteHeader(http.StatusNoContent)
}
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"`
}
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.DB.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.DB.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.DB.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.DB.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
db.DB.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.DB.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.DB.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.DB.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.DB.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.
weekday := int((req.StartTime.Weekday() + 6) % 7)
daysToMonday := int(req.StartTime.Weekday())
if daysToMonday == 0 {
daysToMonday = 7 // Sunday -> next Monday
}
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
bookingTime := req.StartTime.Format("15:04:05")
// Check if there's an exceptional hours entry that makes this time unavailable
var isClosed bool
var checkErr error
checkErr = db.DB.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
db.DB.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)
newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute)
var cnt int
db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(dur),60) FROM (SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id UNION ALL SELECT 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=bookings.id) sub)) > $1
`, req.StartTime, newEnd).Scan(&cnt)
if cnt > 0 {
http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
return
}
// Check for time blocker overlap - admin can proceed with warning
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEnd)
if err != nil {
log.Printf("Failed to check time blocker overlap: %v", err)
}
tx, err := db.DB.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 _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEnd); evictErr != nil {
log.Printf("Failed to evict pending_release bookings (admin create): %v", evictErr)
}
// Create booking directly as confirmed
bookingQuery := `
INSERT INTO bookings (
user_id,
start_time,
status,
notes,
created_by,
idempotency_key
)
VALUES ($1, $2, 'confirmed', $3, $4, $5)
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
`
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 != ""},
).Scan(
&booking.ID,
&booking.User.ID,
&booking.StartTime,
&booking.Status,
&booking.Notes,
&booking.CreatedAt,
&booking.UpdatedAt,
&booking.CreatedBy,
)
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
}
for _, csID := range req.CustomServiceIDs {
if _, err := tx.Exec(r.Context(), `
UPDATE custom_services SET usage_count = usage_count + 1, last_used_at = NOW() WHERE id = $1
`, csID); 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.DB.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.DB.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.DB.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.DB.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.DB.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.DB.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.DB.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.DB.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(time.Now()).Hours()
db.DB.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.DB.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.DB.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.DB.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 {
_ = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM services s WHERE s.id = ANY($1)
`, req.NewServices).Scan(&durMinutes)
} else {
_ = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, 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 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
) sub
`, bookingID).Scan(&durMinutes)
}
if durMinutes <= 0 {
durMinutes = 60
}
// Quick overlap check — block if slot is taken
newEnd := req.NewStartTime.Add(time.Duration(durMinutes) * time.Minute)
var overlapCount int
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 start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id
UNION ALL
SELECT 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 = bookings.id
) sub
)) > $2
`, bookingID, *req.NewStartTime, newEnd).Scan(&overlapCount)
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, time.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 {
_, _ = tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID)
_, _ = tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID)
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)
}
}
}
// Clean up the edit request and reservations
_, _ = tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", editReq.ID)
_, _ = tx.Exec(r.Context(), `DELETE FROM time_blockers WHERE description = $1`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
_, _ = 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 := 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 COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, 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 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
) sub
`, 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.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total)
rows, err := db.DB.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) {
requestID := chi.URLParam(r, "request_id")
if requestID == "" || !validators.IsValidID(requestID) {
http.Error(w, "Edit request not found", http.StatusNotFound)
return
}
tx, err := db.DB.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
db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount)
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 COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, 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 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
) sub
`, 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)
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 start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id
UNION ALL
SELECT 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 = bookings.id
) sub
)) > $2
`, bookingID, *newStartTime, newEndTime).Scan(&overlapCount)
if err != nil {
log.Printf("Failed to check overlap: %v", err)
}
if overlapCount > 0 {
http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict)
return
}
blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime)
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)
weekday := int((newStartTime.Weekday() + 6) % 7)
bookingTime := newStartTime.Format("15:04:05")
daysToMonday := int(newStartTime.Weekday())
if daysToMonday == 0 {
daysToMonday = 7
}
weekStart := newStartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
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)
}
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, time.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
}
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 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.DB.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.DB.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.DB.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.DB.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.DB.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()
var enrichedRequests []*EnrichedEditRequest
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
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.DB.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()
var enrichedRequests []*EnrichedEditRequest
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
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.DB.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.DB.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, userID string) (bool, error) {
count, err := CountUnforgivenNoShows(ctx, userID)
if err != nil {
return false, err
}
if count >= 2 {
// Apply 3 deposits
_, err := db.DB.Exec(ctx, `
UPDATE users SET deposits_required = 3 WHERE id = $1
`, userID)
if err != nil {
return false, err
}
return true, nil
}
return false, nil
}