package bookings import ( "context" "crussell/clock" "crussell/db" "crussell/handlers/notifications" "crussell/handlers/payments" "crussell/handlers/scheduling" "crussell/internal/dav" "crussell/internal/validators" "crussell/mw" "database/sql" "encoding/json" "errors" "fmt" "log" "log/slog" "math" "net/http" "sort" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "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 := []any{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([]any, 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 []any 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 []any 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 []any 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 { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", 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 func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // 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 []any 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 if err := 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); err != nil { log.Printf("Failed to scan preStartPaid for booking %s: %v", existingBooking.ID, err) } populateDepositFields(&existingBooking, existingBooking.DepositRequired, preStartPaid) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(existingBooking); err != nil { log.Printf("Failed to encode JSON response: %v", err) } 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 { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.StartTime.IsZero() { http.Error(w, "Start time is required", http.StatusBadRequest) return } if 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) closeStr, err := getClosingTimeForDate(r.Context(), db.Conn, weekday, localStart) if err != nil { log.Printf("Failed to get hours: %v", err) http.Error(w, "Could not verify hours", http.StatusInternalServerError) return } if closeStr == "00:00" || closeStr == "00:00:00" { http.Error(w, "Not open on this day", http.StatusBadRequest) 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 func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // 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 { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.StartTime.IsZero() { http.Error(w, "Start time is required", http.StatusBadRequest) return } if req.StartTime.Before(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 func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() 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 { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", 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 func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // 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 if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&loyaltyAppliedOnThisBooking); err != nil { log.Printf("Failed to check loyalty applied on booking %s: %v", bookingID, err) } 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 if err := 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); err != nil { log.Printf("Failed to check time-based campaign applied on booking %s: %v", bookingID, err) } 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 if err := tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount); err != nil { log.Printf("Failed to scan user completed booking count: %v", err) } var milestoneCampaignID string var milestonePercent float64 if err := 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); err != nil { log.Printf("Failed to query per-user milestone campaign for booking %s: %v", bookingID, err) } 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 if err := 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); err != nil { log.Printf("Failed to check global milestone applied on booking %s: %v", bookingID, err) } if !globalMilestoneApplied { var globalCount int if err := tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount); err != nil { log.Printf("Failed to scan global completed booking count: %v", err) } var hasInPersonPayment bool if err := tx.QueryRow(r.Context(), ` SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment); err != nil { log.Printf("Failed to check in-person payment on booking %s: %v", bookingID, err) } if hasInPersonPayment { var globalCampaignID string var globalPercent float64 if err := 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); err != nil { log.Printf("Failed to query global milestone campaign for booking %s: %v", bookingID, err) } 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 if err := tx.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate); err != nil { log.Printf("Failed to scan first visit date: %v", err) } 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 { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } for _, override := range req.ServiceOverrides { if override.OverridePrice != nil && *override.OverridePrice < 0 { http.Error(w, "Override price cannot be negative", http.StatusBadRequest) return } if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 { http.Error(w, "Override duration must be positive", http.StatusBadRequest) return } } 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 func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() 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 if err := db.Conn.QueryRow(r.Context(), ` SELECT total_duration_minutes FROM bookings WHERE id = $1 `, bookingID).Scan(&durationMinutes); err != nil { log.Printf("ALERT: failed to scan total_duration_minutes for booking %s: %v", bookingID, err) durationMinutes = 60 } if err := dav.Service.CreateEvent(1, dav.EventInput{ Summary: "Crussell Booking", Start: booking.StartTime, End: booking.StartTime.Add(time.Duration(durationMinutes) * time.Minute), }); err != nil { log.Printf("Failed to create DAV calendar event for booking %s: %v", bookingID, err) } } 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 { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", 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) if err := json.NewEncoder(w).Encode(map[string]string{ "error": "Refund processing failed — cancellation aborted. Please try again or contact support.", }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } 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 func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() 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]any{ "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) if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("Failed to encode JSON response: %v", err) } 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 func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() 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) if err := json.NewEncoder(w).Encode(map[string]any{ "message": "Booking deleted successfully", "id": bookingID, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // 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 { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if req.StartTime.IsZero() { http.Error(w, "Start time is required", http.StatusBadRequest) return } if req.StartTime.Before(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 func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() 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 } // Also check the day isn't closed under a staged default hours change if closeStr, err := getClosingTimeForDate(r.Context(), tx, weekday, localStart); err == nil && (closeStr == "00:00" || closeStr == "00:00:00") { http.Error(w, "This day will be closed under the upcoming schedule change", 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).