diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 0f2516d..245f44d 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -4,6 +4,7 @@ import ( "context" "crussell/db" "crussell/handlers/notifications" + "crussell/handlers/payments" "crussell/handlers/scheduling" "crussell/internal/dav" "crussell/internal/validators" @@ -13,9 +14,12 @@ import ( "errors" "fmt" "log" + "math" "net/http" "strconv" "strings" + + "github.com/jackc/pgx/v5" "time" "github.com/go-chi/chi/v5" @@ -31,33 +35,34 @@ var londonLocation = func() *time.Location { // Booking represents a booking in the system type Booking struct { - ID string `json:"id"` - StartTime time.Time `json:"start_time"` - Status string `json:"status"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CreatedBy *string `json:"created_by,omitempty"` - CreatedByName *string `json:"created_by_name,omitempty"` + ID string `json:"id"` + StartTime time.Time `json:"start_time"` + Status string `json:"status"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy *string `json:"created_by,omitempty"` + CreatedByName *string `json:"created_by_name,omitempty"` // 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"` + DepositRequired bool `json:"deposit_required"` + DepositAmount float64 `json:"deposit_amount,omitempty"` + DepositPaid bool `json:"deposit_paid"` + DepositDeadline *string `json:"deposit_deadline,omitempty"` + DepositProtectedAmount float64 `json:"deposit_protected_amount,omitempty"` // Joined fields - User *UserSummary `json:"user,omitempty"` - Services []BookingService `json:"services"` - Payments []Payment `json:"payments,omitempty"` + User *UserSummary `json:"user,omitempty"` + Services []BookingService `json:"services"` + Payments []Payment `json:"payments,omitempty"` Discounts []BookingDiscount `json:"discounts,omitempty"` - TotalAmount float64 `json:"total_amount"` - AmountPaid float64 `json:"amount_paid"` - AmountDue float64 `json:"amount_due"` - DurationMinutes int `json:"duration_minutes"` + TotalAmount float64 `json:"total_amount"` + AmountPaid float64 `json:"amount_paid"` + AmountDue float64 `json:"amount_due"` + DurationMinutes int `json:"duration_minutes"` } type BookingDiscount struct { @@ -133,13 +138,18 @@ func fetchBookingDiscounts(ctx context.Context, bookingID string) ([]BookingDisc // - 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.DepositAmount = b.TotalAmount * payments.RequiredDepositPct b.DepositPaid = depositRequired && preStartAmountPaid >= b.DepositAmount - deadline := b.StartTime.Add(-24 * time.Hour).Format(time.RFC3339) + deadline := b.StartTime.Add(-payments.DepositDeadlineWindow).Format(time.RFC3339) b.DepositDeadline = &deadline + + paid := math.Max(0, preStartAmountPaid) + maxProtected := b.TotalAmount * payments.ProtectedDepositMaxPct + if paid < maxProtected { + b.DepositProtectedAmount = paid + } else { + b.DepositProtectedAmount = maxProtected + } } // BookingService represents a service associated with a booking @@ -190,7 +200,7 @@ type EditBookingRequest struct { // 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"` + Status string `json:"status" validate:"required,oneof=pending confirmed in_progress completed client_cancelled we_cancelled no_show"` } // ConfirmBookingRequest represents the request payload for confirming a booking @@ -217,7 +227,7 @@ type UpdateBookingServicesRequest struct { // 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"` + Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled"` ForgiveNoShow *bool `json:"forgive_no_show,omitempty"` // Admin-only: forgive a no-show at cancellation time } @@ -276,6 +286,7 @@ type GetAllBookingsRequest struct { EndDate *string `json:"end_date,omitempty"` Page int `json:"page"` PerPage int `json:"per_page"` + Cursor *string `json:"cursor,omitempty"` } // BookingListResponse represents a paginated list of bookings @@ -285,6 +296,7 @@ type BookingListResponse struct { Page int `json:"page"` PerPage int `json:"perPage"` TotalPages int `json:"totalPages"` + NextCursor *string `json:"next_cursor,omitempty"` } // SearchBookingsRequest represents search parameters @@ -341,6 +353,10 @@ func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest { if endDate := r.URL.Query().Get("end_date"); endDate != "" { req.EndDate = &endDate } + // Accept both cursor (preferred) and page (deprecated) for backward compat + if cursorStr := r.URL.Query().Get("cursor"); cursorStr != "" { + req.Cursor = &cursorStr + } if pageStr := r.URL.Query().Get("page"); pageStr != "" { if page, err := strconv.Atoi(pageStr); err == nil && page > 0 { req.Page = page @@ -364,9 +380,50 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { 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 := ` + // Build WHERE conditions shared between count and data queries. + whereClause := " WHERE b.user_id = $1" + whereArgs := []interface{}{userID} + paramCount := 2 + + if req.Status != nil { + whereClause += fmt.Sprintf(" AND b.status = $%d", paramCount) + whereArgs = append(whereArgs, *req.Status) + paramCount++ + } + if req.StartDate != nil { + whereClause += fmt.Sprintf(" AND b.start_time >= $%d", paramCount) + startTime, err := time.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) + whereArgs = append(whereArgs, startTime) + paramCount++ + } + if req.EndDate != nil { + whereClause += fmt.Sprintf(" AND b.start_time <= $%d", paramCount) + endTime, err := time.Parse("2006-01-02", *req.EndDate) + if err != nil { + http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest) + return + } + endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second) + whereArgs = append(whereArgs, endTime) + paramCount++ + } + + // Count query uses the same WHERE but without complex SELECT subqueries, + // cursor, ORDER BY, or LIMIT — just a fast index scan on bookings. + var total int + if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+whereClause, whereArgs...).Scan(&total); err != nil { + log.Printf("Failed to count bookings for user %s: %v", userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Data query with full SELECT but no COUNT(*) OVER() window function. + dataQuery := ` SELECT b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, (SELECT COALESCE(SUM(price_val), 0) FROM ( @@ -390,64 +447,31 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { (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 - ` + FROM bookings b` + whereClause - countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1` - var args, countArgs []interface{} - args = append(args, userID) - countArgs = append(countArgs, userID) - paramCount := 2 + dataArgs := make([]interface{}, len(whereArgs)) + copy(dataArgs, whereArgs) + dataParamCount := paramCount - 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) + // Cursor-based pagination: (created_at, id) + if req.Cursor != nil && *req.Cursor != "" { + cursorCreatedAt, cursorID, err := validators.ParseCursor(*req.Cursor) if err != nil { - http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest) + http.Error(w, "Invalid cursor", 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++ + dataQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", dataParamCount, dataParamCount+1) + dataArgs = append(dataArgs, cursorCreatedAt, cursorID) + dataParamCount += 2 } - baseQuery += " ORDER BY b.start_time ASC" + dataQuery += " ORDER BY b.created_at DESC, b.id 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) + dataQuery += fmt.Sprintf(" LIMIT $%d", dataParamCount) + dataArgs = append(dataArgs, req.PerPage+1) } - 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...) + rows, err := db.DB.Query(r.Context(), dataQuery, dataArgs...) if err != nil { log.Printf("Failed to fetch bookings for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -567,12 +591,32 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { } } + // nextCursor is set only when we fetched perPage+1 items, proving a next page exists. + // The extra item is discarded; the cursor points to the last real item. + var nextCursor *string + if len(bookings) > req.PerPage { + bookings = bookings[:req.PerPage] + last := bookings[len(bookings)-1] + cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID + nextCursor = &cursor + } + + totalPages := (total + req.PerPage - 1) / req.PerPage + if req.PerPage <= 0 { + totalPages = 1 + } + if totalPages == 0 { + totalPages = 1 + } + w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(BookingListResponse{ - Bookings: bookings, - Page: req.Page, - PerPage: req.PerPage, - Total: total, + Bookings: bookings, + Page: req.Page, + PerPage: req.PerPage, + Total: total, + TotalPages: totalPages, + NextCursor: nextCursor, }); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -583,7 +627,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) { func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { req := parseGetAllBookingsRequest(r) - baseQuery := ` + baseQuery := ` WITH booking_totals AS ( SELECT booking_id, @@ -629,25 +673,20 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { (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 + 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 { + if paramCount == 1 { baseQuery += " WHERE " + condition - countQuery += " WHERE " + condition - whereAdded = true + } else { + baseQuery += " AND " + condition } } @@ -677,27 +716,26 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { paramCount++ } - baseQuery += " ORDER BY service_count DESC, b.start_time DESC" + // Cursor-based pagination: (created_at, id) + if req.Cursor != nil && *req.Cursor != "" { + cursorCreatedAt, cursorID, err := validators.ParseCursor(*req.Cursor) + if err != nil { + http.Error(w, "Invalid cursor", http.StatusBadRequest) + return + } + if paramCount == 1 { + baseQuery += " WHERE (b.created_at, b.id) < ($1, $2)" + } else { + baseQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", paramCount, paramCount+1) + } + args = append(args, cursorCreatedAt, cursorID) + paramCount += 2 + } + + baseQuery += " ORDER BY b.created_at DESC, b.id 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 + baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount) + args = append(args, req.PerPage+1) } rows, err := db.DB.Query(r.Context(), baseQuery, args...) @@ -708,6 +746,41 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { } defer rows.Close() + // Count query: simple SELECT COUNT(*) with same WHERE (no CTEs, joins, or subqueries). + var total int + { + countWhere := "" + var countArgs []interface{} + cp := 1 + addCountWhere := func(cond string) { + if cp == 1 { + countWhere = " WHERE " + cond + } else { + countWhere += " AND " + cond + } + cp++ + } + if req.Status != nil { + addCountWhere(fmt.Sprintf("b.status = $%d", cp)) + countArgs = append(countArgs, *req.Status) + } + if req.StartDate != nil { + addCountWhere(fmt.Sprintf("b.start_time >= $%d", cp)) + st, _ := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation) + countArgs = append(countArgs, time.Date(st.Year(), st.Month(), st.Day(), 0, 0, 0, 0, londonLocation)) + } + if req.EndDate != nil { + addCountWhere(fmt.Sprintf("b.start_time <= $%d", cp)) + et, _ := time.Parse("2006-01-02", *req.EndDate) + countArgs = append(countArgs, et.Add(23*time.Hour+59*time.Minute+59*time.Second)) + } + if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+countWhere, countArgs...).Scan(&total); err != nil { + log.Printf("Failed to count admin bookings: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + var bookings []Booking var bookingIDs []string @@ -776,6 +849,21 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { } } + // nextCursor is set only when we fetched perPage+1 items, proving a next page exists. + // The extra item is discarded; the cursor points to the last real item. + var nextCursor *string + if len(bookings) > req.PerPage { + bookings = bookings[:req.PerPage] + last := bookings[len(bookings)-1] + cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID + nextCursor = &cursor + } + + totalPages := (total + req.PerPage - 1) / req.PerPage + if totalPages == 0 { + totalPages = 1 + } + w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(BookingListResponse{ Bookings: bookings, @@ -783,6 +871,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { PerPage: req.PerPage, Total: total, TotalPages: totalPages, + NextCursor: nextCursor, }); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -798,33 +887,49 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { } 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 - } - } + perPage := 5 + cursorStr := query.Get("cursor") if perPageStr := query.Get("per_page"); perPageStr != "" { if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { perPage = pp } } + baseQuery := ` + SELECT b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, + b.deposit_required + FROM bookings b + WHERE b.user_id = $1 + ` + var args []interface{} + args = append(args, userID) + paramCount := 2 + + // Cursor-based pagination: (created_at, id) + if cursorStr != "" { + cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) + if err != nil { + http.Error(w, "Invalid cursor", http.StatusBadRequest) + return + } + baseQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", paramCount, paramCount+1) + args = append(args, cursorCreatedAt, cursorID) + paramCount += 2 + } + + baseQuery += " ORDER BY b.created_at DESC, b.id DESC" + baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount) + args = append(args, perPage+1) + + // Count query: simple SELECT COUNT(*) (no cursor, since that filters rows). var total int - if err := db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1`, userID).Scan(&total); err != nil { + if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b WHERE b.user_id = $1", userID).Scan(&total); err != nil { log.Printf("Failed to count bookings for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - rows, err := db.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) + 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) @@ -833,6 +938,8 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { defer rows.Close() var bookings []Booking + var bookingIDs []string + for rows.Next() { var b Booking var depositRequired bool @@ -845,66 +952,116 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } + b.DepositRequired = depositRequired + bookings = append(bookings, b) + bookingIDs = append(bookingIDs, b.ID) + } + if len(bookingIDs) > 0 { serviceRows, err := db.DB.Query(r.Context(), ` SELECT + bs.booking_id, s.name, COALESCE(bs.override_price, s.price) AS price, COALESCE(bs.override_duration_minutes, s.duration_minutes) AS duration_minutes FROM booking_services bs LEFT JOIN services s ON bs.service_id = s.id - WHERE bs.booking_id = $1 + WHERE bs.booking_id = ANY($1) UNION ALL SELECT + bcs.booking_id, cs.name, COALESCE(bcs.override_price, cs.price) AS price, COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS duration_minutes FROM booking_custom_services bcs LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id - WHERE bcs.booking_id = $1 - `, b.ID) + WHERE bcs.booking_id = ANY($1) + `, bookingIDs) if err != nil { - log.Printf("Failed to fetch services for booking %s: %v", b.ID, err) - continue + log.Printf("Failed to fetch services: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return } - var totalAmount float64 - for serviceRows.Next() { + servicesByBooking := make(map[string][]BookingService) + serviceTotalByBooking := make(map[string]float64) + for serviceRows.Next() { + var bookingID string var svc BookingService var price float64 var dur int - if err := serviceRows.Scan(&svc.ServiceName, &price, &dur); err != nil { + if err := serviceRows.Scan(&bookingID, &svc.ServiceName, &price, &dur); err != nil { log.Printf("Failed to scan service: %v", err) continue } svc.Price = &price svc.DurationMinutes = &dur - totalAmount += price - b.Services = append(b.Services, svc) + servicesByBooking[bookingID] = append(servicesByBooking[bookingID], svc) + serviceTotalByBooking[bookingID] += price } 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) + paymentRows, err := db.DB.Query(r.Context(), ` + SELECT p.booking_id, + COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed'), 0) AS amount_paid, + COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.created_at < b.start_time), 0) AS pre_start_paid + FROM payments p + JOIN bookings b ON b.id = p.booking_id + WHERE p.booking_id = ANY($1) + GROUP BY p.booking_id, b.start_time + `, bookingIDs) + if err != nil { + log.Printf("Failed to fetch payments: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } - b.TotalAmount = totalAmount - b.AmountPaid = amountPaid - b.AmountDue = totalAmount - amountPaid - populateDepositFields(&b, depositRequired, preStartAmountPaid) - bookings = append(bookings, b) + paymentsByBooking := make(map[string]struct{ amountPaid, preStartPaid float64 }) + for paymentRows.Next() { + var bookingID string + var amountPaid, preStartPaid float64 + if err := paymentRows.Scan(&bookingID, &amountPaid, &preStartPaid); err != nil { + log.Printf("Failed to scan payment: %v", err) + continue + } + paymentsByBooking[bookingID] = struct{ amountPaid, preStartPaid float64 }{amountPaid, preStartPaid} + } + paymentRows.Close() + + for i := range bookings { + bid := bookings[i].ID + if svcs, ok := servicesByBooking[bid]; ok { + bookings[i].Services = svcs + bookings[i].TotalAmount = serviceTotalByBooking[bid] + } else { + bookings[i].Services = []BookingService{} + } + + if p, ok := paymentsByBooking[bid]; ok { + bookings[i].AmountPaid = p.amountPaid + bookings[i].AmountDue = bookings[i].TotalAmount - p.amountPaid + populateDepositFields(&bookings[i], bookings[i].DepositRequired, p.preStartPaid) + } else { + bookings[i].AmountDue = bookings[i].TotalAmount + populateDepositFields(&bookings[i], bookings[i].DepositRequired, 0) + } + } } if bookings == nil { bookings = []Booking{} } + // nextCursor is set only when we fetched perPage+1 items, proving a next page exists. + // The extra item is discarded; the cursor points to the last real item. + var nextCursor *string + if len(bookings) > perPage { + bookings = bookings[:perPage] + last := bookings[len(bookings)-1] + cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID + nextCursor = &cursor + } + totalPages := (total + perPage - 1) / perPage if totalPages == 0 { totalPages = 1 @@ -915,9 +1072,9 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) { if err := json.NewEncoder(w).Encode(BookingListResponse{ Bookings: bookings, Total: total, - Page: page, PerPage: perPage, TotalPages: totalPages, + NextCursor: nextCursor, }); err != nil { log.Printf("Failed to encode bookings response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1123,7 +1280,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) { for _, sid := range req.ServiceIDs { if !validators.IsValidID(sid) { - http.Error(w, fmt.Sprintf("Invalid service ID: %s", sid), http.StatusBadRequest) + http.Error(w, "Invalid service ID", http.StatusBadRequest) return } } @@ -1167,20 +1324,49 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) { overrideMap[req.ServiceOverrides[i].ServiceID] = &req.ServiceOverrides[i] } + // Batch load service durations + serviceDurationMap := make(map[string]int) + var serviceIDsNeedingLookup []string + for _, serviceID := range req.ServiceIDs { + if ov, exists := overrideMap[serviceID]; !(exists && ov.OverrideDurationMinutes != nil) { + serviceIDsNeedingLookup = append(serviceIDsNeedingLookup, serviceID) + } + } + if len(serviceIDsNeedingLookup) > 0 { + rows, err := db.DB.Query(r.Context(), `SELECT id, duration_minutes FROM services WHERE id = ANY($1)`, serviceIDsNeedingLookup) + if err != nil { + log.Printf("Failed to batch fetch service durations: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + for rows.Next() { + var svcID string + var dur int + if err := rows.Scan(&svcID, &dur); err != nil { + log.Printf("Failed to scan service duration row: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + serviceDurationMap[svcID] = dur + } + if err := rows.Err(); err != nil { + log.Printf("Failed to iterate service duration rows: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + var newTotalDuration int for _, serviceID := range req.ServiceIDs { var durationMinutes int if ov, exists := overrideMap[serviceID]; exists && ov.OverrideDurationMinutes != nil { durationMinutes = *ov.OverrideDurationMinutes } else { - 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) + var ok bool + durationMinutes, ok = serviceDurationMap[serviceID] + if !ok { + http.Error(w, "Service not found", http.StatusBadRequest) return } } @@ -1448,12 +1634,8 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { 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 - } - } + perPage := 10 + cursorStr := r.URL.Query().Get("cursor") if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" { if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { perPage = pp @@ -1509,10 +1691,13 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { (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 + 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 + -- NOTE: ILIKE with leading wildcard prevents B-tree index usage. + -- At scale, replace with pg_trgm GIN index: CREATE INDEX idx_bookings_search_trgm ON bookings USING GIN (id gin_trgm_ops, notes gin_trgm_ops); + -- Also consider indexes on users (n_first_name, n_last_name, fn, email, phone) for the joined table. WHERE b.id ILIKE $1 ESCAPE '\' OR b.notes ILIKE $1 ESCAPE '\' OR @@ -1532,44 +1717,28 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\' ) - ORDER BY b.start_time DESC - LIMIT $2 OFFSET $3 ` + var args []interface{} + args = append(args, searchPattern) + paramCount := 2 - 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 - LEFT JOIN booking_custom_services bcs ON b.id = bcs.booking_id - LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id - WHERE - b.id ILIKE $1 ESCAPE '\' OR - b.notes ILIKE $1 ESCAPE '\' OR - b.status::text ILIKE $1 ESCAPE '\' OR - u.n_first_name ILIKE $1 ESCAPE '\' OR - u.n_last_name ILIKE $1 ESCAPE '\' OR - u.fn ILIKE $1 ESCAPE '\' OR - u.email ILIKE $1 ESCAPE '\' OR - u.phone ILIKE $1 ESCAPE '\' OR - s.name ILIKE $1 ESCAPE '\' OR - cs.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 + // Cursor-based pagination: (created_at, id) + if cursorStr != "" { + cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) + if err != nil { + http.Error(w, "Invalid cursor", http.StatusBadRequest) + return + } + searchQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", paramCount, paramCount+1) + args = append(args, cursorCreatedAt, cursorID) + paramCount += 2 } - totalPages := (total + perPage - 1) / perPage - if totalPages == 0 { - totalPages = 1 - } + searchQuery += " ORDER BY b.created_at DESC, b.id DESC" + searchQuery += fmt.Sprintf(" LIMIT $%d", paramCount) + args = append(args, perPage+1) - rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, (page-1)*perPage) + rows, err := db.DB.Query(r.Context(), searchQuery, args...) if err != nil { log.Printf("Failed to search bookings: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1577,6 +1746,23 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { } defer rows.Close() + // Count query: SELECT COUNT(*) with the same search WHERE (no CTEs/joins/scoring). + var total int + { + countSQL := `SELECT COUNT(*) FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE + b.id ILIKE $1 ESCAPE '\' OR b.notes ILIKE $1 ESCAPE '\' OR + b.status::text ILIKE $1 ESCAPE '\' OR u.n_first_name ILIKE $1 ESCAPE '\' OR + u.n_last_name ILIKE $1 ESCAPE '\' OR u.fn ILIKE $1 ESCAPE '\' OR + u.email ILIKE $1 ESCAPE '\' OR u.phone ILIKE $1 ESCAPE '\' OR + EXISTS (SELECT 1 FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\') OR + EXISTS (SELECT 1 FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\')` + if err := db.DB.QueryRow(r.Context(), countSQL, searchPattern).Scan(&total); err != nil { + log.Printf("Failed to count search bookings: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + var bookings []Booking var bookingIDs []string @@ -1648,13 +1834,28 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { } } + // nextCursor is set only when we fetched perPage+1 items, proving a next page exists. + // The extra item is discarded; the cursor points to the last real item. + var nextCursor *string + if len(bookings) > perPage { + bookings = bookings[:perPage] + last := bookings[len(bookings)-1] + cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID + nextCursor = &cursor + } + + totalPages := (total + perPage - 1) / perPage + if totalPages == 0 { + totalPages = 1 + } + w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(BookingListResponse{ Bookings: bookings, - Page: page, PerPage: perPage, Total: total, TotalPages: totalPages, + NextCursor: nextCursor, }); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1675,61 +1876,54 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { // 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) + 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.idempotency_key = $1 + `, idempotencyKey).Scan( + &existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status, + &existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy, + &existingBooking.DepositRequired, + ) if err == nil { - // Booking already exists with this key — fetch 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, - ) + // Booking already exists with this key — fetch services and return it + 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 + UNION ALL + SELECT bcs.booking_id, bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes, + cs.name, cs.description, cs.price, cs.duration_minutes + FROM booking_custom_services bcs + JOIN custom_services cs ON bcs.custom_service_id = cs.id + WHERE bcs.booking_id = $1 + `, existingBooking.ID) if err == nil { - // 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 - UNION ALL - SELECT bcs.booking_id, bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes, - cs.name, cs.description, cs.price, cs.duration_minutes - FROM booking_custom_services bcs - JOIN custom_services cs ON bcs.custom_service_id = cs.id - WHERE bcs.booking_id = $1 - `, 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) + 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 } + + // Get payment info + var preStartPaid float64 + 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'`, existingBooking.ID).Scan(&preStartPaid) + populateDepositFields(&existingBooking, 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 } @@ -1801,9 +1995,8 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { 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) + if !isGuest && depositsRequired > 0 && req.StartTime.Before(time.Now().Add(payments.DepositAdvanceWindow)) { + http.Error(w, fmt.Sprintf("When deposits are required, bookings must be made at least %.0f hours in advance to allow time for payment.", payments.DepositAdvanceWindow.Hours()), http.StatusBadRequest) return } @@ -1812,43 +2005,89 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { // 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 !isGuest && len(req.ServiceIDs) > 0 { + patchTestRows, err := db.DB.Query(r.Context(), ` + SELECT id, service_ids, notice_duration_hours, expiry_months + FROM patch_tests + WHERE service_ids::text[] && $1::text[] + `, req.ServiceIDs) + if err != nil { + log.Printf("Failed to query patch tests: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } - if err == nil { + type ptInfo struct { + id string + noticeHours int + expiryMonths int + } + patchTestsByService := make(map[string]ptInfo) + var allPtIDs []string + for patchTestRows.Next() { + var id string + var serviceIDs []string + var noticeHours, expiryMonths int + if err := patchTestRows.Scan(&id, &serviceIDs, ¬iceHours, &expiryMonths); err != nil { + log.Printf("Failed to scan patch test: %v", err) + continue + } + allPtIDs = append(allPtIDs, id) + for _, sid := range serviceIDs { + patchTestsByService[sid] = ptInfo{id, noticeHours, expiryMonths} + } + } + patchTestRows.Close() + + userPatchTests := make(map[string]time.Time) + if len(allPtIDs) > 0 { + uptRows, err := db.DB.Query(r.Context(), ` + SELECT patch_test_id, tested_at + FROM user_patch_tests + WHERE user_id = $1 AND patch_test_id = ANY($2) + `, userID, allPtIDs) + if err != nil { + log.Printf("Failed to query user patch tests: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + for uptRows.Next() { + var ptID string var testedAt time.Time - 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 + if err := uptRows.Scan(&ptID, &testedAt); err != nil { + log.Printf("Failed to scan user patch test: %v", err) + continue } + userPatchTests[ptID] = testedAt + } + uptRows.Close() + } - eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour) - if req.StartTime.Before(eligibleFrom) { - hoursLeft := eligibleFrom.Sub(req.StartTime).Hours() - http.Error(w, fmt.Sprintf("Booking time is before the %.0f hour notice period after patch test. Earliest booking: %s", hoursLeft, eligibleFrom.Format("2006-01-02 15:04")), http.StatusBadRequest) + for _, serviceID := range req.ServiceIDs { + pt, needsPatch := patchTestsByService[serviceID] + if !needsPatch { + continue + } + + testedAt, hasTest := userPatchTests[pt.id] + if !hasTest { + http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest) + return + } + + eligibleFrom := testedAt.Add(time.Duration(pt.noticeHours) * time.Hour) + if req.StartTime.Before(eligibleFrom) { + hoursLeft := eligibleFrom.Sub(req.StartTime).Hours() + http.Error(w, fmt.Sprintf("Booking time is before the %.0f hour notice period after patch test. Earliest booking: %s", hoursLeft, eligibleFrom.Format("2006-01-02 15:04")), http.StatusBadRequest) + return + } + + if pt.expiryMonths > 0 { + expiresAt := testedAt.AddDate(0, pt.expiryMonths, 0) + if req.StartTime.After(expiresAt) { + http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest) return } - - var expiryMonths int - if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil { - expiresAt := testedAt.AddDate(0, expiryMonths, 0) - if req.StartTime.After(expiresAt) { - http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest) - return - } - } } } } @@ -1885,9 +2124,24 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { 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()) + + // Evict any pending_release bookings that overlap this slot. + if _, err := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, endTime); err != nil { + log.Printf("Failed to evict pending_release bookings for slot %s: %v", req.StartTime, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + var cnt int - db.DB.QueryRow(r.Context(), ` - SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed') + if err := tx.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * ( SELECT COALESCE(SUM(dur), 60) FROM ( @@ -1898,18 +2152,22 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id ) sub )) > $1 - `, req.StartTime, endTime).Scan(&cnt) + `, req.StartTime, endTime).Scan(&cnt); err != nil { + log.Printf("Failed to check slot overlap: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } if cnt > 0 { http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict) return } - // Check for time blocker overlap - blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime) + // Check for time blocker overlap (read-only, uses its own connection, safe to call in-tx) + blockerOverlap, _, 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) + http.Error(w, "Cannot book this time - slot is blocked", http.StatusConflict) return } @@ -1918,22 +2176,18 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { 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(), ` + if _, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND (created_by = $1 OR (description LIKE 'RESERVATION:anon:%' AND start_time = $2)) - `, userID, req.StartTime) + `, userID, req.StartTime); err != nil { + log.Printf("Failed to delete reservation time blocker for user %s: %v", userID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } // Insert booking with snapshotted deposit_required var booking Booking @@ -1952,8 +2206,11 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { 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 { + if len(req.ServiceIDs) > 0 { + if _, err := tx.Exec(r.Context(), ` + INSERT INTO booking_services (booking_id, service_id) + SELECT $1, unnest($2::text[]) + `, booking.ID, req.ServiceIDs); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -2028,7 +2285,6 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { 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 } } @@ -2105,35 +2361,30 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { if err := db.DB.QueryRow(r.Context(), ` SELECT COUNT(*) FROM bookings WHERE id != $1 - AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled') + AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') AND start_time < $3 AND start_time + (INTERVAL '1 minute' * ( SELECT COALESCE(SUM(dur), 60) FROM ( SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur - FROM booking_services bs - JOIN services s ON bs.service_id = s.id - WHERE bs.booking_id = bookings.id + FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id UNION ALL SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) - FROM booking_custom_services bcs - JOIN custom_services cs ON bcs.custom_service_id = cs.id - WHERE bcs.booking_id = bookings.id + FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bookings.id ) sub )) > $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) + http.Error(w, "Cannot edit - this time slot is taken", http.StatusConflict) return } - // Check for time blocker overlap - blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime) + blockerOverlap, _, 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) + http.Error(w, "Cannot edit this time - slot is blocked", http.StatusConflict) return } @@ -2207,19 +2458,35 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Invalid request", http.StatusBadRequest) return } + if err := validators.Validate.Struct(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // M8 + // L5 + 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, + "no_show": true, } if !allowed[req.Status] { http.Error(w, "Invalid status", http.StatusBadRequest) return } + tx, txErr := db.DB.Begin(r.Context()) + if txErr != nil { + log.Printf("Failed to begin transaction for booking progress: %v", txErr) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + var booking Booking booking.User = &UserSummary{} - if err := db.DB.QueryRow(r.Context(), ` + if err := tx.QueryRow(r.Context(), ` UPDATE bookings SET status = $1, updated_at = NOW() WHERE id = $2 @@ -2238,7 +2505,9 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { } if req.Status == "completed" { - rows, err := db.DB.Query(r.Context(), ` + // Collect patch test IDs first so the rows are consumed before INSERT operations. + var patchTestIDs []string + ptRows, err := tx.Query(r.Context(), ` SELECT DISTINCT pt.id FROM patch_tests pt JOIN booking_services bs ON bs.booking_id = $1 @@ -2249,25 +2518,26 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { 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) + for ptRows.Next() { + var ptID string + if err := ptRows.Scan(&ptID); err == nil { + patchTestIDs = append(patchTestIDs, ptID) } } + ptRows.Close() + } + for _, ptID := range patchTestIDs { + if _, err := tx.Exec(r.Context(), ` + INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW() + `, booking.User.ID, ptID); err != nil { + log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, ptID, err) + } } var bookingTotal float64 - if err := db.DB.QueryRow(r.Context(), ` + if err := tx.QueryRow(r.Context(), ` SELECT COALESCE(SUM(price_val), 0) FROM ( SELECT COALESCE(bs.override_price, s.price) AS price_val FROM booking_services bs @@ -2284,39 +2554,51 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { } // Apply existing pending loyalty redemption (earned from previous 10 bookings) - if bookingTotal > 0 { + // Skip if already applied at payment time (or by admin) + var loyaltyAlreadyApplied bool + tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&loyaltyAlreadyApplied) + if bookingTotal > 0 && !loyaltyAlreadyApplied { var redemptionID string - if err := db.DB.QueryRow(r.Context(), ` + if err := tx.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(), ` + if _, err := tx.Exec(r.Context(), ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'loyalty', $3, NULL, NULL, 10.00, $4, $5) - `, bookingID, booking.User.ID, redemptionID, bookingTotal, discountAmount) + `, bookingID, booking.User.ID, redemptionID, bookingTotal, discountAmount); err != nil { + log.Printf("ALERT: failed to insert booking discount: %v", err) + } - _, _ = db.DB.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) + `, bookingID, discountAmount, booking.User.ID); err != nil { + log.Printf("ALERT: failed to insert payment record: %v", err) + } - _, _ = db.DB.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW() WHERE id = $2 - `, bookingID, redemptionID) + `, bookingID, redemptionID); err != nil { + log.Printf("ALERT: failed to update loyalty redemption: %v", err) + } - _, _ = db.DB.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - 10) WHERE id = $1 - `, booking.User.ID) + `, booking.User.ID); err != nil { + log.Printf("ALERT: failed to update loyalty stamps: %v", err) + } } } // Increment stamps (max 1 per day, only for paid bookings) + var newStampCount int if bookingTotal > 0 { - if _, err := db.DB.Exec(r.Context(), ` + if err := tx.QueryRow(r.Context(), ` UPDATE users SET loyalty_stamps = loyalty_stamps + 1 WHERE id = $1 @@ -2327,15 +2609,17 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { 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) + RETURNING loyalty_stamps + `, booking.User.ID, bookingID).Scan(&newStampCount); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + 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(), ` + if newStampCount == 10 { + _, err = tx.Exec(r.Context(), ` INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) VALUES ($1, 10, 'pending', NOW()) `, booking.User.ID) @@ -2344,10 +2628,13 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { } } - if bookingTotal > 0 { + // Skip time-based campaign if already applied at payment time + var timeBasedApplied bool + tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based')`, bookingID).Scan(&timeBasedApplied) + if bookingTotal > 0 && !timeBasedApplied { var campaignID string var campaignPercent float64 - if err := db.DB.QueryRow(r.Context(), ` + if err := tx.QueryRow(r.Context(), ` SELECT id, discount_percent FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'time_based' AND start_date <= NOW() AND end_date >= NOW() @@ -2356,29 +2643,35 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { `).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" { discountAmount := roundTo2(bookingTotal * campaignPercent / 100) - _, _ = db.DB.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6) - `, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount) + `, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil { + log.Printf("ALERT: failed to insert booking discount: %v", err) + } - _, _ = db.DB.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) + `, bookingID, discountAmount, booking.User.ID); err != nil { + log.Printf("ALERT: failed to insert payment record: %v", err) + } - _, _ = db.DB.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, campaignID) + `, campaignID); err != nil { + log.Printf("ALERT: failed to update discount campaign usage: %v", err) + } } } if bookingTotal > 0 { var userBookingCount int - _ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount) + _ = tx.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(), ` + _ = tx.QueryRow(r.Context(), ` SELECT id, discount_percent FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' AND milestone_value = $1 @@ -2387,101 +2680,159 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { if milestoneCampaignID != "" { discountAmount := roundTo2(bookingTotal * milestonePercent / 100) - _, _ = db.DB.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6) - `, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount) - _, _ = db.DB.Exec(r.Context(), ` + `, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil { + log.Printf("ALERT: failed to insert booking discount: %v", err) + } + if _, err := tx.Exec(r.Context(), ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) VALUES ($1, 'partial', 'discount', $2, 'completed', $3) - `, bookingID, discountAmount, booking.User.ID) - _, _ = db.DB.Exec(r.Context(), ` + `, bookingID, discountAmount, booking.User.ID); err != nil { + log.Printf("ALERT: failed to insert payment record: %v", err) + } + if _, err := tx.Exec(r.Context(), ` UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 - `, milestoneCampaignID) + `, milestoneCampaignID); err != nil { + log.Printf("ALERT: failed to insert payment record: %v", err) + } } - 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) + var globalMilestoneApplied bool + tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count')`, bookingID).Scan(&globalMilestoneApplied) + if !globalMilestoneApplied { + var globalCount int + _ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) - 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) + var hasInPersonPayment bool + db.DB.QueryRow(r.Context(), ` + SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment) + + if hasInPersonPayment { + var globalCampaignID string + var globalPercent float64 + _ = tx.QueryRow(r.Context(), ` + SELECT id, discount_percent FROM discount_campaigns + WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' + AND milestone_value <= $1 + AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) + ORDER BY milestone_value DESC LIMIT 1 + `, globalCount).Scan(&globalCampaignID, &globalPercent) + + if globalCampaignID != "" { + discountAmount := roundTo2(bookingTotal * globalPercent / 100) + if _, err := tx.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6) + `, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil { + log.Printf("ALERT: failed to insert booking discount: %v", err) + } + if _, err := tx.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID); err != nil { + log.Printf("ALERT: failed to insert payment record: %v", err) + } + if _, err := tx.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, globalCampaignID); err != nil { + log.Printf("ALERT: failed to insert payment record: %v", err) + } + } + } } var firstVisitDate time.Time - _ = db.DB.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate) + _ = tx.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(), ` + annRows, err := tx.Query(r.Context(), ` SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary' AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary') `, booking.User.ID) if err == nil { - 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 + // Collect anniversary campaigns first to avoid interleaving rows with writes. + type annCampaign struct { + id string + pct float64 + value int + unit string + } + var campaigns []annCampaign + for annRows.Next() { + var c annCampaign + if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil { + campaigns = append(campaigns, c) + } + } + annRows.Close() + + for _, c := range campaigns { + var matches bool + elapsed := time.Since(firstVisitDate) + switch c.unit { + case "months": + months := int(elapsed.Hours() / (30 * 24)) + matches = months >= c.value + case "years": + years := int(elapsed.Hours() / (365.25 * 24)) + matches = years >= c.value + } + if matches { + discountAmount := roundTo2(bookingTotal * c.pct / 100) + if _, err := tx.Exec(r.Context(), ` + INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) + VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6) + `, bookingID, booking.User.ID, c.id, c.pct, bookingTotal, discountAmount); err != nil { + log.Printf("ALERT: failed to insert booking discount: %v", err) } - if 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 + if _, err := tx.Exec(r.Context(), ` + INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) + VALUES ($1, 'partial', 'discount', $2, 'completed', $3) + `, bookingID, discountAmount, booking.User.ID); err != nil { + log.Printf("ALERT: failed to insert payment record: %v", err) } + if _, err := tx.Exec(r.Context(), ` + UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 + `, c.id); err != nil { + log.Printf("ALERT: failed to insert payment record: %v", err) + } + break } } } } } - 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) + var paymentExists bool + if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)`, bookingID).Scan(&paymentExists); err == nil && paymentExists { + var newDepositsRequired int + if err := tx.QueryRow(r.Context(), ` + UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) + WHERE id = $1 + RETURNING deposits_required + `, booking.User.ID).Scan(&newDepositsRequired); err != nil { + log.Printf("ALERT: failed to update deposits_required: %v", err) + } else if newDepositsRequired == 0 { + // After 3 paid bookings, forget no-shows so the counter resets. + if _, err := tx.Exec(r.Context(), ` + INSERT INTO forgiven_no_shows (booking_id) + SELECT id FROM bookings + WHERE user_id = $1 AND status = 'no_show' + AND start_time >= NOW() - INTERVAL '6 months' + AND NOT EXISTS (SELECT 1 FROM forgiven_no_shows WHERE booking_id = bookings.id) + `, booking.User.ID); err != nil { + log.Printf("ALERT: failed to auto-forgive no-shows: %v", err) + } + } } - // 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. + } + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit booking progress: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return } w.Header().Set("Content-Type", "application/json") @@ -2534,13 +2885,12 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { } var bkStart time.Time + var dur int 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(dur), 60) FROM ( SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur @@ -2550,11 +2900,23 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1 ) sub `, bookingID).Scan(&dur) - newEnd := bkStart.Add(time.Duration(dur) * time.Minute) + endTime := bkStart.Add(time.Duration(dur) * time.Minute) + + 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 _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, bkStart, endTime); evictErr != nil { + log.Printf("Failed to evict pending_release bookings on confirm: %v", evictErr) + } var cnt int - db.DB.QueryRow(r.Context(), ` - SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('confirmed','in_progress','completed') + if err := tx.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('pending','confirmed','in_progress','completed') AND start_time < $3 AND start_time + (INTERVAL '1 minute' * ( SELECT COALESCE(SUM(dur), 60) FROM ( @@ -2565,19 +2927,15 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id ) sub )) > $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) + `, bookingID, bkStart, endTime).Scan(&cnt); err != nil { + log.Printf("Failed to check overlap on confirm: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - defer tx.Rollback(r.Context()) + if cnt > 0 { + http.Error(w, "Cannot confirm - time slot overlaps with an existing booking", http.StatusConflict) + return + } var booking Booking booking.User = &UserSummary{} @@ -2703,57 +3061,6 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { } } -// 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") @@ -2768,14 +3075,14 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { return } - var paymentCount int - if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount); err != nil { + var paymentExists bool + if err := db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)", bookingID).Scan(&paymentExists); err != nil { log.Printf("Failed to check booking %s for user %s: %v", bookingID, userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - if paymentCount > 0 { + if paymentExists { var req DeleteBookingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) @@ -2787,23 +3094,17 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { return } allowed := map[string]bool{ - "client_cancelled": true, "we_cancelled": true, "re-schedule": true, "no_show": true, + "client_cancelled": true, "we_cancelled": 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()) - + // Get booking info needed for refund (before any transaction) 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 { + var startTime time.Time + if err := db.DB.QueryRow(r.Context(), "SELECT status, start_time FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus, &startTime); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found or access denied", http.StatusNotFound) return @@ -2813,6 +3114,36 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Process refund FIRST — if it fails, the booking stays active and the user can retry + var refundResult *payments.RefundCalculationResult + paySvc := payments.NewPaymentService() + payInfo, payErr := paySvc.GetBookingPaymentInfo(r.Context(), bookingID) + if payErr == nil && payInfo.TotalPaid > 0 { + var calcErr error + refundResult, calcErr = payments.ProcessCancellationRefund( + r.Context(), bookingID, payInfo.TotalAmount, payInfo.TotalPaid, + startTime, time.Now(), "client_cancelled", nil, + ) + if calcErr != nil { + log.Printf("Refund processing failed for booking %s — cancellation aborted: %v", bookingID, calcErr) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Refund processing failed — cancellation aborted. Please try again or contact support.", + }) + return + } + } + + // Refund succeeded (or no refund needed) — now cancel the booking + 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()) + 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) @@ -2832,8 +3163,6 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { } 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 { @@ -2860,13 +3189,27 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ + // After a no-show is recorded, check if user now has 2+ no-shows in 6 months. + if originalStatus == "confirmed" && startTime.Sub(time.Now()).Hours() < 24 && !(req.ForgiveNoShow != nil && *req.ForgiveNoShow) { + if applied, err := ApplyDepositsIfNeeded(r.Context(), userID); err != nil { + log.Printf("Failed to check deposits after no-show for user %s: %v", userID, err) + } else if applied { + log.Printf("Deposits required set to 3 for user %s due to 2+ no-shows in 6 months", userID) + } + } + + resp := map[string]interface{}{ "message": "Booking cancelled successfully", "id": bookingID, "status": req.Reason, - }) + } + if refundResult != nil && refundResult.RefundableAmount > 0 { + resp["refund_calculation"] = refundResult + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) return } @@ -3192,20 +3535,33 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte(icalContent)) } +func sanitizeICS(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, ";", "\\;") + s = strings.ReplaceAll(s, ":", "\\:") + s = strings.ReplaceAll(s, "\n", "\\n") + s = strings.ReplaceAll(s, "\r", "") + return s +} + func generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string { uid := fmt.Sprintf("booking-%d@crussell.com", time.Now().UnixNano()) dtstamp := time.Now().UTC().Format("20060102T150405Z") dtstart := start.Format("20060102T150405") dtend := end.Format("20060102T150405") + sanitizedServiceList := sanitizeICS(serviceList) + sanitizedStatus := sanitizeICS(status) + sanitizedNotes := sanitizeICS(notes) + summary := "Crussell Appointment" - if serviceList != "" { - summary = "Crussell: " + serviceList + if sanitizedServiceList != "" { + summary = "Crussell: " + sanitizedServiceList } - description := fmt.Sprintf("Status: %s\\nServices: %s\\nPrice: £%.2f", status, serviceList, price) - if notes != "" { - description += "\\nNotes: " + notes + description := fmt.Sprintf("Status: %s\\nServices: %s\\nPrice: £%.2f", sanitizedStatus, sanitizedServiceList, price) + if sanitizedNotes != "" { + description += "\\nNotes: " + sanitizedNotes } return fmt.Sprintf(`BEGIN:VCALENDAR @@ -3222,7 +3578,7 @@ SUMMARY:%s DESCRIPTION:%s STATUS:%s END:VEVENT -END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status) +END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, sanitizedStatus) } // OverlappingBooking represents a booking that overlaps with another @@ -3285,7 +3641,7 @@ func GetOverlappingBookingsByTimeHandler(w http.ResponseWriter, r *http.Request) u.phone FROM bookings b LEFT JOIN users u ON b.user_id = u.id - WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') + WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') AND b.start_time < $2 AND b.start_time + (INTERVAL '1 minute' * ( SELECT COALESCE(SUM(dur_val), 60) FROM ( @@ -3406,7 +3762,7 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) { FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE b.id != $1 - AND b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') + AND b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') AND b.start_time < $3 AND b.start_time + (INTERVAL '1 minute' * ( SELECT COALESCE(SUM(dur_val), 60) FROM ( @@ -3516,7 +3872,7 @@ func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) { u.phone FROM bookings b LEFT JOIN users u ON b.user_id = u.id - WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') + WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') AND b.start_time >= $1 AND b.start_time < $2 GROUP BY b.id, b.start_time, b.status, b.notes, b.created_at, u.id, u.fn, u.email, u.phone @@ -3691,7 +4047,9 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) { } var req struct { - StartTime time.Time `json:"start_time"` + StartTime time.Time `json:"start_time"` + ForgiveFees *bool `json:"forgive_fees,omitempty"` + ForgiveNoShow *bool `json:"forgive_noshow,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) @@ -3712,8 +4070,16 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) { return } + adminID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || adminID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + var currentStatus string - if err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus); err != nil { + var bookingUserID string + var startTime time.Time + if err := db.DB.QueryRow(r.Context(), "SELECT status, user_id, start_time FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, &bookingUserID, &startTime); err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return @@ -3728,6 +4094,31 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // All write operations wrapped in a transaction for atomicity. + tx, txErr := db.DB.Begin(r.Context()) + if txErr != nil { + log.Printf("Failed to begin transaction for reschedule: %v", txErr) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + forgiveNoShow := req.ForgiveNoShow != nil && *req.ForgiveNoShow + if forgiveNoShow && bookingUserID != "" { + if _, err := tx.Exec(r.Context(), ` + INSERT INTO forgiven_no_shows (booking_id, forgiven_by) + VALUES ($1, $2) + ON CONFLICT (booking_id) DO NOTHING + `, bookingID, adminID); err != nil { + log.Printf("Failed to insert forgiven_no_show for rescheduled booking %s: %v", bookingID, err) + } + } + + forgiveFees := req.ForgiveFees != nil && *req.ForgiveFees + if forgiveFees { + log.Printf("[AUDIT] Admin %s rescheduled booking %s with fee forgiveness (start_time: %s)", adminID, bookingID, req.StartTime.Format(time.RFC3339)) + } + var durationMinutes int if err := db.DB.QueryRow(r.Context(), ` SELECT COALESCE(SUM(dur), 60) FROM ( @@ -3747,38 +4138,12 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) { } 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(dur), 60) FROM ( - SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur - FROM booking_services bs2 - JOIN services s2 ON bs2.service_id = s2.id - WHERE bs2.booking_id = bookings.id - UNION ALL - SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) - FROM booking_custom_services bcs - JOIN custom_services cs ON bcs.custom_service_id = cs.id - WHERE bcs.booking_id = bookings.id - ) sub - )) > $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 - } - blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime) + blockerOverlap, _, 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 reschedule to this time - slot is blocked: %s", blockerDesc), http.StatusConflict) + http.Error(w, "Cannot reschedule to this time - slot is blocked", http.StatusConflict) return } @@ -3809,9 +4174,40 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) { return } + if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil { + log.Printf("Failed to evict pending_release bookings on reschedule: %v", evictErr) + } + + var overlapCount int + if err := tx.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM bookings + WHERE id != $1 + AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') + AND start_time < $3 + AND start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(dur), 60) FROM ( + SELECT COALESCE(bs2.override_duration_minutes, s2.duration_minutes) AS dur + FROM booking_services bs2 + JOIN services s2 ON bs2.service_id = s2.id + WHERE bs2.booking_id = bookings.id + UNION ALL + SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) + FROM booking_custom_services bcs + JOIN custom_services cs ON bcs.custom_service_id = cs.id + WHERE bcs.booking_id = bookings.id + ) sub + )) > $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 + } + var booking Booking booking.User = &UserSummary{} - if err := db.DB.QueryRow(r.Context(), ` + if err := tx.QueryRow(r.Context(), ` UPDATE bookings SET start_time = $1, updated_at = NOW() WHERE id = $2 @@ -3829,9 +4225,67 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) { return } + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit reschedule for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(booking); err != nil { log.Printf("Failed to encode booking response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } } + +// EvictedBooking holds the ID and user ID of a booking evicted from +// pending_release to deposit_lapsed. +type EvictedBooking struct { + ID string + UserID string +} + +// EvictPendingReleaseOverlapping evicts any pending_release bookings whose slot +// overlaps with [startTime, endTime). The PAYMENT_IN_FLIGHT guard prevents +// evicting a booking that a user is currently paying for (the 5-minute +// time_blocker window). Returns the list of evicted bookings (id + user_id) +// for any caller that needs to react (e.g. notify the affected user). +func EvictPendingReleaseOverlapping(ctx context.Context, tx pgx.Tx, startTime, endTime time.Time) ([]EvictedBooking, error) { + rows, err := tx.Query(ctx, ` + UPDATE bookings SET status = 'deposit_lapsed', updated_at = NOW() + WHERE status = 'pending_release' + AND start_time < $2 + AND start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(dur), 60) FROM ( + SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur + FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id + UNION ALL + SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) + FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bookings.id + ) sub + )) > $1 + AND NOT EXISTS ( + SELECT 1 FROM time_blockers + WHERE description = 'PAYMENT_IN_FLIGHT:' || bookings.id + AND start_time + (duration_minutes * INTERVAL '1 minute') > NOW() + ) + RETURNING id, user_id + `, startTime, endTime) + if err != nil { + return nil, err + } + defer rows.Close() + + var evicted []EvictedBooking + for rows.Next() { + var e EvictedBooking + if err := rows.Scan(&e.ID, &e.UserID); err != nil { + return nil, err + } + evicted = append(evicted, e) + } + return evicted, rows.Err() +} + +// TODO: notify the evicted user that their slot was released +// (e.g. email/SMS — not yet implemented). diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index 8b98938..fa8a215 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -4,8 +4,9 @@ import ( "context" "crussell/db" "crussell/handlers/notifications" - "crussell/internal/validators" + "crussell/handlers/payments" "crussell/handlers/scheduling" + "crussell/internal/validators" "crussell/mw" "database/sql" "encoding/json" @@ -77,15 +78,21 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) { return } - _, _ = tx.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` DELETE FROM booking_edit_requests WHERE booking_id = $1 - `, bookingID) - _, _ = tx.Exec(r.Context(), ` + `, bookingID); err != nil { + log.Printf("ALERT: failed to delete edit requests: %v", err) + } + if _, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description = $1 - `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)) - _, _ = tx.Exec(r.Context(), ` + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil { + log.Printf("ALERT: failed to delete time_blocker: %v", err) + } + if _, err := tx.Exec(r.Context(), ` DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' - `, bookingID) + `, bookingID); err != nil { + log.Printf("ALERT: failed to delete edit_requested notification: %v", err) + } // Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress) if originalStatus != "pending" { @@ -110,8 +117,11 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// AdminCancelBookingHandler allows an admin to cancel any booking. -// The update uses a status filter and checks RowsAffected for existence. +type AdminCancelBookingRequest struct { + ForgiveFees *bool `json:"forgive_fees,omitempty"` + ForgiveNoShow *bool `json:"forgive_noshow,omitempty"` +} + func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { @@ -119,6 +129,51 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) { return } + var req AdminCancelBookingRequest + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("failed to decode admin cancel request body: %v", err) + } + } + forgiveFees := req.ForgiveFees != nil && *req.ForgiveFees + forgiveNoShow := req.ForgiveNoShow != nil && *req.ForgiveNoShow + + adminID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || adminID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + // Process refund FIRST, before the cancel transaction. If the refund fails, + // the booking stays active and the admin can retry. This mirrors the + // DeleteBookingHandler pattern — the booking status change is independent + // of the refund execution. + var refundResult *payments.RefundCalculationResult + paySvc := payments.NewPaymentService() + payInfo, payErr := paySvc.GetBookingPaymentInfo(r.Context(), bookingID) + if payErr != nil { + log.Printf("Failed to get booking payment info for %s: %v", bookingID, payErr) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + totalAmount := payInfo.TotalAmount + totalPaid := payInfo.TotalPaid + if totalPaid > 0 { + if forgiveFees { + refundResult = &payments.RefundCalculationResult{ + TotalPrePaid: totalPaid, + RefundableAmount: totalPaid, + KeptAmount: 0, + Tier: "admin_full_refund", + } + } else { + calc, err := payments.ProcessCancellationRefund(r.Context(), bookingID, totalAmount, totalPaid, payInfo.StartTime, time.Now(), "admin_cancelled", &adminID) + if err == nil { + refundResult = calc + } + } + } + tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) @@ -127,9 +182,10 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) - // Get current status before updating + // Get current status and user ID before updating var originalStatus string - err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus) + var bookingUserID string + err = tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus, &bookingUserID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not cancellable", http.StatusNotFound) @@ -156,6 +212,16 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) { return } + if forgiveNoShow && bookingUserID != "" { + if _, err := tx.Exec(r.Context(), ` + INSERT INTO forgiven_no_shows (booking_id, forgiven_by) + VALUES ($1, $2) + ON CONFLICT (booking_id) DO NOTHING + `, bookingID, adminID); err != nil { + log.Printf("Failed to insert forgiven_no_show for booking %s: %v", bookingID, err) + } + } + // Acknowledge pending notification if exists if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -176,12 +242,37 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) { } } + // Clean up any pending edit requests for this booking. + if _, err := tx.Exec(r.Context(), ` + DELETE FROM booking_edit_requests WHERE booking_id = $1 + `, bookingID); err != nil { + log.Printf("ALERT: failed to delete edit requests on admin cancel: %v", err) + } + if _, err := tx.Exec(r.Context(), ` + DELETE FROM time_blockers WHERE description = $1 + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil { + log.Printf("ALERT: failed to delete edit request time_blocker on admin cancel: %v", err) + } + if _, err := tx.Exec(r.Context(), ` + DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' + `, bookingID); err != nil { + log.Printf("ALERT: failed to delete edit_requested notification on admin cancel: %v", err) + } + if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit admin cancel: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } + if refundResult != nil && refundResult.RefundableAmount > 0 { + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Booking cancelled", + "refund_calculation": refundResult, + }) + return + } + w.WriteHeader(http.StatusNoContent) } @@ -315,15 +406,19 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) { err = db.DB.QueryRow(r.Context(), ` SELECT COUNT(*) FROM bookings WHERE id != $1 - AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled') + AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') AND start_time < $3 AND start_time + (INTERVAL '1 minute' * ( SELECT COALESCE(SUM(dur), 60) FROM ( SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur - FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = bookings.id UNION ALL SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) - FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bookings.id + FROM booking_custom_services bcs + JOIN custom_services cs ON bcs.custom_service_id = cs.id + WHERE bcs.booking_id = bookings.id ) sub )) > $2 `, bookingID, req.StartTime, newEndTime).Scan(&overlapCount) @@ -382,7 +477,15 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) { } // Perform the update - res, err := db.DB.Exec(r.Context(), ` + 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()) + + res, err := tx.Exec(r.Context(), ` UPDATE bookings SET start_time = $1, updated_at = $2 WHERE id = $3 @@ -399,7 +502,7 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) { } // Clear any pending edit requests for this booking (admin edit takes priority) - _, err = db.DB.Exec(r.Context(), ` + _, err = tx.Exec(r.Context(), ` DELETE FROM booking_edit_requests WHERE booking_id = $1 `, bookingID) @@ -408,6 +511,12 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) { // Don't fail the request, just log the error } + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + // TODO: Notify user that their edit request was superseded by admin direct edit (blocked on E5 SMTP) // Return warnings if any @@ -424,14 +533,14 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) { } type AdminCreateBookingForUserRequest struct { - UserID string `json:"user_id" validate:"required"` - StartTime time.Time `json:"start_time" validate:"required"` - ServiceIDs []string `json:"service_ids,omitempty"` - ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` - CustomServiceIDs []string `json:"custom_service_ids,omitempty"` - CustomOverrides []ServiceOverride `json:"custom_service_overrides,omitempty"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` - EnforceDeposits *bool `json:"enforce_deposits,omitempty"` + UserID string `json:"user_id" validate:"required"` + StartTime time.Time `json:"start_time" validate:"required"` + ServiceIDs []string `json:"service_ids,omitempty"` + ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` + CustomServiceIDs []string `json:"custom_service_ids,omitempty"` + CustomOverrides []ServiceOverride `json:"custom_service_overrides,omitempty"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` + EnforceDeposits *bool `json:"enforce_deposits,omitempty"` } func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { @@ -531,44 +640,85 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { } // Check patch test requirements for all regular services (custom services skip patch tests) - for _, serviceID := range req.ServiceIDs { - // Find patch test for this service - var patchTestID string - var noticeHours int - err := db.DB.QueryRow(r.Context(), ` - SELECT id, notice_duration_hours + if len(req.ServiceIDs) > 0 { + patchTestRows, err := db.DB.Query(r.Context(), ` + SELECT id, service_ids, notice_duration_hours, expiry_months FROM patch_tests - WHERE $1 = ANY(service_ids) - `, serviceID).Scan(&patchTestID, ¬iceHours) + WHERE service_ids::text[] && $1::text[] + `, req.ServiceIDs) + if err != nil { + log.Printf("Failed to query patch tests: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } - if err == nil { - // Service requires a patch test - check if user has valid record - var testedAt time.Time - err = db.DB.QueryRow(r.Context(), ` - SELECT tested_at + type ptInfo struct { + id string + noticeHours int + expiryMonths int + } + patchTestsByService := make(map[string]ptInfo) + var allPtIDs []string + for patchTestRows.Next() { + var id string + var serviceIDs []string + var noticeHours, expiryMonths int + if err := patchTestRows.Scan(&id, &serviceIDs, ¬iceHours, &expiryMonths); err != nil { + log.Printf("Failed to scan patch test: %v", err) + continue + } + allPtIDs = append(allPtIDs, id) + for _, sid := range serviceIDs { + patchTestsByService[sid] = ptInfo{id, noticeHours, expiryMonths} + } + } + patchTestRows.Close() + + userPatchTests := make(map[string]time.Time) + if len(allPtIDs) > 0 { + uptRows, err := db.DB.Query(r.Context(), ` + SELECT patch_test_id, tested_at FROM user_patch_tests - WHERE user_id = $1 AND patch_test_id = $2 - `, req.UserID, patchTestID).Scan(&testedAt) - + WHERE user_id = $1 AND patch_test_id = ANY($2) + `, req.UserID, allPtIDs) if err != nil { - // No valid patch test record + log.Printf("Failed to query user patch tests: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + for uptRows.Next() { + var ptID string + var testedAt time.Time + if err := uptRows.Scan(&ptID, &testedAt); err != nil { + log.Printf("Failed to scan user patch test: %v", err) + continue + } + userPatchTests[ptID] = testedAt + } + uptRows.Close() + } + + for _, serviceID := range req.ServiceIDs { + pt, needsPatch := patchTestsByService[serviceID] + if !needsPatch { + continue + } + + testedAt, hasTest := userPatchTests[pt.id] + if !hasTest { http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest) return } - // Check if notice period has passed - eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour) + eligibleFrom := testedAt.Add(time.Duration(pt.noticeHours) * time.Hour) if req.StartTime.Before(eligibleFrom) { hoursNeeded := time.Until(eligibleFrom).Hours() http.Error(w, fmt.Sprintf("Booking time is before the %.0f hour notice period after patch test. Earliest booking: %s", hoursNeeded, eligibleFrom.Format("2006-01-02 15:04")), http.StatusBadRequest) return } - // Check if patch test has expired - var expiryMonths int - err = db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths) - if err == nil { - expiresAt := testedAt.AddDate(0, expiryMonths, 0) + if pt.expiryMonths > 0 { + expiresAt := testedAt.AddDate(0, pt.expiryMonths, 0) if req.StartTime.After(expiresAt) { http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest) return @@ -687,9 +837,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { ) combined `, allIDs).Scan(&dur) newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute) + 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(dur),60) FROM (SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id UNION ALL SELECT COALESCE(bcs.override_duration_minutes,cs.duration_minutes) FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id) sub)) > $1 + SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(dur),60) FROM (SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id UNION ALL SELECT COALESCE(bcs.override_duration_minutes,cs.duration_minutes) FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id) sub)) > $1 `, req.StartTime, newEnd).Scan(&cnt) if cnt > 0 { http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict) @@ -702,7 +853,6 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Failed to check time blocker overlap: %v", err) } - tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) @@ -711,6 +861,10 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) + if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEnd); evictErr != nil { + log.Printf("Failed to evict pending_release bookings (admin create): %v", evictErr) + } + // Create booking directly as confirmed bookingQuery := ` INSERT INTO bookings ( @@ -754,35 +908,35 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { } // Insert booking services - serviceInsertQuery := ` - INSERT INTO booking_services (booking_id, service_id) - VALUES ($1, $2) - ` - - for _, serviceID := range req.ServiceIDs { - _, err := tx.Exec(r.Context(), serviceInsertQuery, booking.ID, serviceID) + if len(req.ServiceIDs) > 0 { + _, err = tx.Exec(r.Context(), ` + INSERT INTO booking_services (booking_id, service_id) + SELECT $1, unnest($2::text[]) + `, booking.ID, req.ServiceIDs) if err != nil { - log.Printf("Failed to insert booking service %s: %v", serviceID, err) + log.Printf("Failed to insert booking services: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } - customServiceInsertQuery := ` - INSERT INTO booking_custom_services (booking_id, custom_service_id) - VALUES ($1, $2) - ` - - for _, csID := range req.CustomServiceIDs { - _, err := tx.Exec(r.Context(), customServiceInsertQuery, booking.ID, csID) + if len(req.CustomServiceIDs) > 0 { + _, err = tx.Exec(r.Context(), ` + INSERT INTO booking_custom_services (booking_id, custom_service_id) + SELECT $1, unnest($2::text[]) + `, booking.ID, req.CustomServiceIDs) if err != nil { - log.Printf("Failed to insert custom booking service %s: %v", csID, err) + log.Printf("Failed to insert custom booking services: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - _, _ = tx.Exec(r.Context(), ` - UPDATE custom_services SET usage_count = usage_count + 1, last_used_at = NOW() WHERE id = $1 - `, csID) + for _, csID := range req.CustomServiceIDs { + if _, err := tx.Exec(r.Context(), ` + UPDATE custom_services SET usage_count = usage_count + 1, last_used_at = NOW() WHERE id = $1 + `, csID); err != nil { + log.Printf("ALERT: failed to update custom service usage: %v", err) + } + } } // Apply overrides (optional) @@ -910,16 +1064,13 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { } - response := map[string]interface{}{ - "booking": booking, + "booking": booking, "warnings": warnings, - } - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) @@ -962,10 +1113,10 @@ type EditServiceDetail struct { } type EditSnapshot struct { - StartTime *time.Time `json:"start_time"` - EndTime *time.Time `json:"end_time"` + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` Services []EditServiceDetail `json:"services"` - Notes *string `json:"notes" validate:"omitempty,max=1000000"` + Notes *string `json:"notes" validate:"omitempty,max=1000000"` } type EditUserSummary struct { @@ -1220,10 +1371,12 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) { return } - _, _ = tx.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description = $1 - `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)) + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil { + log.Printf("ALERT: failed to delete time_blocker: %v", err) + } // Delete the admin notification for this edit request _, err = tx.Exec(r.Context(), ` @@ -1269,6 +1422,14 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) { return } + if err := validators.Validate.Struct(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // M8 + // L5 + // Validate: at least one of new_start_time, new_services, or notes must be provided if req.NewStartTime == nil && len(req.NewServices) == 0 && req.Notes == nil { http.Error(w, "At least one of new_start_time, new_services, or notes is required", http.StatusBadRequest) @@ -1295,7 +1456,9 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) { // Check booking is not already completed/cancelled var currentStatus string - err = db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus) + var currentStartTime time.Time + var depositRequired bool + err = db.DB.QueryRow(r.Context(), "SELECT status, start_time, deposit_required FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, ¤tStartTime, &depositRequired) if err != nil { log.Printf("Failed to get booking status %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1306,6 +1469,33 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden) return } + + // Query payment and timing info (used for validation AND auto-approval later) + var hasPayments bool + hoursUntilCurrent := currentStartTime.Sub(time.Now()).Hours() + db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND status = 'completed')", bookingID).Scan(&hasPayments) + + // Check if booking has discounts (affects auto-approval decisions) + var hasDiscounts bool + db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)", bookingID).Scan(&hasDiscounts) + + if req.NewStartTime != nil && !req.NewStartTime.Equal(currentStartTime) { + if hasPayments && hoursUntilCurrent < 72 { + http.Error(w, "This booking is too close to the appointment time to reschedule online. Please contact us to discuss options, or cancel and rebook (note: cancellation fees may apply based on our deposit policy).", http.StatusForbidden) + return + } + + if !hasPayments && hoursUntilCurrent < 24 { + http.Error(w, "This booking is too close to the appointment time to reschedule online. Please contact us to discuss options, or cancel and rebook.", http.StatusForbidden) + return + } + + // Warn when within 72h with no payments (24-72h window — close enough + // to reschedule but counts toward no-show history). + if !hasPayments && hoursUntilCurrent < 72 { + w.Header().Set("X-No-Show-Warning", "Rescheduling within 72h counts as a no-show towards your deposit obligations. Two no-shows within 6 months will require deposits on future bookings.") + } + } if len(req.NewServices) > 0 { var overrideCount int err = db.DB.QueryRow(r.Context(), ` @@ -1369,11 +1559,121 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) { return } + // Auto-approve if no payments exist, the booking is >48h away, + // and no discounts+time-change combo (requires admin review) + if !hasPayments && hoursUntilCurrent > 48 && !(hasDiscounts && req.NewStartTime != nil) { + if req.NewStartTime != nil { + // Calculate duration for the new time + var durMinutes int + if len(req.NewServices) > 0 { + _ = tx.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(s.duration_minutes), 60) + FROM services s WHERE s.id = ANY($1) + `, req.NewServices).Scan(&durMinutes) + } else { + _ = tx.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(dur), 60) FROM ( + SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur + FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1 + UNION ALL + SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) + FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1 + ) sub + `, bookingID).Scan(&durMinutes) + } + if durMinutes <= 0 { + durMinutes = 60 + } + + // Quick overlap check — block if slot is taken + newEnd := req.NewStartTime.Add(time.Duration(durMinutes) * time.Minute) + var overlapCount int + tx.QueryRow(r.Context(), ` + SELECT COUNT(*) FROM bookings + WHERE id != $1 + AND status NOT IN ('completed','client_cancelled','we_cancelled','no_show','deposit_lapsed') + AND start_time < $3 + AND start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(dur), 60) FROM ( + SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur + FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id + UNION ALL + SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) + FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bookings.id + ) sub + )) > $2 + `, bookingID, *req.NewStartTime, newEnd).Scan(&overlapCount) + if overlapCount > 0 { + http.Error(w, "The requested time slot has been taken. Please choose a different time.", http.StatusConflict) + return + } + } + + // Update booking start_time, notes, and services directly + if req.NewStartTime != nil || req.Notes != nil { + var setClauses []string + var args []interface{} + argNum := 1 + if req.NewStartTime != nil { + setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum)) + args = append(args, *req.NewStartTime) + argNum++ + } + if req.Notes != nil { + setClauses = append(setClauses, fmt.Sprintf("notes = $%d", argNum)) + args = append(args, *req.Notes) + argNum++ + } + setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum)) + args = append(args, time.Now()) + argNum++ + args = append(args, bookingID) + query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum) + if _, err := tx.Exec(r.Context(), query, args...); err != nil { + log.Printf("Failed to auto-approve booking update %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + + // Update services if requested + if len(req.NewServices) > 0 { + _, _ = tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID) + _, _ = tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID) + for _, sid := range req.NewServices { + if _, err := tx.Exec(r.Context(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", bookingID, sid); err != nil { + log.Printf("Failed to insert auto-approve service %s: %v", sid, err) + } + } + } + + // Clean up the edit request and reservations + _, _ = tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", editReq.ID) + _, _ = tx.Exec(r.Context(), `DELETE FROM time_blockers WHERE description = $1`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)) + _, _ = tx.Exec(r.Context(), `UPDATE admin_notifications SET acknowledged_at = NOW() WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL`, bookingID) + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit auto-approve edit request: %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(map[string]interface{}{ + "auto_approved": true, + "edit_request": editReq, + }) + return + } + if req.NewStartTime != nil { - _, _ = tx.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description = $1 - `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)) + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil { + log.Printf("ALERT: failed to delete time_blocker: %v", err) + } var durationMinutes int if len(req.NewServices) > 0 { @@ -1470,7 +1770,8 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) { // AdminListEditRequestsHandler returns all edit requests func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) { baseQuery := ` - SELECT ber.id, ber.booking_id, ber.requested_by, ber.new_start_time, + SELECT + ber.id, ber.booking_id, ber.requested_by, ber.new_start_time, ber.new_services, ber.notes, ber.has_overrides, ber.updated_at, b.start_time as original_start_time, b.status as booking_status, u.fn as user_name @@ -1479,20 +1780,14 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) { JOIN users u ON ber.requested_by = u.id ` - countQuery := `SELECT COUNT(*) FROM booking_edit_requests ber` - var args []interface{} baseQuery += " ORDER BY ber.updated_at DESC" - // Get total count var total int - err := db.DB.QueryRow(r.Context(), countQuery, args...).Scan(&total) - if err != nil { - log.Printf("Failed to count edit requests: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } + + // Count query (no ORDER BY needed). + db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total) rows, err := db.DB.Query(r.Context(), baseQuery, args...) if err != nil { @@ -1514,12 +1809,12 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) { &req.ID, &req.BookingID, &req.RequestedBy, - &req.NewStartTime, - &newServices, - &req.Notes, - &req.HasOverrides, - &req.UpdatedAt, - &origStartTime, + &req.NewStartTime, + &newServices, + &req.Notes, + &req.HasOverrides, + &req.UpdatedAt, + &origStartTime, &bookingStatus, &userName, ) @@ -1597,6 +1892,13 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) { return } + // Check for applied discounts (admin warning only — discounts remain locked in) + var discountCount int + db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount) + if discountCount > 0 { + log.Printf("ADMIN APPROVE EDIT: Booking %s has %d discount(s) applied — discounts remain locked in after reschedule", bookingID, discountCount) + } + // Calculate duration for overlap check - use overrides if has_overrides is true var durationMinutes int if hasOverrides { @@ -1642,23 +1944,19 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) { newEndTime := newStartTime.Add(time.Duration(durationMinutes) * time.Minute) var overlapCount int err = tx.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(dur), 60) FROM ( - SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur - FROM booking_services bs - JOIN services s ON bs.service_id = s.id - WHERE bs.booking_id = bookings.id - UNION ALL - SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) - FROM booking_custom_services bcs - JOIN custom_services cs ON bcs.custom_service_id = cs.id - WHERE bcs.booking_id = bookings.id - ) sub - )) > $2 + SELECT COUNT(*) FROM bookings + WHERE id != $1 + AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed') + AND start_time < $3 + AND start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(dur), 60) FROM ( + SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur + FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id + UNION ALL + SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) + FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bookings.id + ) sub + )) > $2 `, bookingID, *newStartTime, newEndTime).Scan(&overlapCount) if err != nil { log.Printf("Failed to check overlap: %v", err) @@ -1668,12 +1966,12 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) { return } - blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime) + blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime) if err != nil { log.Printf("Failed to check time blocker overlap: %v", err) } if blockerOverlap { - http.Error(w, fmt.Sprintf("This edit would overlap with a time blocker: %s", blockerDesc), http.StatusConflict) + http.Error(w, "This edit would overlap with a time blocker", http.StatusConflict) return } @@ -1710,8 +2008,8 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) { // Build update query for bookings table if newStartTime != nil || notes != nil { - // setClauses contains only hardcoded column name assignments ("start_time = $N", "notes = $N"). - // Column names are never derived from user input. User values are in args and always parameterised. + // setClauses contains only hardcoded column name assignments ("start_time = $N", "notes = $N"). + // Column names are never derived from user input. User values are in args and always parameterised. var setClauses []string var args []interface{} argNum := 1 @@ -1779,10 +2077,12 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) { return } - _, _ = tx.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description = $1 - `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)) + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil { + log.Printf("ALERT: failed to delete time_blocker: %v", err) + } // Acknowledge the admin notification for this edit request _, err = tx.Exec(r.Context(), ` @@ -1850,10 +2150,12 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) { return } - _, _ = tx.Exec(r.Context(), ` + if _, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description = $1 - `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)) + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil { + log.Printf("ALERT: failed to delete time_blocker: %v", err) + } // Acknowledge the admin notification for this edit request _, err = tx.Exec(r.Context(), ` @@ -1928,7 +2230,11 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) { ) if err != nil { if errors.Is(err, sql.ErrNoRows) { - http.Error(w, "No edit request pending for this booking", http.StatusNotFound) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "edit_request": nil, + }) return } log.Printf("Failed to get edit request for booking %s: %v", bookingID, err) @@ -2150,5 +2456,3 @@ func ApplyDepositsIfNeeded(ctx context.Context, userID string) (bool, error) { } return false, nil } - -