Files
Crussell/backend/handlers/bookings/bookings.go
T
popertots e1c815ed09 fix(loyalty): correct stamp accumulation, one-per-day guard, and test coverage
- Handler: apply existing redemption BEFORE incrementing stamps (was creating
  and applying redemption to same booking)
- Handler: guard stamp increment behind bookingTotal > 0 (free bookings don't
  earn stamps)
- Handler: fix global milestone off-by-one (globalCount already includes
  current booking since status updated before discount logic)
- Schema: chk_milestone constraint only requires milestone_unit for anniversary
  type, not per_user_booking_count or global_booking_count
- Tests: rewrite from scratch with 12 focused tests:
  - FullCycle, ExistingRedemptionApplies, OneStampPerDay, ZeroTotalNoStamp,
    CycleRepeats, TimeBasedCampaign, PerUserMilestone, GlobalMilestone,
    AnniversaryMilestone, LoyaltyPriority, NoDiscountOnZeroTotal,
    CampaignMaxRedemptions
- Tests: fix path parameter extraction in makeProgressRequest
- Vendor: go mod tidy + vendor for testify dependency
2026-05-10 13:17:08 +01:00

2638 lines
92 KiB
Go

package bookings
import (
"crussell/db"
"crussell/handlers/notifications"
"crussell/handlers/scheduling"
"crussell/internal/dav"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
var londonLocation = func() *time.Location {
loc, err := time.LoadLocation("Europe/London")
if err != nil {
panic("Europe/London timezone not available")
}
return loc
}()
// Booking represents a booking in the system
type Booking struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *string `json:"created_by,omitempty"`
// Deposit fields.
// DepositRequired is snapshotted at creation from users.deposits_required > 0
// and stored on the bookings row — so historic bookings reflect the obligation
// that existed when they were made, not the user's current standing.
DepositRequired bool `json:"deposit_required"`
DepositAmount float64 `json:"deposit_amount,omitempty"`
DepositPaid bool `json:"deposit_paid"`
DepositDeadline *string `json:"deposit_deadline,omitempty"`
// Joined fields
User *UserSummary `json:"user,omitempty"`
Services []BookingService `json:"services,omitempty"`
Payments []Payment `json:"payments,omitempty"`
TotalAmount float64 `json:"total_amount"`
AmountPaid float64 `json:"amount_paid"`
AmountDue float64 `json:"amount_due"`
DurationMinutes int `json:"duration_minutes"`
}
// populateDepositFields sets the computed deposit fields on a Booking.
// It must be called after TotalAmount, AmountPaid, and StartTime are already set.
//
// - depositRequired: snapshotted value from bookings.deposit_required (set at creation).
// - preStartAmountPaid: sum of completed payments whose created_at < booking.start_time.
func populateDepositFields(b *Booking, depositRequired bool, preStartAmountPaid float64) {
b.DepositRequired = depositRequired
b.DepositAmount = b.TotalAmount * 0.20
// A deposit is considered paid when pre-start payments cover the deposit amount.
// We only declare it paid when a deposit was actually required, so that
// bookings with no deposit obligation don't incorrectly show DepositPaid: true.
b.DepositPaid = depositRequired && preStartAmountPaid >= b.DepositAmount
deadline := b.StartTime.Add(-24 * time.Hour).Format(time.RFC3339)
b.DepositDeadline = &deadline
}
// BookingService represents a service associated with a booking
type BookingService struct {
BookingID string `json:"booking_id"`
ServiceID string `json:"service_id"`
OverridePrice *float64 `json:"override_price,omitempty"`
OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"`
// Service details (joined)
ServiceName *string `json:"service_name,omitempty"`
ServiceDescription *string `json:"service_description,omitempty"`
Price *float64 `json:"price,omitempty"`
DurationMinutes *int `json:"duration_minutes,omitempty"`
}
// Payment represents a payment associated with a booking
type Payment struct {
ID string `json:"id"`
BookingID string `json:"booking_id"`
PaymentType string `json:"payment_type"`
PaymentMethod string `json:"payment_method"`
VendorCode *string `json:"vendor_code,omitempty"`
InvoiceNumber *int `json:"invoice_number,omitempty"`
Status string `json:"status"`
Amount float64 `json:"amount"`
IsVATApplicable bool `json:"is_vat_applicable"`
VATRate *float64 `json:"vat_rate,omitempty"`
VATAmount *float64 `json:"vat_amount,omitempty"`
NetAmount *float64 `json:"net_amount,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *string `json:"created_by,omitempty"`
}
// CreateBookingRequest represents the request payload for creating a new booking
type CreateBookingRequest struct {
StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
Notes *string `json:"notes,omitempty"`
UserID *string `json:"user_id,omitempty"`
}
// EditBookingRequest represents the request payload for editing a booking's start time
type EditBookingRequest struct {
StartTime time.Time `json:"start_time" validate:"required"`
}
// ProgressBookingRequest represents the request payload for updating a booking's status
type ProgressBookingRequest struct {
Status string `json:"status" validate:"required,oneof=pending confirmed in_progress completed client_cancelled we_cancelled re-schedule no_show"`
}
// ConfirmBookingRequest represents the request payload for confirming a booking
type ConfirmBookingRequest struct {
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty"`
}
// ServiceOverride represents override values for a specific service in a booking
type ServiceOverride struct {
ServiceID string `json:"service_id" validate:"required"`
OverridePrice *float64 `json:"override_price,omitempty"`
OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"`
}
// DeleteBookingRequest represents the request payload for deleting a booking with payment
type DeleteBookingRequest struct {
Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"`
ForgiveNoShow *bool `json:"forgive_no_show,omitempty"` // Admin-only: forgive a no-show at cancellation time
}
// AdminUserSummary represents a small user summary for admin views
type AdminUserSummary struct {
FullName string `json:"full_name"`
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
Notes *string `json:"notes,omitempty"`
}
// AdminBookingSummary represents a complete booking summary for admin view
type AdminBookingSummary struct {
Booking Booking `json:"booking"`
User *AdminUserSummary `json:"user,omitempty"`
Services []BookingServiceDetail `json:"services"`
Payments []Payment `json:"payments"`
TotalAmount float64 `json:"total_amount"`
AmountPaid float64 `json:"amount_paid"`
AmountDue float64 `json:"amount_due"`
DurationMinutes int `json:"duration_minutes"`
}
type UserSummary struct {
ID string `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
FullName string `json:"full_name"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
DateOfBirth *string `json:"date_of_birth,omitempty"`
AccountRole string `json:"account_role"`
LoyaltyStamps *int `json:"loyalty_stamps,omitempty"`
ReferralCode *string `json:"referral_code,omitempty"`
ReferralCodeUses *int `json:"referral_code_uses,omitempty"`
CreatedAt string `json:"created_at"`
Notes *string `json:"notes,omitempty"`
}
type BookingServiceDetail struct {
ServiceName string `json:"service_name"`
ServiceDescription *string `json:"service_description,omitempty"`
BasePrice float64 `json:"base_price"`
BaseDurationMinutes int `json:"base_duration_minutes"`
OverridePrice *float64 `json:"override_price,omitempty"`
OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"`
IsActive bool `json:"is_active"`
RequiresPatchTest bool `json:"requires_patch_test"`
MinimumAgeRequired int `json:"minimum_age_required"`
}
// GetAllBookingsRequest represents query parameters for getting all bookings
type GetAllBookingsRequest struct {
Status *string `json:"status,omitempty"`
StartDate *string `json:"start_date,omitempty"`
EndDate *string `json:"end_date,omitempty"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// BookingListResponse represents a paginated list of bookings
type BookingListResponse struct {
Bookings []Booking `json:"bookings"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalPages int `json:"totalPages"`
}
// SearchBookingsRequest represents search parameters
type SearchBookingsRequest struct {
Query string `json:"query"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// SearchBookingsResponse represents search results
type SearchBookingsResponse struct {
Bookings []AdminBookingSummary `json:"bookings"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
}
// Enhanced booking response for user endpoints
type UserBookingDetail struct {
Booking Booking `json:"booking"`
TotalAmount float64 `json:"total_amount"`
AmountPaid float64 `json:"amount_paid"`
AmountDue float64 `json:"amount_due"`
DurationMinutes int `json:"duration_minutes"`
}
// Enhanced booking response for admin endpoints
type AdminBookingDetail struct {
Booking Booking `json:"booking"`
User *AdminUserSummary `json:"user,omitempty"`
TotalAmount float64 `json:"total_amount"`
AmountPaid float64 `json:"amount_paid"`
AmountDue float64 `json:"amount_due"`
DurationMinutes int `json:"duration_minutes"`
}
// roundTo2 rounds a float64 to 2 decimal places
func roundTo2(f float64) float64 {
return float64(int(f*100+0.5)) / 100
}
// Helper function to parse query parameters
func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest {
req := GetAllBookingsRequest{
Page: 1,
PerPage: 10,
}
if status := r.URL.Query().Get("status"); status != "" {
req.Status = &status
}
if startDate := r.URL.Query().Get("start_date"); startDate != "" {
req.StartDate = &startDate
}
if endDate := r.URL.Query().Get("end_date"); endDate != "" {
req.EndDate = &endDate
}
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil && page > 0 {
req.Page = page
}
}
if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" {
if perPage, err := strconv.Atoi(perPageStr); err == nil && perPage > 0 && perPage <= 100 {
req.PerPage = perPage
}
}
return req
}
// GET /api/bookings
func GetAllUserBookingsHandler(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
}
req := parseGetAllBookingsRequest(r)
// deposit_required is read from the bookings row (snapshotted at creation).
// pre_start_amount_paid sums completed payments created before start_time.
baseQuery := `
SELECT
b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
(SELECT COALESCE(SUM(CASE
WHEN bs.override_price IS NOT NULL THEN bs.override_price
ELSE s.price
END), 0)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = b.id) AS total_amount,
(SELECT COALESCE(SUM(amount), 0)
FROM payments
WHERE booking_id = b.id AND status = 'completed') AS amount_paid,
(SELECT COALESCE(SUM(CASE
WHEN bs.override_duration_minutes IS NOT NULL THEN bs.override_duration_minutes
ELSE s.duration_minutes
END), 0)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = b.id) AS duration_minutes,
b.deposit_required,
(SELECT COALESCE(SUM(amount), 0)
FROM payments
WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time) AS pre_start_amount_paid
FROM bookings b
WHERE b.user_id = $1
`
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
var args, countArgs []interface{}
args = append(args, userID)
countArgs = append(countArgs, userID)
paramCount := 2
if req.Status != nil {
baseQuery += fmt.Sprintf(" AND b.status = $%d", paramCount)
countQuery += fmt.Sprintf(" AND status = $%d", paramCount)
args = append(args, *req.Status)
countArgs = append(countArgs, *req.Status)
paramCount++
}
if req.StartDate != nil {
baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount)
countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount)
startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation)
if err != nil {
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
}
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation)
args = append(args, startTime)
countArgs = append(countArgs, startTime)
paramCount++
}
if req.EndDate != nil {
baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount)
countQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount)
endTime, err := time.Parse("2006-01-02", *req.EndDate)
if err != nil {
http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
}
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
args = append(args, endTime)
countArgs = append(countArgs, endTime)
paramCount++
}
baseQuery += " ORDER BY b.start_time ASC"
if req.PerPage > 0 {
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1)
args = append(args, req.PerPage, (req.Page-1)*req.PerPage)
}
var total int
if err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total); err != nil {
log.Printf("Failed to get booking count for user %s: %v", userID, 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 bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var bookings []Booking
for rows.Next() {
var b Booking
var createdBy sql.NullString
var totalAmount, amountPaid, preStartAmountPaid float64
var durationMinutes int
var depositRequired bool
if err := rows.Scan(
&b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy,
&totalAmount, &amountPaid, &durationMinutes,
&depositRequired, &preStartAmountPaid,
); err != nil {
log.Printf("Failed to scan booking row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if createdBy.Valid {
b.CreatedBy = &createdBy.String
}
b.TotalAmount = totalAmount
b.AmountPaid = amountPaid
b.AmountDue = totalAmount - amountPaid
b.DurationMinutes = durationMinutes
populateDepositFields(&b, depositRequired, preStartAmountPaid)
bookings = append(bookings, b)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(BookingListResponse{
Bookings: bookings,
Page: req.Page,
PerPage: req.PerPage,
Total: total,
}); err != nil {
log.Printf("Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// GET /api/admin/bookings
func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
req := parseGetAllBookingsRequest(r)
baseQuery := `
WITH booking_totals AS (
SELECT
bs.booking_id,
SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) AS total_duration,
SUM(COALESCE(bs.override_price, s.price)) AS total_amount
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
GROUP BY bs.booking_id
),
payment_totals AS (
SELECT
booking_id,
SUM(amount) AS total_paid
FROM payments
WHERE status = 'completed'
GROUP BY booking_id
)
SELECT
b.id,
b.start_time,
b.status,
u.fn,
COALESCE(bt.total_duration, 0) AS duration_minutes,
COALESCE(bt.total_amount, 0) AS total_amount,
COALESCE(pt.total_paid, 0) AS amount_paid,
COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due,
b.deposit_required,
(SELECT COALESCE(SUM(p2.amount), 0)
FROM payments p2
WHERE p2.booking_id = b.id AND p2.status = 'completed' AND p2.created_at < b.start_time) AS pre_start_amount_paid
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN booking_totals bt ON b.id = bt.booking_id
LEFT JOIN payment_totals pt ON b.id = pt.booking_id
`
countQuery := `SELECT COUNT(*) FROM bookings b`
var args []interface{}
paramCount := 1
whereAdded := false
addWhereClause := func(condition string) {
if whereAdded {
baseQuery += " AND " + condition
countQuery += " AND " + condition
} else {
baseQuery += " WHERE " + condition
countQuery += " WHERE " + condition
whereAdded = true
}
}
if req.Status != nil {
addWhereClause(fmt.Sprintf("b.status = $%d", paramCount))
args = append(args, *req.Status)
paramCount++
}
if req.StartDate != nil {
addWhereClause(fmt.Sprintf("b.start_time >= $%d", paramCount))
startTime, err := time.Parse("2006-01-02", *req.StartDate)
if err != nil {
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
}
args = append(args, startTime)
paramCount++
}
if req.EndDate != nil {
addWhereClause(fmt.Sprintf("b.start_time <= $%d", paramCount))
endTime, err := time.Parse("2006-01-02", *req.EndDate)
if err != nil {
http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
}
args = append(args, endTime.Add(23*time.Hour+59*time.Minute+59*time.Second))
paramCount++
}
baseQuery += " ORDER BY b.start_time DESC"
if req.PerPage > 0 {
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1)
args = append(args, req.PerPage, (req.Page-1)*req.PerPage)
}
countArgs := args
if req.PerPage > 0 {
countArgs = args[:len(args)-2]
}
var total int
if err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total); err != nil {
log.Printf("Failed to get total booking count: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
totalPages := (total + req.PerPage - 1) / req.PerPage
if totalPages == 0 {
totalPages = 1
}
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
if err != nil {
log.Printf("Failed to fetch all bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var bookings []Booking
var bookingIDs []string
for rows.Next() {
var b Booking
var userFullName string
var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
var depositRequired bool
if err := rows.Scan(
&b.ID, &b.StartTime, &b.Status, &userFullName,
&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
&depositRequired, &preStartAmountPaid,
); err != nil {
log.Printf("Failed to scan booking row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
b.User = &UserSummary{FullName: userFullName}
b.TotalAmount = totalAmount
b.AmountPaid = amountPaid
b.AmountDue = amountDue
populateDepositFields(&b, depositRequired, preStartAmountPaid)
bookings = append(bookings, b)
bookingIDs = append(bookingIDs, b.ID)
}
if len(bookingIDs) > 0 {
serviceRows, err := db.DB.Query(r.Context(), `
SELECT bs.booking_id, s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1)
ORDER BY bs.booking_id, s.name
`, bookingIDs)
if err != nil {
log.Printf("Failed to fetch booking services: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer serviceRows.Close()
servicesByBooking := make(map[string][]BookingService)
for serviceRows.Next() {
var bookingID, serviceName string
if err := serviceRows.Scan(&bookingID, &serviceName); err != nil {
log.Printf("Failed to scan service row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
n := serviceName
servicesByBooking[bookingID] = append(servicesByBooking[bookingID], BookingService{
BookingID: bookingID,
ServiceName: &n,
})
}
for i := range bookings {
if services, exists := servicesByBooking[bookings[i].ID]; exists {
bookings[i].Services = services
}
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(BookingListResponse{
Bookings: bookings,
Page: req.Page,
PerPage: req.PerPage,
Total: total,
TotalPages: totalPages,
}); err != nil {
log.Printf("Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// GET /api/admin/bookings/user/{user_id}
func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "user_id")
if userID == "" || !validators.IsValidID(userID) {
http.Error(w, "User not found", http.StatusNotFound)
return
}
query := r.URL.Query()
page, perPage := 1, 5
if pageStr := query.Get("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
if perPageStr := query.Get("per_page"); perPageStr != "" {
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
perPage = pp
}
}
var total int
if err := db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1`, userID).Scan(&total); err != nil {
log.Printf("Failed to count bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rows, err := db.DB.Query(r.Context(), `
SELECT b.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.user_id = $1
ORDER BY b.start_time DESC
LIMIT $2 OFFSET $3
`, userID, perPage, (page-1)*perPage)
if err != nil {
log.Printf("Failed to fetch bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var bookings []Booking
for rows.Next() {
var b Booking
var depositRequired bool
if err := rows.Scan(
&b.ID, &b.StartTime, &b.Status, &b.Notes,
&b.CreatedAt, &b.UpdatedAt, &b.CreatedBy,
&depositRequired,
); err != nil {
log.Printf("Failed to scan booking row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
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
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, b.ID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", b.ID, err)
continue
}
var totalAmount float64
for serviceRows.Next() {
var svc BookingService
var price float64
var dur int
if err := serviceRows.Scan(&svc.ServiceName, &price, &dur); err != nil {
log.Printf("Failed to scan service: %v", err)
continue
}
svc.Price = &price
svc.DurationMinutes = &dur
totalAmount += price
b.Services = append(b.Services, svc)
}
serviceRows.Close()
// Fetch both all-time and pre-start paid amounts in one query
var amountPaid, preStartAmountPaid float64
db.DB.QueryRow(r.Context(), `
SELECT
COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0),
COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND created_at < $2), 0)
FROM payments
WHERE booking_id = $1
`, b.ID, b.StartTime).Scan(&amountPaid, &preStartAmountPaid)
b.TotalAmount = totalAmount
b.AmountPaid = amountPaid
b.AmountDue = totalAmount - amountPaid
populateDepositFields(&b, depositRequired, preStartAmountPaid)
bookings = append(bookings, b)
}
if bookings == nil {
bookings = []Booking{}
}
totalPages := (total + perPage - 1) / perPage
if totalPages == 0 {
totalPages = 1
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(BookingListResponse{
Bookings: bookings,
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}); err != nil {
log.Printf("Failed to encode bookings response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// GET /api/admin/bookings/{id}
func GetAdminBookingHandler(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 booking Booking
booking.User = &UserSummary{}
var depositRequired bool
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,
u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps,
u.referral_code, u.notes,
b.deposit_required
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.id = $1
`, bookingID).Scan(
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes,
&booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
&booking.User.FullName, &booking.User.Email, &booking.User.Phone,
&booking.User.ProfilePicURL, &booking.User.LoyaltyStamps,
&booking.User.ReferralCode, &booking.User.Notes,
&depositRequired,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var referralCodeUses int
if err := db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1
`, booking.User.ID).Scan(&referralCodeUses); err != nil {
log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err)
}
booking.User.ReferralCodeUses = &referralCodeUses
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
bs.service_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
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer serviceRows.Close()
var totalAmount float64
for serviceRows.Next() {
var serviceID, name string
var price float64
var dur int
if err := serviceRows.Scan(&serviceID, &name, &price, &dur); err != nil {
log.Printf("Failed to scan service for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
totalAmount += price
booking.DurationMinutes += dur
n, p, d := name, price, dur
booking.Services = append(booking.Services, BookingService{
ServiceID: serviceID,
ServiceName: &n,
Price: &p,
DurationMinutes: &d,
})
}
booking.TotalAmount = totalAmount
paymentRows, err := db.DB.Query(r.Context(), `
SELECT payment_type, payment_method, vendor_code, invoice_number,
status, amount, created_at
FROM payments
WHERE booking_id = $1
ORDER BY created_at ASC
`, bookingID)
if err != nil {
log.Printf("Failed to fetch payments for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer paymentRows.Close()
var amountPaid, preStartAmountPaid float64
for paymentRows.Next() {
var p Payment
var vendorCode sql.NullString
var invoiceNumber sql.NullInt32
if err := paymentRows.Scan(
&p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
&p.Status, &p.Amount, &p.CreatedAt,
); err != nil {
log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if vendorCode.Valid && vendorCode.String != "" {
p.VendorCode = &vendorCode.String
}
if invoiceNumber.Valid {
num := int(invoiceNumber.Int32)
p.InvoiceNumber = &num
}
booking.Payments = append(booking.Payments, p)
if p.Status == "completed" {
amountPaid += p.Amount
if p.CreatedAt.Before(booking.StartTime) {
preStartAmountPaid += p.Amount
}
}
}
booking.AmountPaid = amountPaid
booking.AmountDue = totalAmount - amountPaid
populateDepositFields(&booking, depositRequired, preStartAmountPaid)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode booking response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// GET /api/admin/bookings/search
func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "Search query 'q' is required", http.StatusBadRequest)
return
}
page, perPage := 1, 10
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" {
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
perPage = pp
}
}
escapedQuery := strings.ReplaceAll(query, `\`, `\\`)
escapedQuery = strings.ReplaceAll(escapedQuery, `%`, `\%`)
escapedQuery = strings.ReplaceAll(escapedQuery, `_`, `\_`)
searchPattern := "%" + escapedQuery + "%"
searchQuery := `
WITH booking_totals AS (
SELECT
bs.booking_id,
SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) AS total_duration,
SUM(COALESCE(bs.override_price, s.price)) AS total_amount
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
GROUP BY bs.booking_id
),
payment_totals AS (
SELECT
booking_id,
SUM(amount) AS total_paid
FROM payments
WHERE status = 'completed'
GROUP BY booking_id
)
SELECT
b.id,
b.start_time,
b.status,
u.fn AS full_name,
COALESCE(bt.total_duration, 0) AS duration_minutes,
COALESCE(bt.total_amount, 0) AS total_amount,
COALESCE(pt.total_paid, 0) AS amount_paid,
COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due,
b.deposit_required,
(SELECT COALESCE(SUM(p2.amount), 0)
FROM payments p2
WHERE p2.booking_id = b.id AND p2.status = 'completed' AND p2.created_at < b.start_time) AS pre_start_amount_paid
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN booking_totals bt ON b.id = bt.booking_id
LEFT JOIN payment_totals pt ON b.id = pt.booking_id
WHERE
b.id ILIKE $1 ESCAPE '\' OR
b.notes ILIKE $1 ESCAPE '\' OR
b.status::text ILIKE $1 ESCAPE '\' OR
u.n_first_name ILIKE $1 ESCAPE '\' OR
u.n_last_name ILIKE $1 ESCAPE '\' OR
u.fn ILIKE $1 ESCAPE '\' OR
u.email ILIKE $1 ESCAPE '\' OR
u.phone ILIKE $1 ESCAPE '\' OR
EXISTS (
SELECT 1 FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\'
)
ORDER BY b.start_time DESC
LIMIT $2 OFFSET $3
`
countQuery := `
SELECT COUNT(DISTINCT b.id)
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
WHERE
b.id ILIKE $1 ESCAPE '\' OR
b.notes ILIKE $1 ESCAPE '\' OR
b.status::text ILIKE $1 ESCAPE '\' OR
u.n_first_name ILIKE $1 ESCAPE '\' OR
u.n_last_name ILIKE $1 ESCAPE '\' OR
u.fn ILIKE $1 ESCAPE '\' OR
u.email ILIKE $1 ESCAPE '\' OR
u.phone ILIKE $1 ESCAPE '\' OR
s.name ILIKE $1 ESCAPE '\'
`
var total int
if err := db.DB.QueryRow(r.Context(), countQuery, searchPattern).Scan(&total); err != nil {
log.Printf("Failed to get search count: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
totalPages := (total + perPage - 1) / perPage
if totalPages == 0 {
totalPages = 1
}
rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, (page-1)*perPage)
if err != nil {
log.Printf("Failed to search bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var bookings []Booking
var bookingIDs []string
for rows.Next() {
var b Booking
var userFullName string
var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
var depositRequired bool
if err := rows.Scan(
&b.ID, &b.StartTime, &b.Status, &userFullName,
&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
&depositRequired, &preStartAmountPaid,
); err != nil {
log.Printf("Failed to scan booking row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
b.User = &UserSummary{FullName: userFullName}
b.TotalAmount = totalAmount
b.AmountPaid = amountPaid
b.AmountDue = amountDue
populateDepositFields(&b, depositRequired, preStartAmountPaid)
bookings = append(bookings, b)
bookingIDs = append(bookingIDs, b.ID)
}
if len(bookingIDs) > 0 {
serviceRows, err := db.DB.Query(r.Context(), `
SELECT bs.booking_id, s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1)
ORDER BY bs.booking_id, s.name
`, bookingIDs)
if err != nil {
log.Printf("Failed to fetch booking services: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer serviceRows.Close()
servicesByBooking := make(map[string][]BookingService)
for serviceRows.Next() {
var bookingID, serviceName string
if err := serviceRows.Scan(&bookingID, &serviceName); err != nil {
log.Printf("Failed to scan service row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
n := serviceName
servicesByBooking[bookingID] = append(servicesByBooking[bookingID], BookingService{
BookingID: bookingID,
ServiceName: &n,
})
}
for i := range bookings {
if services, exists := servicesByBooking[bookings[i].ID]; exists {
bookings[i].Services = services
} else {
bookings[i].Services = []BookingService{}
}
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(BookingListResponse{
Bookings: bookings,
Page: page,
PerPage: perPage,
Total: total,
TotalPages: totalPages,
}); err != nil {
log.Printf("Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// POST /api/bookings
func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
var req CreateBookingRequest
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
}
// 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
}
}
// If err is sql.ErrNoRows, proceed with creation
}
userID, ok := r.Context().Value(mw.UserIDKey).(string)
isGuest := false
if !ok || userID == "" {
if req.UserID == nil || *req.UserID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var accountRole string
if err := db.DB.QueryRow(r.Context(), `SELECT account_role FROM users WHERE id = $1`, *req.UserID).Scan(&accountRole); err != nil {
http.Error(w, "User not found", http.StatusBadRequest)
return
}
if accountRole != "guest" {
http.Error(w, "Invalid user_id - guest account required", http.StatusBadRequest)
return
}
userID = *req.UserID
isGuest = true
}
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
}
var depositsRequired int
if !isGuest {
if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired); err != nil {
log.Printf("Failed to fetch deposits_required for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if !isGuest && 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')
`, userID).Scan(&activeCount); err != nil {
log.Printf("Failed to check active bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if activeCount > 0 {
http.Error(w, "You already have an active booking. Complete or cancel it before creating a new one.", http.StatusConflict)
return
}
}
// Check 1h minimum advance for all users
if req.StartTime.Before(time.Now().Add(1 * time.Hour)) {
http.Error(w, "Bookings must be at least 1 hour in advance", http.StatusBadRequest)
return
}
// Deposit users must book at least 24h in advance to allow time for deposit payment
if !isGuest && depositsRequired > 0 && req.StartTime.Before(time.Now().Add(24*time.Hour)) {
http.Error(w, "When deposits are required, bookings must be made at least 24 hours in advance to allow time for payment.", http.StatusBadRequest)
return
}
// Snapshot whether a deposit is required at the moment of booking creation.
// Stored on the bookings row so historic GET responses are accurate regardless
// of the user's future deposits_required changes.
depositRequiredSnapshot := depositsRequired > 0
if !isGuest {
for _, serviceID := range req.ServiceIDs {
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 {
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
`, userID, patchTestID).Scan(&testedAt)
if err != nil {
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
return
}
eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
if time.Now().Before(eligibleFrom) {
hoursLeft := time.Until(eligibleFrom).Hours()
http.Error(w, fmt.Sprintf("You must wait %.0f hours after your patch test before booking this service.", hoursLeft), http.StatusBadRequest)
return
}
var expiryMonths int
if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil {
if time.Now().After(testedAt.AddDate(0, expiryMonths, 0)) {
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
return
}
}
}
}
}
if req.StartTime.Before(time.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return
}
var svcDuration int
if err := db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
`, req.ServiceIDs).Scan(&svcDuration); err != nil {
log.Printf("Failed to calc duration: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
weekday := int(req.StartTime.Weekday())
var closeStr string
if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
log.Printf("Failed to get hours: %v", err)
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
return
}
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
closeTime, _ := time.Parse("15:04:05", closeStr)
if endTime.Hour() > closeTime.Hour() || (endTime.Hour() == closeTime.Hour() && endTime.Minute() > closeTime.Minute()) {
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
return
}
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, endTime).Scan(&cnt)
if cnt > 0 {
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
return
}
// Check for time blocker overlap
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime)
if err != nil {
log.Printf("Failed to check time blocker overlap: %v", err)
} else if blockerOverlap {
http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict)
return
}
var createdBy *string
if creatorID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
createdBy = &creatorID
}
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 reservation for this user (max 1 per user)
// Also matches anon reservations by start_time for users who register mid-flow
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description LIKE 'RESERVATION:%'
AND (created_by = $1
OR (description LIKE 'RESERVATION:anon:%' AND start_time = $2))
`, userID, req.StartTime)
// Insert booking with snapshotted deposit_required
var booking Booking
booking.User = &UserSummary{}
if err := tx.QueryRow(r.Context(), `
INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key)
VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6)
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required
`, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}).Scan(
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
&booking.DepositRequired,
); err != nil {
log.Printf("Failed to create booking for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for _, serviceID := range req.ServiceIDs {
if _, err := tx.Exec(r.Context(), `INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)`, booking.ID, serviceID); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Determine notification reason: has notes OR booking is for today
notificationReason := "pending_booking"
if req.Notes != nil && *req.Notes != "" {
notificationReason = "pending_booking" // Notes means pending approval too
} else {
// Check if booking is for today (same day in London timezone)
london, _ := time.LoadLocation("Europe/London")
now := time.Now().In(london)
bookingDay := req.StartTime.In(london)
if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.YearDay() {
notificationReason = "pending_booking" // Today's booking
}
}
if _, err := tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)
`, notificationReason, booking.ID, userID); err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Fetch services for the response
booking.Services = []BookingService{}
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
`, booking.ID)
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
}
booking.Services = append(booking.Services, bs)
}
}
// Populate deposit display fields on the creation response.
// No payments exist yet so pre-start paid is 0 and DepositPaid will be false.
populateDepositFields(&booking, depositRequiredSnapshot, 0)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode booking response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// PUT /api/bookings/{id}
func EditBookingHandler(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 EditBookingRequest
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 req.StartTime.IsZero() {
http.Error(w, "Start time is required", http.StatusBadRequest)
return
}
if req.StartTime.Before(time.Now()) {
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
return
}
var currentStatus string
if err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&currentStatus); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
return
}
log.Printf("Failed to get booking %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
}
var durationMinutes int
if 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); err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60
}
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
var overlapCount int
if 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); err != nil {
log.Printf("Failed to check overlap %s: %v", bookingID, err)
}
if overlapCount > 0 {
http.Error(w, "This time slot overlaps with an existing booking", http.StatusConflict)
return
}
// Check for time blocker overlap
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime)
if err != nil {
log.Printf("Failed to check time blocker overlap: %v", err)
} else if blockerOverlap {
http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict)
return
}
weekday := int(req.StartTime.Weekday())
bookingTime := req.StartTime.Format("15:04:05")
daysToMonday := weekday
if daysToMonday == 0 {
daysToMonday = 7
}
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
var isClosed bool
if 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); err != nil {
log.Printf("Failed to check exceptional hours: %v", err)
}
if isClosed {
http.Error(w, "Cannot book on a closed day", http.StatusBadRequest)
return
}
var booking Booking
booking.User = &UserSummary{}
if err := db.DB.QueryRow(r.Context(), `
UPDATE bookings
SET start_time = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
`, req.StartTime, bookingID, userID).Scan(
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
return
}
log.Printf("Failed to update booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode booking response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// PUT /api/bookings/{id}/progress
func ProgressBookingHandler(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 ProgressBookingRequest
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
}
allowed := map[string]bool{
"pending": true, "confirmed": true, "in_progress": true,
"completed": true, "client_cancelled": true, "we_cancelled": true,
"re-schedule": true, "no_show": true,
}
if !allowed[req.Status] {
http.Error(w, "Invalid status", http.StatusBadRequest)
return
}
var booking Booking
booking.User = &UserSummary{}
if err := db.DB.QueryRow(r.Context(), `
UPDATE bookings
SET status = $1, updated_at = NOW()
WHERE id = $2
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
`, req.Status, bookingID).Scan(
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to update booking status for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if req.Status == "completed" {
rows, err := db.DB.Query(r.Context(), `
SELECT DISTINCT pt.id
FROM patch_tests pt
JOIN booking_services bs ON bs.booking_id = $1
WHERE pt.id IN (
SELECT pt_inner.id FROM patch_tests pt_inner WHERE bs.service_id = ANY(pt_inner.service_ids)
)
`, bookingID)
if err != nil {
log.Printf("Failed to fetch patch tests for booking %s: %v", bookingID, err)
} else {
defer rows.Close()
for rows.Next() {
var patchTestID string
if err := rows.Scan(&patchTestID); err != nil {
log.Printf("Failed to scan patch test: %v", err)
continue
}
if _, err := db.DB.Exec(r.Context(), `
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW()
`, booking.User.ID, patchTestID); err != nil {
log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, patchTestID, err)
}
}
}
var bookingTotal float64
if err := db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, bookingID).Scan(&bookingTotal); err != nil {
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
}
// Apply existing pending loyalty redemption (earned from previous 10 bookings)
if bookingTotal > 0 {
var redemptionID string
if err := db.DB.QueryRow(r.Context(), `
SELECT id FROM loyalty_redemptions
WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW()
ORDER BY redeemed_at ASC LIMIT 1
`, booking.User.ID).Scan(&redemptionID); err == nil && redemptionID != "" {
discountAmount := roundTo2(bookingTotal * 0.10)
_, _ = db.DB.Exec(r.Context(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'loyalty', $3, NULL, NULL, 10.00, $4, $5)
`, bookingID, booking.User.ID, redemptionID, bookingTotal, discountAmount)
_, _ = db.DB.Exec(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, booking.User.ID)
_, _ = db.DB.Exec(r.Context(), `
UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW()
WHERE id = $2
`, bookingID, redemptionID)
_, _ = db.DB.Exec(r.Context(), `
UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - 10) WHERE id = $1
`, booking.User.ID)
}
}
// Increment stamps (max 1 per day, only for paid bookings)
if bookingTotal > 0 {
if _, err := db.DB.Exec(r.Context(), `
UPDATE users
SET loyalty_stamps = loyalty_stamps + 1
WHERE id = $1
AND NOT EXISTS (
SELECT 1 FROM bookings b
WHERE b.user_id = users.id
AND b.status = 'completed'
AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
AND b.id != $2
)
`, booking.User.ID, bookingID); err != nil {
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
}
}
// Create pending redemption when stamps reach 10
var newStampCount int
if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, booking.User.ID).Scan(&newStampCount); err == nil && newStampCount == 10 {
_, err = db.DB.Exec(r.Context(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, 10, 'pending', NOW())
`, booking.User.ID)
if err != nil {
log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err)
}
}
if bookingTotal > 0 {
var hasLoyaltyDiscount bool
_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&hasLoyaltyDiscount)
if !hasLoyaltyDiscount {
var campaignID string
var campaignPercent float64
if err := db.DB.QueryRow(r.Context(), `
SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'time_based'
AND start_date <= NOW() AND end_date >= NOW()
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
ORDER BY discount_percent DESC LIMIT 1
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
_, _ = db.DB.Exec(r.Context(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
`, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount)
_, _ = db.DB.Exec(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, booking.User.ID)
_, _ = db.DB.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, campaignID)
}
}
}
if bookingTotal > 0 {
var hasDiscount bool
_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)`, bookingID).Scan(&hasDiscount)
if !hasDiscount {
var userBookingCount int
_ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount)
var milestoneCampaignID string
var milestonePercent float64
_ = db.DB.QueryRow(r.Context(), `
SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
AND milestone_value = $1
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
`, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent)
if milestoneCampaignID != "" {
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
_, _ = db.DB.Exec(r.Context(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
`, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount)
_, _ = db.DB.Exec(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, booking.User.ID)
_, _ = db.DB.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, milestoneCampaignID)
}
if milestoneCampaignID == "" {
var globalCount int
_ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
var globalCampaignID string
var globalPercent float64
_ = db.DB.QueryRow(r.Context(), `
SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
AND milestone_value = $1
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
`, globalCount).Scan(&globalCampaignID, &globalPercent)
if globalCampaignID != "" {
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
_, _ = db.DB.Exec(r.Context(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
`, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount)
_, _ = db.DB.Exec(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, booking.User.ID)
_, _ = db.DB.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, globalCampaignID)
}
}
if milestoneCampaignID == "" {
var firstVisitDate time.Time
_ = db.DB.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate)
if !firstVisitDate.IsZero() {
rows, err := db.DB.Query(r.Context(), `
SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
`, booking.User.ID)
if err == nil {
defer rows.Close()
for rows.Next() {
var annID string
var annPercent float64
var annValue int
var annUnit string
if rows.Scan(&annID, &annPercent, &annValue, &annUnit) == nil {
var matches bool
elapsed := time.Since(firstVisitDate)
switch annUnit {
case "months":
months := int(elapsed.Hours() / (30 * 24))
matches = months >= annValue
case "years":
years := int(elapsed.Hours() / (365.25 * 24))
matches = years >= annValue
}
if matches {
discountAmount := roundTo2(bookingTotal * annPercent / 100)
_, _ = db.DB.Exec(r.Context(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
`, bookingID, booking.User.ID, annID, annPercent, bookingTotal, discountAmount)
_, _ = db.DB.Exec(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, booking.User.ID)
_, _ = db.DB.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, annID)
break
}
}
}
}
}
}
}
}
var paymentCount int
db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount)
if paymentCount > 0 {
db.DB.Exec(r.Context(), `UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) WHERE id = $1`, booking.User.ID)
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode booking response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// POST /api/bookings/{id}/confirm
func ConfirmBookingHandler(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 ConfirmBookingRequest
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
}
for _, override := range req.ServiceOverrides {
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
}
}
var bkStart time.Time
if err := db.DB.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil {
log.Printf("Failed to get start time: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var dur int
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(&dur)
newEnd := bkStart.Add(time.Duration(dur) * time.Minute)
var cnt int
db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('confirmed','in_progress','completed')
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, bkStart, newEnd).Scan(&cnt)
if cnt > 0 {
http.Error(w, "Cannot confirm - time slot overlaps with existing booking", http.StatusConflict)
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())
var booking Booking
booking.User = &UserSummary{}
if err := tx.QueryRow(r.Context(), `
UPDATE bookings
SET status = 'confirmed', notes = COALESCE($1, notes), updated_at = NOW()
WHERE id = $2 AND status = 'pending'
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
`, req.Notes, bookingID).Scan(
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found or already confirmed", http.StatusNotFound)
return
}
log.Printf("Failed to confirm booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if len(req.ServiceOverrides) > 0 {
serviceIDs := make([]string, len(req.ServiceOverrides))
for i, o := range req.ServiceOverrides {
serviceIDs[i] = o.ServiceID
}
var count int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM booking_services WHERE booking_id = $1 AND service_id = ANY($2)
`, bookingID, serviceIDs).Scan(&count); err != nil {
log.Printf("Failed to verify services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if count != len(req.ServiceOverrides) {
http.Error(w, "One or more service IDs do not belong to this booking", http.StatusBadRequest)
return
}
for _, override := range req.ServiceOverrides {
if _, err := tx.Exec(r.Context(), `
UPDATE booking_services
SET override_price = $1, override_duration_minutes = $2
WHERE booking_id = $3 AND service_id = $4
`, override.OverridePrice, override.OverrideDurationMinutes, bookingID, override.ServiceID); err != nil {
log.Printf("Failed to update service override for booking %s, service %s: %v", bookingID, 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 booking confirmation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if dav.Service != nil {
var durationMinutes int
db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(override_duration_minutes), (SELECT SUM(duration_minutes) FROM booking_services WHERE booking_id = $1))
FROM booking_services WHERE booking_id = $1
`, bookingID).Scan(&durationMinutes)
if durationMinutes == 0 {
durationMinutes = 60
}
dav.Service.CreateEvent(1, dav.EventInput{
Summary: "Crussell Booking",
Start: booking.StartTime,
End: booking.StartTime.Add(time.Duration(durationMinutes) * time.Minute),
})
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode booking response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// POST /api/admin/bookings/{id}/cancel
func CancelBookingHandler(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())
var booking Booking
booking.User = &UserSummary{}
if err := tx.QueryRow(r.Context(), `
UPDATE bookings
SET status = 'we_cancelled', updated_at = NOW()
WHERE id = $1 AND status NOT IN ('completed', 'cancelled', 'client_cancelled', 'we_cancelled')
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
`, bookingID).Scan(
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found or cannot be cancelled", http.StatusNotFound)
return
}
log.Printf("Failed to cancel booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('cancelled_booking', $1, $2)
`, bookingID, booking.User.ID)
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit booking cancellation: %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(booking)
}
// DELETE /api/bookings/{id}
func DeleteBookingHandler(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 paymentCount int
if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount); err != nil {
log.Printf("Failed to check booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if paymentCount > 0 {
var req DeleteBookingRequest
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
}
allowed := map[string]bool{
"client_cancelled": true, "we_cancelled": true, "re-schedule": true, "no_show": true,
}
if !allowed[req.Reason] {
http.Error(w, "Invalid reason", http.StatusBadRequest)
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())
var originalStatus string
if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
return
}
log.Printf("Failed to get booking status %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
result, err := tx.Exec(r.Context(), `
UPDATE bookings SET status = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3
`, req.Reason, bookingID, userID)
if err != nil {
log.Printf("Failed to cancel booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if result.RowsAffected() == 0 {
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
return
}
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if originalStatus == "confirmed" {
var startTime time.Time
tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime)
noticeHours := startTime.Sub(time.Now()).Hours()
if noticeHours < 24 {
isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow
if !isForgiving {
tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID)
} else {
tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID)
}
}
if _, err := tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)
`, "cancelled_booking", bookingID, userID); 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 {
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{}{
"message": "Booking cancelled successfully",
"id": bookingID,
"status": req.Reason,
})
return
}
// Hard delete — no payments exist
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())
var originalStatus string
if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
return
}
log.Printf("Failed to get booking status %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if originalStatus != "pending" {
if _, err := tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)
`, "cancelled_booking", bookingID, userID); err != nil {
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
result, err := tx.Exec(r.Context(), "DELETE FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID)
if err != nil {
log.Printf("Failed to delete booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Failed to delete booking", http.StatusInternalServerError)
return
}
if result.RowsAffected() == 0 {
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
return
}
if err := tx.Commit(r.Context()); err != nil {
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{}{
"message": "Booking deleted successfully",
"id": bookingID,
})
}
// GET /api/bookings/{id}
func GetBookingHandler(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 booking Booking
booking.Payments = []Payment{}
booking.Services = []BookingService{}
booking.User = &UserSummary{}
var createdBy sql.NullString
var depositRequired bool
if 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 AND b.user_id = $2
`, bookingID, userID).Scan(
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy,
&depositRequired,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
return
}
log.Printf("Failed to fetch booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if createdBy.Valid {
booking.CreatedBy = &createdBy.String
}
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer serviceRows.Close()
var totalAmount float64
var durationMinutes int
for serviceRows.Next() {
var s BookingService
var overridePrice sql.NullFloat64
var overrideDuration sql.NullInt32
var name, description sql.NullString
var basePrice sql.NullFloat64
var baseDuration sql.NullInt32
if err := serviceRows.Scan(
&s.ServiceID,
&overridePrice, &overrideDuration,
&name, &description, &basePrice, &baseDuration,
); err != nil {
log.Printf("Failed to scan service row for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var priceToAdd float64
var durationToAdd int
if overridePrice.Valid {
s.OverridePrice = &overridePrice.Float64
priceToAdd = overridePrice.Float64
} else if basePrice.Valid {
priceToAdd = basePrice.Float64
}
if overrideDuration.Valid {
d := int(overrideDuration.Int32)
s.OverrideDurationMinutes = &d
durationToAdd = d
} else if baseDuration.Valid {
durationToAdd = int(baseDuration.Int32)
}
totalAmount += priceToAdd
durationMinutes += durationToAdd
if name.Valid {
s.ServiceName = &name.String
}
if description.Valid {
s.ServiceDescription = &description.String
}
if basePrice.Valid {
s.Price = &basePrice.Float64
}
if baseDuration.Valid {
d := int(baseDuration.Int32)
s.DurationMinutes = &d
}
booking.Services = append(booking.Services, s)
}
paymentRows, err := db.DB.Query(r.Context(), `
SELECT
id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
created_at, updated_at, created_by
FROM payments
WHERE booking_id = $1
ORDER BY created_at ASC
`, bookingID)
if err != nil {
log.Printf("Failed to fetch payments for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer paymentRows.Close()
var amountPaid, preStartAmountPaid float64
for paymentRows.Next() {
var p Payment
var vendorCode sql.NullString
var invoiceNumber sql.NullInt32
var vatRate, vatAmount, netAmount sql.NullFloat64
var pCreatedBy sql.NullString
if err := paymentRows.Scan(
&p.ID, &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
&p.Status, &p.Amount, &p.IsVATApplicable, &vatRate, &vatAmount, &netAmount,
&p.CreatedAt, &p.UpdatedAt, &pCreatedBy,
); err != nil {
log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if vendorCode.Valid {
p.VendorCode = &vendorCode.String
}
if invoiceNumber.Valid {
num := int(invoiceNumber.Int32)
p.InvoiceNumber = &num
}
if vatRate.Valid {
p.VATRate = &vatRate.Float64
}
if vatAmount.Valid {
p.VATAmount = &vatAmount.Float64
}
if netAmount.Valid {
p.NetAmount = &netAmount.Float64
}
if pCreatedBy.Valid {
p.CreatedBy = &pCreatedBy.String
}
if p.Status == "completed" {
amountPaid += p.Amount
if p.CreatedAt.Before(booking.StartTime) {
preStartAmountPaid += p.Amount
}
}
booking.Payments = append(booking.Payments, p)
}
booking.TotalAmount = totalAmount
booking.AmountPaid = amountPaid
booking.AmountDue = totalAmount - amountPaid
booking.DurationMinutes = durationMinutes
populateDepositFields(&booking, depositRequired, preStartAmountPaid)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode booking response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// GET /api/bookings/{id}/calendar - returns standalone .ics file
func GetBookingCalendarHandler(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, "Unauthorized", http.StatusUnauthorized)
return
}
var bookingIDDB, userIDDB, status, notes, createdBy string
var startTime, createdAt, updatedAt time.Time
var durationMinutes int
if err := db.DB.QueryRow(r.Context(), `
SELECT id, user_id, start_time, status, COALESCE(notes, ''), COALESCE(created_by, ''), created_at, updated_at,
COALESCE((SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id), 60)
FROM bookings
WHERE id = $1 AND user_id = $2
`, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, &notes, &createdBy, &createdAt, &updatedAt, &durationMinutes); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch booking for calendar: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rows, err := db.DB.Query(r.Context(), `
SELECT s.name, COALESCE(bs.override_price, s.price)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, bookingID)
if err != nil {
log.Printf("Failed to fetch services: %v", err)
}
defer rows.Close()
var services []string
var totalPrice float64
for rows.Next() {
var name string
var price float64
rows.Scan(&name, &price)
services = append(services, name)
totalPrice += price
}
serviceList := strings.Join(services, ", ")
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
icalContent := generateICS(serviceList, startTime, endTime, status, notes, totalPrice)
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"booking-%s.ics\"", bookingID))
w.WriteHeader(http.StatusOK)
w.Write([]byte(icalContent))
}
func generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string {
uid := fmt.Sprintf("booking-%d@crussell.com", time.Now().UnixNano())
dtstamp := time.Now().UTC().Format("20060102T150405Z")
dtstart := start.Format("20060102T150405")
dtend := end.Format("20060102T150405")
summary := "Crussell Appointment"
if serviceList != "" {
summary = "Crussell: " + serviceList
}
description := fmt.Sprintf("Status: %s\\nServices: %s\\nPrice: £%.2f", status, serviceList, price)
if notes != "" {
description += "\\nNotes: " + notes
}
return fmt.Sprintf(`BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Crussell//Booking//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:%s
DTSTAMP:%s
DTSTART:%s
DTEND:%s
SUMMARY:%s
DESCRIPTION:%s
STATUS:%s
END:VEVENT
END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status)
}
// OverlappingBooking represents a booking that overlaps with another
type OverlappingBooking struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
Duration int `json:"duration_minutes"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
User *UserSummary `json:"user,omitempty"`
Services []string `json:"services,omitempty"`
}
// OverlappingBookingsResponse is the response for the overlapping bookings endpoint
type OverlappingBookingsResponse struct {
Bookings []OverlappingBooking `json:"bookings"`
}
// GET /api/admin/bookings/{id}/overlapping
// Returns all bookings that overlap with the specified booking
func GetOverlappingBookingsHandler(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
}
// Get the booking's start time and duration
var startTime time.Time
var durationMinutes int
if err := db.DB.QueryRow(r.Context(), `
SELECT b.start_time,
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM bookings b
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
WHERE b.id = $1
GROUP BY b.start_time
`, bookingID).Scan(&startTime, &durationMinutes); 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
}
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
// Find overlapping bookings (excluding the current booking and cancelled/completed ones)
rows, err := db.DB.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
b.created_at,
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration,
u.fn,
u.email
FROM bookings b
LEFT JOIN booking_services bs ON b.id = bs.booking_id
LEFT JOIN services s ON bs.service_id = s.id
LEFT JOIN users u ON b.user_id = u.id
WHERE b.id != $1
AND b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
AND b.start_time < $3
AND b.start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60)
FROM booking_services bs2
JOIN services s2 ON bs2.service_id = s2.id
WHERE bs2.booking_id = b.id
)) > $2
GROUP BY b.id, b.start_time, b.status, b.created_at, u.fn, u.email
ORDER BY b.created_at ASC
`, bookingID, startTime, endTime)
if err != nil {
log.Printf("Failed to query overlapping bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var bookings []OverlappingBooking
for rows.Next() {
var ob OverlappingBooking
ob.User = &UserSummary{}
if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &ob.CreatedAt, &ob.Duration, &ob.User.FullName, &ob.User.Email); err != nil {
log.Printf("Failed to scan overlapping booking: %v", err)
continue
}
// Get services for this booking
serviceRows, err := db.DB.Query(r.Context(), `
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
ORDER BY s.name
`, ob.ID)
if err == nil {
for serviceRows.Next() {
var name string
if err := serviceRows.Scan(&name); err == nil {
ob.Services = append(ob.Services, name)
}
}
serviceRows.Close()
}
bookings = append(bookings, ob)
}
if bookings == nil {
bookings = []OverlappingBooking{}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil {
log.Printf("Failed to encode overlapping bookings response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}