Two-tier notification system: new_booking (all public bookings) + pending_booking (notes/today). Priority-sorted queue, unread count polling, enriched responses with user_name/booking_start_time. Fix critical bug: edit_requested cleanup was broken (wrong reason string in 3 handlers). Add 15 new tests covering priority ordering, enrichment, and notification creation flows. Update Admin Manual, Technical Manual, and gap backlog docs.
2976 lines
103 KiB
Go
2976 lines
103 KiB
Go
package bookings
|
|
|
|
import (
|
|
"crussell/db"
|
|
"crussell/handlers/notifications"
|
|
"crussell/handlers/scheduling"
|
|
"crussell/internal/dav"
|
|
"crussell/internal/validators"
|
|
"crussell/mw"
|
|
"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 is snapshotted at creation from users.deposits_required > 0
|
|
// and stored on the bookings row — so historic bookings reflect the obligation
|
|
// that existed when they were made, not the user's current standing.
|
|
DepositRequired bool `json:"deposit_required"`
|
|
DepositAmount float64 `json:"deposit_amount,omitempty"`
|
|
DepositPaid bool `json:"deposit_paid"`
|
|
DepositDeadline *string `json:"deposit_deadline,omitempty"`
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// populateDepositFields sets the computed deposit fields on a Booking.
|
|
// It must be called after TotalAmount, AmountPaid, and StartTime are already set.
|
|
//
|
|
// - depositRequired: snapshotted value from bookings.deposit_required (set at creation).
|
|
// - preStartAmountPaid: sum of completed payments whose created_at < booking.start_time.
|
|
func populateDepositFields(b *Booking, depositRequired bool, preStartAmountPaid float64) {
|
|
b.DepositRequired = depositRequired
|
|
b.DepositAmount = b.TotalAmount * 0.20
|
|
// A deposit is considered paid when pre-start payments cover the deposit amount.
|
|
// We only declare it paid when a deposit was actually required, so that
|
|
// bookings with no deposit obligation don't incorrectly show DepositPaid: true.
|
|
b.DepositPaid = depositRequired && preStartAmountPaid >= b.DepositAmount
|
|
deadline := b.StartTime.Add(-24 * time.Hour).Format(time.RFC3339)
|
|
b.DepositDeadline = &deadline
|
|
}
|
|
|
|
// 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"`
|
|
UserID *string `json:"user_id,omitempty"`
|
|
}
|
|
|
|
// EditBookingRequest represents the request payload for editing a booking's start time
|
|
type EditBookingRequest struct {
|
|
StartTime time.Time `json:"start_time" validate:"required"`
|
|
}
|
|
|
|
// ProgressBookingRequest represents the request payload for updating a booking's status
|
|
type ProgressBookingRequest struct {
|
|
Status string `json:"status" validate:"required,oneof=pending confirmed in_progress completed client_cancelled we_cancelled 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"`
|
|
}
|
|
|
|
// UpdateBookingServicesRequest represents the request payload for admin updating a booking's services and notes
|
|
type UpdateBookingServicesRequest struct {
|
|
ServiceIDs []string `json:"service_ids"`
|
|
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// DeleteBookingRequest represents the request payload for deleting a booking with payment
|
|
type DeleteBookingRequest struct {
|
|
Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"`
|
|
ForgiveNoShow *bool `json:"forgive_no_show,omitempty"` // Admin-only: forgive a no-show at cancellation time
|
|
}
|
|
|
|
// AdminUserSummary represents a small user summary for admin views
|
|
type AdminUserSummary struct {
|
|
FullName string `json:"full_name"`
|
|
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// roundTo2 rounds a float64 to 2 decimal places
|
|
func roundTo2(f float64) float64 {
|
|
return float64(int(f*100+0.5)) / 100
|
|
}
|
|
|
|
// Helper function to parse query parameters
|
|
func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest {
|
|
req := GetAllBookingsRequest{
|
|
Page: 1,
|
|
PerPage: 10,
|
|
}
|
|
if status := r.URL.Query().Get("status"); status != "" {
|
|
req.Status = &status
|
|
}
|
|
if startDate := r.URL.Query().Get("start_date"); startDate != "" {
|
|
req.StartDate = &startDate
|
|
}
|
|
if endDate := r.URL.Query().Get("end_date"); endDate != "" {
|
|
req.EndDate = &endDate
|
|
}
|
|
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
|
|
}
|
|
|
|
req := parseGetAllBookingsRequest(r)
|
|
|
|
// deposit_required is read from the bookings row (snapshotted at creation).
|
|
// pre_start_amount_paid sums completed payments created before start_time.
|
|
baseQuery := `
|
|
SELECT
|
|
b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
|
|
(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 = b.id) AS total_amount,
|
|
(SELECT COALESCE(SUM(amount), 0)
|
|
FROM payments
|
|
WHERE booking_id = b.id AND status = 'completed') AS amount_paid,
|
|
(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 = b.id) AS duration_minutes,
|
|
b.deposit_required,
|
|
(SELECT COALESCE(SUM(amount), 0)
|
|
FROM payments
|
|
WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time) AS pre_start_amount_paid
|
|
FROM bookings b
|
|
WHERE b.user_id = $1
|
|
`
|
|
|
|
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
|
|
var args, countArgs []interface{}
|
|
args = append(args, userID)
|
|
countArgs = append(countArgs, userID)
|
|
paramCount := 2
|
|
|
|
if req.Status != nil {
|
|
baseQuery += fmt.Sprintf(" AND b.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 b.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
|
|
}
|
|
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 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)
|
|
countArgs = append(countArgs, endTime)
|
|
paramCount++
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
var total int
|
|
if err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total); err != nil {
|
|
log.Printf("Failed to get booking count for user %s: %v", userID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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 totalAmount, amountPaid, preStartAmountPaid float64
|
|
var durationMinutes int
|
|
var depositRequired bool
|
|
|
|
if err := rows.Scan(
|
|
&b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy,
|
|
&totalAmount, &amountPaid, &durationMinutes,
|
|
&depositRequired, &preStartAmountPaid,
|
|
); err != nil {
|
|
log.Printf("Failed to scan booking row: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if createdBy.Valid {
|
|
b.CreatedBy = &createdBy.String
|
|
}
|
|
b.TotalAmount = totalAmount
|
|
b.AmountPaid = amountPaid
|
|
b.AmountDue = totalAmount - amountPaid
|
|
b.DurationMinutes = durationMinutes
|
|
populateDepositFields(&b, depositRequired, preStartAmountPaid)
|
|
bookings = append(bookings, b)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(BookingListResponse{
|
|
Bookings: bookings,
|
|
Page: req.Page,
|
|
PerPage: req.PerPage,
|
|
Total: total,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// GET /api/admin/bookings
|
|
func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|
req := parseGetAllBookingsRequest(r)
|
|
|
|
baseQuery := `
|
|
WITH booking_totals AS (
|
|
SELECT
|
|
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) AS total_amount,
|
|
COALESCE(pt.total_paid, 0) AS amount_paid,
|
|
COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due,
|
|
b.deposit_required,
|
|
(SELECT COALESCE(SUM(p2.amount), 0)
|
|
FROM payments p2
|
|
WHERE p2.booking_id = b.id AND p2.status = 'completed' AND p2.created_at < b.start_time) AS pre_start_amount_paid
|
|
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
|
|
whereAdded := false
|
|
|
|
addWhereClause := func(condition string) {
|
|
if whereAdded {
|
|
baseQuery += " AND " + condition
|
|
countQuery += " AND " + condition
|
|
} else {
|
|
baseQuery += " WHERE " + condition
|
|
countQuery += " WHERE " + condition
|
|
whereAdded = true
|
|
}
|
|
}
|
|
|
|
if req.Status != nil {
|
|
addWhereClause(fmt.Sprintf("b.status = $%d", paramCount))
|
|
args = append(args, *req.Status)
|
|
paramCount++
|
|
}
|
|
if req.StartDate != nil {
|
|
addWhereClause(fmt.Sprintf("b.start_time >= $%d", paramCount))
|
|
startTime, err := time.Parse("2006-01-02", *req.StartDate)
|
|
if err != nil {
|
|
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
|
|
return
|
|
}
|
|
args = append(args, startTime)
|
|
paramCount++
|
|
}
|
|
if req.EndDate != nil {
|
|
addWhereClause(fmt.Sprintf("b.start_time <= $%d", paramCount))
|
|
endTime, err := time.Parse("2006-01-02", *req.EndDate)
|
|
if err != nil {
|
|
http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
|
|
return
|
|
}
|
|
args = append(args, endTime.Add(23*time.Hour+59*time.Minute+59*time.Second))
|
|
paramCount++
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
countArgs := args
|
|
if req.PerPage > 0 {
|
|
countArgs = args[:len(args)-2]
|
|
}
|
|
|
|
var total int
|
|
if err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total); err != nil {
|
|
log.Printf("Failed to get total booking count: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
totalPages := (total + req.PerPage - 1) / req.PerPage
|
|
if totalPages == 0 {
|
|
totalPages = 1
|
|
}
|
|
|
|
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
|
|
var bookingIDs []string
|
|
|
|
for rows.Next() {
|
|
var b Booking
|
|
var userFullName string
|
|
var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
|
|
var depositRequired bool
|
|
|
|
if err := rows.Scan(
|
|
&b.ID, &b.StartTime, &b.Status, &userFullName,
|
|
&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
|
|
&depositRequired, &preStartAmountPaid,
|
|
); err != nil {
|
|
log.Printf("Failed to scan booking row: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
b.User = &UserSummary{FullName: userFullName}
|
|
b.TotalAmount = totalAmount
|
|
b.AmountPaid = amountPaid
|
|
b.AmountDue = amountDue
|
|
populateDepositFields(&b, depositRequired, preStartAmountPaid)
|
|
bookings = append(bookings, b)
|
|
bookingIDs = append(bookingIDs, b.ID)
|
|
}
|
|
|
|
if len(bookingIDs) > 0 {
|
|
serviceRows, err := db.DB.Query(r.Context(), `
|
|
SELECT bs.booking_id, s.name
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = ANY($1)
|
|
ORDER BY bs.booking_id, s.name
|
|
`, bookingIDs)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch booking services: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer serviceRows.Close()
|
|
|
|
servicesByBooking := make(map[string][]BookingService)
|
|
for serviceRows.Next() {
|
|
var bookingID, serviceName string
|
|
if err := serviceRows.Scan(&bookingID, &serviceName); err != nil {
|
|
log.Printf("Failed to scan service row: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
n := serviceName
|
|
servicesByBooking[bookingID] = append(servicesByBooking[bookingID], BookingService{
|
|
BookingID: bookingID,
|
|
ServiceName: &n,
|
|
})
|
|
}
|
|
for i := range bookings {
|
|
if services, exists := servicesByBooking[bookings[i].ID]; exists {
|
|
bookings[i].Services = services
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(BookingListResponse{
|
|
Bookings: bookings,
|
|
Page: req.Page,
|
|
PerPage: req.PerPage,
|
|
Total: total,
|
|
TotalPages: totalPages,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// GET /api/admin/bookings/user/{user_id}
|
|
func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID := chi.URLParam(r, "user_id")
|
|
if userID == "" || !validators.IsValidID(userID) {
|
|
http.Error(w, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
query := r.URL.Query()
|
|
page, perPage := 1, 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
|
|
}
|
|
}
|
|
|
|
var total int
|
|
if err := db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1`, userID).Scan(&total); err != nil {
|
|
log.Printf("Failed to count bookings for user %s: %v", userID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
|
|
b.deposit_required
|
|
FROM bookings b
|
|
WHERE b.user_id = $1
|
|
ORDER BY b.start_time DESC
|
|
LIMIT $2 OFFSET $3
|
|
`, userID, perPage, (page-1)*perPage)
|
|
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 depositRequired bool
|
|
if err := rows.Scan(
|
|
&b.ID, &b.StartTime, &b.Status, &b.Notes,
|
|
&b.CreatedAt, &b.UpdatedAt, &b.CreatedBy,
|
|
&depositRequired,
|
|
); err != nil {
|
|
log.Printf("Failed to scan booking row: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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 svc BookingService
|
|
var price float64
|
|
var dur int
|
|
if err := serviceRows.Scan(&svc.ServiceName, &price, &dur); err != nil {
|
|
log.Printf("Failed to scan service: %v", err)
|
|
continue
|
|
}
|
|
svc.Price = &price
|
|
svc.DurationMinutes = &dur
|
|
totalAmount += price
|
|
b.Services = append(b.Services, svc)
|
|
}
|
|
serviceRows.Close()
|
|
|
|
// Fetch both all-time and pre-start paid amounts in one query
|
|
var amountPaid, preStartAmountPaid float64
|
|
db.DB.QueryRow(r.Context(), `
|
|
SELECT
|
|
COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0),
|
|
COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND created_at < $2), 0)
|
|
FROM payments
|
|
WHERE booking_id = $1
|
|
`, b.ID, b.StartTime).Scan(&amountPaid, &preStartAmountPaid)
|
|
|
|
b.TotalAmount = totalAmount
|
|
b.AmountPaid = amountPaid
|
|
b.AmountDue = totalAmount - amountPaid
|
|
populateDepositFields(&b, depositRequired, preStartAmountPaid)
|
|
bookings = append(bookings, b)
|
|
}
|
|
|
|
if bookings == nil {
|
|
bookings = []Booking{}
|
|
}
|
|
|
|
totalPages := (total + perPage - 1) / perPage
|
|
if totalPages == 0 {
|
|
totalPages = 1
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(BookingListResponse{
|
|
Bookings: bookings,
|
|
Total: total,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
TotalPages: totalPages,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode bookings response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// GET /api/admin/bookings/{id}
|
|
func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var booking Booking
|
|
booking.User = &UserSummary{}
|
|
var depositRequired bool
|
|
|
|
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,
|
|
b.deposit_required
|
|
FROM bookings b
|
|
LEFT JOIN users u ON b.user_id = u.id
|
|
WHERE b.id = $1
|
|
`, bookingID).Scan(
|
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes,
|
|
&booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
|
&booking.User.FullName, &booking.User.Email, &booking.User.Phone,
|
|
&booking.User.ProfilePicURL, &booking.User.LoyaltyStamps,
|
|
&booking.User.ReferralCode, &booking.User.Notes,
|
|
&depositRequired,
|
|
)
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
var referralCodeUses int
|
|
if err := db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1
|
|
`, booking.User.ID).Scan(&referralCodeUses); err != nil {
|
|
log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err)
|
|
}
|
|
booking.User.ReferralCodeUses = &referralCodeUses
|
|
|
|
serviceRows, err := db.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
|
|
for serviceRows.Next() {
|
|
var serviceID, name string
|
|
var price float64
|
|
var dur int
|
|
if err := serviceRows.Scan(&serviceID, &name, &price, &dur); err != nil {
|
|
log.Printf("Failed to scan service for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
totalAmount += price
|
|
booking.DurationMinutes += dur
|
|
n, p, d := name, price, dur
|
|
booking.Services = append(booking.Services, BookingService{
|
|
ServiceID: serviceID,
|
|
ServiceName: &n,
|
|
Price: &p,
|
|
DurationMinutes: &d,
|
|
})
|
|
}
|
|
booking.TotalAmount = totalAmount
|
|
|
|
paymentRows, err := db.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 amountPaid, preStartAmountPaid float64
|
|
for paymentRows.Next() {
|
|
var p Payment
|
|
var vendorCode sql.NullString
|
|
var invoiceNumber sql.NullInt32
|
|
if err := paymentRows.Scan(
|
|
&p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
|
|
&p.Status, &p.Amount, &p.CreatedAt,
|
|
); err != nil {
|
|
log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if vendorCode.Valid && vendorCode.String != "" {
|
|
p.VendorCode = &vendorCode.String
|
|
}
|
|
if invoiceNumber.Valid {
|
|
num := int(invoiceNumber.Int32)
|
|
p.InvoiceNumber = &num
|
|
}
|
|
booking.Payments = append(booking.Payments, p)
|
|
if p.Status == "completed" {
|
|
amountPaid += p.Amount
|
|
if p.CreatedAt.Before(booking.StartTime) {
|
|
preStartAmountPaid += p.Amount
|
|
}
|
|
}
|
|
}
|
|
|
|
booking.AmountPaid = amountPaid
|
|
booking.AmountDue = totalAmount - amountPaid
|
|
populateDepositFields(&booking, depositRequired, preStartAmountPaid)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
|
log.Printf("Failed to encode booking response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// PUT /api/admin/bookings/{id}/services
|
|
func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || adminID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req UpdateBookingServicesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Printf("Failed to decode request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.ServiceIDs) == 0 {
|
|
http.Error(w, "At least one service is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
for _, sid := range req.ServiceIDs {
|
|
if !validators.IsValidID(sid) {
|
|
http.Error(w, fmt.Sprintf("Invalid service ID: %s", sid), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
for _, override := range req.ServiceOverrides {
|
|
if override.OverridePrice != nil && *override.OverridePrice < 0 {
|
|
http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
|
|
http.Error(w, "Override duration must be positive", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
var startTime time.Time
|
|
var currentStatus string
|
|
if err := db.DB.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, ¤tStatus); 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
|
|
}
|
|
|
|
rejectedStatuses := map[string]bool{
|
|
"completed": true,
|
|
"client_cancelled": true,
|
|
"we_cancelled": true,
|
|
"no_show": true,
|
|
}
|
|
if rejectedStatuses[currentStatus] {
|
|
http.Error(w, "Cannot update services on a completed, cancelled, or no-show booking", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
overrideMap := make(map[string]*ServiceOverride)
|
|
for i := range req.ServiceOverrides {
|
|
overrideMap[req.ServiceOverrides[i].ServiceID] = &req.ServiceOverrides[i]
|
|
}
|
|
|
|
var newTotalDuration int
|
|
for _, serviceID := range req.ServiceIDs {
|
|
var durationMinutes int
|
|
if ov, exists := overrideMap[serviceID]; exists && ov.OverrideDurationMinutes != nil {
|
|
durationMinutes = *ov.OverrideDurationMinutes
|
|
} else {
|
|
err := db.DB.QueryRow(r.Context(), "SELECT duration_minutes FROM services WHERE id = $1", serviceID).Scan(&durationMinutes)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, fmt.Sprintf("Service not found: %s", serviceID), http.StatusBadRequest)
|
|
return
|
|
}
|
|
log.Printf("Failed to fetch service %s: %v", serviceID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
newTotalDuration += durationMinutes
|
|
}
|
|
|
|
newEndTime := startTime.Add(time.Duration(newTotalDuration) * time.Minute)
|
|
|
|
var nextBookingStart *time.Time
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT start_time FROM bookings
|
|
WHERE start_time > $1
|
|
AND status IN ('confirmed', 'pending', 'in_progress')
|
|
ORDER BY start_time ASC
|
|
LIMIT 1
|
|
`, startTime).Scan(&nextBookingStart)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
log.Printf("Failed to check next booking: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if nextBookingStart != nil && newEndTime.After(*nextBookingStart) {
|
|
http.Error(w, fmt.Sprintf("New booking duration overlaps with next appointment starting at %s", nextBookingStart.Format(time.RFC3339)), 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())
|
|
|
|
if _, err := tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID); err != nil {
|
|
log.Printf("Failed to delete booking services for %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
for _, serviceID := range req.ServiceIDs {
|
|
var ovPrice *float64
|
|
var ovDuration *int
|
|
if ov, exists := overrideMap[serviceID]; exists {
|
|
ovPrice = ov.OverridePrice
|
|
ovDuration = ov.OverrideDurationMinutes
|
|
}
|
|
if _, err := tx.Exec(r.Context(), `
|
|
INSERT INTO booking_services (booking_id, service_id, override_price, override_duration_minutes)
|
|
VALUES ($1, $2, $3, $4)
|
|
`, bookingID, serviceID, ovPrice, ovDuration); err != nil {
|
|
log.Printf("Failed to insert booking service %s for booking %s: %v", serviceID, bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if req.Notes != nil {
|
|
if _, err := tx.Exec(r.Context(), "UPDATE bookings SET notes = $1 WHERE id = $2", *req.Notes, bookingID); err != nil {
|
|
log.Printf("Failed to update notes for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit transaction for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var booking Booking
|
|
booking.User = &UserSummary{}
|
|
var depositRequired bool
|
|
|
|
err = db.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,
|
|
b.deposit_required
|
|
FROM bookings b
|
|
LEFT JOIN users u ON b.user_id = u.id
|
|
WHERE b.id = $1
|
|
`, bookingID).Scan(
|
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes,
|
|
&booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
|
&booking.User.FullName, &booking.User.Email, &booking.User.Phone,
|
|
&booking.User.ProfilePicURL, &booking.User.LoyaltyStamps,
|
|
&booking.User.ReferralCode, &booking.User.Notes,
|
|
&depositRequired,
|
|
)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch updated booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var referralCodeUses int
|
|
if err := db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1
|
|
`, booking.User.ID).Scan(&referralCodeUses); err != nil {
|
|
log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err)
|
|
}
|
|
booking.User.ReferralCodeUses = &referralCodeUses
|
|
|
|
serviceRows, err := db.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()
|
|
|
|
var totalAmount float64
|
|
for serviceRows.Next() {
|
|
var serviceID string
|
|
var overridePrice sql.NullFloat64
|
|
var overrideDuration sql.NullInt32
|
|
var name, description sql.NullString
|
|
var basePrice sql.NullFloat64
|
|
var baseDuration sql.NullInt32
|
|
|
|
if err := serviceRows.Scan(&serviceID, &overridePrice, &overrideDuration, &name, &description, &basePrice, &baseDuration); err != nil {
|
|
log.Printf("Failed to scan service for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var priceToAdd float64
|
|
var durationToAdd int
|
|
var bs BookingService
|
|
bs.ServiceID = serviceID
|
|
|
|
if overridePrice.Valid {
|
|
bs.OverridePrice = &overridePrice.Float64
|
|
priceToAdd = overridePrice.Float64
|
|
} else if basePrice.Valid {
|
|
priceToAdd = basePrice.Float64
|
|
p := basePrice.Float64
|
|
bs.Price = &p
|
|
}
|
|
if overrideDuration.Valid {
|
|
d := int(overrideDuration.Int32)
|
|
bs.OverrideDurationMinutes = &d
|
|
durationToAdd = d
|
|
} else if baseDuration.Valid {
|
|
durationToAdd = int(baseDuration.Int32)
|
|
d := int(baseDuration.Int32)
|
|
bs.DurationMinutes = &d
|
|
}
|
|
|
|
totalAmount += priceToAdd
|
|
booking.DurationMinutes += durationToAdd
|
|
|
|
if name.Valid {
|
|
n := name.String
|
|
bs.ServiceName = &n
|
|
}
|
|
if description.Valid {
|
|
bs.ServiceDescription = &description.String
|
|
}
|
|
booking.Services = append(booking.Services, bs)
|
|
}
|
|
booking.TotalAmount = totalAmount
|
|
|
|
paymentRows, err := db.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 amountPaid, preStartAmountPaid float64
|
|
for paymentRows.Next() {
|
|
var p Payment
|
|
var vendorCode sql.NullString
|
|
var invoiceNumber sql.NullInt32
|
|
if err := paymentRows.Scan(
|
|
&p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
|
|
&p.Status, &p.Amount, &p.CreatedAt,
|
|
); err != nil {
|
|
log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if vendorCode.Valid && vendorCode.String != "" {
|
|
p.VendorCode = &vendorCode.String
|
|
}
|
|
if invoiceNumber.Valid {
|
|
num := int(invoiceNumber.Int32)
|
|
p.InvoiceNumber = &num
|
|
}
|
|
booking.Payments = append(booking.Payments, p)
|
|
if p.Status == "completed" {
|
|
amountPaid += p.Amount
|
|
if p.CreatedAt.Before(booking.StartTime) {
|
|
preStartAmountPaid += p.Amount
|
|
}
|
|
}
|
|
}
|
|
|
|
booking.AmountPaid = amountPaid
|
|
booking.AmountDue = totalAmount - amountPaid
|
|
populateDepositFields(&booking, depositRequired, preStartAmountPaid)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
|
log.Printf("Failed to encode booking response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// GET /api/admin/bookings/search
|
|
func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query().Get("q")
|
|
if query == "" {
|
|
http.Error(w, "Search query 'q' is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
page, perPage := 1, 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
|
|
}
|
|
}
|
|
|
|
escapedQuery := strings.ReplaceAll(query, `\`, `\\`)
|
|
escapedQuery = strings.ReplaceAll(escapedQuery, `%`, `\%`)
|
|
escapedQuery = strings.ReplaceAll(escapedQuery, `_`, `\_`)
|
|
searchPattern := "%" + escapedQuery + "%"
|
|
|
|
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) AS total_amount,
|
|
COALESCE(pt.total_paid, 0) AS amount_paid,
|
|
COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due,
|
|
b.deposit_required,
|
|
(SELECT COALESCE(SUM(p2.amount), 0)
|
|
FROM payments p2
|
|
WHERE p2.booking_id = b.id AND p2.status = 'completed' AND p2.created_at < b.start_time) AS pre_start_amount_paid
|
|
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 '\'
|
|
`
|
|
|
|
var total int
|
|
if err := db.DB.QueryRow(r.Context(), countQuery, searchPattern).Scan(&total); err != nil {
|
|
log.Printf("Failed to get search count: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
totalPages := (total + perPage - 1) / perPage
|
|
if totalPages == 0 {
|
|
totalPages = 1
|
|
}
|
|
|
|
rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, (page-1)*perPage)
|
|
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
|
|
var bookingIDs []string
|
|
|
|
for rows.Next() {
|
|
var b Booking
|
|
var userFullName string
|
|
var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
|
|
var depositRequired bool
|
|
|
|
if err := rows.Scan(
|
|
&b.ID, &b.StartTime, &b.Status, &userFullName,
|
|
&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
|
|
&depositRequired, &preStartAmountPaid,
|
|
); err != nil {
|
|
log.Printf("Failed to scan booking row: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
b.User = &UserSummary{FullName: userFullName}
|
|
b.TotalAmount = totalAmount
|
|
b.AmountPaid = amountPaid
|
|
b.AmountDue = amountDue
|
|
populateDepositFields(&b, depositRequired, preStartAmountPaid)
|
|
bookings = append(bookings, b)
|
|
bookingIDs = append(bookingIDs, b.ID)
|
|
}
|
|
|
|
if len(bookingIDs) > 0 {
|
|
serviceRows, err := db.DB.Query(r.Context(), `
|
|
SELECT bs.booking_id, s.name
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = ANY($1)
|
|
ORDER BY bs.booking_id, s.name
|
|
`, bookingIDs)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch booking services: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer serviceRows.Close()
|
|
|
|
servicesByBooking := make(map[string][]BookingService)
|
|
for serviceRows.Next() {
|
|
var bookingID, serviceName string
|
|
if err := serviceRows.Scan(&bookingID, &serviceName); err != nil {
|
|
log.Printf("Failed to scan service row: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
n := serviceName
|
|
servicesByBooking[bookingID] = append(servicesByBooking[bookingID], BookingService{
|
|
BookingID: bookingID,
|
|
ServiceName: &n,
|
|
})
|
|
}
|
|
for i := range bookings {
|
|
if services, exists := servicesByBooking[bookings[i].ID]; exists {
|
|
bookings[i].Services = services
|
|
} else {
|
|
bookings[i].Services = []BookingService{}
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(BookingListResponse{
|
|
Bookings: bookings,
|
|
Page: page,
|
|
PerPage: perPage,
|
|
Total: total,
|
|
TotalPages: totalPages,
|
|
}); err != nil {
|
|
log.Printf("Failed to encode response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// POST /api/bookings
|
|
func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateBookingRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Printf("Failed to decode request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Extract idempotency key from header
|
|
idempotencyKey := r.Header.Get("Idempotency-Key")
|
|
|
|
// If idempotency key provided, check for existing booking
|
|
if idempotencyKey != "" {
|
|
var existingID string
|
|
err := db.DB.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID)
|
|
if err == nil {
|
|
// Booking already exists with this key — fetch and return it
|
|
var existingBooking Booking
|
|
existingBooking.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, b.deposit_required
|
|
FROM bookings b WHERE b.id = $1
|
|
`, existingID).Scan(
|
|
&existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status,
|
|
&existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy,
|
|
&existingBooking.DepositRequired,
|
|
)
|
|
if err == nil {
|
|
// Fetch services for the response
|
|
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
|
|
`, existingID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var bs BookingService
|
|
if err := rows.Scan(
|
|
&bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes,
|
|
&bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes,
|
|
); err != nil {
|
|
break
|
|
}
|
|
existingBooking.Services = append(existingBooking.Services, bs)
|
|
}
|
|
}
|
|
|
|
// Get deposit info
|
|
var depositRequired bool
|
|
var preStartPaid float64
|
|
db.DB.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
|
|
db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
|
|
populateDepositFields(&existingBooking, depositRequired, preStartPaid)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(existingBooking)
|
|
return
|
|
}
|
|
}
|
|
// If err is sql.ErrNoRows, proceed with creation
|
|
}
|
|
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
isGuest := false
|
|
|
|
if !ok || userID == "" {
|
|
if req.UserID == nil || *req.UserID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var accountRole string
|
|
if err := db.DB.QueryRow(r.Context(), `SELECT account_role FROM users WHERE id = $1`, *req.UserID).Scan(&accountRole); err != nil {
|
|
http.Error(w, "User not found", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if accountRole != "guest" {
|
|
http.Error(w, "Invalid user_id - guest account required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
userID = *req.UserID
|
|
isGuest = true
|
|
}
|
|
|
|
if req.StartTime.IsZero() {
|
|
http.Error(w, "Start time is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.ServiceIDs) == 0 {
|
|
http.Error(w, "At least one service is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var depositsRequired int
|
|
if !isGuest {
|
|
if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired); err != nil {
|
|
log.Printf("Failed to fetch deposits_required for user %s: %v", userID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if !isGuest && depositsRequired > 0 {
|
|
var activeCount int
|
|
if err := db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings
|
|
WHERE user_id = $1 AND status IN ('pending', 'confirmed')
|
|
`, userID).Scan(&activeCount); err != nil {
|
|
log.Printf("Failed to check active bookings for user %s: %v", userID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if activeCount > 0 {
|
|
http.Error(w, "You already have an active booking. Complete or cancel it before creating a new one.", http.StatusConflict)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check 1h minimum advance for all users
|
|
if req.StartTime.Before(time.Now().Add(1 * time.Hour)) {
|
|
http.Error(w, "Bookings must be at least 1 hour in advance", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Deposit users must book at least 24h in advance to allow time for deposit payment
|
|
if !isGuest && depositsRequired > 0 && req.StartTime.Before(time.Now().Add(24*time.Hour)) {
|
|
http.Error(w, "When deposits are required, bookings must be made at least 24 hours in advance to allow time for payment.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Snapshot whether a deposit is required at the moment of booking creation.
|
|
// Stored on the bookings row so historic GET responses are accurate regardless
|
|
// of the user's future deposits_required changes.
|
|
depositRequiredSnapshot := depositsRequired > 0
|
|
|
|
if !isGuest {
|
|
for _, serviceID := range req.ServiceIDs {
|
|
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 {
|
|
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 {
|
|
http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var expiryMonths int
|
|
if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil {
|
|
if time.Now().After(testedAt.AddDate(0, expiryMonths, 0)) {
|
|
http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if req.StartTime.Before(time.Now()) {
|
|
http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var cnt int
|
|
db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
|
|
AND start_time < $2
|
|
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
|
|
)) > $1
|
|
`, req.StartTime, endTime).Scan(&cnt)
|
|
if cnt > 0 {
|
|
http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Check for time blocker overlap
|
|
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime)
|
|
if err != nil {
|
|
log.Printf("Failed to check time blocker overlap: %v", err)
|
|
} else if blockerOverlap {
|
|
http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
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())
|
|
|
|
// Delete any existing reservation for this user (max 1 per user)
|
|
// Also matches anon reservations by start_time for users who register mid-flow
|
|
_, _ = tx.Exec(r.Context(), `
|
|
DELETE FROM time_blockers
|
|
WHERE description LIKE 'RESERVATION:%'
|
|
AND (created_by = $1
|
|
OR (description LIKE 'RESERVATION:anon:%' AND start_time = $2))
|
|
`, userID, req.StartTime)
|
|
|
|
// Insert booking with snapshotted deposit_required
|
|
var booking Booking
|
|
booking.User = &UserSummary{}
|
|
if err := tx.QueryRow(r.Context(), `
|
|
INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key)
|
|
VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6)
|
|
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required
|
|
`, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}).Scan(
|
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
|
&booking.DepositRequired,
|
|
); err != nil {
|
|
log.Printf("Failed to create booking for user %s: %v", userID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
for _, serviceID := range req.ServiceIDs {
|
|
if _, err := tx.Exec(r.Context(), `INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)`, booking.ID, serviceID); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Always create low-priority notification for all bookings
|
|
if _, err := tx.Exec(r.Context(), `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('new_booking', $1, $2)
|
|
`, booking.ID, userID); err != nil {
|
|
log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// If booking needs approval (has notes or is for today), also create high-priority notification
|
|
needsApproval := false
|
|
if req.Notes != nil && *req.Notes != "" {
|
|
needsApproval = true
|
|
} else {
|
|
london, _ := time.LoadLocation("Europe/London")
|
|
now := time.Now().In(london)
|
|
bookingDay := req.StartTime.In(london)
|
|
if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.YearDay() {
|
|
needsApproval = true
|
|
}
|
|
}
|
|
|
|
if needsApproval {
|
|
if _, err := tx.Exec(r.Context(), `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2)
|
|
`, booking.ID, userID); err != nil {
|
|
log.Printf("Failed to create pending approval notification for booking %s: %v", booking.ID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Fetch services for the response
|
|
booking.Services = []BookingService{}
|
|
rows, err := db.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
|
|
if err := rows.Scan(
|
|
&bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes,
|
|
&bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes,
|
|
); err != nil {
|
|
break
|
|
}
|
|
booking.Services = append(booking.Services, bs)
|
|
}
|
|
}
|
|
|
|
// Populate deposit display fields on the creation response.
|
|
// No payments exist yet so pre-start paid is 0 and DepositPaid will be false.
|
|
populateDepositFields(&booking, depositRequiredSnapshot, 0)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
|
log.Printf("Failed to encode booking response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
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
|
|
}
|
|
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req EditBookingRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Printf("Failed to decode request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if 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
|
|
}
|
|
|
|
var currentStatus string
|
|
if err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(¤tStatus); 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
|
|
}
|
|
|
|
if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
|
|
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var durationMinutes int
|
|
if 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); err != nil {
|
|
log.Printf("Failed to get booking duration %s: %v", bookingID, err)
|
|
durationMinutes = 60
|
|
}
|
|
|
|
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
|
|
var overlapCount int
|
|
if 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); 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 for time blocker overlap
|
|
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime)
|
|
if err != nil {
|
|
log.Printf("Failed to check time blocker overlap: %v", err)
|
|
} else if blockerOverlap {
|
|
http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
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)
|
|
|
|
var isClosed bool
|
|
if 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); 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
|
|
}
|
|
|
|
var booking Booking
|
|
booking.User = &UserSummary{}
|
|
if err := db.DB.QueryRow(r.Context(), `
|
|
UPDATE bookings
|
|
SET start_time = $1, updated_at = NOW()
|
|
WHERE id = $2 AND user_id = $3
|
|
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
|
`, req.StartTime, bookingID, userID).Scan(
|
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
|
); err != nil {
|
|
if errors.Is(err, 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
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
|
log.Printf("Failed to encode booking response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// PUT /api/bookings/{id}/progress
|
|
func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var req ProgressBookingRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Printf("Failed to decode request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
var booking Booking
|
|
booking.User = &UserSummary{}
|
|
if err := db.DB.QueryRow(r.Context(), `
|
|
UPDATE bookings
|
|
SET status = $1, updated_at = NOW()
|
|
WHERE id = $2
|
|
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
|
`, req.Status, bookingID).Scan(
|
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
|
); err != nil {
|
|
if errors.Is(err, 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" {
|
|
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
|
|
}
|
|
if _, 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); err != nil {
|
|
log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, patchTestID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var bookingTotal float64
|
|
if err := db.DB.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
|
|
`, bookingID).Scan(&bookingTotal); err != nil {
|
|
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
|
|
}
|
|
|
|
// Apply existing pending loyalty redemption (earned from previous 10 bookings)
|
|
if bookingTotal > 0 {
|
|
var redemptionID string
|
|
if err := db.DB.QueryRow(r.Context(), `
|
|
SELECT id FROM loyalty_redemptions
|
|
WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW()
|
|
ORDER BY redeemed_at ASC LIMIT 1
|
|
`, booking.User.ID).Scan(&redemptionID); err == nil && redemptionID != "" {
|
|
discountAmount := roundTo2(bookingTotal * 0.10)
|
|
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
|
VALUES ($1, $2, 'loyalty', $3, NULL, NULL, 10.00, $4, $5)
|
|
`, bookingID, booking.User.ID, redemptionID, bookingTotal, discountAmount)
|
|
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
|
`, bookingID, discountAmount, booking.User.ID)
|
|
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW()
|
|
WHERE id = $2
|
|
`, bookingID, redemptionID)
|
|
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - 10) WHERE id = $1
|
|
`, booking.User.ID)
|
|
}
|
|
}
|
|
|
|
// Increment stamps (max 1 per day, only for paid bookings)
|
|
if bookingTotal > 0 {
|
|
if _, 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); err != nil {
|
|
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
|
|
}
|
|
}
|
|
|
|
// Create pending redemption when stamps reach 10
|
|
var newStampCount int
|
|
if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, booking.User.ID).Scan(&newStampCount); err == nil && newStampCount == 10 {
|
|
_, err = db.DB.Exec(r.Context(), `
|
|
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
|
|
VALUES ($1, 10, 'pending', NOW())
|
|
`, booking.User.ID)
|
|
if err != nil {
|
|
log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err)
|
|
}
|
|
}
|
|
|
|
if bookingTotal > 0 {
|
|
var hasLoyaltyDiscount bool
|
|
_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&hasLoyaltyDiscount)
|
|
|
|
if !hasLoyaltyDiscount {
|
|
var campaignID string
|
|
var campaignPercent float64
|
|
if err := db.DB.QueryRow(r.Context(), `
|
|
SELECT id, discount_percent FROM discount_campaigns
|
|
WHERE status = 'active' AND campaign_type = 'time_based'
|
|
AND start_date <= NOW() AND end_date >= NOW()
|
|
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
|
ORDER BY discount_percent DESC LIMIT 1
|
|
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
|
|
discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
|
|
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
|
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
|
|
`, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount)
|
|
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
|
`, bookingID, discountAmount, booking.User.ID)
|
|
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
|
`, campaignID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if bookingTotal > 0 {
|
|
var hasDiscount bool
|
|
_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)`, bookingID).Scan(&hasDiscount)
|
|
|
|
if !hasDiscount {
|
|
var userBookingCount int
|
|
_ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount)
|
|
|
|
var milestoneCampaignID string
|
|
var milestonePercent float64
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT id, discount_percent FROM discount_campaigns
|
|
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
|
|
AND milestone_value = $1
|
|
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
|
|
`, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent)
|
|
|
|
if milestoneCampaignID != "" {
|
|
discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
|
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
|
|
`, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount)
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
|
`, bookingID, discountAmount, booking.User.ID)
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
|
`, milestoneCampaignID)
|
|
}
|
|
|
|
if milestoneCampaignID == "" {
|
|
var globalCount int
|
|
_ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
|
|
var globalCampaignID string
|
|
var globalPercent float64
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT id, discount_percent FROM discount_campaigns
|
|
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
|
|
AND milestone_value = $1
|
|
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
|
|
`, globalCount).Scan(&globalCampaignID, &globalPercent)
|
|
|
|
if globalCampaignID != "" {
|
|
discountAmount := roundTo2(bookingTotal * globalPercent / 100)
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
|
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
|
|
`, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount)
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
|
`, bookingID, discountAmount, booking.User.ID)
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
|
`, globalCampaignID)
|
|
}
|
|
}
|
|
|
|
if milestoneCampaignID == "" {
|
|
var firstVisitDate time.Time
|
|
_ = db.DB.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate)
|
|
if !firstVisitDate.IsZero() {
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns
|
|
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
|
|
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
|
|
`, booking.User.ID)
|
|
if err == nil {
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var annID string
|
|
var annPercent float64
|
|
var annValue int
|
|
var annUnit string
|
|
if rows.Scan(&annID, &annPercent, &annValue, &annUnit) == nil {
|
|
var matches bool
|
|
elapsed := time.Since(firstVisitDate)
|
|
switch annUnit {
|
|
case "months":
|
|
months := int(elapsed.Hours() / (30 * 24))
|
|
matches = months >= annValue
|
|
case "years":
|
|
years := int(elapsed.Hours() / (365.25 * 24))
|
|
matches = years >= annValue
|
|
}
|
|
if matches {
|
|
discountAmount := roundTo2(bookingTotal * annPercent / 100)
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
|
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
|
|
`, bookingID, booking.User.ID, annID, annPercent, bookingTotal, discountAmount)
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
|
|
VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
|
|
`, bookingID, discountAmount, booking.User.ID)
|
|
_, _ = db.DB.Exec(r.Context(), `
|
|
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
|
|
`, annID)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
// TODO: When online payments are live, create 'deposit_paid' notification here for:
|
|
// - Online deposit payments (user pays deposit via Square)
|
|
// - Early/late balance payments made online by the user
|
|
// NOT for admin-recorded in-person payments — admin already knows about those.
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
|
log.Printf("Failed to encode booking response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// POST /api/bookings/{id}/confirm
|
|
func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var req ConfirmBookingRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
log.Printf("Failed to decode request: %v", err)
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
for _, override := range req.ServiceOverrides {
|
|
if override.OverridePrice != nil && *override.OverridePrice < 0 {
|
|
http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
|
|
http.Error(w, "Override duration must be positive", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
var 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())
|
|
|
|
var booking Booking
|
|
booking.User = &UserSummary{}
|
|
if err := tx.QueryRow(r.Context(), `
|
|
UPDATE bookings
|
|
SET status = 'confirmed', notes = COALESCE($1, notes), updated_at = NOW()
|
|
WHERE id = $2 AND status = 'pending'
|
|
RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
|
|
`, req.Notes, bookingID).Scan(
|
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
|
); err != nil {
|
|
if errors.Is(err, 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
|
|
}
|
|
|
|
if len(req.ServiceOverrides) > 0 {
|
|
serviceIDs := make([]string, len(req.ServiceOverrides))
|
|
for i, o := range req.ServiceOverrides {
|
|
serviceIDs[i] = o.ServiceID
|
|
}
|
|
var count int
|
|
if err := tx.QueryRow(r.Context(), `
|
|
SELECT COUNT(*) FROM booking_services WHERE booking_id = $1 AND service_id = ANY($2)
|
|
`, bookingID, serviceIDs).Scan(&count); err != nil {
|
|
log.Printf("Failed to verify services for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if count != len(req.ServiceOverrides) {
|
|
http.Error(w, "One or more service IDs do not belong to this booking", http.StatusBadRequest)
|
|
return
|
|
}
|
|
for _, override := range req.ServiceOverrides {
|
|
if _, err := tx.Exec(r.Context(), `
|
|
UPDATE booking_services
|
|
SET override_price = $1, override_duration_minutes = $2
|
|
WHERE booking_id = $3 AND service_id = $4
|
|
`, override.OverridePrice, override.OverrideDurationMinutes, bookingID, override.ServiceID); err != nil {
|
|
log.Printf("Failed to update service override for booking %s, service %s: %v", bookingID, override.ServiceID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
log.Printf("Failed to commit booking confirmation: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if dav.Service != nil {
|
|
var durationMinutes int
|
|
db.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),
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
|
log.Printf("Failed to encode booking response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// POST /api/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())
|
|
|
|
var booking Booking
|
|
booking.User = &UserSummary{}
|
|
if err := tx.QueryRow(r.Context(), `
|
|
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
|
|
`, bookingID).Scan(
|
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
|
); err != nil {
|
|
if errors.Is(err, 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
|
|
}
|
|
|
|
tx.Exec(r.Context(), `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('cancelled_booking', $1, $2)
|
|
`, 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
|
|
}
|
|
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var paymentCount int
|
|
if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount); 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 {
|
|
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
|
|
}
|
|
|
|
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())
|
|
|
|
var originalStatus string
|
|
if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus); 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
|
|
}
|
|
|
|
result, err := tx.Exec(r.Context(), `
|
|
UPDATE bookings SET status = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3
|
|
`, req.Reason, bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to cancel booking %s for user %s: %v", bookingID, userID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if result.RowsAffected() == 0 {
|
|
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if originalStatus == "confirmed" {
|
|
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 < 24 {
|
|
isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow
|
|
|
|
if !isForgiving {
|
|
tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID)
|
|
} else {
|
|
tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID)
|
|
}
|
|
}
|
|
|
|
if _, err := tx.Exec(r.Context(), `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)
|
|
`, "cancelled_booking", bookingID, userID); err != nil {
|
|
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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 — no payments exist
|
|
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())
|
|
|
|
var originalStatus string
|
|
if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus); 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
|
|
}
|
|
|
|
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if originalStatus != "pending" {
|
|
if _, err := tx.Exec(r.Context(), `
|
|
INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)
|
|
`, "cancelled_booking", bookingID, userID); err != nil {
|
|
log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
result, err := tx.Exec(r.Context(), "DELETE FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete booking %s for user %s: %v", bookingID, userID, err)
|
|
http.Error(w, "Failed to delete booking", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if result.RowsAffected() == 0 {
|
|
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"message": "Booking deleted successfully",
|
|
"id": bookingID,
|
|
})
|
|
}
|
|
|
|
// GET /api/bookings/{id}
|
|
func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var booking Booking
|
|
booking.Payments = []Payment{}
|
|
booking.Services = []BookingService{}
|
|
booking.User = &UserSummary{}
|
|
|
|
var createdBy sql.NullString
|
|
var depositRequired bool
|
|
|
|
if err := db.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,
|
|
b.deposit_required
|
|
FROM bookings b
|
|
WHERE b.id = $1 AND b.user_id = $2
|
|
`, bookingID, userID).Scan(
|
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy,
|
|
&depositRequired,
|
|
); err != nil {
|
|
if errors.Is(err, 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
|
|
}
|
|
|
|
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()
|
|
|
|
var totalAmount float64
|
|
var durationMinutes int
|
|
for serviceRows.Next() {
|
|
var s BookingService
|
|
var overridePrice sql.NullFloat64
|
|
var overrideDuration sql.NullInt32
|
|
var name, description sql.NullString
|
|
var basePrice sql.NullFloat64
|
|
var baseDuration sql.NullInt32
|
|
|
|
if err := serviceRows.Scan(
|
|
&s.ServiceID,
|
|
&overridePrice, &overrideDuration,
|
|
&name, &description, &basePrice, &baseDuration,
|
|
); err != nil {
|
|
log.Printf("Failed to scan service row for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var priceToAdd float64
|
|
var durationToAdd int
|
|
if overridePrice.Valid {
|
|
s.OverridePrice = &overridePrice.Float64
|
|
priceToAdd = overridePrice.Float64
|
|
} else if basePrice.Valid {
|
|
priceToAdd = basePrice.Float64
|
|
}
|
|
if overrideDuration.Valid {
|
|
d := int(overrideDuration.Int32)
|
|
s.OverrideDurationMinutes = &d
|
|
durationToAdd = d
|
|
} else if baseDuration.Valid {
|
|
durationToAdd = int(baseDuration.Int32)
|
|
}
|
|
|
|
totalAmount += priceToAdd
|
|
durationMinutes += durationToAdd
|
|
|
|
if name.Valid {
|
|
s.ServiceName = &name.String
|
|
}
|
|
if description.Valid {
|
|
s.ServiceDescription = &description.String
|
|
}
|
|
if basePrice.Valid {
|
|
s.Price = &basePrice.Float64
|
|
}
|
|
if baseDuration.Valid {
|
|
d := int(baseDuration.Int32)
|
|
s.DurationMinutes = &d
|
|
}
|
|
booking.Services = append(booking.Services, s)
|
|
}
|
|
|
|
paymentRows, err := db.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()
|
|
|
|
var amountPaid, preStartAmountPaid float64
|
|
for paymentRows.Next() {
|
|
var p Payment
|
|
var vendorCode sql.NullString
|
|
var invoiceNumber sql.NullInt32
|
|
var vatRate, vatAmount, netAmount sql.NullFloat64
|
|
var pCreatedBy sql.NullString
|
|
|
|
if err := paymentRows.Scan(
|
|
&p.ID, &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
|
|
&p.Status, &p.Amount, &p.IsVATApplicable, &vatRate, &vatAmount, &netAmount,
|
|
&p.CreatedAt, &p.UpdatedAt, &pCreatedBy,
|
|
); err != nil {
|
|
log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if vendorCode.Valid {
|
|
p.VendorCode = &vendorCode.String
|
|
}
|
|
if invoiceNumber.Valid {
|
|
num := int(invoiceNumber.Int32)
|
|
p.InvoiceNumber = &num
|
|
}
|
|
if vatRate.Valid {
|
|
p.VATRate = &vatRate.Float64
|
|
}
|
|
if vatAmount.Valid {
|
|
p.VATAmount = &vatAmount.Float64
|
|
}
|
|
if netAmount.Valid {
|
|
p.NetAmount = &netAmount.Float64
|
|
}
|
|
if pCreatedBy.Valid {
|
|
p.CreatedBy = &pCreatedBy.String
|
|
}
|
|
|
|
if p.Status == "completed" {
|
|
amountPaid += p.Amount
|
|
if p.CreatedAt.Before(booking.StartTime) {
|
|
preStartAmountPaid += p.Amount
|
|
}
|
|
}
|
|
booking.Payments = append(booking.Payments, p)
|
|
}
|
|
|
|
booking.TotalAmount = totalAmount
|
|
booking.AmountPaid = amountPaid
|
|
booking.AmountDue = totalAmount - amountPaid
|
|
booking.DurationMinutes = durationMinutes
|
|
|
|
populateDepositFields(&booking, depositRequired, preStartAmountPaid)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(booking); err != nil {
|
|
log.Printf("Failed to encode booking response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// GET /api/bookings/{id}/calendar - returns standalone .ics file
|
|
func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var bookingIDDB, userIDDB, status, notes, createdBy string
|
|
var startTime, createdAt, updatedAt time.Time
|
|
var durationMinutes int
|
|
|
|
if err := db.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); 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)
|
|
}
|
|
|
|
// OverlappingBooking represents a booking that overlaps with another
|
|
type OverlappingBooking struct {
|
|
ID string `json:"id"`
|
|
StartTime time.Time `json:"start_time"`
|
|
Duration int `json:"duration_minutes"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
User *UserSummary `json:"user,omitempty"`
|
|
Services []string `json:"services,omitempty"`
|
|
}
|
|
|
|
// OverlappingBookingsResponse is the response for the overlapping bookings endpoint
|
|
type OverlappingBookingsResponse struct {
|
|
Bookings []OverlappingBooking `json:"bookings"`
|
|
}
|
|
|
|
// GET /api/admin/bookings/{id}/overlapping
|
|
// Returns all bookings that overlap with the specified booking
|
|
func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|
bookingID := chi.URLParam(r, "id")
|
|
if bookingID == "" || !validators.IsValidID(bookingID) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Get the booking's start time and duration
|
|
var startTime time.Time
|
|
var durationMinutes int
|
|
if err := db.DB.QueryRow(r.Context(), `
|
|
SELECT b.start_time,
|
|
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
|
|
FROM bookings b
|
|
LEFT JOIN booking_services bs ON b.id = bs.booking_id
|
|
LEFT JOIN services s ON bs.service_id = s.id
|
|
WHERE b.id = $1
|
|
GROUP BY b.start_time
|
|
`, bookingID).Scan(&startTime, &durationMinutes); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("Failed to get booking %s: %v", bookingID, err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
|
|
|
|
// Find overlapping bookings (excluding the current booking and cancelled/completed ones)
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT
|
|
b.id,
|
|
b.start_time,
|
|
b.status,
|
|
b.created_at,
|
|
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration,
|
|
u.fn,
|
|
u.email
|
|
FROM bookings b
|
|
LEFT JOIN booking_services bs ON b.id = bs.booking_id
|
|
LEFT JOIN services s ON bs.service_id = s.id
|
|
LEFT JOIN users u ON b.user_id = u.id
|
|
WHERE b.id != $1
|
|
AND b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
|
|
AND b.start_time < $3
|
|
AND b.start_time + (INTERVAL '1 minute' * (
|
|
SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60)
|
|
FROM booking_services bs2
|
|
JOIN services s2 ON bs2.service_id = s2.id
|
|
WHERE bs2.booking_id = b.id
|
|
)) > $2
|
|
GROUP BY b.id, b.start_time, b.status, b.created_at, u.fn, u.email
|
|
ORDER BY b.created_at ASC
|
|
`, bookingID, startTime, endTime)
|
|
if err != nil {
|
|
log.Printf("Failed to query overlapping bookings: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var bookings []OverlappingBooking
|
|
for rows.Next() {
|
|
var ob OverlappingBooking
|
|
ob.User = &UserSummary{}
|
|
if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &ob.CreatedAt, &ob.Duration, &ob.User.FullName, &ob.User.Email); err != nil {
|
|
log.Printf("Failed to scan overlapping booking: %v", err)
|
|
continue
|
|
}
|
|
|
|
// Get services for this booking
|
|
serviceRows, err := db.DB.Query(r.Context(), `
|
|
SELECT s.name
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = $1
|
|
ORDER BY s.name
|
|
`, ob.ID)
|
|
if err == nil {
|
|
for serviceRows.Next() {
|
|
var name string
|
|
if err := serviceRows.Scan(&name); err == nil {
|
|
ob.Services = append(ob.Services, name)
|
|
}
|
|
}
|
|
serviceRows.Close()
|
|
}
|
|
|
|
bookings = append(bookings, ob)
|
|
}
|
|
|
|
if bookings == nil {
|
|
bookings = []OverlappingBooking{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil {
|
|
log.Printf("Failed to encode overlapping bookings response: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|