Pass excludeUserID to CheckTimeBlockerOverlap in AdminReserveSlotHandler, CreateBookingHandler, EditBookingHandler, AdminRescheduleBookingHandler, AdminCreateBookingForUserHandler, AdminApproveEditRequestHandler, and ReserveSlotHandler. Also clean up stale reservations before overlap check in AdminReserveSlotHandler and ReserveSlotHandler via db.Conn.Exec for cross-connection visibility. Improve error handling in RequestEditHandler's service update block. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
4318 lines
150 KiB
Go
4318 lines
150 KiB
Go
package bookings
|
|
|
|
import (
|
|
"context"
|
|
"crussell/db"
|
|
"crussell/clock"
|
|
"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"
|
|
"sort"
|
|
"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("failed to load Europe/London timezone: " + err.Error())
|
|
}
|
|
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"`
|
|
OutOfHours bool `json:"out_of_hours"`
|
|
|
|
// 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.Conn.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.Parse("2006-01-02", *req.StartDate)
|
|
if err != nil {
|
|
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
|
|
return
|
|
}
|
|
londonDate := startTime.In(londonLocation)
|
|
startTime = time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC()
|
|
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
|
|
}
|
|
londonEnd := endTime.In(londonLocation)
|
|
endTime = time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC()
|
|
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.Conn.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,
|
|
b.total_amount,
|
|
COALESCE(pt.amount_paid, 0) AS amount_paid,
|
|
b.total_duration_minutes AS duration_minutes,
|
|
b.deposit_required,
|
|
COALESCE(pt.pre_start_amount_paid, 0) AS pre_start_amount_paid
|
|
FROM bookings b
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) AS amount_paid,
|
|
COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND created_at < b.start_time), 0) AS pre_start_amount_paid
|
|
FROM payments
|
|
WHERE booking_id = b.id
|
|
) pt ON true` + 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.Conn.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.Conn.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,
|
|
COALESCE(pre_pay.pre_start_amount_paid, 0) AS pre_start_amount_paid,
|
|
b.out_of_hours
|
|
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
|
|
LEFT JOIN LATERAL (
|
|
SELECT COALESCE(SUM(amount), 0) AS pre_start_amount_paid
|
|
FROM payments
|
|
WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time
|
|
) pre_pay ON true
|
|
`
|
|
|
|
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
|
|
}
|
|
londonDate := startTime.In(londonLocation)
|
|
args = append(args, time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC())
|
|
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
|
|
}
|
|
londonEnd := endTime.In(londonLocation)
|
|
args = append(args, time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC())
|
|
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)
|
|
}
|
|
|
|
// Count query: simple SELECT COUNT(*) with same WHERE (no CTEs, joins, or subqueries).
|
|
// Run BEFORE the data query to avoid "conn busy" errors when routing
|
|
// through a per-test transaction (pgx.Tx does not support concurrent queries).
|
|
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.Parse("2006-01-02", *req.StartDate)
|
|
londonDate := st.In(londonLocation)
|
|
countArgs = append(countArgs, time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC())
|
|
}
|
|
if req.EndDate != nil {
|
|
addCountWhere(fmt.Sprintf("b.start_time <= $%d", cp))
|
|
et, _ := time.Parse("2006-01-02", *req.EndDate)
|
|
londonEnd := et.In(londonLocation)
|
|
countArgs = append(countArgs, time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC())
|
|
}
|
|
if err := db.Conn.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
|
|
}
|
|
}
|
|
|
|
rows, err := db.Conn.Query(r.Context(), baseQuery, args...)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch all bookings: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var bookings []Booking
|
|
var bookingIDs []string
|
|
|
|
for rows.Next() {
|
|
var b Booking
|
|
var userFullName string
|
|
var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
|
|
var depositRequired bool
|
|
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,
|
|
&b.OutOfHours,
|
|
); 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.Conn.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.Conn.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.Conn.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.Conn.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.Conn.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.Conn.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.Conn.QueryRow(r.Context(), `
|
|
SELECT
|
|
b.id, b.user_id, b.start_time, b.status, b.notes,
|
|
b.created_at, b.updated_at, b.created_by,
|
|
b.out_of_hours,
|
|
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.OutOfHours,
|
|
&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.Conn.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.Conn.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.Conn.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.Conn.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.Conn.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, ¤tStatus); 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.Conn.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)
|
|
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
// Evict any pending_release bookings that overlap this slot.
|
|
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, startTime, newEndTime); evictErr != nil {
|
|
log.Printf("Failed to evict pending_release bookings for slot %s: %v", startTime, evictErr)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var overlapCount int
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings
|
|
WHERE id != $1
|
|
AND status IN ('confirmed', 'pending', 'in_progress', 'completed')
|
|
AND start_time < $3
|
|
AND end_time > $2
|
|
`, bookingID, startTime, newEndTime).Scan(&overlapCount); err != nil {
|
|
log.Printf("Failed to check overlap: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if overlapCount > 0 {
|
|
http.Error(w, "New booking duration overlaps with an existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
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.Conn.QueryRow(r.Context(), `
|
|
SELECT
|
|
b.id, b.user_id, b.start_time, b.status, b.notes,
|
|
b.created_at, b.updated_at, b.created_by,
|
|
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.Conn.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.Conn.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.Conn.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,
|
|
COALESCE(pre_pay.pre_start_amount_paid, 0) AS pre_start_amount_paid,
|
|
b.out_of_hours
|
|
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
|
|
LEFT JOIN LATERAL (
|
|
SELECT COALESCE(SUM(amount), 0) AS pre_start_amount_paid
|
|
FROM payments
|
|
WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time
|
|
) pre_pay ON true
|
|
-- 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.Conn.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
|
|
}
|
|
|
|
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,
|
|
&b.OutOfHours,
|
|
); 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)
|
|
}
|
|
rows.Close()
|
|
|
|
// Count query runs AFTER the data query result set is consumed to avoid pgx "conn busy".
|
|
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.Conn.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
|
|
}
|
|
}
|
|
|
|
if len(bookingIDs) > 0 {
|
|
serviceRows, err := db.Conn.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.Conn.QueryRow(r.Context(), `
|
|
SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required
|
|
FROM bookings b WHERE b.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.Conn.Query(r.Context(), `
|
|
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
|
|
s.name, s.description, s.price, s.duration_minutes
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
UNION ALL
|
|
SELECT bcs.booking_id, bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
|
|
cs.name, cs.description, cs.price, cs.duration_minutes
|
|
FROM booking_custom_services bcs
|
|
JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
WHERE bcs.booking_id = $1
|
|
`, existingBooking.ID)
|
|
if err == nil {
|
|
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)
|
|
}
|
|
rows.Close()
|
|
}
|
|
|
|
// Get payment info
|
|
var preStartPaid float64
|
|
db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, 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.Conn.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.Conn.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.Conn.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(clock.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(clock.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.Conn.Query(r.Context(), `
|
|
SELECT id, service_ids, notice_duration_hours, expiry_months
|
|
FROM patch_tests
|
|
WHERE service_ids::text[] && $1::text[]
|
|
`, req.ServiceIDs)
|
|
if err != nil {
|
|
log.Printf("Failed to query patch tests: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
type ptInfo struct {
|
|
id string
|
|
noticeHours int
|
|
expiryMonths int
|
|
}
|
|
patchTestsByService := make(map[string]ptInfo)
|
|
var allPtIDs []string
|
|
for patchTestRows.Next() {
|
|
var id string
|
|
var serviceIDs []string
|
|
var noticeHours, expiryMonths int
|
|
if err := patchTestRows.Scan(&id, &serviceIDs, ¬iceHours, &expiryMonths); err != nil {
|
|
log.Printf("Failed to scan patch test: %v", err)
|
|
continue
|
|
}
|
|
allPtIDs = append(allPtIDs, id)
|
|
for _, sid := range serviceIDs {
|
|
patchTestsByService[sid] = ptInfo{id, noticeHours, expiryMonths}
|
|
}
|
|
}
|
|
patchTestRows.Close()
|
|
|
|
userPatchTests := make(map[string]time.Time)
|
|
if len(allPtIDs) > 0 {
|
|
uptRows, err := db.Conn.Query(r.Context(), `
|
|
SELECT patch_test_id, tested_at
|
|
FROM user_patch_tests
|
|
WHERE user_id = $1 AND patch_test_id = ANY($2)
|
|
`, 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(clock.Now()) {
|
|
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var svcDuration int
|
|
if err := db.Conn.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.Conn.QueryRow(r.Context(), `SELECT end_time 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)
|
|
localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
|
|
if err := checkClosingHours(localEndLondon, closeStr); err != nil {
|
|
http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Clean up any existing reservation for this user BEFORE the transaction
|
|
// and time-blocker check. The reservation was created by the reserve step
|
|
// (POST /api/bookings/reserve) and is stored in the time_blockers table.
|
|
// If not deleted here, CheckTimeBlockerOverlap below would detect this
|
|
// reservation as a conflict and reject the booking — a self-blocking race.
|
|
// Using db.Conn.Exec (not tx.Exec) so the delete is visible to the
|
|
// separate connection used by CheckTimeBlockerOverlap.
|
|
if _, err := db.Conn.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
|
|
}
|
|
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
// Evict any pending_release bookings that overlap this slot.
|
|
if _, 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 end_time > $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)
|
|
// Pass excludeUserID so the user's own RESERVATION doesn't self-block
|
|
blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime, &userID)
|
|
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 {
|
|
londonNow := clock.Now().In(londonLocation)
|
|
londonBookingDay := req.StartTime.In(londonLocation)
|
|
if londonNow.Year() == londonBookingDay.Year() && londonNow.YearDay() == londonBookingDay.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.Conn.Query(r.Context(), `
|
|
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
|
|
s.name, s.description, s.price, s.duration_minutes
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
`, 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(clock.Now()) {
|
|
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var currentStatus string
|
|
if err := db.Conn.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(¤tStatus); 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
|
|
}
|
|
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
var durationMinutes int
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT total_duration_minutes FROM bookings WHERE id = $1
|
|
`, bookingID).Scan(&durationMinutes); err != nil {
|
|
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
|
|
durationMinutes = 60
|
|
}
|
|
|
|
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
|
|
|
|
// Evict any pending_release bookings that overlap this slot.
|
|
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil {
|
|
log.Printf("Failed to evict pending_release bookings on edit: %v", evictErr)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var overlapCount int
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings
|
|
WHERE id != $1
|
|
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
|
|
AND start_time < $3
|
|
AND end_time > $2
|
|
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
|
|
log.Printf("Failed to check overlap %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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, &userID)
|
|
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
|
|
}
|
|
|
|
localStart := req.StartTime.In(londonLocation)
|
|
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
|
|
weekday := int((localStart.Weekday() + 6) % 7)
|
|
bookingTime := localStart.Format("15:04:05")
|
|
daysToMonday := int(localStart.Weekday())
|
|
if daysToMonday == 0 {
|
|
daysToMonday = 7
|
|
}
|
|
tm := localStart.AddDate(0, 0, -daysToMonday+1)
|
|
// Use UTC midnight so the time.Time has Location=UTC at the London calendar date.
|
|
// tm has Location=London (from .In(londonLocation) above), so tm.Year/Month/Day()
|
|
// return London calendar values. Creating a UTC midnight of those values produces
|
|
// a Location=UTC time at the correct London calendar Monday. pgx's DATE codec
|
|
// extracts the calendar date from the time's own location — so this maps correctly
|
|
// to ega.week_start (DATE column), regardless of BST/GMT.
|
|
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
|
|
|
|
var isClosed bool
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM exceptional_working_hours ewh
|
|
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
|
|
WHERE ega.week_start = $1
|
|
AND ewh.weekday = $2
|
|
AND ewh.is_open = false
|
|
AND ewh.start_time <= $3
|
|
AND ewh.end_time >= $3
|
|
)
|
|
`, weekStart, weekday, bookingTime).Scan(&isClosed); err != nil {
|
|
log.Printf("Failed to check exceptional hours: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if isClosed {
|
|
http.Error(w, "Cannot book on a closed day", http.StatusBadRequest)
|
|
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 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
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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.Conn.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())
|
|
|
|
// Read current status before updating to validate the transition
|
|
var currentStatus string
|
|
if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 FOR UPDATE", bookingID).Scan(¤tStatus); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to fetch current status for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
validTransitions := map[string]map[string]bool{
|
|
"pending": {"confirmed": true, "completed": true, "client_cancelled": true, "we_cancelled": true},
|
|
"confirmed": {"in_progress": true, "completed": true, "client_cancelled": true, "we_cancelled": true},
|
|
"in_progress": {"completed": true},
|
|
"pending_release": {"pending": true, "confirmed": true, "client_cancelled": true, "we_cancelled": true},
|
|
"no_show": {},
|
|
"deposit_lapsed": {},
|
|
}
|
|
if targets, ok := validTransitions[currentStatus]; ok {
|
|
if !targets[req.Status] {
|
|
http.Error(w, fmt.Sprintf("Cannot transition booking from '%s' to '%s'", currentStatus, req.Status), http.StatusBadRequest)
|
|
return
|
|
}
|
|
} else if currentStatus != req.Status {
|
|
http.Error(w, fmt.Sprintf("Cannot transition booking from '%s' to '%s'", currentStatus, req.Status), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
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" {
|
|
if currentStatus == "completed" {
|
|
log.Printf("Booking %s is already completed — skipping duplicate completion", bookingID)
|
|
} else {
|
|
// 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 total_amount FROM bookings WHERE id = $1
|
|
`, 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
|
|
tx.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()
|
|
|
|
// Sort by milestone_value descending so we apply the longest anniversary only
|
|
sort.Slice(campaigns, func(i, j int) bool {
|
|
return campaigns[i].value > campaigns[j].value
|
|
})
|
|
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 // apply longest matching only
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
} // close the else from alreadyCompleted check
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
tx, err := db.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
var bkStart time.Time
|
|
var dur int
|
|
if err := tx.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
|
|
}
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT total_duration_minutes FROM bookings WHERE id = $1
|
|
`, bookingID).Scan(&dur); err != nil {
|
|
log.Printf("Failed to calculate duration on confirm: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
endTime := bkStart.Add(time.Duration(dur) * time.Minute)
|
|
|
|
if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, bkStart, endTime); evictErr != nil {
|
|
log.Printf("Failed to evict pending_release bookings on confirm: %v", evictErr)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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 end_time > $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.Conn.QueryRow(r.Context(), `
|
|
SELECT total_duration_minutes FROM bookings WHERE id = $1
|
|
`, bookingID).Scan(&durationMinutes)
|
|
if durationMinutes == 0 {
|
|
durationMinutes = 60
|
|
}
|
|
dav.Service.CreateEvent(1, dav.EventInput{
|
|
Summary: "Crussell Booking",
|
|
Start: booking.StartTime,
|
|
End: booking.StartTime.Add(time.Duration(durationMinutes) * time.Minute),
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
|
log.Printf("Failed to encode booking response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// 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.Conn.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.Conn.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, clock.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.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
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" {
|
|
// Only apply no-show logic for future bookings — past bookings
|
|
// that happen to still be "confirmed" should not retroactively
|
|
// receive a no-show penalty when cancelled after the fact.
|
|
noticeHours := startTime.Sub(clock.Now()).Hours()
|
|
|
|
if startTime.After(clock.Now()) && noticeHours < 24 {
|
|
isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow
|
|
|
|
if !isForgiving {
|
|
if _, err := tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID); err != nil {
|
|
log.Printf("Failed to update no-show status for booking %s: %v", bookingID, err)
|
|
}
|
|
} else {
|
|
if _, err := tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID); err != nil {
|
|
log.Printf("Failed to update client_cancelled status for booking %s: %v", bookingID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
// Only apply for future bookings — past confirmed bookings should not
|
|
// trigger deposit requirements when cancelled after the fact.
|
|
if originalStatus == "confirmed" && startTime.After(clock.Now()) && startTime.Sub(clock.Now()).Hours() < 24 && !(req.ForgiveNoShow != nil && *req.ForgiveNoShow) {
|
|
if applied, err := ApplyDepositsIfNeeded(r.Context(), db.Conn, 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.Conn.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
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.Conn.QueryRow(r.Context(), `
|
|
SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
|
|
b.deposit_required
|
|
FROM bookings b
|
|
WHERE b.id = $1 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.Conn.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.Conn.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.Conn.QueryRow(r.Context(), `
|
|
SELECT id, user_id, start_time, status, COALESCE(notes, ''), COALESCE(created_by, ''), created_at, updated_at,
|
|
total_duration_minutes
|
|
FROM bookings
|
|
WHERE id = $1 AND user_id = $2
|
|
`, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, ¬es, &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.Conn.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
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
log.Printf("Row iteration error in GetBookingICalHandler: %v", err)
|
|
}
|
|
|
|
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", clock.Now().UnixNano())
|
|
dtstamp := clock.Now().UTC().Format("20060102T150405Z")
|
|
dtstart := start.UTC().Format("20060102T150405Z")
|
|
dtend := end.UTC().Format("20060102T150405Z")
|
|
|
|
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.Conn.Query(r.Context(), `
|
|
SELECT
|
|
b.id,
|
|
b.start_time,
|
|
b.status,
|
|
b.created_at,
|
|
b.total_duration_minutes 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.end_time > $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.Conn.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.Conn.QueryRow(r.Context(), `
|
|
SELECT b.start_time, b.total_duration_minutes
|
|
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.Conn.Query(r.Context(), `
|
|
SELECT
|
|
b.id,
|
|
b.start_time,
|
|
b.status,
|
|
b.created_at,
|
|
b.total_duration_minutes 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.end_time > $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.Conn.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
|
|
}
|
|
|
|
// Convert date-only params to London-aligned boundaries so bookings
|
|
// at BST midnight (23:xx UTC = 00:xx BST next day) are correctly included.
|
|
startLondon := startTime.In(londonLocation)
|
|
startTime = time.Date(startLondon.Year(), startLondon.Month(), startLondon.Day(), 0, 0, 0, 0, londonLocation).UTC()
|
|
endLondon := endTime.In(londonLocation)
|
|
endOfDay := time.Date(endLondon.Year(), endLondon.Month(), endLondon.Day(), 23, 59, 59, 999999999, londonLocation).UTC()
|
|
|
|
rows, err := db.Conn.Query(r.Context(), `
|
|
SELECT
|
|
b.id,
|
|
b.start_time,
|
|
b.status,
|
|
b.notes,
|
|
b.created_at,
|
|
b.total_duration_minutes 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, ¬es, &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.Conn.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.Conn.Query(r.Context(), `
|
|
SELECT
|
|
b.id,
|
|
b.start_time,
|
|
b.status,
|
|
b.notes,
|
|
b.created_at,
|
|
b.total_duration_minutes 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, ¬es, &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.Conn.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(clock.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.Conn.QueryRow(r.Context(), "SELECT status, user_id, start_time FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, &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.Conn.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.Conn.QueryRow(r.Context(), `
|
|
SELECT total_duration_minutes FROM bookings WHERE id = $1
|
|
`, bookingID).Scan(&durationMinutes); err != nil {
|
|
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
|
|
durationMinutes = 60
|
|
}
|
|
|
|
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
|
|
|
|
blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime, &adminID)
|
|
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
|
|
}
|
|
|
|
localStart := req.StartTime.In(londonLocation)
|
|
weekday := int((localStart.Weekday() + 6) % 7)
|
|
bookingTime := localStart.Format("15:04:05")
|
|
daysToMonday := int(localStart.Weekday())
|
|
if daysToMonday == 0 {
|
|
daysToMonday = 7
|
|
}
|
|
tm := localStart.AddDate(0, 0, -daysToMonday+1)
|
|
// Use UTC midnight so the time.Time has Location=UTC at the London calendar date.
|
|
// tm has Location=London (from .In(londonLocation) above), so tm.Year/Month/Day()
|
|
// return London calendar values. Creating a UTC midnight of those values produces
|
|
// a Location=UTC time at the correct London calendar Monday. pgx's DATE codec
|
|
// extracts the calendar date from the time's own location — so this maps correctly
|
|
// to ega.week_start (DATE column), regardless of BST/GMT.
|
|
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
|
|
|
|
var isClosed bool
|
|
if err := db.Conn.QueryRow(r.Context(), `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM exceptional_working_hours ewh
|
|
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
|
|
WHERE ega.week_start = $1
|
|
AND ewh.weekday = $2
|
|
AND ewh.is_open = false
|
|
AND ewh.start_time <= $3
|
|
AND ewh.end_time >= $3
|
|
)
|
|
`, weekStart, weekday, bookingTime).Scan(&isClosed); err != nil {
|
|
log.Printf("Failed to check exceptional hours: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if isClosed {
|
|
http.Error(w, "Cannot 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)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var overlapCount int
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings
|
|
WHERE id != $1
|
|
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
|
|
AND start_time < $3
|
|
AND end_time > $2
|
|
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
|
|
log.Printf("Failed to check overlap %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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 end_time > $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).
|