2462 lines
76 KiB
Go
2462 lines
76 KiB
Go
package bookings
|
|
|
|
import (
|
|
"crussell/db"
|
|
"crussell/handlers/notifications"
|
|
"crussell/internal/dav"
|
|
"crussell/mw"
|
|
"crussell/internal/validators"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
var londonLocation = func() *time.Location {
|
|
loc, err := time.LoadLocation("Europe/London")
|
|
if err != nil {
|
|
panic("Europe/London timezone not available")
|
|
}
|
|
return loc
|
|
}()
|
|
|
|
// Booking represents a booking in the system
|
|
type Booking struct {
|
|
ID string `json:"id"`
|
|
StartTime time.Time `json:"start_time"`
|
|
Status string `json:"status"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedBy *string `json:"created_by,omitempty"`
|
|
|
|
// Deposit fields
|
|
DepositRequired bool `json:"deposit_required"`
|
|
DepositAmount float64 `json:"deposit_amount,omitempty"`
|
|
DepositPaid bool `json:"deposit_paid"`
|
|
DepositDeadline *string `json:"deposit_deadline,omitempty"`
|
|
|
|
// Joined fields
|
|
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
|
|
type BookingService struct {
|
|
BookingID string `json:"booking_id"`
|
|
ServiceID string `json:"service_id"`
|
|
OverridePrice *float64 `json:"override_price,omitempty"`
|
|
OverrideDurationMinutes *int `json:"override_duration_minutes,omitempty"`
|
|
|
|
// Service details (joined)
|
|
ServiceName *string `json:"service_name,omitempty"`
|
|
ServiceDescription *string `json:"service_description,omitempty"`
|
|
Price *float64 `json:"price,omitempty"`
|
|
DurationMinutes *int `json:"duration_minutes,omitempty"`
|
|
}
|
|
|
|
// Payment represents a payment associated with a booking
|
|
type Payment struct {
|
|
ID string `json:"id"`
|
|
BookingID string `json:"booking_id"`
|
|
PaymentType string `json:"payment_type"`
|
|
PaymentMethod string `json:"payment_method"`
|
|
VendorCode *string `json:"vendor_code,omitempty"`
|
|
InvoiceNumber *int `json:"invoice_number,omitempty"`
|
|
Status string `json:"status"`
|
|
Amount float64 `json:"amount"`
|
|
IsVATApplicable bool `json:"is_vat_applicable"`
|
|
VATRate *float64 `json:"vat_rate,omitempty"`
|
|
VATAmount *float64 `json:"vat_amount,omitempty"`
|
|
NetAmount *float64 `json:"net_amount,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedBy *string `json:"created_by,omitempty"`
|
|
}
|
|
|
|
// CreateBookingRequest represents the request payload for creating a new booking
|
|
type CreateBookingRequest struct {
|
|
StartTime time.Time `json:"start_time" validate:"required"`
|
|
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// 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 {
|
|
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 {
|
|
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"`
|
|
Total int `json:"total"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"perPage"`
|
|
TotalPages int `json:"totalPages"`
|
|
}
|
|
|
|
// 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: 10, // 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
|
|
// We now calculate Total Amount, Amount Paid, AND Duration
|
|
baseQuery := `
|
|
SELECT
|
|
id, start_time, status, notes, created_at, updated_at, created_by,
|
|
-- Total Amount
|
|
(SELECT COALESCE(SUM(CASE
|
|
WHEN bs.override_price IS NOT NULL THEN bs.override_price
|
|
ELSE s.price
|
|
END), 0)
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = bookings.id) as total_amount,
|
|
-- Amount Paid
|
|
(SELECT COALESCE(SUM(amount), 0)
|
|
FROM payments
|
|
WHERE booking_id = bookings.id AND status = 'completed') as amount_paid,
|
|
-- Duration Minutes
|
|
(SELECT COALESCE(SUM(CASE
|
|
WHEN bs.override_duration_minutes IS NOT NULL THEN bs.override_duration_minutes
|
|
ELSE s.duration_minutes
|
|
END), 0)
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = bookings.id) as duration_minutes
|
|
FROM bookings
|
|
WHERE user_id = $1
|
|
`
|
|
|
|
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
|
|
var args []interface{}
|
|
var countArgs []interface{}
|
|
args = append(args, userID)
|
|
countArgs = append(countArgs, 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)
|
|
countArgs = append(countArgs, *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.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)
|
|
countArgs = append(countArgs, 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)
|
|
countArgs = append(countArgs, endTime)
|
|
paramCount++
|
|
}
|
|
|
|
// Add ordering and pagination
|
|
baseQuery += " ORDER BY 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)
|
|
}
|
|
|
|
// Get total count
|
|
var total int
|
|
err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).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
|
|
// Scan duration_minutes as well
|
|
var totalAmount, amountPaid float64
|
|
var durationMinutes int
|
|
|
|
err := rows.Scan(
|
|
&b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy,
|
|
&totalAmount,
|
|
&amountPaid,
|
|
&durationMinutes,
|
|
)
|
|
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
|
|
}
|
|
|
|
b.TotalAmount = totalAmount
|
|
b.AmountPaid = amountPaid
|
|
b.AmountDue = totalAmount - amountPaid
|
|
b.DurationMinutes = durationMinutes // Populate the struct
|
|
|
|
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 - only what the component needs
|
|
baseQuery := `
|
|
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 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 b.status = $%d", paramCount)
|
|
args = append(args, *req.Status)
|
|
paramCount++
|
|
whereAdded = true
|
|
}
|
|
|
|
if req.StartDate != nil {
|
|
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)
|
|
return
|
|
}
|
|
args = append(args, startTime)
|
|
paramCount++
|
|
}
|
|
|
|
if req.EndDate != nil {
|
|
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)
|
|
return
|
|
}
|
|
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
|
args = append(args, endTime)
|
|
paramCount++
|
|
}
|
|
|
|
// Add ordering and pagination (NO GROUP BY needed here)
|
|
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)
|
|
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, countArgs...).Scan(&total)
|
|
if err != nil {
|
|
log.Printf("Failed to get total booking count: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Calculate total pages
|
|
var totalPages int
|
|
if req.PerPage > 0 {
|
|
totalPages = (total + req.PerPage - 1) / req.PerPage
|
|
}
|
|
if totalPages == 0 {
|
|
totalPages = 1
|
|
}
|
|
|
|
// 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
|
|
bookingIDs := []string{}
|
|
|
|
for rows.Next() {
|
|
var b Booking
|
|
var userFullName string
|
|
|
|
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
|
|
}
|
|
|
|
// 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{
|
|
Bookings: bookings,
|
|
Page: req.Page,
|
|
PerPage: req.PerPage,
|
|
Total: total,
|
|
TotalPages: totalPages,
|
|
}
|
|
|
|
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 == "" || !validators.IsValidID(userID) {
|
|
http.Error(w, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Parse pagination parameters
|
|
query := r.URL.Query()
|
|
page := 1
|
|
perPage := 5
|
|
|
|
if pageStr := query.Get("page"); pageStr != "" {
|
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
|
page = p
|
|
}
|
|
}
|
|
|
|
if perPageStr := query.Get("per_page"); perPageStr != "" {
|
|
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
|
|
perPage = pp
|
|
}
|
|
}
|
|
|
|
offset := (page - 1) * perPage
|
|
|
|
// Get total count
|
|
var total int
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*)
|
|
FROM bookings
|
|
WHERE user_id = $1
|
|
`, userID).Scan(&total)
|
|
if err != nil {
|
|
log.Printf("Failed to count bookings for user %s: %v", userID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Fetch bookings with pagination, ordered by start_time DESC (future to past)
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT
|
|
b.id,
|
|
b.start_time,
|
|
b.status,
|
|
b.notes,
|
|
b.created_at,
|
|
b.updated_at,
|
|
b.created_by
|
|
FROM bookings b
|
|
WHERE b.user_id = $1
|
|
ORDER BY b.start_time DESC
|
|
LIMIT $2 OFFSET $3
|
|
`, userID, perPage, offset)
|
|
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
|
|
err := rows.Scan(
|
|
&b.ID,
|
|
&b.StartTime,
|
|
&b.Status,
|
|
&b.Notes,
|
|
&b.CreatedAt,
|
|
&b.UpdatedAt,
|
|
&b.CreatedBy,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to scan booking row: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Fetch services for this booking
|
|
serviceRows, err := db.DB.Query(r.Context(), `
|
|
SELECT
|
|
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
|
|
`, b.ID)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch services for booking %s: %v", b.ID, err)
|
|
continue
|
|
}
|
|
|
|
var totalAmount float64
|
|
for serviceRows.Next() {
|
|
var service BookingService
|
|
var price float64
|
|
var durationMinutes int
|
|
|
|
if err := serviceRows.Scan(&service.ServiceName, &price, &durationMinutes); err != nil {
|
|
log.Printf("Failed to scan service: %v", err)
|
|
continue
|
|
}
|
|
|
|
service.Price = &price
|
|
service.DurationMinutes = &durationMinutes
|
|
totalAmount += price
|
|
|
|
b.Services = append(b.Services, service)
|
|
}
|
|
serviceRows.Close()
|
|
|
|
b.TotalAmount = totalAmount
|
|
bookings = append(bookings, b)
|
|
}
|
|
|
|
if bookings == nil {
|
|
bookings = []Booking{}
|
|
}
|
|
|
|
// Calculate total pages
|
|
totalPages := (total + perPage - 1) / perPage
|
|
if totalPages == 0 {
|
|
totalPages = 1
|
|
}
|
|
|
|
response := BookingListResponse{
|
|
Bookings: bookings,
|
|
Total: total,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
TotalPages: totalPages,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
log.Printf("Failed to encode bookings 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 == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// ----------------------------
|
|
// 1. Fetch booking + user
|
|
// ----------------------------
|
|
var booking Booking
|
|
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.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.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 errors.Is(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
|
|
}
|
|
|
|
// ----------------------------
|
|
// 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
|
|
}
|
|
booking.User.ReferralCodeUses = &referralCodeUses
|
|
|
|
// ----------------------------
|
|
// 2. Fetch services and calculate totals
|
|
// ----------------------------
|
|
serviceRows, err := db.DB.Query(r.Context(), `
|
|
SELECT
|
|
bs.service_id,
|
|
s.name,
|
|
coalesce(bs.override_price, s.price) as price,
|
|
coalesce(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
|
|
FROM booking_services bs
|
|
LEFT JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
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()
|
|
|
|
var totalAmount float64
|
|
var durationMinutesTotal int
|
|
|
|
for serviceRows.Next() {
|
|
var serviceID string
|
|
var name string
|
|
var price float64
|
|
var durationMinutes int
|
|
|
|
if err := serviceRows.Scan(&serviceID, &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
|
|
}
|
|
|
|
// Calculate totals
|
|
totalAmount += price
|
|
durationMinutesTotal += durationMinutes
|
|
booking.Services = append(booking.Services, BookingService{
|
|
ServiceID: serviceID, // now set
|
|
ServiceName: &name,
|
|
Price: &price,
|
|
DurationMinutes: &durationMinutes,
|
|
})
|
|
}
|
|
|
|
booking.TotalAmount = totalAmount
|
|
booking.DurationMinutes = durationMinutesTotal
|
|
|
|
// ----------------------------
|
|
// 3. Fetch payments and calculate amount paid
|
|
// ----------------------------
|
|
paymentRows, err := db.DB.Query(r.Context(), `
|
|
SELECT
|
|
payment_type, payment_method, vendor_code, invoice_number,
|
|
status, amount, created_at
|
|
FROM payments
|
|
WHERE booking_id = $1
|
|
ORDER BY created_at ASC
|
|
`, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch payments for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer paymentRows.Close()
|
|
|
|
var payments []Payment
|
|
var amountPaid float64
|
|
|
|
for paymentRows.Next() {
|
|
var p Payment
|
|
var vendorCode sql.NullString
|
|
var invoiceNumber sql.NullInt32
|
|
|
|
err := paymentRows.Scan(
|
|
&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)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if vendorCode.Valid && vendorCode.String != "" {
|
|
p.VendorCode = &vendorCode.String
|
|
}
|
|
if invoiceNumber.Valid {
|
|
num := int(invoiceNumber.Int32)
|
|
p.InvoiceNumber = &num
|
|
}
|
|
|
|
payments = append(payments, p)
|
|
|
|
if p.Status == "completed" {
|
|
amountPaid += p.Amount
|
|
}
|
|
}
|
|
|
|
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(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/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 := 10
|
|
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 using CTEs to match your actual schema
|
|
searchQuery := `
|
|
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 DESC
|
|
LIMIT $2 OFFSET $3
|
|
`
|
|
|
|
countQuery := `
|
|
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
|
|
|
|
// 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
|
|
}
|
|
|
|
// Calculate total pages
|
|
var totalPages int
|
|
if perPage > 0 {
|
|
totalPages = (total + perPage - 1) / perPage
|
|
}
|
|
if totalPages == 0 {
|
|
totalPages = 1
|
|
}
|
|
|
|
// 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 bookings []Booking
|
|
bookingIDs := []string{}
|
|
|
|
for rows.Next() {
|
|
var b Booking
|
|
var userFullName string
|
|
|
|
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
|
|
}
|
|
|
|
// 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 (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
|
|
`
|
|
|
|
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
|
|
} else {
|
|
// Ensure services is never nil
|
|
bookings[i].Services = []BookingService{}
|
|
}
|
|
}
|
|
}
|
|
|
|
response := BookingListResponse{
|
|
Bookings: bookings,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
Total: total,
|
|
TotalPages: totalPages,
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Check patch test requirements for all services
|
|
for _, serviceID := range req.ServiceIDs {
|
|
// Find patch test for this service
|
|
var patchTestID string
|
|
var noticeHours int
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT id, notice_duration_hours
|
|
FROM patch_tests
|
|
WHERE $1 = ANY(service_ids)
|
|
`, serviceID).Scan(&patchTestID, ¬iceHours)
|
|
|
|
if err == nil {
|
|
// Service requires a patch test - check if user has valid record
|
|
var testedAt time.Time
|
|
err = db.DB.QueryRow(r.Context(), `
|
|
SELECT tested_at
|
|
FROM user_patch_tests
|
|
WHERE user_id = $1 AND patch_test_id = $2
|
|
`, userID, patchTestID).Scan(&testedAt)
|
|
|
|
if err != nil {
|
|
// No valid patch test record
|
|
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if notice period has passed
|
|
eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
|
|
if time.Now().Before(eligibleFrom) {
|
|
hoursLeft := time.Until(eligibleFrom).Hours()
|
|
http.Error(w, fmt.Sprintf("You must wait %.0f hours after your patch test before booking this service.", hoursLeft), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if patch test has expired
|
|
var expiryMonths int
|
|
err = db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths)
|
|
if err == nil {
|
|
expiresAt := testedAt.AddDate(0, expiryMonths, 0)
|
|
if time.Now().After(expiresAt) {
|
|
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate start time is not in the past
|
|
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
|
|
}
|
|
|
|
// Validate start time is not in the past
|
|
if req.StartTime.Before(time.Now()) {
|
|
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
|
|
return
|
|
return
|
|
}
|
|
|
|
// Validate booking fits within operating hours for regular users
|
|
var svcDuration int
|
|
if err := db.DB.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
|
|
`, req.ServiceIDs).Scan(&svcDuration); err != nil {
|
|
log.Printf("Failed to calc duration: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
weekday := int(req.StartTime.Weekday())
|
|
var closeStr string
|
|
if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
|
|
log.Printf("Failed to get hours: %v", err)
|
|
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
|
|
closeTime, _ := time.Parse("15:04:05", closeStr)
|
|
if endTime.Hour() > closeTime.Hour() || (endTime.Hour() == closeTime.Hour() && endTime.Minute() > closeTime.Minute()) {
|
|
http.Error(w, "Cannot book this time - services would extend beyond closing hours", 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
|
|
booking.User = &UserSummary{}
|
|
err = tx.QueryRow(r.Context(),
|
|
bookingQuery,
|
|
userID,
|
|
req.StartTime,
|
|
req.Notes,
|
|
createdBy,
|
|
).Scan(
|
|
&booking.ID,
|
|
&booking.User.ID,
|
|
&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
|
|
}
|
|
|
|
// Calculate total price and check deposit requirements
|
|
var totalPrice float64
|
|
tx.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0)
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
`, booking.ID).Scan(&totalPrice)
|
|
|
|
// Check if user needs to pay deposit (deposits_required > 0)
|
|
var depositsRequired int
|
|
tx.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired)
|
|
|
|
// If deposits_required > 0, require min 48h notice
|
|
if depositsRequired > 0 {
|
|
minStartTime := time.Now().Add(48 * time.Hour)
|
|
if req.StartTime.Before(minStartTime) {
|
|
tx.Rollback(r.Context())
|
|
http.Error(w, "You must book at least 48 hours in advance. Complete more appointments to remove this requirement.", http.StatusBadRequest)
|
|
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
|
|
}
|
|
}
|
|
|
|
// Create admin notification for pending booking
|
|
notificationQuery := `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
|
VALUES ($1, $2, $3)
|
|
`
|
|
_, err = tx.Exec(r.Context(), notificationQuery, "pending_booking", booking.ID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Fetch services for the response
|
|
booking.Services = []BookingService{}
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
|
|
s.name, s.description, s.price, s.duration_minutes
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
`, booking.ID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var bs BookingService
|
|
err := rows.Scan(
|
|
&bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes,
|
|
&bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes,
|
|
)
|
|
if err != nil {
|
|
break
|
|
}
|
|
booking.Services = append(booking.Services, bs)
|
|
}
|
|
}
|
|
|
|
// 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 == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
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
|
|
}
|
|
|
|
// Check if user owns this booking and get current status
|
|
var currentStatus string
|
|
err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(¤tStatus)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Users cannot edit completed or cancelled bookings
|
|
if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
|
|
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Get booking duration for overlap check
|
|
var durationMinutes int
|
|
err = db.DB.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
`, bookingID).Scan(&durationMinutes)
|
|
if err != nil {
|
|
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
|
|
durationMinutes = 60
|
|
}
|
|
|
|
// Check for overlapping bookings (user is blocked if overlap exists)
|
|
var overlapCount int
|
|
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
|
|
err = db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings
|
|
WHERE id != $1
|
|
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
|
|
AND start_time < $3
|
|
AND start_time + (INTERVAL '1 minute' * (
|
|
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = bookings.id
|
|
)) > $2
|
|
`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount)
|
|
if err != nil {
|
|
log.Printf("Failed to check overlap %s: %v", bookingID, err)
|
|
}
|
|
if overlapCount > 0 {
|
|
http.Error(w, "This time slot overlaps with an existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Check if salon is closed (exceptional hours) - user is blocked on closed days
|
|
weekday := int(req.StartTime.Weekday())
|
|
bookingTime := req.StartTime.Format("15:04:05")
|
|
daysToMonday := weekday
|
|
if daysToMonday == 0 {
|
|
daysToMonday = 7
|
|
}
|
|
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
|
|
|
// Check if salon is closed (exceptional hours)
|
|
var isClosed bool
|
|
err = db.DB.QueryRow(r.Context(), `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM exceptional_working_hours ewh
|
|
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
|
|
WHERE ega.week_start = $1
|
|
AND ewh.weekday = $2
|
|
AND ewh.is_open = false
|
|
AND ewh.start_time <= $3
|
|
AND ewh.end_time >= $3
|
|
)
|
|
`, weekStart, weekday, bookingTime).Scan(&isClosed)
|
|
if err != nil {
|
|
log.Printf("Failed to check exceptional hours: %v", err)
|
|
}
|
|
|
|
if isClosed {
|
|
http.Error(w, "Cannot book on a closed day", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// 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
|
|
booking.User = &UserSummary{}
|
|
err = db.DB.QueryRow(r.Context(),
|
|
query,
|
|
req.StartTime,
|
|
bookingID,
|
|
userID,
|
|
).Scan(
|
|
&booking.ID,
|
|
&booking.User.ID,
|
|
&booking.StartTime,
|
|
&booking.Status,
|
|
&booking.Notes,
|
|
&booking.CreatedAt,
|
|
&booking.UpdatedAt,
|
|
&booking.CreatedBy,
|
|
)
|
|
|
|
if err != nil {
|
|
if errors.Is(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 == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
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
|
|
}
|
|
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 := `
|
|
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
|
|
booking.User = &UserSummary{}
|
|
err := db.DB.QueryRow(r.Context(),
|
|
query,
|
|
req.Status,
|
|
bookingID,
|
|
).Scan(
|
|
&booking.ID,
|
|
&booking.User.ID,
|
|
&booking.StartTime,
|
|
&booking.Status,
|
|
&booking.Notes,
|
|
&booking.CreatedAt,
|
|
&booking.UpdatedAt,
|
|
&booking.CreatedBy,
|
|
)
|
|
|
|
if err != nil {
|
|
if errors.Is(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
|
|
}
|
|
|
|
if req.Status == "completed" {
|
|
// When a booking is completed, extend patch test validity for any related patch tests
|
|
// Get all services in this booking
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT DISTINCT pt.id
|
|
FROM patch_tests pt
|
|
JOIN booking_services bs ON bs.booking_id = $1
|
|
WHERE pt.id IN (
|
|
SELECT pt_inner.id
|
|
FROM patch_tests pt_inner
|
|
WHERE bs.service_id = ANY(pt_inner.service_ids)
|
|
)
|
|
`, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch patch tests for booking %s: %v", bookingID, err)
|
|
} else {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var patchTestID string
|
|
if err := rows.Scan(&patchTestID); err != nil {
|
|
log.Printf("Failed to scan patch test: %v", err)
|
|
continue
|
|
}
|
|
// Update or insert user_patch_tests record
|
|
_, err := db.DB.Exec(r.Context(), `
|
|
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
|
|
VALUES ($1, $2, NOW())
|
|
ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW()
|
|
`, booking.User.ID, patchTestID)
|
|
if err != nil {
|
|
log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, patchTestID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add loyalty stamp when booking completed - max 1 per day per user
|
|
_, err = db.DB.Exec(r.Context(),
|
|
`UPDATE users
|
|
SET loyalty_stamps = loyalty_stamps + 1
|
|
WHERE id = $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM bookings b
|
|
WHERE b.user_id = users.id
|
|
AND b.status = 'completed'
|
|
AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
|
|
AND b.id != $2
|
|
)`,
|
|
booking.User.ID, bookingID,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
|
|
}
|
|
|
|
// Reduce deposits_required if payment was made for this booking
|
|
var paymentCount int
|
|
db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount)
|
|
if paymentCount > 0 {
|
|
db.DB.Exec(r.Context(), `UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) WHERE id = $1`, booking.User.ID)
|
|
}
|
|
}
|
|
|
|
// Return updated booking
|
|
w.Header().Set("Content-Type", "application/json")
|
|
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 == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var req ConfirmBookingRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Printf("Failed to decode request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Check for overlapping confirmed/in_progress/completed bookings before confirming
|
|
var bkStart time.Time
|
|
if err := db.DB.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil {
|
|
log.Printf("Failed to get start time: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var dur int
|
|
db.DB.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
|
|
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
|
|
`, bookingID).Scan(&dur)
|
|
newEnd := bkStart.Add(time.Duration(dur) * time.Minute)
|
|
|
|
var cnt int
|
|
db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('confirmed','in_progress','completed') AND start_time < $3 AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id)) > $2
|
|
`, bookingID, bkStart, newEnd).Scan(&cnt)
|
|
if cnt > 0 {
|
|
http.Error(w, "Cannot confirm - time slot overlaps with existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
|
|
tx, err := db.DB.Begin(r.Context())
|
|
if err != nil {
|
|
log.Printf("Failed to start transaction: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
// 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
|
|
booking.User = &UserSummary{}
|
|
err = tx.QueryRow(r.Context(),
|
|
bookingQuery,
|
|
req.Notes,
|
|
bookingID,
|
|
).Scan(
|
|
&booking.ID,
|
|
&booking.User.ID,
|
|
&booking.StartTime,
|
|
&booking.Status,
|
|
&booking.Notes,
|
|
&booking.CreatedAt,
|
|
&booking.UpdatedAt,
|
|
&booking.CreatedBy,
|
|
)
|
|
|
|
if err != nil {
|
|
if errors.Is(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
|
|
}
|
|
|
|
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
|
|
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
|
|
}
|
|
|
|
// Sync to CalDAV when booking confirmed - DB is source of truth
|
|
if dav.Service != nil {
|
|
var durationMinutes int
|
|
db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(override_duration_minutes), (SELECT SUM(duration_minutes) FROM booking_services WHERE booking_id = $1)) FROM booking_services WHERE booking_id = $1`, bookingID).Scan(&durationMinutes)
|
|
if durationMinutes == 0 {
|
|
durationMinutes = 60
|
|
}
|
|
dav.Service.CreateEvent(1, dav.EventInput{
|
|
Summary: "Crussell Booking",
|
|
Start: booking.StartTime,
|
|
End: booking.StartTime.Add(time.Duration(durationMinutes) * time.Minute),
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// POST /api/admin/bookings/{id}/cancel
|
|
func CancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
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())
|
|
|
|
updateQuery := `
|
|
UPDATE bookings
|
|
SET status = 'we_cancelled', updated_at = NOW()
|
|
WHERE id = $1 AND status NOT IN ('completed', 'cancelled', 'client_cancelled', 'we_cancelled')
|
|
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
|
`
|
|
|
|
var booking Booking
|
|
booking.User = &UserSummary{}
|
|
err = tx.QueryRow(r.Context(), updateQuery, bookingID).Scan(
|
|
&booking.ID,
|
|
&booking.User.ID,
|
|
&booking.StartTime,
|
|
&booking.Status,
|
|
&booking.Notes,
|
|
&booking.CreatedAt,
|
|
&booking.UpdatedAt,
|
|
&booking.CreatedBy,
|
|
)
|
|
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not found or cannot be cancelled", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to cancel booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
notificationQuery := `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
|
VALUES ('cancelled_booking', $1, $2)
|
|
`
|
|
tx.Exec(r.Context(), notificationQuery, bookingID, booking.User.ID)
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit booking cancellation: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(booking)
|
|
}
|
|
|
|
// DELETE /api/bookings/{id}
|
|
func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
|
|
// Start transaction for cancellation and notification
|
|
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())
|
|
|
|
// Get current status before updating
|
|
var originalStatus string
|
|
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Update booking status instead of deleting
|
|
query := `
|
|
UPDATE bookings
|
|
SET status = $1, updated_at = NOW()
|
|
WHERE id = $2 AND user_id = $3
|
|
`
|
|
result, err := tx.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
|
|
}
|
|
|
|
// Acknowledge pending notification if exists
|
|
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Only notify on cancellation if booking was confirmed (not pending)
|
|
if originalStatus == "confirmed" {
|
|
// Check notice period
|
|
var startTime time.Time
|
|
tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime)
|
|
|
|
noticeHours := startTime.Sub(time.Now()).Hours()
|
|
|
|
if noticeHours < 12 {
|
|
// Less than 12h notice = count as no-show, add 3 deposits required
|
|
tx.Exec(r.Context(), "UPDATE users SET deposits_required = deposits_required + 3 WHERE id = $1", userID)
|
|
tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID)
|
|
} else if noticeHours < 24 {
|
|
// Less than 24h notice - create admin notification about potential deposit requirement
|
|
notificationQuery := `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
|
VALUES ($1, $2, $3)
|
|
`
|
|
tx.Exec(r.Context(), notificationQuery, "late_cancellation", bookingID, userID)
|
|
}
|
|
|
|
notificationQuery := `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
|
VALUES ($1, $2, $3)
|
|
`
|
|
_, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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 - use transaction for notification
|
|
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())
|
|
|
|
// Get current status before deleting
|
|
var originalStatus string
|
|
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking status %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Acknowledge pending notification if exists
|
|
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
|
|
if originalStatus != "pending" {
|
|
notificationQuery := `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
|
VALUES ($1, $2, $3)
|
|
`
|
|
_, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Now delete the booking
|
|
query := "DELETE FROM bookings WHERE id = $1 AND user_id = $2"
|
|
result, err := tx.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
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"message": "Booking deleted successfully",
|
|
"id": bookingID,
|
|
})
|
|
}
|
|
|
|
// GET /api/bookings/{id}
|
|
func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// ----------------------------
|
|
// 1. Fetch booking
|
|
// ----------------------------
|
|
var booking Booking
|
|
// Initialize slices/maps to avoid null in JSON
|
|
booking.Payments = []Payment{}
|
|
booking.Services = []BookingService{}
|
|
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.User.ID, &booking.StartTime, &booking.Status,
|
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(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
|
|
}
|
|
|
|
// Calculate Totals based on overrides or base values
|
|
var priceToAdd float64
|
|
var durationToAdd int
|
|
|
|
if overridePrice.Valid {
|
|
s.OverridePrice = &overridePrice.Float64
|
|
priceToAdd = overridePrice.Float64
|
|
} else if basePrice.Valid {
|
|
priceToAdd = basePrice.Float64
|
|
}
|
|
|
|
if overrideDuration.Valid {
|
|
d := int(overrideDuration.Int32)
|
|
s.OverrideDurationMinutes = &d
|
|
durationToAdd = d
|
|
} else if baseDuration.Valid {
|
|
durationToAdd = int(baseDuration.Int32)
|
|
}
|
|
|
|
totalAmount += priceToAdd
|
|
durationMinutes += durationToAdd
|
|
|
|
// Map nullable strings to pointers
|
|
if name.Valid {
|
|
s.ServiceName = &name.String
|
|
}
|
|
if description.Valid {
|
|
s.ServiceDescription = &description.String
|
|
}
|
|
if basePrice.Valid {
|
|
s.Price = &basePrice.Float64
|
|
}
|
|
if baseDuration.Valid {
|
|
d := int(baseDuration.Int32)
|
|
s.DurationMinutes = &d
|
|
}
|
|
|
|
booking.Services = append(booking.Services, s)
|
|
}
|
|
|
|
// ----------------------------
|
|
// 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 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)
|
|
}
|
|
|
|
// ----------------------------
|
|
// 4. Assign calculated totals to Booking Struct
|
|
// ----------------------------
|
|
booking.TotalAmount = totalAmount
|
|
booking.AmountPaid = amountPaid
|
|
booking.AmountDue = totalAmount - amountPaid
|
|
booking.DurationMinutes = durationMinutes
|
|
|
|
// ----------------------------
|
|
// 5. Return Response
|
|
// ----------------------------
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
// We encode the 'booking' object directly.
|
|
// This matches the frontend expectation: selectedBooking = data;
|
|
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
|
log.Printf("Failed to encode booking response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// GET /api/bookings/{id}/calendar - returns standalone .ics file
|
|
func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Check auth first (security by design - don't reveal if booking exists to unauthenticated users)
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Now check if booking exists and belongs to user
|
|
var bookingIDDB, userIDDB, status, notes, createdBy string
|
|
var startTime, createdAt, updatedAt time.Time
|
|
var durationMinutes int
|
|
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT id, user_id, start_time, status, COALESCE(notes, ''), COALESCE(created_by, ''), created_at, updated_at,
|
|
COALESCE((SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
|
|
FROM booking_services bs JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = bookings.id), 60)
|
|
FROM bookings
|
|
WHERE id = $1 AND user_id = $2
|
|
`, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, ¬es, &createdBy, &createdAt, &updatedAt, &durationMinutes)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to fetch booking for calendar: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT s.name, COALESCE(bs.override_price, s.price)
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
`, bookingID)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch services: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var services []string
|
|
var totalPrice float64
|
|
for rows.Next() {
|
|
var name string
|
|
var price float64
|
|
rows.Scan(&name, &price)
|
|
services = append(services, name)
|
|
totalPrice += price
|
|
}
|
|
|
|
serviceList := strings.Join(services, ", ")
|
|
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
|
|
|
|
icalContent := generateICS(serviceList, startTime, endTime, status, notes, totalPrice)
|
|
|
|
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"booking-%s.ics\"", bookingID))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(icalContent))
|
|
}
|
|
|
|
func generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string {
|
|
uid := fmt.Sprintf("booking-%d@crussell.com", time.Now().UnixNano())
|
|
dtstamp := time.Now().UTC().Format("20060102T150405Z")
|
|
|
|
dtstart := start.Format("20060102T150405")
|
|
dtend := end.Format("20060102T150405")
|
|
|
|
summary := "Crussell Appointment"
|
|
if serviceList != "" {
|
|
summary = "Crussell: " + serviceList
|
|
}
|
|
|
|
description := fmt.Sprintf("Status: %s\\nServices: %s\\nPrice: £%.2f", status, serviceList, price)
|
|
if notes != "" {
|
|
description += "\\nNotes: " + notes
|
|
}
|
|
|
|
return fmt.Sprintf(`BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//Crussell//Booking//EN
|
|
CALSCALE:GREGORIAN
|
|
METHOD:PUBLISH
|
|
BEGIN:VEVENT
|
|
UID:%s
|
|
DTSTAMP:%s
|
|
DTSTART:%s
|
|
DTEND:%s
|
|
SUMMARY:%s
|
|
DESCRIPTION:%s
|
|
STATUS:%s
|
|
END:VEVENT
|
|
END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status)
|
|
}
|