diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go new file mode 100644 index 0000000..5a1a73b --- /dev/null +++ b/backend/handlers/bookings/bookings.go @@ -0,0 +1,1100 @@ +package bookings + +import ( + "crussell/db" + "crussell/mw" + "database/sql" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/go-chi/chi/v5" +) + +// Booking represents a booking in the system +type Booking struct { + ID string `json:"id"` + UserID string `json:"user_id"` + StartTime time.Time `json:"start_time"` + Status string `json:"status"` + Notes *string `json:"notes,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy *string `json:"created_by,omitempty"` + + // Joined fields + Services []BookingService `json:"services,omitempty"` + Payments []Payment `json:"payments,omitempty"` +} + +// 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"` + BasePrice *float64 `json:"base_price,omitempty"` + BaseDurationMinutes *int `json:"base_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"` +} + +// EditBookingRequest represents the request payload for editing a booking's start time +type EditBookingRequest struct { + StartTime time.Time `json:"start_time" validate:"required"` +} + +// ProgressBookingRequest represents the request payload for updating a booking's status +type ProgressBookingRequest struct { + Status string `json:"status" validate:"required,oneof=pending confirmed in_progress completed client_cancelled we_cancelled re-schedule no_show"` +} + +// ConfirmBookingRequest represents the request payload for confirming a booking +type ConfirmBookingRequest struct { + OverridePrice *float64 `json:"override_price,omitempty"` + OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +// DeleteBookingRequest represents the request payload for deleting a booking with payment +type DeleteBookingRequest struct { + Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"` +} + +// AdminBookingSummary represents a complete booking summary for admin view +type AdminBookingSummary struct { + Booking Booking `json:"booking"` + User *UserSummary `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 { + 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"` + DateOfBirth *string `json:"date_of_birth,omitempty"` + AccountRole string `json:"account_role"` + LoyaltyStamps int `json:"loyalty_stamps"` + ReferralCode *string `json:"referral_code,omitempty"` + CreatedAt string `json:"created_at"` +} + +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"` +} + +// GET /api/admin/bookings/{id}/summary +func GetAdminBookingSummaryHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" { + http.Error(w, "Booking ID is required", http.StatusBadRequest) + return + } + + // ---------------------------- + // 1. Fetch booking + user + // ---------------------------- + var summary AdminBookingSummary + var booking Booking + var user UserSummary + var createdBy sql.NullString + var email, phone, referralCode sql.NullString + var dateOfBirth, userCreatedAt sql.NullTime + + err := db.DB.QueryRow(r.Context(), ` + SELECT + b.id, b.user_id, b.start_time, b.status, b.notes, + b.created_at, b.updated_at, b.created_by, + u.n_first_name, u.n_last_name, u.fn, u.email, u.phone, + u.date_of_birth, u.account_role, u.loyalty_stamps, + u.referral_code, u.created_at as user_created_at + FROM bookings b + LEFT JOIN users u ON b.user_id = u.id + WHERE b.id = $1 + `, bookingID).Scan( + &booking.ID, &booking.UserID, &booking.StartTime, &booking.Status, &booking.Notes, + &booking.CreatedAt, &booking.UpdatedAt, &createdBy, + &user.FirstName, &user.LastName, &user.FullName, &email, &phone, + &dateOfBirth, &user.AccountRole, &user.LoyaltyStamps, &referralCode, &userCreatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if createdBy.Valid { + booking.CreatedBy = &createdBy.String + } + if email.Valid { + user.Email = &email.String + } + if phone.Valid { + user.Phone = &phone.String + } + if referralCode.Valid { + user.ReferralCode = &referralCode.String + } + if dateOfBirth.Valid { + dob := dateOfBirth.Time.Format("2006-01-02") + user.DateOfBirth = &dob + } + if userCreatedAt.Valid { + user.CreatedAt = userCreatedAt.Time.Format(time.RFC3339) + } + + summary.Booking = booking + summary.User = &user + + // ---------------------------- + // 2. Fetch services + // ---------------------------- + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT + bs.override_price, bs.override_duration_minutes, + s.name as service_name, s.description as service_description, + s.price as base_price, s.duration_minutes as base_duration_minutes, + s.is_active, s.patch_test_duration_hours, s.minimum_age_required + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, bookingID) + if err != nil { + log.Printf("Failed to fetch services for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer serviceRows.Close() + + for serviceRows.Next() { + var s BookingServiceDetail + var overridePrice sql.NullFloat64 + var overrideDuration sql.NullInt32 + var patchTestHours sql.NullInt32 + + err := serviceRows.Scan( + &overridePrice, &overrideDuration, + &s.ServiceName, &s.ServiceDescription, + &s.BasePrice, &s.BaseDurationMinutes, + &s.IsActive, &patchTestHours, &s.MinimumAgeRequired, + ) + if err != nil { + log.Printf("Failed to scan service row for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if overridePrice.Valid { + s.OverridePrice = &overridePrice.Float64 + } + if overrideDuration.Valid { + d := int(overrideDuration.Int32) + s.OverrideDurationMinutes = &d + } + if patchTestHours.Valid { + s.RequiresPatchTest = patchTestHours.Int32 > 0 + } + + summary.Services = append(summary.Services, s) + + // Compute totals + if s.OverridePrice != nil { + summary.TotalAmount += *s.OverridePrice + } else { + summary.TotalAmount += s.BasePrice + } + + if s.OverrideDurationMinutes != nil { + summary.DurationMinutes += *s.OverrideDurationMinutes + } else { + summary.DurationMinutes += s.BaseDurationMinutes + } + } + + // ---------------------------- + // 3. Fetch payments + // ---------------------------- + paymentRows, err := db.DB.Query(r.Context(), ` + SELECT + id, payment_type, payment_method, vendor_code, invoice_number, + status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, + created_at, updated_at, created_by + FROM payments + WHERE booking_id = $1 + ORDER BY created_at DESC + `, 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() + + for paymentRows.Next() { + var p Payment + var vendorCode sql.NullString + var invoiceNumber sql.NullInt32 + var vatRate, vatAmount, netAmount sql.NullFloat64 + var createdBy sql.NullString + + err := paymentRows.Scan( + &p.ID, &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber, + &p.Status, &p.Amount, &p.IsVATApplicable, &vatRate, &vatAmount, &netAmount, + &p.CreatedAt, &p.UpdatedAt, &createdBy, + ) + if 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 createdBy.Valid { + p.CreatedBy = &createdBy.String + } + + summary.Payments = append(summary.Payments, p) + + if p.Status == "completed" { + summary.AmountPaid += p.Amount + } + } + + summary.AmountDue = summary.TotalAmount - summary.AmountPaid + + // ---------------------------- + // Return JSON response + // ---------------------------- + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(summary); err != nil { + log.Printf("Failed to encode booking response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } +} + +// POST /api/bookings +func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { + // Get user ID from context + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Parse and validate 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 + } + + // Basic validation + 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 + } + if req.StartTime.Before(time.Now()) { + http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) + return + } + + // Get created by from context (if available) + var createdBy *string + if creatorID, ok := r.Context().Value(mw.UserIDKey).(string); ok { + createdBy = &creatorID + } + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + log.Printf("Failed to start transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + // Insert new booking + bookingQuery := ` + INSERT INTO bookings (user_id, start_time, notes, created_by) + VALUES ($1, $2, $3, $4) + RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by + ` + + var booking Booking + err = tx.QueryRow(r.Context(), + bookingQuery, + userID, + req.StartTime, + req.Notes, + createdBy, + ).Scan( + &booking.ID, + &booking.UserID, + &booking.StartTime, + &booking.Status, + &booking.Notes, + &booking.CreatedAt, + &booking.UpdatedAt, + &booking.CreatedBy, + ) + + if err != nil { + log.Printf("Failed to create booking for user %s: %v", userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Insert booking services + serviceQuery := ` + INSERT INTO booking_services (booking_id, service_id) + VALUES ($1, $2) + ` + for _, serviceID := range req.ServiceIDs { + _, err := tx.Exec(r.Context(), serviceQuery, booking.ID, serviceID) + if err != nil { + 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 + } + + // Return created booking + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(booking); err != nil { + log.Printf("Failed to encode booking response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } +} + +// PUT /api/bookings/{id} +func EditBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" { + http.Error(w, "Booking ID is required", http.StatusBadRequest) + return + } + + // Get user ID from context + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Parse and validate request + var req EditBookingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Failed to decode request: %v", err) + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if req.StartTime.IsZero() { + http.Error(w, "Start time is required", http.StatusBadRequest) + return + } + if req.StartTime.Before(time.Now()) { + http.Error(w, "Start time cannot be in the past", http.StatusBadRequest) + return + } + + // Update booking start time (only for user's own bookings) + query := ` + 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 + ` + + var booking Booking + err := db.DB.QueryRow(r.Context(), + query, + req.StartTime, + bookingID, + userID, + ).Scan( + &booking.ID, + &booking.UserID, + &booking.StartTime, + &booking.Status, + &booking.Notes, + &booking.CreatedAt, + &booking.UpdatedAt, + &booking.CreatedBy, + ) + + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not found or access denied", http.StatusNotFound) + return + } + log.Printf("Failed to update booking %s for user %s: %v", bookingID, userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Return updated booking + 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) + return + } +} + +// PUT /api/bookings/{id}/progress +func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" { + http.Error(w, "Booking ID is required", http.StatusBadRequest) + return + } + + // Parse and validate request + 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 + } + + // Update booking status + query := ` + 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 + ` + + var booking Booking + err := db.DB.QueryRow(r.Context(), + query, + req.Status, + bookingID, + ).Scan( + &booking.ID, + &booking.UserID, + &booking.StartTime, + &booking.Status, + &booking.Notes, + &booking.CreatedAt, + &booking.UpdatedAt, + &booking.CreatedBy, + ) + + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + log.Printf("Failed to update booking status for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Return updated booking + 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) + return + } +} + +// POST /api/bookings/{id}/confirm +func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" { + http.Error(w, "Booking ID is required", http.StatusBadRequest) + return + } + + // Parse and validate request + 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 req.OverridePrice != nil && *req.OverridePrice < 0 { + http.Error(w, "Override price cannot be negative", http.StatusBadRequest) + return + } + if req.OverrideDurationMinutes != nil && *req.OverrideDurationMinutes <= 0 { + http.Error(w, "Override duration must be positive", http.StatusBadRequest) + return + } + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + log.Printf("Failed to start transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + // Update booking status and notes + bookingQuery := ` + 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 + ` + + var booking Booking + err = tx.QueryRow(r.Context(), + bookingQuery, + req.Notes, + bookingID, + ).Scan( + &booking.ID, + &booking.UserID, + &booking.StartTime, + &booking.Status, + &booking.Notes, + &booking.CreatedAt, + &booking.UpdatedAt, + &booking.CreatedBy, + ) + + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not found or already confirmed", http.StatusNotFound) + return + } + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Update booking services with overrides if provided + if req.OverridePrice != nil || req.OverrideDurationMinutes != nil { + serviceQuery := ` + UPDATE booking_services + SET override_price = COALESCE($1, override_price), + override_duration_minutes = COALESCE($2, override_duration_minutes) + WHERE booking_id = $3 + ` + _, err := tx.Exec(r.Context(), + serviceQuery, + req.OverridePrice, + req.OverrideDurationMinutes, + bookingID, + ) + if err != nil { + log.Printf("Failed to update service overrides 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 booking confirmation: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Return confirmed booking + 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) + return + } +} + +// DELETE /api/bookings/{id} +func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" { + http.Error(w, "Booking ID is required", http.StatusBadRequest) + return + } + + // Get user ID from context + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Check if booking has payments + var paymentCount int + paymentCheckQuery := "SELECT COUNT(*) FROM payments WHERE booking_id = $1" + err := db.DB.QueryRow(r.Context(), paymentCheckQuery, bookingID).Scan(&paymentCount) + if err != nil { + log.Printf("Failed to check booking %s for user %s: %v", bookingID, userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if paymentCount > 0 { + // Parse delete reason for bookings with payments + 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 + } + + // Update booking status instead of deleting + query := ` + UPDATE bookings + SET status = $1, updated_at = NOW() + WHERE id = $2 AND user_id = $3 + ` + result, err := db.DB.Exec(r.Context(), query, 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 + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Booking cancelled successfully", + "id": bookingID, + "status": req.Reason, + }) + return + } + + // Hard delete if no payments exist + query := "DELETE FROM bookings WHERE id = $1 AND user_id = $2" + result, err := db.DB.Exec(r.Context(), query, 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 + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Booking deleted successfully", + "id": bookingID, + }) +} + +// GET /api/bookings/{id} +func GetBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" { + http.Error(w, "Booking ID is required", http.StatusBadRequest) + return + } + + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // ---------------------------- + // 1. Fetch booking + // ---------------------------- + var booking Booking + var createdBy sql.NullString + err := db.DB.QueryRow(r.Context(), ` + SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by + FROM bookings + WHERE id = $1 AND user_id = $2 + `, bookingID, userID).Scan( + &booking.ID, &booking.UserID, &booking.StartTime, &booking.Status, + &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy, + ) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not found or access denied", http.StatusNotFound) + return + } + log.Printf("Failed to fetch booking %s for user %s: %v", bookingID, userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if createdBy.Valid { + booking.CreatedBy = &createdBy.String + } + + // ---------------------------- + // 2. Fetch services + // ---------------------------- + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT + bs.service_id, bs.override_price, bs.override_duration_minutes, + s.name, s.description, s.price, s.duration_minutes + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, bookingID) + if err != nil { + log.Printf("Failed to fetch services for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer serviceRows.Close() + + 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 + + err := serviceRows.Scan( + &s.ServiceID, + &overridePrice, &overrideDuration, + &name, &description, &basePrice, &baseDuration, + ) + if err != nil { + log.Printf("Failed to scan service row for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + 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.BasePrice = &basePrice.Float64 + } + if baseDuration.Valid { + d := int(baseDuration.Int32) + s.BaseDurationMinutes = &d + } + + booking.Services = append(booking.Services, s) + } + + // ---------------------------- + // 3. Fetch payments + // ---------------------------- + paymentRows, err := db.DB.Query(r.Context(), ` + SELECT + id, payment_type, payment_method, vendor_code, invoice_number, + status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, + created_at, updated_at, created_by + FROM payments + WHERE booking_id = $1 + ORDER BY created_at DESC + `, 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() + + for paymentRows.Next() { + var p Payment + var vendorCode sql.NullString + var invoiceNumber sql.NullInt32 + var vatRate, vatAmount, netAmount sql.NullFloat64 + var createdBy sql.NullString + + err := paymentRows.Scan( + &p.ID, &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber, + &p.Status, &p.Amount, &p.IsVATApplicable, &vatRate, &vatAmount, &netAmount, + &p.CreatedAt, &p.UpdatedAt, &createdBy, + ) + if 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 createdBy.Valid { + p.CreatedBy = &createdBy.String + } + + booking.Payments = append(booking.Payments, p) + } + + 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) + return + } +} + +// GET /api/admin/bookings/{id} +func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" { + http.Error(w, "Booking ID is required", http.StatusBadRequest) + return + } + + // ---------------------------- + // 1. Fetch booking (no user restriction) + // ---------------------------- + var booking Booking + var createdBy sql.NullString + err := db.DB.QueryRow(r.Context(), ` + SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by + FROM bookings + WHERE id = $1 + `, bookingID).Scan( + &booking.ID, &booking.UserID, &booking.StartTime, &booking.Status, + &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy, + ) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + log.Printf("Failed to fetch admin booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if createdBy.Valid { + booking.CreatedBy = &createdBy.String + } + + // ---------------------------- + // 2. Fetch services + // ---------------------------- + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT + bs.service_id, bs.override_price, bs.override_duration_minutes, + s.name, s.description, s.price, s.duration_minutes + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, bookingID) + if err != nil { + log.Printf("Failed to fetch services for admin booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer serviceRows.Close() + + 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 + + err := serviceRows.Scan( + &s.ServiceID, + &overridePrice, &overrideDuration, + &name, &description, &basePrice, &baseDuration, + ) + if err != nil { + log.Printf("Failed to scan service row for admin booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + 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.BasePrice = &basePrice.Float64 + } + if baseDuration.Valid { + d := int(baseDuration.Int32) + s.BaseDurationMinutes = &d + } + + booking.Services = append(booking.Services, s) + } + + // ---------------------------- + // 3. Fetch payments + // ---------------------------- + paymentRows, err := db.DB.Query(r.Context(), ` + SELECT + id, payment_type, payment_method, vendor_code, invoice_number, + status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, + created_at, updated_at, created_by + FROM payments + WHERE booking_id = $1 + ORDER BY created_at DESC + `, bookingID) + if err != nil { + log.Printf("Failed to fetch payments for admin booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer paymentRows.Close() + + for paymentRows.Next() { + var p Payment + var vendorCode sql.NullString + var invoiceNumber sql.NullInt32 + var vatRate, vatAmount, netAmount sql.NullFloat64 + var createdBy sql.NullString + + err := paymentRows.Scan( + &p.ID, &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber, + &p.Status, &p.Amount, &p.IsVATApplicable, &vatRate, &vatAmount, &netAmount, + &p.CreatedAt, &p.UpdatedAt, &createdBy, + ) + if err != nil { + log.Printf("Failed to scan payment row for admin 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 createdBy.Valid { + p.CreatedBy = &createdBy.String + } + + booking.Payments = append(booking.Payments, p) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(booking); err != nil { + log.Printf("Failed to encode admin booking response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } +}