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

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

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

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

4319 lines
151 KiB
Go

package bookings
import (
"context"
"crussell/db"
"crussell/handlers/notifications"
"crussell/handlers/payments"
"crussell/handlers/scheduling"
"crussell/internal/dav"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"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" validate:"omitempty,max=1000000"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *string `json:"created_by,omitempty"`
CreatedByName *string `json:"created_by_name,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"`
DepositProtectedAmount float64 `json:"deposit_protected_amount,omitempty"`
// Joined fields
User *UserSummary `json:"user,omitempty"`
Services []BookingService `json:"services"`
Payments []Payment `json:"payments,omitempty"`
Discounts []BookingDiscount `json:"discounts,omitempty"`
TotalAmount float64 `json:"total_amount"`
AmountPaid float64 `json:"amount_paid"`
AmountDue float64 `json:"amount_due"`
DurationMinutes int `json:"duration_minutes"`
}
type BookingDiscount struct {
ID string `json:"id"`
BookingID string `json:"booking_id"`
UserID string `json:"user_id"`
DiscountSource string `json:"discount_source"`
SourceID *string `json:"source_id,omitempty"`
CampaignName *string `json:"campaign_name,omitempty"`
CampaignType *string `json:"campaign_type,omitempty"`
MilestoneType *string `json:"milestone_type,omitempty"`
DiscountPercent float64 `json:"discount_percent"`
OriginalTotal float64 `json:"original_total"`
DiscountAmount float64 `json:"discount_amount"`
AppliedAt time.Time `json:"applied_at"`
}
func fetchBookingDiscounts(ctx context.Context, bookingID string) ([]BookingDiscount, error) {
discountRows, err := db.DB.Query(ctx, `
SELECT
bd.id, bd.booking_id, bd.user_id, bd.discount_source, bd.source_id,
bd.campaign_type, bd.milestone_type, bd.discount_percent, bd.original_total, bd.discount_amount, bd.applied_at,
c.name AS campaign_name
FROM booking_discounts bd
LEFT JOIN discount_campaigns c ON bd.discount_source = 'campaign' AND bd.source_id = c.id
WHERE bd.booking_id = $1
ORDER BY bd.applied_at ASC
`, bookingID)
if err != nil {
return nil, err
}
defer discountRows.Close()
var discounts []BookingDiscount
for discountRows.Next() {
var d BookingDiscount
var campaignName sql.NullString
var sourceID sql.NullString
var campaignType sql.NullString
var milestoneType sql.NullString
if err := discountRows.Scan(
&d.ID, &d.BookingID, &d.UserID, &d.DiscountSource, &sourceID,
&campaignType, &milestoneType, &d.DiscountPercent, &d.OriginalTotal, &d.DiscountAmount, &d.AppliedAt,
&campaignName,
); err != nil {
return nil, err
}
if sourceID.Valid {
s := sourceID.String
d.SourceID = &s
}
if campaignName.Valid && campaignName.String != "" {
c := campaignName.String
d.CampaignName = &c
}
if campaignType.Valid && campaignType.String != "" {
c := campaignType.String
d.CampaignType = &c
}
if milestoneType.Valid && milestoneType.String != "" {
m := milestoneType.String
d.MilestoneType = &m
}
discounts = append(discounts, d)
}
return discounts, nil
}
// 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 * payments.RequiredDepositPct
b.DepositPaid = depositRequired && preStartAmountPaid >= b.DepositAmount
deadline := b.StartTime.Add(-payments.DepositDeadlineWindow).Format(time.RFC3339)
b.DepositDeadline = &deadline
paid := math.Max(0, preStartAmountPaid)
maxProtected := b.TotalAmount * payments.ProtectedDepositMaxPct
if paid < maxProtected {
b.DepositProtectedAmount = paid
} else {
b.DepositProtectedAmount = maxProtected
}
}
// 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" validate:"omitempty,max=1000000"`
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 no_show"`
}
// ConfirmBookingRequest represents the request payload for confirming a booking
type ConfirmBookingRequest struct {
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
CustomServiceOverrides []ServiceOverride `json:"custom_service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}
// 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"`
}
// UpdateBookingServicesRequest represents the request payload for admin updating a booking's services and notes
type UpdateBookingServicesRequest struct {
ServiceIDs []string `json:"service_ids"`
CustomServiceIDs []string `json:"custom_service_ids,omitempty"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}
// 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"`
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" validate:"omitempty,max=1000000"`
}
// 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" validate:"omitempty,max=1000000"`
PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,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"`
Cursor *string `json:"cursor,omitempty"`
}
// 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"`
NextCursor *string `json:"next_cursor,omitempty"`
}
// 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
}
// Accept both cursor (preferred) and page (deprecated) for backward compat
if cursorStr := r.URL.Query().Get("cursor"); cursorStr != "" {
req.Cursor = &cursorStr
}
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 <= 500 {
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)
// Build WHERE conditions shared between count and data queries.
whereClause := " WHERE b.user_id = $1"
whereArgs := []interface{}{userID}
paramCount := 2
if req.Status != nil {
whereClause += fmt.Sprintf(" AND b.status = $%d", paramCount)
whereArgs = append(whereArgs, *req.Status)
paramCount++
}
if req.StartDate != nil {
whereClause += fmt.Sprintf(" AND b.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)
whereArgs = append(whereArgs, startTime)
paramCount++
}
if req.EndDate != nil {
whereClause += fmt.Sprintf(" AND 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
}
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
whereArgs = append(whereArgs, endTime)
paramCount++
}
// Count query uses the same WHERE but without complex SELECT subqueries,
// cursor, ORDER BY, or LIMIT — just a fast index scan on bookings.
var total int
if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+whereClause, whereArgs...).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
}
// Data query with full SELECT but no COUNT(*) OVER() window function.
dataQuery := `
SELECT
b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
(SELECT COALESCE(SUM(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) AS total_amount,
(SELECT COALESCE(SUM(amount), 0)
FROM payments
WHERE booking_id = b.id AND status = 'completed') AS amount_paid,
(SELECT COALESCE(SUM(dur_val), 0) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) 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` + whereClause
dataArgs := make([]interface{}, len(whereArgs))
copy(dataArgs, whereArgs)
dataParamCount := paramCount
// Cursor-based pagination: (created_at, id)
if req.Cursor != nil && *req.Cursor != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(*req.Cursor)
if err != nil {
http.Error(w, "Invalid cursor", http.StatusBadRequest)
return
}
dataQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", dataParamCount, dataParamCount+1)
dataArgs = append(dataArgs, cursorCreatedAt, cursorID)
dataParamCount += 2
}
dataQuery += " ORDER BY b.created_at DESC, b.id DESC"
if req.PerPage > 0 {
dataQuery += fmt.Sprintf(" LIMIT $%d", dataParamCount)
dataArgs = append(dataArgs, req.PerPage+1)
}
rows, err := db.DB.Query(r.Context(), dataQuery, dataArgs...)
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
var bookingIDs []string
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
}
b.DurationMinutes = durationMinutes
if createdBy.Valid {
b.CreatedBy = &createdBy.String
}
b.TotalAmount = totalAmount
b.AmountPaid = amountPaid
b.AmountDue = totalAmount - amountPaid
b.DurationMinutes = durationMinutes
populateDepositFields(&b, depositRequired, preStartAmountPaid)
b.Services = []BookingService{}
bookings = append(bookings, b)
bookingIDs = append(bookingIDs, b.ID)
}
if len(bookingIDs) > 0 {
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,
bs.booking_id
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1)
UNION ALL
SELECT
bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
cs.name, cs.description, cs.price, cs.duration_minutes,
bcs.booking_id
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = ANY($1)
ORDER BY booking_id, 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 s BookingService
var overridePrice sql.NullFloat64
var overrideDuration sql.NullInt32
var name, description sql.NullString
var basePrice sql.NullFloat64
var baseDuration sql.NullInt32
var bookingID string
if err := serviceRows.Scan(
&s.ServiceID,
&overridePrice, &overrideDuration,
&name, &description, &basePrice, &baseDuration,
&bookingID,
); err != nil {
log.Printf("Failed to scan service row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
s.BookingID = bookingID
if overridePrice.Valid {
s.OverridePrice = &overridePrice.Float64
}
if overrideDuration.Valid {
d := int(overrideDuration.Int32)
s.OverrideDurationMinutes = &d
}
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
}
servicesByBooking[bookingID] = append(servicesByBooking[bookingID], s)
}
for i := range bookings {
if services, exists := servicesByBooking[bookings[i].ID]; exists {
bookings[i].Services = services
}
}
}
// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
// The extra item is discarded; the cursor points to the last real item.
var nextCursor *string
if len(bookings) > req.PerPage {
bookings = bookings[:req.PerPage]
last := bookings[len(bookings)-1]
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
nextCursor = &cursor
}
totalPages := (total + req.PerPage - 1) / req.PerPage
if req.PerPage <= 0 {
totalPages = 1
}
if totalPages == 0 {
totalPages = 1
}
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,
NextCursor: nextCursor,
}); 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
booking_id,
COUNT(*) AS service_count,
SUM(total_duration) AS total_duration,
SUM(total_amount) AS total_amount
FROM (
SELECT
bs.booking_id,
COALESCE(bs.override_duration_minutes, s.duration_minutes) AS total_duration,
COALESCE(bs.override_price, s.price) AS total_amount
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
UNION ALL
SELECT
bcs.booking_id,
COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS total_duration,
COALESCE(bcs.override_price, cs.price) AS total_amount
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
) combined
GROUP BY 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.created_at,
b.user_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,
COALESCE(bt.service_count, 0) AS service_count,
(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
`
var args []interface{}
paramCount := 1
addWhereClause := func(condition string) {
if paramCount == 1 {
baseQuery += " WHERE " + condition
} else {
baseQuery += " AND " + condition
}
}
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++
}
// Cursor-based pagination: (created_at, id)
if req.Cursor != nil && *req.Cursor != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(*req.Cursor)
if err != nil {
http.Error(w, "Invalid cursor", http.StatusBadRequest)
return
}
if paramCount == 1 {
baseQuery += " WHERE (b.start_time, b.id) < ($1, $2)"
} else {
baseQuery += fmt.Sprintf(" AND (b.start_time, b.id) < ($%d, $%d)", paramCount, paramCount+1)
}
args = append(args, cursorCreatedAt, cursorID)
paramCount += 2
}
baseQuery += " ORDER BY b.start_time DESC, b.id DESC"
if req.PerPage > 0 {
baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
args = append(args, req.PerPage+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()
// Count query: simple SELECT COUNT(*) with same WHERE (no CTEs, joins, or subqueries).
var total int
{
countWhere := ""
var countArgs []interface{}
cp := 1
addCountWhere := func(cond string) {
if cp == 1 {
countWhere = " WHERE " + cond
} else {
countWhere += " AND " + cond
}
cp++
}
if req.Status != nil {
addCountWhere(fmt.Sprintf("b.status = $%d", cp))
countArgs = append(countArgs, *req.Status)
}
if req.StartDate != nil {
addCountWhere(fmt.Sprintf("b.start_time >= $%d", cp))
st, _ := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation)
countArgs = append(countArgs, time.Date(st.Year(), st.Month(), st.Day(), 0, 0, 0, 0, londonLocation))
}
if req.EndDate != nil {
addCountWhere(fmt.Sprintf("b.start_time <= $%d", cp))
et, _ := time.Parse("2006-01-02", *req.EndDate)
countArgs = append(countArgs, et.Add(23*time.Hour+59*time.Minute+59*time.Second))
}
if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+countWhere, countArgs...).Scan(&total); err != nil {
log.Printf("Failed to count admin bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
var bookings []Booking
var bookingIDs []string
for rows.Next() {
var b Booking
var userFullName string
var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
var depositRequired bool
var createdAt time.Time
var bookingUserID string
if err := rows.Scan(
&b.ID, &createdAt, &bookingUserID, &b.StartTime, &b.Status, &userFullName,
&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
&depositRequired, new(int), &preStartAmountPaid,
); err != nil {
log.Printf("Failed to scan booking row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
b.User = &UserSummary{ID: bookingUserID, FullName: userFullName}
b.CreatedAt = createdAt
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)
UNION ALL
SELECT bcs.booking_id, cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = ANY($1)
ORDER BY booking_id, 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
}
}
}
{
seenUserIDs := make(map[string]struct{})
var userIDs []string
for _, b := range bookings {
if b.User != nil && b.User.ID != "" {
if _, seen := seenUserIDs[b.User.ID]; !seen {
seenUserIDs[b.User.ID] = struct{}{}
userIDs = append(userIDs, b.User.ID)
}
}
}
if len(userIDs) > 0 {
nhRows, err := db.DB.Query(r.Context(), `
SELECT DISTINCT ON (user_id) user_id, previous_first_name, previous_last_name
FROM name_history
WHERE user_id = ANY($1) AND booking_id IS NULL
ORDER BY user_id, changed_at ASC
`, userIDs)
if err == nil {
prevByUser := make(map[string][2]string)
for nhRows.Next() {
var uid, pfn, pln string
if err := nhRows.Scan(&uid, &pfn, &pln); err == nil {
prevByUser[uid] = [2]string{pfn, pln}
}
}
nhRows.Close()
for i := range bookings {
if bookings[i].User != nil {
if prev, ok := prevByUser[bookings[i].User.ID]; ok {
bookings[i].User.PreviousFirstName = &prev[0]
bookings[i].User.PreviousLastName = &prev[1]
}
}
}
}
}
}
// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
// The extra item is discarded; the cursor points to the last real item.
var nextCursor *string
if len(bookings) > req.PerPage {
bookings = bookings[:req.PerPage]
last := bookings[len(bookings)-1]
cursor := last.StartTime.Format(time.RFC3339Nano) + "|" + last.ID
nextCursor = &cursor
}
totalPages := (total + req.PerPage - 1) / req.PerPage
if totalPages == 0 {
totalPages = 1
}
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,
NextCursor: nextCursor,
}); 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()
perPage := 5
cursorStr := query.Get("cursor")
if perPageStr := query.Get("per_page"); perPageStr != "" {
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
perPage = pp
}
}
baseQuery := `
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
`
var args []interface{}
args = append(args, userID)
paramCount := 2
if cursorStr != "" {
cursorStartTime, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor", http.StatusBadRequest)
return
}
baseQuery += fmt.Sprintf(" AND (b.start_time, b.id) < ($%d, $%d)", paramCount, paramCount+1)
args = append(args, cursorStartTime, cursorID)
paramCount += 2
}
baseQuery += " ORDER BY b.start_time DESC, b.id DESC"
baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
args = append(args, perPage+1)
// Count query: simple SELECT COUNT(*) (no cursor, since that filters rows).
var total int
if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b WHERE b.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(), 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
var bookingIDs []string
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
}
b.DepositRequired = depositRequired
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,
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 = ANY($1)
UNION ALL
SELECT
bcs.booking_id,
cs.name,
COALESCE(bcs.override_price, cs.price) AS price,
COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS duration_minutes
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = ANY($1)
`, bookingIDs)
if err != nil {
log.Printf("Failed to fetch services: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
servicesByBooking := make(map[string][]BookingService)
serviceTotalByBooking := make(map[string]float64)
for serviceRows.Next() {
var bookingID string
var svc BookingService
var price float64
var dur int
if err := serviceRows.Scan(&bookingID, &svc.ServiceName, &price, &dur); err != nil {
log.Printf("Failed to scan service: %v", err)
continue
}
svc.Price = &price
svc.DurationMinutes = &dur
servicesByBooking[bookingID] = append(servicesByBooking[bookingID], svc)
serviceTotalByBooking[bookingID] += price
}
serviceRows.Close()
paymentRows, err := db.DB.Query(r.Context(), `
SELECT p.booking_id,
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed'), 0) AS amount_paid,
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.created_at < b.start_time), 0) AS pre_start_paid
FROM payments p
JOIN bookings b ON b.id = p.booking_id
WHERE p.booking_id = ANY($1)
GROUP BY p.booking_id, b.start_time
`, bookingIDs)
if err != nil {
log.Printf("Failed to fetch payments: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
paymentsByBooking := make(map[string]struct{ amountPaid, preStartPaid float64 })
for paymentRows.Next() {
var bookingID string
var amountPaid, preStartPaid float64
if err := paymentRows.Scan(&bookingID, &amountPaid, &preStartPaid); err != nil {
log.Printf("Failed to scan payment: %v", err)
continue
}
paymentsByBooking[bookingID] = struct{ amountPaid, preStartPaid float64 }{amountPaid, preStartPaid}
}
paymentRows.Close()
for i := range bookings {
bid := bookings[i].ID
if svcs, ok := servicesByBooking[bid]; ok {
bookings[i].Services = svcs
bookings[i].TotalAmount = serviceTotalByBooking[bid]
} else {
bookings[i].Services = []BookingService{}
}
if p, ok := paymentsByBooking[bid]; ok {
bookings[i].AmountPaid = p.amountPaid
bookings[i].AmountDue = bookings[i].TotalAmount - p.amountPaid
populateDepositFields(&bookings[i], bookings[i].DepositRequired, p.preStartPaid)
} else {
bookings[i].AmountDue = bookings[i].TotalAmount
populateDepositFields(&bookings[i], bookings[i].DepositRequired, 0)
}
}
}
if bookings == nil {
bookings = []Booking{}
}
// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
// The extra item is discarded; the cursor points to the last real item.
var nextCursor *string
if len(bookings) > perPage {
bookings = bookings[:perPage]
last := bookings[len(bookings)-1]
cursor := last.StartTime.Format(time.RFC3339Nano) + "|" + last.ID
nextCursor = &cursor
}
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,
PerPage: perPage,
TotalPages: totalPages,
NextCursor: nextCursor,
}); err != nil {
log.Printf("Failed to encode bookings response: %v", err)
}
}
// 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
var dateOfBirth sql.NullTime
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.date_of_birth, u.loyalty_stamps,
u.referral_code, u.notes,
creator.fn,
b.deposit_required
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
LEFT JOIN users creator ON b.created_by = creator.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, &dateOfBirth, &booking.User.LoyaltyStamps,
&booking.User.ReferralCode, &booking.User.Notes,
&booking.CreatedByName,
&depositRequired,
)
if dateOfBirth.Valid {
s := dateOfBirth.Time.Format("2006-01-02")
booking.User.DateOfBirth = &s
}
if err != nil {
if errors.Is(err, pgx.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
var prevFirstName, prevLastName sql.NullString
err = db.DB.QueryRow(r.Context(), `
SELECT nh.previous_first_name, nh.previous_last_name
FROM name_history nh
WHERE nh.user_id = $1 AND nh.booking_id IS NULL
ORDER BY nh.changed_at ASC
LIMIT 1
`, booking.User.ID).Scan(&prevFirstName, &prevLastName)
if err == nil && prevFirstName.Valid && prevLastName.Valid {
booking.User.PreviousFirstName = &prevFirstName.String
booking.User.PreviousLastName = &prevLastName.String
}
serviceRows, err := db.DB.Query(r.Context(), `
SELECT service_id, name, price, duration_minutes FROM (
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
UNION ALL
SELECT
bcs.custom_service_id, cs.name,
COALESCE(bcs.override_price, cs.price) AS price,
COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS duration_minutes
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub ORDER BY 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)
discounts, err := fetchBookingDiscounts(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to fetch discounts for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
booking.Discounts = discounts
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/admin/bookings/{id}/services
func UpdateBookingServicesHandler(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
}
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var req UpdateBookingServicesRequest
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
}
if len(req.ServiceIDs) == 0 {
http.Error(w, "At least one service is required", http.StatusBadRequest)
return
}
for _, sid := range req.ServiceIDs {
if !validators.IsValidID(sid) {
http.Error(w, "Invalid service ID", 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 startTime time.Time
var currentStatus string
if err := db.DB.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, &currentStatus); err != nil {
if errors.Is(err, pgx.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
}
rejectedStatuses := map[string]bool{
"completed": true,
"client_cancelled": true,
"we_cancelled": true,
"no_show": true,
}
if rejectedStatuses[currentStatus] {
http.Error(w, "Cannot update services on a completed, cancelled, or no-show booking", http.StatusForbidden)
return
}
overrideMap := make(map[string]*ServiceOverride)
for i := range req.ServiceOverrides {
overrideMap[req.ServiceOverrides[i].ServiceID] = &req.ServiceOverrides[i]
}
// Batch load service durations
serviceDurationMap := make(map[string]int)
var serviceIDsNeedingLookup []string
for _, serviceID := range req.ServiceIDs {
if ov, exists := overrideMap[serviceID]; !(exists && ov.OverrideDurationMinutes != nil) {
serviceIDsNeedingLookup = append(serviceIDsNeedingLookup, serviceID)
}
}
if len(serviceIDsNeedingLookup) > 0 {
rows, err := db.DB.Query(r.Context(), `SELECT id, duration_minutes FROM services WHERE id = ANY($1)`, serviceIDsNeedingLookup)
if err != nil {
log.Printf("Failed to batch fetch service durations: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var svcID string
var dur int
if err := rows.Scan(&svcID, &dur); err != nil {
log.Printf("Failed to scan service duration row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
serviceDurationMap[svcID] = dur
}
if err := rows.Err(); err != nil {
log.Printf("Failed to iterate service duration rows: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
var newTotalDuration int
for _, serviceID := range req.ServiceIDs {
var durationMinutes int
if ov, exists := overrideMap[serviceID]; exists && ov.OverrideDurationMinutes != nil {
durationMinutes = *ov.OverrideDurationMinutes
} else {
var ok bool
durationMinutes, ok = serviceDurationMap[serviceID]
if !ok {
http.Error(w, "Service not found", http.StatusBadRequest)
return
}
}
newTotalDuration += durationMinutes
}
newEndTime := startTime.Add(time.Duration(newTotalDuration) * time.Minute)
var nextBookingStart *time.Time
err := db.DB.QueryRow(r.Context(), `
SELECT start_time FROM bookings
WHERE start_time > $1
AND status IN ('confirmed', 'pending', 'in_progress')
ORDER BY start_time ASC
LIMIT 1
`, startTime).Scan(&nextBookingStart)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to check next booking: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if nextBookingStart != nil && newEndTime.After(*nextBookingStart) {
http.Error(w, fmt.Sprintf("New booking duration overlaps with next appointment starting at %s", nextBookingStart.Format(time.RFC3339)), 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())
if _, err := tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID); err != nil {
log.Printf("Failed to delete booking services for %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if _, err := tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID); err != nil {
log.Printf("Failed to delete booking custom services for %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for _, serviceID := range req.ServiceIDs {
var ovPrice *float64
var ovDuration *int
if ov, exists := overrideMap[serviceID]; exists {
ovPrice = ov.OverridePrice
ovDuration = ov.OverrideDurationMinutes
}
if _, err := tx.Exec(r.Context(), `
INSERT INTO booking_services (booking_id, service_id, override_price, override_duration_minutes)
VALUES ($1, $2, $3, $4)
`, bookingID, serviceID, ovPrice, ovDuration); err != nil {
log.Printf("Failed to insert booking service %s for booking %s: %v", serviceID, bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
for _, csID := range req.CustomServiceIDs {
if _, err := tx.Exec(r.Context(), `
INSERT INTO booking_custom_services (booking_id, custom_service_id)
VALUES ($1, $2)
`, bookingID, csID); err != nil {
log.Printf("Failed to insert custom booking service %s: %v", csID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if req.Notes != nil {
if _, err := tx.Exec(r.Context(), "UPDATE bookings SET notes = $1 WHERE id = $2", *req.Notes, bookingID); err != nil {
log.Printf("Failed to update notes 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 transaction for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
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 {
log.Printf("Failed to fetch updated 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, 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
UNION ALL
SELECT
bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
cs.name, cs.description, cs.price, cs.duration_minutes
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
ORDER BY 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 string
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(&serviceID, &overridePrice, &overrideDuration, &name, &description, &basePrice, &baseDuration); err != nil {
log.Printf("Failed to scan service for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var priceToAdd float64
var durationToAdd int
var bs BookingService
bs.ServiceID = serviceID
if overridePrice.Valid {
bs.OverridePrice = &overridePrice.Float64
priceToAdd = overridePrice.Float64
} else if basePrice.Valid {
priceToAdd = basePrice.Float64
p := basePrice.Float64
bs.Price = &p
}
if overrideDuration.Valid {
d := int(overrideDuration.Int32)
bs.OverrideDurationMinutes = &d
durationToAdd = d
} else if baseDuration.Valid {
durationToAdd = int(baseDuration.Int32)
d := int(baseDuration.Int32)
bs.DurationMinutes = &d
}
totalAmount += priceToAdd
booking.DurationMinutes += durationToAdd
if name.Valid {
n := name.String
bs.ServiceName = &n
}
if description.Valid {
bs.ServiceDescription = &description.String
}
booking.Services = append(booking.Services, bs)
}
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
}
if len(query) > 200 {
http.Error(w, "search query too long", http.StatusBadRequest)
return
}
perPage := 10
cursorStr := r.URL.Query().Get("cursor")
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
booking_id,
SUM(total_duration) AS total_duration,
SUM(total_amount) AS total_amount
FROM (
SELECT
bs.booking_id,
COALESCE(bs.override_duration_minutes, s.duration_minutes) AS total_duration,
COALESCE(bs.override_price, s.price) AS total_amount
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
UNION ALL
SELECT
bcs.booking_id,
COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS total_duration,
COALESCE(bcs.override_price, cs.price) AS total_amount
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
) combined
GROUP BY 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
-- NOTE: ILIKE with leading wildcard prevents B-tree index usage.
-- At scale, replace with pg_trgm GIN index: CREATE INDEX idx_bookings_search_trgm ON bookings USING GIN (id gin_trgm_ops, notes gin_trgm_ops);
-- Also consider indexes on users (n_first_name, n_last_name, fn, email, phone) for the joined table.
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 '\'
) OR
EXISTS (
SELECT 1 FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\'
)
`
var args []interface{}
args = append(args, searchPattern)
paramCount := 2
// Cursor-based pagination: (created_at, id)
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor", http.StatusBadRequest)
return
}
searchQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", paramCount, paramCount+1)
args = append(args, cursorCreatedAt, cursorID)
paramCount += 2
}
searchQuery += " ORDER BY b.created_at DESC, b.id DESC"
searchQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
args = append(args, perPage+1)
rows, err := db.DB.Query(r.Context(), searchQuery, args...)
if err != nil {
log.Printf("Failed to search bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
// Count query: SELECT COUNT(*) with the same search WHERE (no CTEs/joins/scoring).
var total int
{
countSQL := `SELECT COUNT(*) FROM bookings b LEFT JOIN users u ON b.user_id = u.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 '\') OR
EXISTS (SELECT 1 FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\')`
if err := db.DB.QueryRow(r.Context(), countSQL, searchPattern).Scan(&total); err != nil {
log.Printf("Failed to count search bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
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 booking_id, name FROM (
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)
UNION ALL
SELECT bcs.booking_id, cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = ANY($1)
) sub ORDER BY booking_id, 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{}
}
}
}
// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
// The extra item is discarded; the cursor points to the last real item.
var nextCursor *string
if len(bookings) > perPage {
bookings = bookings[:perPage]
last := bookings[len(bookings)-1]
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
nextCursor = &cursor
}
totalPages := (total + perPage - 1) / perPage
if totalPages == 0 {
totalPages = 1
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(BookingListResponse{
Bookings: bookings,
PerPage: perPage,
Total: total,
TotalPages: totalPages,
NextCursor: nextCursor,
}); 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 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.idempotency_key = $1
`, idempotencyKey).Scan(
&existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status,
&existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy,
&existingBooking.DepositRequired,
)
if err == nil {
// Booking already exists with this key — fetch services and return it
rows, err := db.DB.Query(r.Context(), `
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT bcs.booking_id, bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
cs.name, cs.description, cs.price, cs.duration_minutes
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
`, existingBooking.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
}
existingBooking.Services = append(existingBooking.Services, bs)
}
}
// Get payment info
var preStartPaid float64
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'`, existingBooking.ID).Scan(&preStartPaid)
populateDepositFields(&existingBooking, 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 err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), 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
}
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
}
if !isGuest && depositsRequired > 0 && req.StartTime.Before(time.Now().Add(payments.DepositAdvanceWindow)) {
http.Error(w, fmt.Sprintf("When deposits are required, bookings must be made at least %.0f hours in advance to allow time for payment.", payments.DepositAdvanceWindow.Hours()), http.StatusBadRequest)
return
}
// 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 && len(req.ServiceIDs) > 0 {
patchTestRows, err := db.DB.Query(r.Context(), `
SELECT id, service_ids, notice_duration_hours, expiry_months
FROM patch_tests
WHERE service_ids::text[] && $1::text[]
`, req.ServiceIDs)
if err != nil {
log.Printf("Failed to query patch tests: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
type ptInfo struct {
id string
noticeHours int
expiryMonths int
}
patchTestsByService := make(map[string]ptInfo)
var allPtIDs []string
for patchTestRows.Next() {
var id string
var serviceIDs []string
var noticeHours, expiryMonths int
if err := patchTestRows.Scan(&id, &serviceIDs, &noticeHours, &expiryMonths); err != nil {
log.Printf("Failed to scan patch test: %v", err)
continue
}
allPtIDs = append(allPtIDs, id)
for _, sid := range serviceIDs {
patchTestsByService[sid] = ptInfo{id, noticeHours, expiryMonths}
}
}
patchTestRows.Close()
userPatchTests := make(map[string]time.Time)
if len(allPtIDs) > 0 {
uptRows, err := db.DB.Query(r.Context(), `
SELECT patch_test_id, tested_at
FROM user_patch_tests
WHERE user_id = $1 AND patch_test_id = ANY($2)
`, userID, allPtIDs)
if err != nil {
log.Printf("Failed to query user patch tests: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for uptRows.Next() {
var ptID string
var testedAt time.Time
if err := uptRows.Scan(&ptID, &testedAt); err != nil {
log.Printf("Failed to scan user patch test: %v", err)
continue
}
userPatchTests[ptID] = testedAt
}
uptRows.Close()
}
for _, serviceID := range req.ServiceIDs {
pt, needsPatch := patchTestsByService[serviceID]
if !needsPatch {
continue
}
testedAt, hasTest := userPatchTests[pt.id]
if !hasTest {
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
return
}
eligibleFrom := testedAt.Add(time.Duration(pt.noticeHours) * time.Hour)
if req.StartTime.Before(eligibleFrom) {
hoursLeft := eligibleFrom.Sub(req.StartTime).Hours()
http.Error(w, fmt.Sprintf("Booking time is before the %.0f hour notice period after patch test. Earliest booking: %s", hoursLeft, eligibleFrom.Format("2006-01-02 15:04")), http.StatusBadRequest)
return
}
if pt.expiryMonths > 0 {
expiresAt := testedAt.AddDate(0, pt.expiryMonths, 0)
if req.StartTime.After(expiresAt) {
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
return
}
}
}
}
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
}
localStart := req.StartTime.In(londonLocation)
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
weekday := int((localStart.Weekday() + 6) % 7)
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)
localEnd := localStart.Add(time.Duration(svcDuration) * time.Minute)
closeTime, _ := time.Parse("15:04:05", closeStr)
if localEnd.Hour() > closeTime.Hour() || (localEnd.Hour() == closeTime.Hour() && localEnd.Minute() > closeTime.Minute()) {
http.Error(w, "Cannot book this time - services would extend beyond closing hours", 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())
// Evict any pending_release bookings that overlap this slot.
if _, err := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, endTime); err != nil {
log.Printf("Failed to evict pending_release bookings for slot %s: %v", req.StartTime, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var cnt int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
AND start_time < $2
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes,cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id
) sub
)) > $1
`, req.StartTime, endTime).Scan(&cnt); err != nil {
log.Printf("Failed to check slot overlap: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if cnt > 0 {
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
return
}
// Check for time blocker overlap (read-only, uses its own connection, safe to call in-tx)
blockerOverlap, _, 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, "Cannot book this time - slot is blocked", http.StatusConflict)
return
}
var createdBy *string
if creatorID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
createdBy = &creatorID
}
// Delete any existing reservation for this user (max 1 per user)
// Also matches anon reservations by start_time for users who register mid-flow
if _, err := 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); err != nil {
log.Printf("Failed to delete reservation time blocker for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// 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
}
if len(req.ServiceIDs) > 0 {
if _, err := tx.Exec(r.Context(), `
INSERT INTO booking_services (booking_id, service_id)
SELECT $1, unnest($2::text[])
`, booking.ID, req.ServiceIDs); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Always create low-priority notification for all bookings
if _, err := tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('new_booking', $1, $2)
`, 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 booking needs approval (has notes or is for today), also create high-priority notification
needsApproval := false
if req.Notes != nil && *req.Notes != "" {
needsApproval = true
} else {
london, _ := time.LoadLocation("Europe/London")
now := time.Now().In(london)
bookingDay := req.StartTime.In(london)
if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.YearDay() {
needsApproval = true
}
}
if needsApproval {
if _, err := tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2)
`, booking.ID, userID); err != nil {
log.Printf("Failed to create pending approval 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)
}
}
// 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 err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), 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, pgx.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(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes); err != 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', 'no_show', 'deposit_lapsed')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bookings.id
) sub
)) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
log.Printf("Failed to check overlap %s: %v", bookingID, err)
}
if overlapCount > 0 {
http.Error(w, "Cannot edit - this time slot is taken", http.StatusConflict)
return
}
blockerOverlap, _, 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, "Cannot edit this time - slot is blocked", http.StatusConflict)
return
}
// 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)
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, pgx.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
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// M8
// L5
allowed := map[string]bool{
"pending": true, "confirmed": true, "in_progress": true,
"completed": true, "client_cancelled": true, "we_cancelled": true,
"no_show": true,
}
if !allowed[req.Status] {
http.Error(w, "Invalid status", http.StatusBadRequest)
return
}
tx, txErr := db.DB.Begin(r.Context())
if txErr != nil {
log.Printf("Failed to begin transaction for booking progress: %v", txErr)
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 = $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, pgx.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" {
// Collect patch test IDs first so the rows are consumed before INSERT operations.
var patchTestIDs []string
ptRows, err := tx.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 {
for ptRows.Next() {
var ptID string
if err := ptRows.Scan(&ptID); err == nil {
patchTestIDs = append(patchTestIDs, ptID)
}
}
ptRows.Close()
}
for _, ptID := range patchTestIDs {
if _, err := tx.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, ptID); err != nil {
log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, ptID, err)
}
}
var bookingTotal float64
if err := tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&bookingTotal); err != nil {
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
}
// Don't award a stamp if this booking already used a loyalty redemption
// (take or receive, never both).
var loyaltyAppliedOnThisBooking bool
tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&loyaltyAppliedOnThisBooking)
var newStampCount int
if bookingTotal > 0 && !loyaltyAppliedOnThisBooking {
if err := tx.QueryRow(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
)
RETURNING loyalty_stamps
`, booking.User.ID, bookingID).Scan(&newStampCount); err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
}
}
}
// Create pending redemption when stamps reach LoyaltyStampCost
if newStampCount == payments.LoyaltyStampCost {
_, err = tx.Exec(r.Context(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, $2, 'pending', NOW())
`, booking.User.ID, payments.LoyaltyStampCost)
if err != nil {
log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err)
}
}
// Skip time-based campaign if already applied at payment time
var timeBasedApplied bool
tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based')`, bookingID).Scan(&timeBasedApplied)
if bookingTotal > 0 && !timeBasedApplied {
var campaignID string
var campaignPercent float64
if err := tx.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)
if _, err := tx.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); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.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); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, campaignID); err != nil {
log.Printf("ALERT: failed to update discount campaign usage: %v", err)
}
}
}
if bookingTotal > 0 {
var userBookingCount int
_ = tx.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
_ = tx.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)
if _, err := tx.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); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.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); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, milestoneCampaignID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
}
var globalMilestoneApplied bool
tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count')`, bookingID).Scan(&globalMilestoneApplied)
if !globalMilestoneApplied {
var globalCount int
_ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
var hasInPersonPayment bool
db.DB.QueryRow(r.Context(), `
SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment)
if hasInPersonPayment {
var globalCampaignID string
var globalPercent float64
_ = tx.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)
ORDER BY milestone_value DESC LIMIT 1
`, globalCount).Scan(&globalCampaignID, &globalPercent)
if globalCampaignID != "" {
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
if _, err := tx.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); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.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); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, globalCampaignID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
}
}
}
var firstVisitDate time.Time
_ = tx.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate)
if !firstVisitDate.IsZero() {
annRows, err := tx.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 {
// Collect anniversary campaigns first to avoid interleaving rows with writes.
type annCampaign struct {
id string
pct float64
value int
unit string
}
var campaigns []annCampaign
for annRows.Next() {
var c annCampaign
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil {
campaigns = append(campaigns, c)
}
}
annRows.Close()
for _, c := range campaigns {
var matches bool
elapsed := time.Since(firstVisitDate)
switch c.unit {
case "months":
months := int(elapsed.Hours() / (30 * 24))
matches = months >= c.value
case "years":
years := int(elapsed.Hours() / (365.25 * 24))
matches = years >= c.value
}
if matches {
discountAmount := roundTo2(bookingTotal * c.pct / 100)
if _, err := tx.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, c.id, c.pct, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err)
}
if _, err := tx.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); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
if _, err := tx.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, c.id); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err)
}
break
}
}
}
}
}
var paymentExists bool
if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)`, bookingID).Scan(&paymentExists); err == nil && paymentExists {
var newDepositsRequired int
if err := tx.QueryRow(r.Context(), `
UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1)
WHERE id = $1
RETURNING deposits_required
`, booking.User.ID).Scan(&newDepositsRequired); err != nil {
log.Printf("ALERT: failed to update deposits_required: %v", err)
} else if newDepositsRequired == 0 {
// After 3 paid bookings, forget no-shows so the counter resets.
if _, err := tx.Exec(r.Context(), `
INSERT INTO forgiven_no_shows (booking_id)
SELECT id FROM bookings
WHERE user_id = $1 AND status = 'no_show'
AND start_time >= NOW() - INTERVAL '6 months'
AND NOT EXISTS (SELECT 1 FROM forgiven_no_shows WHERE booking_id = bookings.id)
`, booking.User.ID); err != nil {
log.Printf("ALERT: failed to auto-forgive no-shows: %v", err)
}
}
}
// Consume unconsumed name_history entries — this booking is the "first post-name-change
// booking" that completes. After this, we no longer show "formerly" on displays.
if _, err := tx.Exec(r.Context(), `
UPDATE name_history SET booking_id = $1
WHERE user_id = $2 AND booking_id IS NULL
`, bookingID, booking.User.ID); err != nil {
log.Printf("Failed to consume name_history for user %s: %v", booking.User.ID, err)
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit booking progress: %v", 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)
}
}
// 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
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), 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
}
}
for _, override := range req.CustomServiceOverrides {
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
var dur int
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
}
db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&dur)
endTime := bkStart.Add(time.Duration(dur) * time.Minute)
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, bkStart, endTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings on confirm: %v", evictErr)
}
var cnt int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('pending','confirmed','in_progress','completed')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes,cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id
) sub
)) > $2
`, bookingID, bkStart, endTime).Scan(&cnt); err != nil {
log.Printf("Failed to check overlap on confirm: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if cnt > 0 {
http.Error(w, "Cannot confirm - time slot overlaps with an existing booking", http.StatusConflict)
return
}
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, pgx.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 len(req.CustomServiceOverrides) > 0 {
customServiceIDs := make([]string, len(req.CustomServiceOverrides))
for i, o := range req.CustomServiceOverrides {
customServiceIDs[i] = o.ServiceID
}
var customCount int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = ANY($2)
`, bookingID, customServiceIDs).Scan(&customCount); err != nil {
log.Printf("Failed to verify custom services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if customCount != len(req.CustomServiceOverrides) {
http.Error(w, "One or more custom service IDs do not belong to this booking", http.StatusBadRequest)
return
}
for _, override := range req.CustomServiceOverrides {
if _, err := tx.Exec(r.Context(), `
UPDATE booking_custom_services
SET override_price = $1, override_duration_minutes = $2
WHERE booking_id = $3 AND custom_service_id = $4
`, override.OverridePrice, override.OverrideDurationMinutes, bookingID, override.ServiceID); err != nil {
log.Printf("Failed to update custom service override for booking %s, custom 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(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes)
if 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)
}
}
// 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 paymentExists bool
if err := db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)", bookingID).Scan(&paymentExists); 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 paymentExists {
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
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
allowed := map[string]bool{
"client_cancelled": true, "we_cancelled": true,
}
if !allowed[req.Reason] {
http.Error(w, "Invalid reason", http.StatusBadRequest)
return
}
// Get booking info needed for refund (before any transaction)
var originalStatus string
var startTime time.Time
if err := db.DB.QueryRow(r.Context(), "SELECT status, start_time FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus, &startTime); err != nil {
if errors.Is(err, pgx.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
}
// Process refund FIRST — if it fails, the booking stays active and the user can retry
var refundResult *payments.RefundCalculationResult
paySvc := payments.NewPaymentService()
payInfo, payErr := paySvc.GetBookingPaymentInfo(r.Context(), bookingID)
if payErr == nil && payInfo.TotalPaid > 0 {
var calcErr error
refundResult, calcErr = payments.ProcessCancellationRefund(
r.Context(), bookingID, payInfo.TotalAmount, payInfo.TotalPaid,
startTime, time.Now(), "client_cancelled", nil,
)
if calcErr != nil {
log.Printf("Refund processing failed for booking %s — cancellation aborted: %v", bookingID, calcErr)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Refund processing failed — cancellation aborted. Please try again or contact support.",
})
return
}
}
// Refund succeeded (or no refund needed) — now cancel the booking
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())
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" {
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
}
// After a no-show is recorded, check if user now has 2+ no-shows in 6 months.
if originalStatus == "confirmed" && startTime.Sub(time.Now()).Hours() < 24 && !(req.ForgiveNoShow != nil && *req.ForgiveNoShow) {
if applied, err := ApplyDepositsIfNeeded(r.Context(), userID); err != nil {
log.Printf("Failed to check deposits after no-show for user %s: %v", userID, err)
} else if applied {
log.Printf("Deposits required set to 3 for user %s due to 2+ no-shows in 6 months", userID)
}
}
resp := map[string]interface{}{
"message": "Booking cancelled successfully",
"id": bookingID,
"status": req.Reason,
}
if refundResult != nil && refundResult.RefundableAmount > 0 {
resp["refund_calculation"] = refundResult
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
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())
if _, err := tx.Exec(r.Context(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID); err != nil {
log.Printf("Failed to delete admin notifications 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, pgx.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
UNION ALL
SELECT
bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
cs.name, cs.description, cs.price, cs.duration_minutes
FROM booking_custom_services bcs
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
ORDER BY 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)
discounts, err := fetchBookingDiscounts(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to fetch discounts for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
booking.Discounts = discounts
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 COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub), 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, pgx.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
UNION ALL
SELECT cs.name, COALESCE(bcs.override_price, cs.price)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.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 sanitizeICS(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, ":", "\\:")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
return s
}
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")
sanitizedServiceList := sanitizeICS(serviceList)
sanitizedStatus := sanitizeICS(status)
sanitizedNotes := sanitizeICS(notes)
summary := "Crussell Appointment"
if sanitizedServiceList != "" {
summary = "Crussell: " + sanitizedServiceList
}
description := fmt.Sprintf("Status: %s\\nServices: %s\\nPrice: £%.2f", sanitizedStatus, sanitizedServiceList, price)
if sanitizedNotes != "" {
description += "\\nNotes: " + sanitizedNotes
}
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, sanitizedStatus)
}
// 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/overlapping?start=ISO&end=ISO
// Returns all bookings that overlap with the proposed time range
func GetOverlappingBookingsByTimeHandler(w http.ResponseWriter, r *http.Request) {
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr == "" || endStr == "" {
http.Error(w, "start and end query parameters are required", http.StatusBadRequest)
return
}
startTime, err := time.Parse(time.RFC3339, startStr)
if err != nil {
http.Error(w, "invalid start format, expected RFC3339", http.StatusBadRequest)
return
}
endTime, err := time.Parse(time.RFC3339, endStr)
if err != nil {
http.Error(w, "invalid end format, expected RFC3339", http.StatusBadRequest)
return
}
if !endTime.After(startTime) {
http.Error(w, "end must be after start", http.StatusBadRequest)
return
}
rows, err := db.DB.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
b.created_at,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) as duration,
u.id as user_id,
u.fn,
u.email,
u.phone
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
AND b.start_time < $2
AND b.start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur_val
FROM booking_services bs2 JOIN services s2 ON bs2.service_id = s2.id WHERE bs2.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs2.override_duration_minutes, cs2.duration_minutes)
FROM booking_custom_services bcs2 JOIN custom_services cs2 ON bcs2.custom_service_id = cs2.id WHERE bcs2.booking_id = b.id
) sub
)) > $1
GROUP BY b.id, b.start_time, b.status, b.created_at, u.id, u.fn, u.email, u.phone
ORDER BY b.start_time ASC
`, 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.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil {
log.Printf("Failed to scan overlapping booking: %v", err)
continue
}
serviceRows, err := db.DB.Query(r.Context(), `
SELECT name FROM (
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub ORDER BY 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)
}
}
// 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,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub)
FROM bookings b
WHERE b.id = $1
`, bookingID).Scan(&startTime, &durationMinutes); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
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,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) as duration,
u.fn,
u.email
FROM bookings b
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', 'deposit_lapsed')
AND b.start_time < $3
AND b.start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur_val
FROM booking_services bs2 JOIN services s2 ON bs2.service_id = s2.id WHERE bs2.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs2.override_duration_minutes, cs2.duration_minutes)
FROM booking_custom_services bcs2 JOIN custom_services cs2 ON bcs2.custom_service_id = cs2.id WHERE bcs2.booking_id = b.id
) sub
)) > $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 name FROM (
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub ORDER BY 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)
}
}
// GET /api/admin/bookings/by-date-range?start=YYYY-MM-DD&end=YYYY-MM-DD
func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr == "" || endStr == "" {
http.Error(w, "start and end query parameters are required", http.StatusBadRequest)
return
}
startTime, err := time.Parse("2006-01-02", startStr)
if err != nil {
http.Error(w, "invalid start format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
endTime, err := time.Parse("2006-01-02", endStr)
if err != nil {
http.Error(w, "invalid end format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
endOfDay := endTime.Add(24 * time.Hour)
rows, err := db.DB.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
b.notes,
b.created_at,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) as duration,
u.id as user_id,
u.fn,
u.email,
u.phone
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
AND b.start_time >= $1
AND b.start_time < $2
GROUP BY b.id, b.start_time, b.status, b.notes, b.created_at, u.id, u.fn, u.email, u.phone
ORDER BY b.start_time ASC
`, startTime, endOfDay)
if err != nil {
log.Printf("Failed to query bookings by date range: %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{}
var notes sql.NullString
if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &notes, &ob.CreatedAt, &ob.Duration, &ob.User.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil {
log.Printf("Failed to scan booking: %v", err)
continue
}
serviceRows, err := db.DB.Query(r.Context(), `
SELECT name FROM (
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub ORDER BY 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 bookings response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// GET /api/admin/bookings/by-created-range?start=ISO&end=ISO
// Returns all bookings created within a time range (filters by created_at)
func GetBookingsByCreatedRangeHandler(w http.ResponseWriter, r *http.Request) {
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr == "" || endStr == "" {
http.Error(w, "start and end query parameters are required", http.StatusBadRequest)
return
}
startTime, err := time.Parse(time.RFC3339, startStr)
if err != nil {
startTime, err = time.Parse("2006-01-02T15:04:05Z", startStr)
if err != nil {
http.Error(w, "invalid start format, expected RFC3339 or YYYY-MM-DDTHH:MM:SSZ", http.StatusBadRequest)
return
}
}
endTime, err := time.Parse(time.RFC3339, endStr)
if err != nil {
endTime, err = time.Parse("2006-01-02T15:04:05Z", endStr)
if err != nil {
http.Error(w, "invalid end format, expected RFC3339 or YYYY-MM-DDTHH:MM:SSZ", http.StatusBadRequest)
return
}
}
rows, err := db.DB.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
b.notes,
b.created_at,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id
) sub) as duration,
u.id as user_id,
u.fn,
u.email,
u.phone
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.created_at >= $1
AND b.created_at < $2
GROUP BY b.id, b.start_time, b.status, b.notes, b.created_at, u.id, u.fn, u.email, u.phone
ORDER BY b.created_at ASC
`, startTime, endTime)
if err != nil {
log.Printf("Failed to query bookings by created range: %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{}
var notes sql.NullString
if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &notes, &ob.CreatedAt, &ob.Duration, &ob.User.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil {
log.Printf("Failed to scan booking: %v", err)
continue
}
serviceRows, err := db.DB.Query(r.Context(), `
SELECT name FROM (
SELECT s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub ORDER BY 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 bookings response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// PUT /api/admin/bookings/{id}/reschedule
func AdminRescheduleBookingHandler(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 struct {
StartTime time.Time `json:"start_time"`
ForgiveFees *bool `json:"forgive_fees,omitempty"`
ForgiveNoShow *bool `json:"forgive_noshow,omitempty"`
}
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
}
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
}
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
var currentStatus string
var bookingUserID string
var startTime time.Time
if err := db.DB.QueryRow(r.Context(), "SELECT status, user_id, start_time FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus, &bookingUserID, &startTime); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
http.Error(w, "Cannot reschedule a completed or cancelled booking", http.StatusForbidden)
return
}
// All write operations wrapped in a transaction for atomicity.
tx, txErr := db.DB.Begin(r.Context())
if txErr != nil {
log.Printf("Failed to begin transaction for reschedule: %v", txErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
forgiveNoShow := req.ForgiveNoShow != nil && *req.ForgiveNoShow
if forgiveNoShow && bookingUserID != "" {
if _, err := tx.Exec(r.Context(), `
INSERT INTO forgiven_no_shows (booking_id, forgiven_by)
VALUES ($1, $2)
ON CONFLICT (booking_id) DO NOTHING
`, bookingID, adminID); err != nil {
log.Printf("Failed to insert forgiven_no_show for rescheduled booking %s: %v", bookingID, err)
}
}
forgiveFees := req.ForgiveFees != nil && *req.ForgiveFees
if forgiveFees {
log.Printf("[AUDIT] Admin %s rescheduled booking %s with fee forgiveness (start_time: %s)", adminID, bookingID, req.StartTime.Format(time.RFC3339))
}
var durationMinutes int
if err := db.DB.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = $1
) sub
`, bookingID).Scan(&durationMinutes); err != nil {
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
durationMinutes = 60
}
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
blockerOverlap, _, 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, "Cannot reschedule to this time - slot is blocked", http.StatusConflict)
return
}
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)
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 reschedule to a closed day", http.StatusBadRequest)
return
}
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil {
log.Printf("Failed to evict pending_release bookings on reschedule: %v", evictErr)
}
var overlapCount int
if err := tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE id != $1
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur
FROM booking_services bs2
JOIN services s2 ON bs2.service_id = s2.id
WHERE bs2.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = bookings.id
) sub
)) > $2
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); 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
}
var booking Booking
booking.User = &UserSummary{}
if err := tx.QueryRow(r.Context(), `
UPDATE bookings
SET start_time = $1, updated_at = NOW()
WHERE id = $2
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
`, req.StartTime, 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, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to reschedule 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 reschedule for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
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)
}
}
// EvictedBooking holds the ID and user ID of a booking evicted from
// pending_release to deposit_lapsed.
type EvictedBooking struct {
ID string
UserID string
}
// EvictPendingReleaseOverlapping evicts any pending_release bookings whose slot
// overlaps with [startTime, endTime). The PAYMENT_IN_FLIGHT guard prevents
// evicting a booking that a user is currently paying for (the 5-minute
// time_blocker window). Returns the list of evicted bookings (id + user_id)
// for any caller that needs to react (e.g. notify the affected user).
func EvictPendingReleaseOverlapping(ctx context.Context, tx pgx.Tx, startTime, endTime time.Time) ([]EvictedBooking, error) {
rows, err := tx.Query(ctx, `
UPDATE bookings SET status = 'deposit_lapsed', updated_at = NOW()
WHERE status = 'pending_release'
AND start_time < $2
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id
UNION ALL
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bookings.id
) sub
)) > $1
AND NOT EXISTS (
SELECT 1 FROM time_blockers
WHERE description = 'PAYMENT_IN_FLIGHT:' || bookings.id
AND start_time + (duration_minutes * INTERVAL '1 minute') > NOW()
)
RETURNING id, user_id
`, startTime, endTime)
if err != nil {
return nil, err
}
defer rows.Close()
var evicted []EvictedBooking
for rows.Next() {
var e EvictedBooking
if err := rows.Scan(&e.ID, &e.UserID); err != nil {
return nil, err
}
evicted = append(evicted, e)
}
return evicted, rows.Err()
}
// TODO: notify the evicted user that their slot was released
// (e.g. email/SMS — not yet implemented).