Files
Crussell/backend/handlers/bookings/manage.go
T
popertotsandSisyphus 577cceb304 fix(bookings): add request struct validation and fix patch test notice logic
Add validators.Validate.Struct() calls across booking handlers. Fix patch test notice period check to compare against booking start time (not current time) and fix expiry check similarly. Update test to match new error message.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 18:49:07 +01:00

2026 lines
64 KiB
Go

package bookings
import (
"context"
"crussell/db"
"crussell/handlers/notifications"
"crussell/internal/validators"
"crussell/handlers/scheduling"
"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, sql.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
}
_, _ = tx.Exec(r.Context(), `
DELETE FROM booking_edit_requests WHERE booking_id = $1
`, bookingID)
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
_, _ = tx.Exec(r.Context(), `
DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'
`, bookingID)
// 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)
}
// AdminCancelBookingHandler allows an admin to cancel any booking.
// The update uses a status filter and checks RowsAffected for existence.
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
}
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", bookingID).Scan(&originalStatus)
if err != nil {
if errors.Is(err, sql.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
}
// 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
}
}
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
}
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, sql.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, sql.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(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, 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')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
)) > $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
res, err := db.DB.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 = db.DB.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
}
// 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" validate:"required,min=1"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff
EnforceDeposits *bool `json:"enforce_deposits,omitempty"` // Optional: if true, enforce outstanding deposit checks; if false or omitted, bypass checks
}
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
`, 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 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
// Check patch test requirements for all services
for _, serviceID := range req.ServiceIDs {
// Find patch test for this service
var patchTestID string
var noticeHours int
err := db.DB.QueryRow(r.Context(), `
SELECT id, notice_duration_hours
FROM patch_tests
WHERE $1 = ANY(service_ids)
`, serviceID).Scan(&patchTestID, &noticeHours)
if err == nil {
// Service requires a patch test - check if user has valid record
var testedAt time.Time
err = db.DB.QueryRow(r.Context(), `
SELECT tested_at
FROM user_patch_tests
WHERE user_id = $1 AND patch_test_id = $2
`, req.UserID, patchTestID).Scan(&testedAt)
if err != nil {
// No valid patch test record
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
return
}
// Check if notice period has passed
eligibleFrom := testedAt.Add(time.Duration(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
}
// Check if patch test has expired
var expiryMonths int
err = db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths)
if err == nil {
expiresAt := testedAt.AddDate(0, 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 {
http.Error(w, "At least one 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
var dur int
db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
`, req.ServiceIDs).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 ('confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id)) > $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())
// 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
serviceInsertQuery := `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`
for _, serviceID := range req.ServiceIDs {
_, err := tx.Exec(r.Context(), serviceInsertQuery, booking.ID, serviceID)
if err != nil {
log.Printf("Failed to insert booking service %s: %v", serviceID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// 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 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"`
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"`
}
type EditUserSummary struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,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"`
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
ORDER BY s.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
err := db.DB.QueryRow(ctx, `
SELECT id, fn, email, phone FROM users WHERE id = $1
`, userID).Scan(&summary.ID, &summary.FullName, &summary.Email, &summary.Phone)
if err != nil {
return nil, err
}
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, sql.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
}
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
// 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"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// 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, sql.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
err = db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus)
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
}
if len(req.NewServices) > 0 {
var overrideCount int
err = db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM booking_services
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
`, 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
}
if req.NewStartTime != nil {
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
var durationMinutes 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(&durationMinutes)
} else {
_ = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_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
`
countQuery := `SELECT COUNT(*) FROM booking_edit_requests ber`
var args []interface{}
baseQuery += " ORDER BY ber.updated_at DESC"
// Get total count
var total int
err := db.DB.QueryRow(r.Context(), countQuery, args...).Scan(&total)
if err != nil {
log.Printf("Failed to count edit requests: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
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, sql.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
}
// 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(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_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(s.duration_minutes), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, 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')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
)) > $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, blockerDesc, 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, fmt.Sprintf("This edit would overlap with a time blocker: %s", blockerDesc), 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
}
// 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
}
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
// 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, sql.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
}
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
// 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, sql.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, sql.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,
})
}
// 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, sql.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
}