From 5cedba21e7dbbc5073962891bb2f7a5c233095b4 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 23 Oct 2025 22:35:10 +0100 Subject: [PATCH] Bookings --- backend/handlers/bookings/bookings.go | 996 +++++------- backend/handlers/bookings/bookings.txt | 1997 ------------------------ backend/main.go | 1 - frontend/src/routes/admin/+page.svelte | 632 ++++++-- init-scripts/init-script.sql | 3 +- local-dev.sh | 123 +- 6 files changed, 970 insertions(+), 2782 deletions(-) delete mode 100644 backend/handlers/bookings/bookings.txt diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 61cf358..2ecf5b6 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -15,10 +15,17 @@ import ( "github.com/go-chi/chi/v5" ) +var londonLocation = func() *time.Location { + loc, err := time.LoadLocation("Europe/London") + if err != nil { + panic("Europe/London timezone not available") + } + return loc +}() + // Booking represents a booking in the system type Booking struct { ID string `json:"id"` - UserID string `json:"user_id"` StartTime time.Time `json:"start_time"` Status string `json:"status"` Notes *string `json:"notes,omitempty"` @@ -27,8 +34,13 @@ type Booking struct { CreatedBy *string `json:"created_by,omitempty"` // Joined fields - Services []BookingService `json:"services,omitempty"` - Payments []Payment `json:"payments,omitempty"` + User *UserSummary `json:"user,omitempty"` + Services []BookingService `json:"services,omitempty"` + Payments []Payment `json:"payments,omitempty"` + TotalAmount float64 `json:"total_amount"` + AmountPaid float64 `json:"amount_paid"` + AmountDue float64 `json:"amount_due"` + DurationMinutes int `json:"duration_minutes"` } // BookingService represents a service associated with a booking @@ -39,10 +51,10 @@ type BookingService struct { 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"` + 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 @@ -119,16 +131,20 @@ type AdminBookingSummary struct { } 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"` + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + FullName string `json:"full_name"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + ProfilePicURL *string `json:"profile_pic_url,omitempty"` + DateOfBirth *string `json:"date_of_birth,omitempty"` + AccountRole string `json:"account_role"` + LoyaltyStamps *int `json:"loyalty_stamps,omitempty"` + ReferralCode *string `json:"referral_code,omitempty"` + ReferralCodeUses *int `json:"referral_code_uses,omitempty"` + CreatedAt string `json:"created_at"` + Notes *string `json:"notes,omitempty"` } type BookingServiceDetail struct { @@ -198,7 +214,7 @@ type AdminBookingDetail struct { func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest { req := GetAllBookingsRequest{ Page: 1, - PerPage: 20, // default page size + PerPage: 10, // default page size } if status := r.URL.Query().Get("status"); status != "" { @@ -241,7 +257,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { // Build base query with user filter baseQuery := ` - SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by + SELECT id, start_time, status, notes, created_at, updated_at, created_by FROM bookings WHERE user_id = $1 ` @@ -262,11 +278,13 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { if req.StartDate != nil { baseQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) - startTime, err := time.Parse("2006-01-02", *req.StartDate) + startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation) if err != nil { http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) return } + // Ensure it's at start of day in London time + startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation) args = append(args, startTime) paramCount++ } @@ -314,7 +332,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { for rows.Next() { var b Booking var createdBy sql.NullString - err := rows.Scan(&b.ID, &b.UserID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy) + err := rows.Scan(&b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy) if err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -346,29 +364,61 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { // Parse query parameters req := parseGetAllBookingsRequest(r) - // Build base query without user filter + // Build base query - only what the component needs baseQuery := ` - SELECT b.id, user_id, start_time, status, b.notes, b.created_at, b.updated_at, b.created_by, - u.fn, u.profile_pic_url, u.notes as user_notes - FROM bookings b - LEFT JOIN users u ON b.user_id = u.id - ` + WITH booking_totals AS ( + SELECT + bs.booking_id, + SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) as total_duration, + SUM(COALESCE(bs.override_price, s.price)) as total_amount + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + GROUP BY bs.booking_id + ), + payment_totals AS ( + SELECT + booking_id, + SUM(amount) as total_paid + FROM payments + WHERE status = 'completed' + GROUP BY booking_id + ) + SELECT + b.id, + b.start_time, + b.status, + u.fn, + COALESCE(bt.total_duration, 0) as duration_minutes, + COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) as amount_due + FROM bookings b + LEFT JOIN users u ON b.user_id = u.id + LEFT JOIN booking_totals bt ON b.id = bt.booking_id + LEFT JOIN payment_totals pt ON b.id = pt.booking_id + ` - countQuery := `SELECT COUNT(*) FROM bookings` + countQuery := `SELECT COUNT(*) FROM bookings b` var args []interface{} paramCount := 1 // Add filters + whereAdded := false if req.Status != nil { baseQuery += fmt.Sprintf(" WHERE b.status = $%d", paramCount) - countQuery += fmt.Sprintf(" WHERE status = $%d", paramCount) + countQuery += fmt.Sprintf(" WHERE b.status = $%d", paramCount) args = append(args, *req.Status) paramCount++ + whereAdded = true } if req.StartDate != nil { - baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) - countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) + if whereAdded { + baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) + countQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) + } else { + baseQuery += fmt.Sprintf(" WHERE b.start_time >= $%d", paramCount) + countQuery += fmt.Sprintf(" WHERE b.start_time >= $%d", paramCount) + whereAdded = true + } 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) @@ -379,8 +429,13 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { } if req.EndDate != nil { - baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount) - countQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount) + if whereAdded { + baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount) + countQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount) + } else { + baseQuery += fmt.Sprintf(" WHERE b.start_time <= $%d", paramCount) + countQuery += fmt.Sprintf(" WHERE 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) @@ -391,16 +446,22 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { paramCount++ } - // Add ordering and pagination + // Add ordering and pagination (NO GROUP BY needed here) baseQuery += " ORDER BY b.start_time ASC" if req.PerPage > 0 { baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1) args = append(args, req.PerPage, (req.Page-1)*req.PerPage) + paramCount += 2 } // Get total count + countArgs := args + if req.PerPage > 0 { + countArgs = args[:len(args)-2] + } + var total int - err := db.DB.QueryRow(r.Context(), countQuery).Scan(&total) + err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total) if err != nil { log.Printf("Failed to get total booking count: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -417,22 +478,71 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { defer rows.Close() var bookings []Booking + bookingIDs := []string{} + for rows.Next() { var b Booking - var createdBy sql.NullString - var userFN, profilePicURL, userNotes sql.NullString + var userFullName string - err := rows.Scan(&b.ID, &b.UserID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy, - &userFN, &profilePicURL, &userNotes) + err := rows.Scan(&b.ID, &b.StartTime, &b.Status, &userFullName, &b.DurationMinutes, &b.AmountDue) if err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - if createdBy.Valid { - b.CreatedBy = &createdBy.String + + // Create minimal user with just full_name + b.User = &UserSummary{ + FullName: userFullName, } + bookings = append(bookings, b) + bookingIDs = append(bookingIDs, b.ID) + } + + // Fetch service names only + if len(bookingIDs) > 0 { + servicesQuery := ` + SELECT bs.booking_id, s.name + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = ANY($1) + ORDER BY bs.booking_id, s.name + ` + + serviceRows, err := db.DB.Query(r.Context(), servicesQuery, 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 string + var 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 + } + + service := BookingService{ + BookingID: bookingID, + ServiceName: &serviceName, + } + servicesByBooking[bookingID] = append(servicesByBooking[bookingID], service) + } + + // Assign services to each booking + for i := range bookings { + if services, exists := servicesByBooking[bookings[i].ID]; exists { + bookings[i].Services = services + } + } } response := BookingListResponse{ @@ -486,11 +596,13 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { if req.StartDate != nil { baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) - startTime, err := time.Parse("2006-01-02", *req.StartDate) + startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation) if err != nil { http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) return } + // Ensure it's at start of day in London time + startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation) args = append(args, startTime) paramCount++ } @@ -536,11 +648,24 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { var bookings []Booking for rows.Next() { var b Booking + b.User = &UserSummary{} var createdBy sql.NullString - var userFN, profilePicURL, userNotes sql.NullString - err := rows.Scan(&b.ID, &b.UserID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy, - &userFN, &profilePicURL, &userNotes) + var userFullName, userPicURL, userNotes sql.NullString + err := rows.Scan( + &b.ID, &b.User.ID, &b.StartTime, &b.Status, &b.Notes, + &b.CreatedAt, &b.UpdatedAt, &createdBy, + &userFullName, &userPicURL, &userNotes, + ) + if userFullName.Valid { + b.User.FullName = userFullName.String + } + if userPicURL.Valid { + b.User.ProfilePicURL = &userPicURL.String + } + if userNotes.Valid { + b.User.Notes = &userNotes.String + } if err != nil { log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -567,8 +692,8 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { } } -// GET /api/admin/bookings/{id}/summary -func GetAdminBookingSummaryHandler(w http.ResponseWriter, r *http.Request) { +// 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) @@ -578,24 +703,23 @@ func GetAdminBookingSummaryHandler(w http.ResponseWriter, r *http.Request) { // ---------------------------- // 1. Fetch booking + user // ---------------------------- - var summary AdminBookingSummary var booking Booking - var user AdminUserSummary - var createdBy sql.NullString - var profilePicURL, notes sql.NullString + booking.User = &UserSummary{} err := db.DB.QueryRow(r.Context(), ` SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, - u.fn, u.profile_pic_url, u.notes + u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps, + u.referral_code, u.notes 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.FullName, &profilePicURL, ¬es, + &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, ) if err != nil { if err == sql.ErrNoRows { @@ -607,32 +731,30 @@ func GetAdminBookingSummaryHandler(w http.ResponseWriter, r *http.Request) { return } - if createdBy.Valid { - booking.CreatedBy = &createdBy.String + // ---------------------------- + // 1.5. Fetch referral code uses count + // ---------------------------- + var referralCodeUses int + err = db.DB.QueryRow(r.Context(), ` + SELECT COUNT(*) + FROM user_referrals + WHERE referrer_id = $1 +`, booking.User.ID).Scan(&referralCodeUses) + if err != nil { + log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err) + // Don't fail the entire request, just log and continue with 0 + referralCodeUses = 0 } - - // Only include profile_pic_url if not null and not empty - if profilePicURL.Valid && profilePicURL.String != "" { - user.ProfilePicURL = &profilePicURL.String - } - - // Only include notes if not null and not empty - if notes.Valid && notes.String != "" { - user.Notes = ¬es.String - } - - summary.Booking = booking - summary.User = &user + booking.User.ReferralCodeUses = &referralCodeUses // ---------------------------- - // 2. Fetch services + // 2. Fetch services and calculate totals // ---------------------------- 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 + 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 @@ -645,60 +767,40 @@ func GetAdminBookingSummaryHandler(w http.ResponseWriter, r *http.Request) { } defer serviceRows.Close() - for serviceRows.Next() { - var s BookingServiceDetail - var overridePrice sql.NullFloat64 - var overrideDuration sql.NullInt32 - var patchTestHours sql.NullInt32 + var totalAmount float64 + var durationMinutes int - 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) + for serviceRows.Next() { + var name string + var price float64 + var durationMinutes int + + if err := serviceRows.Scan(&name, &price, &durationMinutes); err != nil { + log.Printf("Failed to scan service for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - // Set overrides if they exist - 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 using overrides where they exist - 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 - } + // Calculate totals + totalAmount += price + durationMinutes += durationMinutes + booking.Services = append(booking.Services, BookingService{ + ServiceName: &name, + Price: &price, + DurationMinutes: &durationMinutes, + }) } + booking.TotalAmount = totalAmount + booking.DurationMinutes = durationMinutes + // ---------------------------- - // 3. Fetch payments + // 3. Fetch payments and calculate amount paid // ---------------------------- 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 + payment_type, payment_method, vendor_code, invoice_number, + status, amount, created_at FROM payments WHERE booking_id = $1 ORDER BY created_at ASC @@ -710,17 +812,17 @@ func GetAdminBookingSummaryHandler(w http.ResponseWriter, r *http.Request) { } defer paymentRows.Close() + var payments []Payment + var amountPaid float64 + 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, + &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber, + &p.Status, &p.Amount, &p.CreatedAt, ) if err != nil { log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err) @@ -728,266 +830,39 @@ func GetAdminBookingSummaryHandler(w http.ResponseWriter, r *http.Request) { return } - if vendorCode.Valid { + if vendorCode.Valid && vendorCode.String != "" { 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) + payments = append(payments, p) if p.Status == "completed" { - summary.AmountPaid += p.Amount + amountPaid += p.Amount } } - summary.AmountDue = summary.TotalAmount - summary.AmountPaid + if len(payments) > 0 { + booking.Payments = payments + } + booking.AmountPaid = amountPaid + booking.AmountDue = totalAmount - amountPaid // ---------------------------- // Return JSON response // ---------------------------- w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(summary); err != nil { + 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 + user summary - // ---------------------------- - var booking Booking - var createdBy sql.NullString - var userFN, profilePicURL, userNotes sql.NullString - var userSummary *AdminUserSummary - - err := db.DB.QueryRow(r.Context(), ` - SELECT - b.id, b.user_id, b.start_time, b.status, b.notes, - b.created_at, b.updated_at, b.created_by, - u.fn, u.profile_pic_url, u.notes - 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, - &userFN, &profilePicURL, &userNotes, - ) - - 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 - } - - // Create user summary if user exists - if userFN.Valid { - userSummary = &AdminUserSummary{ - FullName: userFN.String, - } - if profilePicURL.Valid && profilePicURL.String != "" { - userSummary.ProfilePicURL = &profilePicURL.String - } - if userNotes.Valid && userNotes.String != "" { - userSummary.Notes = &userNotes.String - } - } - - // ---------------------------- - // 2. Fetch services - // ---------------------------- - var totalAmount float64 - var durationMinutes int - - 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 - totalAmount += *s.OverridePrice - } else if basePrice.Valid { - totalAmount += basePrice.Float64 - } - - if overrideDuration.Valid { - d := int(overrideDuration.Int32) - s.OverrideDurationMinutes = &d - durationMinutes += *s.OverrideDurationMinutes - } else if baseDuration.Valid { - durationMinutes += int(baseDuration.Int32) - } - 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 - // ---------------------------- - var amountPaid float64 - - paymentRows, err := db.DB.Query(r.Context(), ` - SELECT - id, payment_type, payment_method, vendor_code, invoice_number, - status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount, - created_at, updated_at, created_by - FROM payments - WHERE booking_id = $1 - ORDER BY created_at ASC - `, bookingID) - if err != nil { - log.Printf("Failed to fetch payments for admin booking %s: %v", bookingID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - 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 - } - - if p.Status == "completed" { - amountPaid += p.Amount - } - booking.Payments = append(booking.Payments, p) - } - - amountDue := totalAmount - amountPaid - - // Create enhanced response with totals - response := 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"` - }{ - Booking: booking, - User: userSummary, - TotalAmount: totalAmount, - AmountPaid: amountPaid, - AmountDue: amountDue, - DurationMinutes: durationMinutes, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - log.Printf("Failed to encode admin booking response: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } -} - // GET /api/admin/bookings/search func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") @@ -998,7 +873,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { // Parse pagination page := 1 - perPage := 20 + perPage := 10 if pageStr := r.URL.Query().Get("page"); pageStr != "" { if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { page = p @@ -1016,67 +891,71 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { escapedQuery = strings.ReplaceAll(escapedQuery, `_`, `\_`) searchPattern := "%" + escapedQuery + "%" - // Build the main search query with JOINs to get all relevant data in one go + // Build the main search query using CTEs to match your actual schema searchQuery := ` - WITH matched_bookings AS ( - SELECT DISTINCT b.id - FROM bookings b - LEFT JOIN users u ON b.user_id = u.id - LEFT JOIN booking_services bs ON b.id = bs.booking_id - LEFT JOIN services s ON bs.service_id = s.id - LEFT JOIN payments p ON b.id = p.booking_id - WHERE - b.id ILIKE $1 ESCAPE '\' OR - b.notes ILIKE $1 ESCAPE '\' OR - b.status::text ILIKE $1 ESCAPE '\' OR - u.n_first_name ILIKE $1 ESCAPE '\' OR - u.n_last_name ILIKE $1 ESCAPE '\' OR - u.fn ILIKE $1 ESCAPE '\' OR - u.email ILIKE $1 ESCAPE '\' OR - u.phone ILIKE $1 ESCAPE '\' OR - s.name ILIKE $1 ESCAPE '\' OR - s.description ILIKE $1 ESCAPE '\' OR - p.vendor_code ILIKE $1 ESCAPE '\' OR - p.invoice_number::text ILIKE $1 ESCAPE '\' - ) - 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.profile_pic_url, u.notes as user_notes, - u.n_first_name, u.n_last_name, 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 - INNER JOIN matched_bookings mb ON b.id = mb.id - ORDER BY b.start_time ASC - LIMIT $2 OFFSET $3 - ` + WITH booking_totals AS ( + SELECT + bs.booking_id, + SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) as total_duration, + SUM(COALESCE(bs.override_price, s.price)) as total_amount + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + GROUP BY bs.booking_id + ), + payment_totals AS ( + SELECT + booking_id, + SUM(amount) as total_paid + FROM payments + WHERE status = 'completed' + GROUP BY booking_id + ) + SELECT + b.id, + b.start_time, + b.status, + u.fn as full_name, + COALESCE(bt.total_duration, 0) as duration_minutes, + COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) as amount_due + FROM bookings b + LEFT JOIN users u ON b.user_id = u.id + LEFT JOIN booking_totals bt ON b.id = bt.booking_id + LEFT JOIN payment_totals pt ON b.id = pt.booking_id + WHERE + b.id ILIKE $1 ESCAPE '\' OR + b.notes ILIKE $1 ESCAPE '\' OR + b.status::text ILIKE $1 ESCAPE '\' OR + u.n_first_name ILIKE $1 ESCAPE '\' OR + u.n_last_name ILIKE $1 ESCAPE '\' OR + u.fn ILIKE $1 ESCAPE '\' OR + u.email ILIKE $1 ESCAPE '\' OR + u.phone ILIKE $1 ESCAPE '\' OR + EXISTS ( + SELECT 1 FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\' + ) + ORDER BY b.start_time ASC + LIMIT $2 OFFSET $3 + ` countQuery := ` - WITH matched_bookings AS ( - SELECT DISTINCT b.id - FROM bookings b - LEFT JOIN users u ON b.user_id = u.id - LEFT JOIN booking_services bs ON b.id = bs.booking_id - LEFT JOIN services s ON bs.service_id = s.id - LEFT JOIN payments p ON b.id = p.booking_id - WHERE - b.id ILIKE $1 ESCAPE '\' OR - b.notes ILIKE $1 ESCAPE '\' OR - b.status::text ILIKE $1 ESCAPE '\' OR - u.n_first_name ILIKE $1 ESCAPE '\' OR - u.n_last_name ILIKE $1 ESCAPE '\' OR - u.fn ILIKE $1 ESCAPE '\' OR - u.email ILIKE $1 ESCAPE '\' OR - u.phone ILIKE $1 ESCAPE '\' OR - s.name ILIKE $1 ESCAPE '\' OR - s.description ILIKE $1 ESCAPE '\' OR - p.vendor_code ILIKE $1 ESCAPE '\' OR - p.invoice_number::text ILIKE $1 ESCAPE '\' - ) - SELECT COUNT(*) FROM matched_bookings - ` + SELECT COUNT(DISTINCT b.id) + FROM bookings b + LEFT JOIN users u ON b.user_id = u.id + LEFT JOIN booking_services bs ON b.id = bs.booking_id + LEFT JOIN services s ON bs.service_id = s.id + WHERE + b.id ILIKE $1 ESCAPE '\' OR + b.notes ILIKE $1 ESCAPE '\' OR + b.status::text ILIKE $1 ESCAPE '\' OR + u.n_first_name ILIKE $1 ESCAPE '\' OR + u.n_last_name ILIKE $1 ESCAPE '\' OR + u.fn ILIKE $1 ESCAPE '\' OR + u.email ILIKE $1 ESCAPE '\' OR + u.phone ILIKE $1 ESCAPE '\' OR + s.name ILIKE $1 ESCAPE '\' + ` offset := (page - 1) * perPage @@ -1098,226 +977,79 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { } defer rows.Close() - var bookingIDs []string - bookingMap := make(map[string]*AdminBookingSummary) + var bookings []Booking + bookingIDs := []string{} for rows.Next() { - var booking Booking - var user AdminUserSummary - var fullUser UserSummary - var createdBy sql.NullString - var profilePicURL, userNotes sql.NullString - var email, phone, referralCode sql.NullString - var dateOfBirth, userCreatedAt sql.NullTime + var b Booking + var userFullName string - err := rows.Scan( - &booking.ID, &booking.UserID, &booking.StartTime, &booking.Status, &booking.Notes, - &booking.CreatedAt, &booking.UpdatedAt, &createdBy, - &fullUser.FullName, &profilePicURL, &userNotes, - &fullUser.FirstName, &fullUser.LastName, &email, &phone, - &dateOfBirth, &fullUser.AccountRole, &fullUser.LoyaltyStamps, - &referralCode, &userCreatedAt, - ) + err := rows.Scan(&b.ID, &b.StartTime, &b.Status, &userFullName, &b.DurationMinutes, &b.AmountDue) if err != nil { - log.Printf("Failed to scan search result: %v", err) + log.Printf("Failed to scan booking row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - if createdBy.Valid { - booking.CreatedBy = &createdBy.String + // Create minimal user with just full_name + b.User = &UserSummary{ + FullName: userFullName, } - // Build user summary for response - if fullUser.FullName != "" { - user.FullName = fullUser.FullName - if profilePicURL.Valid && profilePicURL.String != "" { - user.ProfilePicURL = &profilePicURL.String - } - if userNotes.Valid && userNotes.String != "" { - user.Notes = &userNotes.String - } - } - - summary := AdminBookingSummary{ - Booking: booking, - User: &user, - } - - bookingIDs = append(bookingIDs, booking.ID) - bookingMap[booking.ID] = &summary + bookings = append(bookings, b) + bookingIDs = append(bookingIDs, b.ID) } - // If no bookings found, return empty result - if len(bookingIDs) == 0 { - response := SearchBookingsResponse{ - Bookings: []AdminBookingSummary{}, - Page: page, - PerPage: perPage, - Total: total, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - return - } + // Fetch service names only (same as GetAllAdminBookingsHandler) + if len(bookingIDs) > 0 { + servicesQuery := ` + SELECT bs.booking_id, s.name + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = ANY($1) + ORDER BY bs.booking_id, s.name + ` - // Fetch services for all found bookings - serviceQuery := ` - SELECT - bs.booking_id, - 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 = ANY($1::text[]) - ORDER BY bs.booking_id, s.name - ` - - serviceRows, err := db.DB.Query(r.Context(), serviceQuery, bookingIDs) - if err != nil { - log.Printf("Failed to fetch services for search: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - defer serviceRows.Close() - - for serviceRows.Next() { - var bookingID string - var s BookingServiceDetail - var overridePrice sql.NullFloat64 - var overrideDuration sql.NullInt32 - var patchTestHours sql.NullInt32 - - err := serviceRows.Scan( - &bookingID, - &overridePrice, &overrideDuration, - &s.ServiceName, &s.ServiceDescription, - &s.BasePrice, &s.BaseDurationMinutes, - &s.IsActive, &patchTestHours, &s.MinimumAgeRequired, - ) + serviceRows, err := db.DB.Query(r.Context(), servicesQuery, bookingIDs) if err != nil { - log.Printf("Failed to scan service row for search: %v", err) + log.Printf("Failed to fetch booking services: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } + defer serviceRows.Close() - 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 + servicesByBooking := make(map[string][]BookingService) + + for serviceRows.Next() { + var bookingID string + var 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 + } + + service := BookingService{ + BookingID: bookingID, + ServiceName: &serviceName, + } + servicesByBooking[bookingID] = append(servicesByBooking[bookingID], service) } - summary := bookingMap[bookingID] - if summary != nil { - summary.Services = append(summary.Services, s) - - // Calculate totals using overrides - if s.OverridePrice != nil { - summary.TotalAmount += *s.OverridePrice + // Assign services to each booking + for i := range bookings { + if services, exists := servicesByBooking[bookings[i].ID]; exists { + bookings[i].Services = services } else { - summary.TotalAmount += s.BasePrice - } - - if s.OverrideDurationMinutes != nil { - summary.DurationMinutes += *s.OverrideDurationMinutes - } else { - summary.DurationMinutes += s.BaseDurationMinutes + // Ensure services is never nil + bookings[i].Services = []BookingService{} } } } - // Fetch payments for all found bookings - paymentQuery := ` - SELECT - p.booking_id, - p.id, p.payment_type, p.payment_method, p.vendor_code, p.invoice_number, - p.status, p.amount, p.is_vat_applicable, p.vat_rate, p.vat_amount, p.net_amount, - p.created_at, p.updated_at, p.created_by - FROM payments p - WHERE p.booking_id = ANY($1::text[]) - ORDER BY p.booking_id, p.created_at ASC - ` - - paymentRows, err := db.DB.Query(r.Context(), paymentQuery, bookingIDs) - if err != nil { - log.Printf("Failed to fetch payments for search: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - defer paymentRows.Close() - - for paymentRows.Next() { - var bookingID string - var p Payment - var vendorCode sql.NullString - var invoiceNumber sql.NullInt32 - var vatRate, vatAmount, netAmount sql.NullFloat64 - var createdBy sql.NullString - - err := paymentRows.Scan( - &bookingID, - &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 search: %v", 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 := bookingMap[bookingID] - if summary != nil { - summary.Payments = append(summary.Payments, p) - - if p.Status == "completed" { - summary.AmountPaid += p.Amount - } - } - } - - // Calculate amount due for each booking - for _, summary := range bookingMap { - summary.AmountDue = summary.TotalAmount - summary.AmountPaid - } - - // Build response array in the same order as bookingIDs - var results []AdminBookingSummary - for _, id := range bookingIDs { - if summary, exists := bookingMap[id]; exists { - results = append(results, *summary) - } - } - - response := SearchBookingsResponse{ - Bookings: results, + response := BookingListResponse{ + Bookings: bookings, Page: page, PerPage: perPage, Total: total, @@ -1325,7 +1057,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { - log.Printf("Failed to encode search response: %v", err) + log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -1384,6 +1116,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { ` var booking Booking + booking.User = &UserSummary{} err = tx.QueryRow(r.Context(), bookingQuery, userID, @@ -1392,7 +1125,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { createdBy, ).Scan( &booking.ID, - &booking.UserID, + &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, @@ -1476,6 +1209,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { ` var booking Booking + booking.User = &UserSummary{} err := db.DB.QueryRow(r.Context(), query, req.StartTime, @@ -1483,7 +1217,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { userID, ).Scan( &booking.ID, - &booking.UserID, + &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, @@ -1527,6 +1261,15 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Invalid request", http.StatusBadRequest) return } + allowed := map[string]bool{ + "pending": true, "confirmed": true, "in_progress": true, + "completed": true, "client_cancelled": true, "we_cancelled": true, + "re-schedule": true, "no_show": true, + } + if !allowed[req.Status] { + http.Error(w, "Invalid status", http.StatusBadRequest) + return + } // Update booking status query := ` @@ -1537,13 +1280,14 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { ` var booking Booking + booking.User = &UserSummary{} err := db.DB.QueryRow(r.Context(), query, req.Status, bookingID, ).Scan( &booking.ID, - &booking.UserID, + &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, @@ -1617,13 +1361,14 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { ` var booking Booking + booking.User = &UserSummary{} err = tx.QueryRow(r.Context(), bookingQuery, req.Notes, bookingID, ).Scan( &booking.ID, - &booking.UserID, + &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, @@ -1740,6 +1485,13 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Invalid request", http.StatusBadRequest) return } + allowed := map[string]bool{ + "client_cancelled": true, "we_cancelled": true, "re-schedule": true, "no_show": true, + } + if !allowed[req.Reason] { + http.Error(w, "Invalid reason", http.StatusBadRequest) + return + } // Update booking status instead of deleting query := ` @@ -1809,13 +1561,15 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { // 1. Fetch booking // ---------------------------- var booking Booking + booking.Payments = []Payment{} + booking.User = &UserSummary{} 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.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy, ) if err != nil { @@ -1894,11 +1648,11 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) { s.ServiceDescription = &description.String } if basePrice.Valid { - s.BasePrice = &basePrice.Float64 + s.Price = &basePrice.Float64 } if baseDuration.Valid { d := int(baseDuration.Int32) - s.BaseDurationMinutes = &d + s.DurationMinutes = &d } booking.Services = append(booking.Services, s) diff --git a/backend/handlers/bookings/bookings.txt b/backend/handlers/bookings/bookings.txt deleted file mode 100644 index aca8d8d..0000000 --- a/backend/handlers/bookings/bookings.txt +++ /dev/null @@ -1,1997 +0,0 @@ -package bookings - -import ( - "crussell/db" - "crussell/mw" - "database/sql" - "encoding/json" - "fmt" - "log" - "net/http" - "strconv" - "strings" - "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 { - ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` - Notes *string `json:"notes,omitempty"` -} - -// ServiceOverride represents override values for a specific service in a booking -type ServiceOverride struct { - ServiceID string `json:"service_id" validate:"required"` - OverridePrice *float64 `json:"override_price,omitempty"` - OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"` -} - -// DeleteBookingRequest represents the request payload for deleting a booking with payment -type DeleteBookingRequest struct { - Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"` -} - -// AdminUserSummary represents a small user summary for admin views -type AdminUserSummary struct { - FullName string `json:"full_name"` - ProfilePicURL *string `json:"profile_pic_url,omitempty"` - Notes *string `json:"notes,omitempty"` -} - -// AdminBookingSummary represents a complete booking summary for admin view -type AdminBookingSummary struct { - Booking Booking `json:"booking"` - User *AdminUserSummary `json:"user,omitempty"` - Services []BookingServiceDetail `json:"services"` - Payments []Payment `json:"payments"` - TotalAmount float64 `json:"total_amount"` - AmountPaid float64 `json:"amount_paid"` - AmountDue float64 `json:"amount_due"` - DurationMinutes int `json:"duration_minutes"` -} - -type UserSummary struct { - 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"` -} - -// GetAllBookingsRequest represents query parameters for getting all bookings -type GetAllBookingsRequest struct { - Status *string `json:"status,omitempty"` - StartDate *string `json:"start_date,omitempty"` - EndDate *string `json:"end_date,omitempty"` - Page int `json:"page"` - PerPage int `json:"per_page"` -} - -// BookingListResponse represents a paginated list of bookings -type BookingListResponse struct { - Bookings []Booking `json:"bookings"` - Page int `json:"page"` - PerPage int `json:"per_page"` - Total int `json:"total"` -} - -// 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"` -} - -// Helper function to parse query parameters -func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest { - req := GetAllBookingsRequest{ - Page: 1, - PerPage: 20, // default page size - } - - if status := r.URL.Query().Get("status"); status != "" { - req.Status = &status - } - - if startDate := r.URL.Query().Get("start_date"); startDate != "" { - req.StartDate = &startDate - } - - if endDate := r.URL.Query().Get("end_date"); endDate != "" { - req.EndDate = &endDate - } - - if pageStr := r.URL.Query().Get("page"); pageStr != "" { - if page, err := strconv.Atoi(pageStr); err == nil && page > 0 { - req.Page = page - } - } - - if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" { - if perPage, err := strconv.Atoi(perPageStr); err == nil && perPage > 0 && perPage <= 100 { - req.PerPage = perPage - } - } - - return req -} - -// GET /api/bookings -func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { - userID, ok := r.Context().Value(mw.UserIDKey).(string) - if !ok || userID == "" { - http.Error(w, "Authentication required", http.StatusUnauthorized) - return - } - - // Parse query parameters - req := parseGetAllBookingsRequest(r) - - // Build base query with user filter - baseQuery := ` - SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by - FROM bookings - WHERE user_id = $1 - ` - - countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1` - var args []interface{} - args = append(args, userID) - paramCount := 2 - - // Add filters - if req.Status != nil { - baseQuery += fmt.Sprintf(" AND status = $%d", paramCount) - countQuery += fmt.Sprintf(" AND status = $%d", paramCount) - args = append(args, *req.Status) - paramCount++ - } - - if req.StartDate != nil { - baseQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) - countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) - startTime, err := time.Parse("2006-01-02", *req.StartDate) - if err != nil { - http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) - return - } - args = append(args, startTime) - paramCount++ - } - - if req.EndDate != nil { - baseQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount) - countQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount) - endTime, err := time.Parse("2006-01-02", *req.EndDate) - if err != nil { - http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest) - return - } - // Add end of day - endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second) - args = append(args, endTime) - paramCount++ - } - - // Add ordering and pagination - baseQuery += " ORDER BY start_time DESC" - if req.PerPage > 0 { - baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1) - args = append(args, req.PerPage, (req.Page-1)*req.PerPage) - } - - // Get total count - var total int - err := db.DB.QueryRow(r.Context(), countQuery, args[:1]...).Scan(&total) - if err != nil { - log.Printf("Failed to get booking count for user %s: %v", userID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // Get bookings - rows, err := db.DB.Query(r.Context(), baseQuery, args...) - if err != nil { - log.Printf("Failed to fetch bookings for user %s: %v", userID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var bookings []Booking - for rows.Next() { - var b Booking - var createdBy sql.NullString - err := rows.Scan(&b.ID, &b.UserID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy) - if err != nil { - log.Printf("Failed to scan booking row: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - if createdBy.Valid { - b.CreatedBy = &createdBy.String - } - bookings = append(bookings, b) - } - - response := BookingListResponse{ - Bookings: bookings, - Page: req.Page, - PerPage: req.PerPage, - Total: total, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - log.Printf("Failed to encode response: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } -} - -// GET /api/admin/bookings -func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { - // Parse query parameters - req := parseGetAllBookingsRequest(r) - - // Build base query without user filter - baseQuery := ` - SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by, - u.fn, u.profile_pic_url, u.notes as user_notes - FROM bookings b - LEFT JOIN users u ON b.user_id = u.id - ` - - countQuery := `SELECT COUNT(*) FROM bookings` - var args []interface{} - paramCount := 1 - - // Add filters - if req.Status != nil { - baseQuery += fmt.Sprintf(" WHERE b.status = $%d", paramCount) - countQuery += fmt.Sprintf(" WHERE status = $%d", paramCount) - args = append(args, *req.Status) - paramCount++ - } else { - baseQuery += " WHERE 1=1" - } - - if req.StartDate != nil { - baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) - countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) - startTime, err := time.Parse("2006-01-02", *req.StartDate) - if err != nil { - http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) - return - } - args = append(args, startTime) - paramCount++ - } - - if req.EndDate != nil { - baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount) - countQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount) - endTime, err := time.Parse("2006-01-02", *req.EndDate) - if err != nil { - http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest) - return - } - endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second) - args = append(args, endTime) - paramCount++ - } - - // Add ordering and pagination - baseQuery += " ORDER BY b.start_time DESC" - if req.PerPage > 0 { - baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1) - args = append(args, req.PerPage, (req.Page-1)*req.PerPage) - } - - // Get total count - var total int - err := db.DB.QueryRow(r.Context(), countQuery, args...).Scan(&total) - if err != nil { - log.Printf("Failed to get total booking count: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // Get bookings - rows, err := db.DB.Query(r.Context(), baseQuery, args...) - if err != nil { - log.Printf("Failed to fetch all bookings: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var bookings []Booking - for rows.Next() { - var b Booking - var createdBy sql.NullString - var userFN, profilePicURL, userNotes sql.NullString - - err := rows.Scan(&b.ID, &b.UserID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy, - &userFN, &profilePicURL, &userNotes) - if err != nil { - log.Printf("Failed to scan booking row: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - if createdBy.Valid { - b.CreatedBy = &createdBy.String - } - bookings = append(bookings, b) - } - - response := BookingListResponse{ - Bookings: bookings, - Page: req.Page, - PerPage: req.PerPage, - Total: total, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - log.Printf("Failed to encode response: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } -} - -// GET /api/admin/bookings/user/{user_id} -func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { - userID := chi.URLParam(r, "user_id") - if userID == "" { - http.Error(w, "User ID is required", http.StatusBadRequest) - return - } - - // Parse query parameters - req := parseGetAllBookingsRequest(r) - - // Build query with specific user filter - baseQuery := ` - SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by, - u.fn, u.profile_pic_url, u.notes as user_notes - FROM bookings b - LEFT JOIN users u ON b.user_id = u.id - WHERE b.user_id = $1 - ` - - countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1` - var args []interface{} - args = append(args, userID) - paramCount := 2 - - // Add filters - if req.Status != nil { - baseQuery += fmt.Sprintf(" AND b.status = $%d", paramCount) - countQuery += fmt.Sprintf(" AND status = $%d", paramCount) - args = append(args, *req.Status) - paramCount++ - } - - if req.StartDate != nil { - baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) - countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount) - startTime, err := time.Parse("2006-01-02", *req.StartDate) - if err != nil { - http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) - return - } - args = append(args, startTime) - paramCount++ - } - - if req.EndDate != nil { - baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount) - countQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount) - endTime, err := time.Parse("2006-01-02", *req.EndDate) - if err != nil { - http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest) - return - } - endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second) - args = append(args, endTime) - paramCount++ - } - - // Add ordering and pagination - baseQuery += " ORDER BY b.start_time DESC" - if req.PerPage > 0 { - baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1) - args = append(args, req.PerPage, (req.Page-1)*req.PerPage) - } - - // Get total count - var total int - err := db.DB.QueryRow(r.Context(), countQuery, args[:1]...).Scan(&total) - if err != nil { - log.Printf("Failed to get booking count for user %s: %v", userID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // Get bookings - rows, err := db.DB.Query(r.Context(), baseQuery, args...) - if err != nil { - log.Printf("Failed to fetch bookings for user %s: %v", userID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var bookings []Booking - for rows.Next() { - var b Booking - var createdBy sql.NullString - var userFN, profilePicURL, userNotes sql.NullString - - err := rows.Scan(&b.ID, &b.UserID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy, - &userFN, &profilePicURL, &userNotes) - if err != nil { - log.Printf("Failed to scan booking row: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - if createdBy.Valid { - b.CreatedBy = &createdBy.String - } - bookings = append(bookings, b) - } - - response := BookingListResponse{ - Bookings: bookings, - Page: req.Page, - PerPage: req.PerPage, - Total: total, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - log.Printf("Failed to encode response: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } -} - -// 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 AdminUserSummary - var createdBy sql.NullString - var profilePicURL, notes sql.NullString - - err := db.DB.QueryRow(r.Context(), ` - SELECT - b.id, b.user_id, b.start_time, b.status, b.notes, - b.created_at, b.updated_at, b.created_by, - u.fn, u.profile_pic_url, u.notes - 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.FullName, &profilePicURL, ¬es, - ) - if err != nil { - if err == sql.ErrNoRows { - http.Error(w, "Booking not found", http.StatusNotFound) - return - } - log.Printf("Failed to fetch booking %s: %v", bookingID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - if createdBy.Valid { - booking.CreatedBy = &createdBy.String - } - - // Only include profile_pic_url if not null and not empty - if profilePicURL.Valid && profilePicURL.String != "" { - user.ProfilePicURL = &profilePicURL.String - } - - // Only include notes if not null and not empty - if notes.Valid && notes.String != "" { - user.Notes = ¬es.String - } - - 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 - } - - // Set overrides if they exist - 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 using overrides where they exist - 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 - } -} - -// 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 + user summary - // ---------------------------- - var booking Booking - var createdBy sql.NullString - var userFN, profilePicURL, userNotes sql.NullString - var userSummary *AdminUserSummary - - err := db.DB.QueryRow(r.Context(), ` - SELECT - b.id, b.user_id, b.start_time, b.status, b.notes, - b.created_at, b.updated_at, b.created_by, - u.fn, u.profile_pic_url, u.notes - 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, - &userFN, &profilePicURL, &userNotes, - ) - - 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 - } - - // Create user summary if user exists - if userFN.Valid { - userSummary = &AdminUserSummary{ - FullName: userFN.String, - } - if profilePicURL.Valid && profilePicURL.String != "" { - userSummary.ProfilePicURL = &profilePicURL.String - } - if userNotes.Valid && userNotes.String != "" { - userSummary.Notes = &userNotes.String - } - } - - // ---------------------------- - // 2. Fetch services - // ---------------------------- - var totalAmount float64 - var durationMinutes int - - 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 - totalAmount += *s.OverridePrice - } else if basePrice.Valid { - totalAmount += basePrice.Float64 - } - - if overrideDuration.Valid { - d := int(overrideDuration.Int32) - s.OverrideDurationMinutes = &d - durationMinutes += *s.OverrideDurationMinutes - } else if baseDuration.Valid { - durationMinutes += int(baseDuration.Int32) - } - 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 - // ---------------------------- - var amountPaid float64 - - 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 - } - - 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 - } - - if p.Status == "completed" { - amountPaid += p.Amount - } - booking.Payments = append(booking.Payments, p) - } - - amountDue := totalAmount - amountPaid - - // Create enhanced response with totals - response := 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"` - }{ - Booking: booking, - User: userSummary, - TotalAmount: totalAmount, - AmountPaid: amountPaid, - AmountDue: amountDue, - DurationMinutes: durationMinutes, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - log.Printf("Failed to encode admin booking response: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } -} - -// 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 - } - - // Parse pagination - page := 1 - perPage := 20 - if pageStr := r.URL.Query().Get("page"); pageStr != "" { - if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { - page = p - } - } - if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" { - if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { - perPage = pp - } - } - - // Escape the search query to prevent SQL injection in LIKE patterns - escapedQuery := strings.ReplaceAll(query, `\`, `\\`) - escapedQuery = strings.ReplaceAll(escapedQuery, `%`, `\%`) - escapedQuery = strings.ReplaceAll(escapedQuery, `_`, `\_`) - searchPattern := "%" + escapedQuery + "%" - - // Build the main search query with JOINs to get all relevant data in one go - searchQuery := ` - WITH matched_bookings AS ( - SELECT DISTINCT b.id - FROM bookings b - LEFT JOIN users u ON b.user_id = u.id - LEFT JOIN booking_services bs ON b.id = bs.booking_id - LEFT JOIN services s ON bs.service_id = s.id - LEFT JOIN payments p ON b.id = p.booking_id - WHERE - b.id ILIKE $1 ESCAPE '\' OR - b.notes ILIKE $1 ESCAPE '\' OR - b.status::text ILIKE $1 ESCAPE '\' OR - u.n_first_name ILIKE $1 ESCAPE '\' OR - u.n_last_name ILIKE $1 ESCAPE '\' OR - u.fn ILIKE $1 ESCAPE '\' OR - u.email ILIKE $1 ESCAPE '\' OR - u.phone ILIKE $1 ESCAPE '\' OR - s.name ILIKE $1 ESCAPE '\' OR - s.description ILIKE $1 ESCAPE '\' OR - p.vendor_code ILIKE $1 ESCAPE '\' OR - p.invoice_number::text ILIKE $1 ESCAPE '\' - ) - 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.profile_pic_url, u.notes as user_notes, - u.n_first_name, u.n_last_name, 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 - INNER JOIN matched_bookings mb ON b.id = mb.id - ORDER BY b.start_time DESC - LIMIT $2 OFFSET $3 - ` - - countQuery := ` - WITH matched_bookings AS ( - SELECT DISTINCT b.id - FROM bookings b - LEFT JOIN users u ON b.user_id = u.id - LEFT JOIN booking_services bs ON b.id = bs.booking_id - LEFT JOIN services s ON bs.service_id = s.id - LEFT JOIN payments p ON b.id = p.booking_id - WHERE - b.id ILIKE $1 ESCAPE '\' OR - b.notes ILIKE $1 ESCAPE '\' OR - b.status::text ILIKE $1 ESCAPE '\' OR - u.n_first_name ILIKE $1 ESCAPE '\' OR - u.n_last_name ILIKE $1 ESCAPE '\' OR - u.fn ILIKE $1 ESCAPE '\' OR - u.email ILIKE $1 ESCAPE '\' OR - u.phone ILIKE $1 ESCAPE '\' OR - s.name ILIKE $1 ESCAPE '\' OR - s.description ILIKE $1 ESCAPE '\' OR - p.vendor_code ILIKE $1 ESCAPE '\' OR - p.invoice_number::text ILIKE $1 ESCAPE '\' - ) - SELECT COUNT(*) FROM matched_bookings - ` - - offset := (page - 1) * perPage - - // Get total count - var total int - err := db.DB.QueryRow(r.Context(), countQuery, searchPattern).Scan(&total) - if err != nil { - log.Printf("Failed to get search count: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // Get bookings - rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, offset) - if err != nil { - log.Printf("Failed to search bookings: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - defer rows.Close() - - var bookingIDs []string - bookingMap := make(map[string]*AdminBookingSummary) - - for rows.Next() { - var booking Booking - var user AdminUserSummary - var fullUser UserSummary - var createdBy sql.NullString - var profilePicURL, userNotes sql.NullString - var email, phone, referralCode sql.NullString - var dateOfBirth, userCreatedAt sql.NullTime - - err := rows.Scan( - &booking.ID, &booking.UserID, &booking.StartTime, &booking.Status, &booking.Notes, - &booking.CreatedAt, &booking.UpdatedAt, &createdBy, - &fullUser.FullName, &profilePicURL, &userNotes, - &fullUser.FirstName, &fullUser.LastName, &email, &phone, - &dateOfBirth, &fullUser.AccountRole, &fullUser.LoyaltyStamps, - &referralCode, &userCreatedAt, - ) - if err != nil { - log.Printf("Failed to scan search result: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - if createdBy.Valid { - booking.CreatedBy = &createdBy.String - } - - // Build user summary for response - if fullUser.FullName != "" { - user.FullName = fullUser.FullName - if profilePicURL.Valid && profilePicURL.String != "" { - user.ProfilePicURL = &profilePicURL.String - } - if userNotes.Valid && userNotes.String != "" { - user.Notes = &userNotes.String - } - } - - summary := AdminBookingSummary{ - Booking: booking, - User: &user, - } - - bookingIDs = append(bookingIDs, booking.ID) - bookingMap[booking.ID] = &summary - } - - // If no bookings found, return empty result - if len(bookingIDs) == 0 { - response := SearchBookingsResponse{ - Bookings: []AdminBookingSummary{}, - Page: page, - PerPage: perPage, - Total: total, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - return - } - - // Fetch services for all found bookings - serviceQuery := ` - SELECT - bs.booking_id, - 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 = ANY($1::text[]) - ORDER BY bs.booking_id, s.name - ` - - serviceRows, err := db.DB.Query(r.Context(), serviceQuery, bookingIDs) - if err != nil { - log.Printf("Failed to fetch services for search: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - defer serviceRows.Close() - - for serviceRows.Next() { - var bookingID string - var s BookingServiceDetail - var overridePrice sql.NullFloat64 - var overrideDuration sql.NullInt32 - var patchTestHours sql.NullInt32 - - err := serviceRows.Scan( - &bookingID, - &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 search: %v", 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 := bookingMap[bookingID] - if summary != nil { - summary.Services = append(summary.Services, s) - - // Calculate totals using overrides - 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 - } - } - } - - // Fetch payments for all found bookings - paymentQuery := ` - SELECT - p.booking_id, - p.id, p.payment_type, p.payment_method, p.vendor_code, p.invoice_number, - p.status, p.amount, p.is_vat_applicable, p.vat_rate, p.vat_amount, p.net_amount, - p.created_at, p.updated_at, p.created_by - FROM payments p - WHERE p.booking_id = ANY($1::text[]) - ORDER BY p.booking_id, p.created_at DESC - ` - - paymentRows, err := db.DB.Query(r.Context(), paymentQuery, bookingIDs) - if err != nil { - log.Printf("Failed to fetch payments for search: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - defer paymentRows.Close() - - for paymentRows.Next() { - var bookingID string - var p Payment - var vendorCode sql.NullString - var invoiceNumber sql.NullInt32 - var vatRate, vatAmount, netAmount sql.NullFloat64 - var createdBy sql.NullString - - err := paymentRows.Scan( - &bookingID, - &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 search: %v", 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 := bookingMap[bookingID] - if summary != nil { - summary.Payments = append(summary.Payments, p) - - if p.Status == "completed" { - summary.AmountPaid += p.Amount - } - } - } - - // Calculate amount due for each booking - for _, summary := range bookingMap { - summary.AmountDue = summary.TotalAmount - summary.AmountPaid - } - - // Build response array in the same order as bookingIDs - var results []AdminBookingSummary - for _, id := range bookingIDs { - if summary, exists := bookingMap[id]; exists { - results = append(results, *summary) - } - } - - response := SearchBookingsResponse{ - Bookings: results, - Page: page, - PerPage: perPage, - Total: total, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - log.Printf("Failed to encode search 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 - } - - // Validate override values - 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 - } - } - - 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 - } - log.Printf("Failed to confirm booking %s: %v", bookingID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // Update service overrides individually - if len(req.ServiceOverrides) > 0 { - // First, verify all service IDs belong to this booking - serviceCheckQuery := ` - SELECT COUNT(*) FROM booking_services - WHERE booking_id = $1 AND service_id = ANY($2) - ` - serviceIDs := make([]string, len(req.ServiceOverrides)) - for i, override := range req.ServiceOverrides { - serviceIDs[i] = override.ServiceID - } - - var count int - err = tx.QueryRow(r.Context(), serviceCheckQuery, bookingID, serviceIDs).Scan(&count) - if 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 - } - - // Update each service override - serviceUpdateQuery := ` - UPDATE booking_services - SET override_price = $1, - override_duration_minutes = $2 - WHERE booking_id = $3 AND service_id = $4 - ` - - for _, override := range req.ServiceOverrides { - _, err := tx.Exec(r.Context(), - serviceUpdateQuery, - override.OverridePrice, - override.OverrideDurationMinutes, - bookingID, - override.ServiceID, - ) - if err != nil { - log.Printf("Failed to update service override for booking %s, service %s: %v", - bookingID, override.ServiceID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - } - } - - if err := tx.Commit(r.Context()); err != nil { - log.Printf("Failed to commit booking confirmation: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // 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 - // ---------------------------- - var totalAmount float64 - var durationMinutes int - - 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 - totalAmount += *s.OverridePrice - } else if basePrice.Valid { - totalAmount += basePrice.Float64 - } - - if overrideDuration.Valid { - d := int(overrideDuration.Int32) - s.OverrideDurationMinutes = &d - durationMinutes += *s.OverrideDurationMinutes - } else if baseDuration.Valid { - durationMinutes += int(baseDuration.Int32) - } - - 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 - // ---------------------------- - var amountPaid float64 - 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 - } - - if p.Status == "completed" { - amountPaid += p.Amount - } - - booking.Payments = append(booking.Payments, p) - } - - amountDue := totalAmount - amountPaid - - // Create enhanced response with user-friendly totals - enhancedResponse := 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"` - }{ - Booking: booking, - TotalAmount: totalAmount, - AmountPaid: amountPaid, - AmountDue: amountDue, - DurationMinutes: durationMinutes, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(enhancedResponse); err != nil { - log.Printf("Failed to encode booking response: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } -} diff --git a/backend/main.go b/backend/main.go index 9ea2e0e..8aaf22f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -129,7 +129,6 @@ func main() { r.Get("/search", bookings.SearchAdminBookingsHandler) r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler) r.Get("/{id}", bookings.GetAdminBookingHandler) - r.Get("/{id}/summary", bookings.GetAdminBookingSummaryHandler) r.Put("/{id}/progress", bookings.ProgressBookingHandler) r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) }) diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index b19554a..b4dcd28 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -491,17 +491,75 @@ type Booking = { id: string; - user_id: string; - start_time: string; - status: 'Confirmed' | 'Completed' | 'Cancelled' | 'Pending' | 'In Progress'; + start_time: string; // ISO 8601 + status: + | 'pending' + | 'confirmed' + | 'in_progress' + | 'completed' + | 'client_cancelled' + | 'we_cancelled' + | 're-schedule' + | 'no_show'; notes?: string; - created_at: string; - services?: { id: string; name: string }[]; + created_at: string; // ISO 8601 + updated_at: string; // ISO 8601 + created_by?: string; + + // Nested user object user?: { - full_name?: string; + id: string; + first_name: string; + last_name: string; + full_name: string; email?: string; phone?: string; + profile_pic_url?: string; + date_of_birth?: string; + account_role: string; + loyalty_stamps?: number; + referral_code?: string; + referral_code_uses?: number; + created_at: string; + notes?: string; }; + + // Services array - always present (backend ensures this) + services: Array<{ + booking_id: string; + service_id: string; + override_price?: number; + override_duration_minutes?: number; + service_name?: string; + service_description?: string; + price?: number; + duration_minutes?: number; + }>; + + // Payments array + payments: Array<{ + id: string; + booking_id: string; + payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial'; + payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount'; + vendor_code?: string; + invoice_number?: number; + status: 'pending' | 'completed' | 'failed' | 'refunded'; + amount: number; + is_vat_applicable: boolean; + vat_rate?: number; + vat_amount?: number; + net_amount?: number; + created_at: string; + updated_at: string; + created_by?: string; + }>; + + // Computed/derived fields + total_amount: number; + amount_paid: number; + amount_due: number; + duration_minutes: number; }; let bookings = $state([]); @@ -524,27 +582,39 @@ }); if (response.ok) { const data = await response.json(); - if (data.bookings.length === 0) { + console.log('Bookings API response:', data); // Debug log + + if (data.bookings && data.bookings.length === 0) { + bookings = []; return; } + + // Map the response correctly - the backend returns the full Booking objects bookings = data.bookings.map((b: any) => ({ id: b.id, - user_id: b.user_id, start_time: b.start_time, status: b.status, notes: b.notes, created_at: b.created_at, - services: b.services?.map((s: any) => ({ - id: s.service_id, - name: s.service_name || 'Unknown Service' - })), - user: { - full_name: b.user?.full_name, - email: b.user?.email, - phone: b.user?.phone - } + updated_at: b.updated_at, + created_by: b.created_by, + // User info is nested under user object + user: b.user + ? { + id: b.user.id, + full_name: b.user.full_name + // Add other user fields if needed + } + : undefined, + // Services array should be present (even if empty) + services: b.services || [], + // Other computed fields from backend + total_amount: b.total_amount || 0, + amount_paid: b.amount_paid || 0, + amount_due: b.amount_due || 0, + duration_minutes: b.duration_minutes || 0 })); - console.log(bookings); + console.log('Mapped bookings:', bookings); // Debug log } else { const text = await response.text(); toast.error('Failed to load bookings: ' + text); @@ -561,6 +631,14 @@ async function searchBookings() { if (pageState !== 'authorized') return; loadingSearch = true; + + // If no search query, use the regular get-all endpoint + if (!bookingQuery.trim()) { + await fetchBookings(); + loadingSearch = false; + return; + } + try { const response = await fetch( `/api/admin/bookings/search?q=${encodeURIComponent(bookingQuery)}`, @@ -574,22 +652,28 @@ ); if (response.ok) { const data = await response.json(); + console.log('Search API response:', data); // Debug log + + // Map the search response correctly (same structure as fetchBookings) bookings = data.bookings.map((b: any) => ({ - id: b.booking.id, - user_id: b.booking.user_id, - start_time: b.booking.start_time, - status: b.booking.status, - notes: b.booking.notes, - created_at: b.booking.created_at, - services: b.services?.map((s: any) => ({ - id: s.service_id, - name: s.service_name || 'Unknown Service' - })), - user: { - full_name: b.user?.full_name, - email: b.user?.email, - phone: b.user?.phone - } + id: b.id, + start_time: b.start_time, + status: b.status, + notes: b.notes, + created_at: b.created_at, + updated_at: b.updated_at, + created_by: b.created_by, + user: b.user + ? { + id: b.user.id, + full_name: b.user.full_name + } + : undefined, + services: b.services || [], + total_amount: b.total_amount || 0, + amount_paid: b.amount_paid || 0, + amount_due: b.amount_due || 0, + duration_minutes: b.duration_minutes || 0 })); } else { const text = await response.text(); @@ -607,7 +691,7 @@ async function openBookingModal(bookingId: string) { if (pageState !== 'authorized') return; try { - const response = await fetch(`/api/admin/bookings/${bookingId}/summary`, { + const response = await fetch(`/api/admin/bookings/${bookingId}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -616,22 +700,63 @@ }); if (response.ok) { const data = await response.json(); + console.log('Booking details API response:', data); // Debug log + selectedBooking = { - id: data.booking.id, - user_id: data.booking.user_id, - start_time: data.booking.start_time, - status: data.booking.status, - notes: data.booking.notes, - created_at: data.booking.created_at, - services: data.services?.map((s: any) => ({ - id: s.service_id, - name: s.service_name || 'Unknown Service' + id: data.id, + start_time: data.start_time, + status: data.status, + notes: data.notes, + user: data.user + ? { + id: data.user.id, + first_name: data.user.first_name, + last_name: data.user.last_name, + full_name: data.user.full_name, + email: data.user.email, + phone: data.user.phone, + profile_pic_url: data.user.profile_pic_url, + date_of_birth: data.user.date_of_birth, + account_role: data.user.account_role, + loyalty_stamps: data.user.loyalty_stamps, + referral_code: data.user.referral_code, + referral_code_uses: data.user.referral_code_uses, + created_at: data.user.created_at, + notes: data.user.notes + } + : undefined, + services: (data.services || []).map((s: any) => ({ + booking_id: s.booking_id, + service_id: s.service_id, + service_name: s.service_name, + service_description: s.service_description, + price: s.price, + duration_minutes: s.duration_minutes })), - user: { - full_name: data.user?.full_name, - email: data.user?.email, - phone: data.user?.phone - } + payments: (data.payments || []).map((p: any) => ({ + id: p.id, + booking_id: p.booking_id, + payment_type: p.payment_type, + payment_method: p.payment_method, + vendor_code: p.vendor_code, + invoice_number: p.invoice_number, + status: p.status, + amount: p.amount, + is_vat_applicable: p.is_vat_applicable, + vat_rate: p.vat_rate, + vat_amount: p.vat_amount, + net_amount: p.net_amount, + created_at: p.created_at, + updated_at: p.updated_at, + created_by: p.created_by + })), + total_amount: data.total_amount || 0, + amount_paid: data.amount_paid || 0, + amount_due: data.amount_due || 0, + duration_minutes: data.duration_minutes || 0, + created_at: data.created_at, + updated_at: data.updated_at, + created_by: data.created_by }; showBookingModal = true; } else { @@ -687,7 +812,7 @@ // Filter demo bookings for this user bookingUserHistory = bookings - .filter((b) => b.user_id === userId) + .filter((b) => b?.user?.id === userId) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); showUserModal = true; @@ -770,7 +895,6 @@ // Toggle service active status async function toggleService(serviceId: string) { servicesUpdating[serviceId] = true; - console.log('toggling', serviceId); try { const response = await fetch(`/api/admin/services/${serviceId}/toggle`, { method: 'PUT', @@ -1424,7 +1548,7 @@
{ if ((e as KeyboardEvent).key === 'Enter') searchBookings(); @@ -1444,14 +1568,137 @@ {:else} {#each bookings as b}
-
+
- {new Date(b.start_time).toLocaleString()} + {(() => { + const date = new Date(b.start_time); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const bookingDate = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ); + const daysDiff = Math.floor( + (bookingDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) + ); + + const days = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday' + ]; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'June', + 'July', + 'Aug', + 'Sept', + 'Oct', + 'Nov', + 'Dec' + ]; + + const day = days[date.getDay()]; + const dateNum = date.getDate(); + const month = months[date.getMonth()]; + const year = date.getFullYear(); + const currentYear = now.getFullYear(); + const hours = date.getHours(); + const minutes = date.getMinutes().toString().padStart(2, '0'); + const ampm = hours >= 12 ? 'pm' : 'am'; + const hour12 = hours % 12 || 12; + const time = `${hour12}:${minutes}${ampm}`; + + // Today + if (daysDiff === 0) { + return `Today, ${time}`; + } + + // Tomorrow + if (daysDiff === 1) { + return `Tomorrow, ${time}`; + } + + // Within next 6 days (2-6 days ahead) + if (daysDiff > 1 && daysDiff <= 6) { + return `${day}, ${time}`; + } + + // Last 6 days (1-6 days ago) + if (daysDiff < 0 && daysDiff >= -6) { + return `Last ${day}, ${time}`; + } + + // Otherwise, full date + const suffix = + dateNum === 1 || dateNum === 21 || dateNum === 31 + ? 'st' + : dateNum === 2 || dateNum === 22 + ? 'nd' + : dateNum === 3 || dateNum === 23 + ? 'rd' + : 'th'; + const yearStr = year !== currentYear ? ` ${year}` : ''; + return `${day} the ${dateNum}${suffix} of ${month}${yearStr}, ${time}`; + })()}
-
- {b.status} • {b.user?.full_name || 'Unknown User'} • {b.services - ?.map((s) => s.name) - .join(', ')} +
+ + + {b.status} + + • {b.user?.full_name || 'Unknown User'} + • {(b.services || []) + .map((s) => s.service_name || 'Unknown Service') + .join(', ')}
@@ -2451,7 +2698,7 @@
{new Date(hb.start_time).toLocaleString()}
- {hb.status} • {hb.services?.map((s) => s.name).join(', ')} + {hb.status} • {hb.services.map((s) => s.service_name).join(', ')}
@@ -2469,56 +2716,243 @@ {#if selectedBooking} - + - - Booking: {selectedBooking.id} - + Booking Details +
ID: {selectedBooking.id}
- -
- -
-
Customer
-
- {selectedBooking.user?.full_name || 'Unknown User'} -
-
Email
-
{selectedBooking.user?.email || '—'}
-
Phone
-
{selectedBooking.user?.phone || '—'}
-
Status
-
{selectedBooking.status}
+
+ +
+ + {selectedBooking.status.charAt(0).toUpperCase() + selectedBooking.status.slice(1)} +
- -
-
Scheduled
-
- {new Date(selectedBooking.start_time).toLocaleString()} + +
+

+ Customer Information +

+
+
+
Name
+
{selectedBooking.user?.full_name || '—'}
+
+
+
Email
+
{selectedBooking.user?.email || '—'}
+
+
+
Phone
+
{selectedBooking.user?.phone || '—'}
+
+
+
Customer ID
+
{selectedBooking.user?.id || '—'}
+
+ {#if selectedBooking.user?.loyalty_stamps !== undefined && selectedBooking.user?.loyalty_stamps !== null} +
+
Loyalty Stamps
+
{selectedBooking.user.loyalty_stamps}
+
+ {/if} + {#if selectedBooking.user?.referral_code} +
+
Referral Code
+
{selectedBooking.user.referral_code}
+
+ {/if} + {#if selectedBooking.user?.referral_code_uses !== undefined && selectedBooking.user?.referral_code_uses !== null} +
+
Referral Uses
+
{selectedBooking.user.referral_code_uses}
+
+ {/if}
-
Services
-
- {selectedBooking.services?.map((s) => s.name).join(', ') || '—'} -
-
- - -
-
Customer Notes
- {#if selectedBooking.notes && selectedBooking.notes.length > 0} - -
- {selectedBooking.notes || 'No customer notes provided.'} + {#if selectedBooking.user?.notes} +
+
Customer Notes
+
{selectedBooking.user.notes}
- {:else} -
{/if}
- + + +
+

+ Appointment Details +

+
+
+
Scheduled Date & Time
+
+ {new Date(selectedBooking.start_time).toLocaleString()} +
+
+
+
Duration
+
{selectedBooking.duration_minutes} minutes
+
+
+
Created
+
{new Date(selectedBooking.created_at).toLocaleString()}
+
+
+
Last Updated
+
{new Date(selectedBooking.updated_at).toLocaleString()}
+
+ {#if selectedBooking.created_by} +
+
Created By
+
{selectedBooking.created_by}
+
+ {/if} +
+ {#if selectedBooking.notes} +
+
Booking Notes
+
{selectedBooking.notes}
+
+ {/if} +
+ + + {#if selectedBooking.services && selectedBooking.services.length > 0} +
+

+ Services +

+
+ {#each selectedBooking.services as service} +
+
{service.service_name || '—'}
+ {#if service.service_description} +
{service.service_description}
+ {/if} +
+ {service.duration_minutes} min + £{service.price?.toFixed(2) || '0.00'} +
+
+ {/each} +
+
+ {/if} + + +
+

+ Financial Summary +

+
+
+ Total Amount + £{selectedBooking.total_amount.toFixed(2)} +
+
+ Amount Paid + £{selectedBooking.amount_paid.toFixed(2)} +
+
+ Amount Due + + £{selectedBooking.amount_due.toFixed(2)} + +
+
+
+ + + {#if selectedBooking.payments && selectedBooking.payments.length > 0} +
+

+ Payment History +

+
+ {#each selectedBooking.payments as payment} +
+
+
+
+ {payment.payment_method.replace('_', ' ')} + + {payment.status} + +
+
+ {payment.payment_type.charAt(0).toUpperCase() + + payment.payment_type.slice(1)} +
+ {#if payment.vendor_code || payment.invoice_number} +
+ {#if payment.vendor_code}Vendor: {payment.vendor_code}{/if} + {#if payment.vendor_code && payment.invoice_number} + • + {/if} + {#if payment.invoice_number}Invoice: #{payment.invoice_number}{/if} +
+ {/if} + {#if payment.is_vat_applicable} +
+
Net: £{payment.net_amount?.toFixed(2) || '0.00'}
+
+ VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount?.toFixed( + 2 + ) || '0.00'} +
+
+ {/if} +
+ {new Date(payment.created_at).toLocaleString()} +
+
+
+ £{payment.amount.toFixed(2)} +
+
+
+ {/each} +
+
+ {/if}
diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index e48cb67..b39ef13 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -14,8 +14,8 @@ CREATE TYPE account_role AS ENUM ('unverified_email', 'verified_email', 'admin', CREATE TYPE account_type AS ENUM ('email', 'google', 'microsoft', 'facebook', 'guest'); CREATE TYPE payment_type AS ENUM ('deposit', 'full', 'tip', 'balance', 'partial'); CREATE TYPE payment_method AS ENUM ('online_square', 'in_person_card', 'cash', 'giftcard', 'discount'); -CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'in_progress', 'completed', 'client_cancelled', 'we_cancelled', 're-schedule', 'no_show'); CREATE TYPE payment_status AS ENUM ('pending', 'completed', 'failed', 'refunded'); +CREATE TYPE booking_status AS ENUM ('pending', 'confirmed', 'in_progress', 'completed', 'client_cancelled', 'we_cancelled', 're-schedule', 'no_show'); -- ======================================= -- SHORT ID GENERATION @@ -130,6 +130,7 @@ CREATE TABLE user_referrals ( referrer_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, referred_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, referred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + claimed_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, PRIMARY KEY (referrer_id, referred_id) ); diff --git a/local-dev.sh b/local-dev.sh index 3d748f4..50e21ef 100755 --- a/local-dev.sh +++ b/local-dev.sh @@ -209,76 +209,73 @@ done echo "" echo "7️⃣ Creating 6 Demo Bookings..." -# Check if we have enough services created -if [ ${#SERVICE_IDS[@]} -lt 6 ]; then - echo "❌ ERROR: Only ${#SERVICE_IDS[@]} services were created successfully." - echo " Need at least 6 services to create demo bookings." - echo " Skipping booking creation..." - echo "" -else - echo "✅ All 6 services available. Proceeding with booking creation..." +# Function to create a booking +create_booking() { + local TOKEN=$1 + local START_TIME=$2 + local SERVICE_IDS_JSON=$3 + local NOTES=$4 + local BOOKING_NAME=$5 - # Function to create a booking - create_booking() { - local TOKEN=$1 - local START_TIME=$2 - local SERVICE_IDS_JSON=$3 - local NOTES=$4 - local BOOKING_NAME=$5 - - local BOOKING_JSON="{\"start_time\":\"$START_TIME\",\"service_ids\":$SERVICE_IDS_JSON" - if [ -n "$NOTES" ]; then - BOOKING_JSON="$BOOKING_JSON,\"notes\":\"$NOTES\"" + local BOOKING_JSON="{\"start_time\":\"$START_TIME\",\"service_ids\":$SERVICE_IDS_JSON" + if [ -n "$NOTES" ]; then + BOOKING_JSON="$BOOKING_JSON,\"notes\":\"$NOTES\"" + fi + BOOKING_JSON="$BOOKING_JSON}" + + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Creating: $BOOKING_NAME" + echo "Time: $START_TIME" + echo "Services: $SERVICE_IDS_JSON" + + CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d "$BOOKING_JSON" \ + "$BASE_URL/bookings") + + HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1) + RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" = "201" ]; then + echo "✅ Created: $BOOKING_NAME" + BOOKING_ID=$(echo "$RESPONSE_BODY" | grep -o '"id":"[^"]*' | cut -d'"' -f4) + echo " Booking ID: $BOOKING_ID" + else + echo "❌ Failed to create: $BOOKING_NAME (HTTP $HTTP_CODE)" + if [ -n "$RESPONSE_BODY" ]; then + echo "Response body: $RESPONSE_BODY" fi - BOOKING_JSON="$BOOKING_JSON}" - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Creating: $BOOKING_NAME" - echo "Time: $START_TIME" - echo "Services: $SERVICE_IDS_JSON" - - CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer $TOKEN" \ - -d "$BOOKING_JSON" \ - "$BASE_URL/bookings") - - HTTP_CODE=$(echo "$CREATE_RESPONSE" | tail -n1) - RESPONSE_BODY=$(echo "$CREATE_RESPONSE" | sed '$d') - - if [ "$HTTP_CODE" = "201" ]; then - echo "✅ Created: $BOOKING_NAME" - BOOKING_ID=$(echo "$RESPONSE_BODY" | grep -o '"id":"[^"]*' | cut -d'"' -f4) - echo " Booking ID: $BOOKING_ID" - else - echo "❌ Failed to create: $BOOKING_NAME (HTTP $HTTP_CODE)" - if [ -n "$RESPONSE_BODY" ]; then - echo "Response body: $RESPONSE_BODY" - fi - fi - - sleep 0.2 - } + fi + + sleep 0.2 +} - # 1. Establish the base day (Tomorrow) with a time that guarantees it's in the future. - TOMORROW_BASE=$(date -u -d "tomorrow 08:00:00" +%Y-%m-%d) +# Use TZ=Europe/London to generate London-local times with correct offset +# Note: date command respects TZ for parsing and formatting - # 2. Calculate the date 7 days after the TOMORROW_BASE date - NEXT_WEEK_DATE=$(date -u -d "$TOMORROW_BASE +7 days" +%Y-%m-%d) +# Get tomorrow at 08:00 London time as base (ensures future) +TOMORROW_BASE=$(TZ=Europe/London date -d "tomorrow 08:00" +%Y-%m-%d) - # 3. Calculate the date 14 days after the TOMORROW_BASE date - WEEK_AFTER_DATE=$(date -u -d "$TOMORROW_BASE +14 days" +%Y-%m-%d) +# Calculate future dates in London time +NEXT_WEEK_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +7 days" +%Y-%m-%d) +WEEK_AFTER_DATE=$(TZ=Europe/London date -d "$TOMORROW_BASE +14 days" +%Y-%m-%d) +# Helper function to format a London time as RFC3339 with 'T' (required by Go backend) +format_london_time() { + local DATE_PART="$1" + local TIME_PART="$2" + TZ=Europe/London date -d "$DATE_PART $TIME_PART" +"%Y-%m-%dT%H:%M:%S%:z" +} - # Create demo bookings using the full timestamp (using the fixed dates) - create_booking "$USER_TOKEN" "$(date -u -d "$TOMORROW_BASE 10:00:00" +%Y-%m-%dT%H:%M:%SZ)" "[\"${SERVICE_IDS[0]}\"]" "" "Classic Manicure - Tomorrow 10:00" - create_booking "$USER_TOKEN" "$(date -u -d "$TOMORROW_BASE 14:30:00" +%Y-%m-%dT%H:%M:%SZ)" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "Want French manicure with simple nail art on accent fingers" "Gel Manicure + Nail Art - Tomorrow 14:30" - create_booking "$USER_TOKEN" "$(date -u -d "$NEXT_WEEK_DATE 11:00:00" +%Y-%m-%dT%H:%M:%SZ)" "[\"${SERVICE_IDS[2]}\"]" "Special treat for myself" "Luxury Pedicure - Next Week 11:00" - create_booking "$USER_TOKEN" "$(date -u -d "$NEXT_WEEK_DATE 15:45:00" +%Y-%m-%dT%H:%M:%SZ)" "[\"${SERVICE_IDS[3]}\"]" "Need quick refresh before event" "Express Mani & Pedi - Next Week 15:45" - create_booking "$USER_TOKEN" "$(date -u -d "$WEEK_AFTER_DATE 13:15:00" +%Y-%m-%dT%H:%M:%SZ)" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[1]}\"]" "Remove old gel and apply new BIAB" "Gel Removal + New Gel - Week After 13:15" - create_booking "$USER_TOKEN" "$(date -u -d "$WEEK_AFTER_DATE 16:30:00" +%Y-%m-%dT%H:%M:%SZ)" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Birthday celebration - want something special!" "Classic + Nail Art - Week After 16:30" -fi +# Create demo bookings using London time with proper offset +create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "10:00:00")" "[\"${SERVICE_IDS[0]}\"]" "" "Classic Manicure - Tomorrow 10:00" +create_booking "$USER_TOKEN" "$(format_london_time "$TOMORROW_BASE" "14:30:00")" "[\"${SERVICE_IDS[1]}\",\"${SERVICE_IDS[5]}\"]" "Want French manicure with simple nail art on accent fingers" "Gel Manicure + Nail Art - Tomorrow 14:30" +create_booking "$USER_TOKEN" "$(format_london_time "$NEXT_WEEK_DATE" "11:00:00")" "[\"${SERVICE_IDS[2]}\"]" "Special treat for myself" "Luxury Pedicure - Next Week 11:00" +create_booking "$USER_TOKEN" "$(format_london_time "$NEXT_WEEK_DATE" "15:45:00")" "[\"${SERVICE_IDS[3]}\"]" "Need quick refresh before event" "Express Mani & Pedi - Next Week 15:45" +create_booking "$USER_TOKEN" "$(format_london_time "$WEEK_AFTER_DATE" "13:15:00")" "[\"${SERVICE_IDS[4]}\",\"${SERVICE_IDS[1]}\"]" "Remove old gel and apply new BIAB" "Gel Removal + New Gel - Week After 13:15" +create_booking "$USER_TOKEN" "$(format_london_time "$WEEK_AFTER_DATE" "16:30:00")" "[\"${SERVICE_IDS[0]}\",\"${SERVICE_IDS[5]}\"]" "Birthday celebration - want something special!" "Classic + Nail Art - Week After 16:30" # --- 8️⃣ Create Holiday Exceptional Groups --- echo ""