package bookings import ( "context" "crussell/db" "crussell/clock" "github.com/jackc/pgx/v5" "crussell/handlers/notifications" "crussell/handlers/payments" "crussell/handlers/scheduling" "crussell/internal/validators" "crussell/mw" "database/sql" "encoding/json" "errors" "fmt" "log" "net/http" "strings" "time" "github.com/go-chi/chi/v5" ) // UserCancelBookingHandler allows an authenticated user to cancel a booking they own. // The update is performed in a transaction with notification handling. func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) // Get current status before updating var originalStatus string err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Booking not cancellable", http.StatusNotFound) return } log.Printf("Failed to get booking status %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } res, err := tx.Exec(r.Context(), ` UPDATE bookings SET status = 'client_cancelled', updated_at = $1 WHERE id = $2 AND user_id = $3 AND status IN ('pending', 'confirmed', 'in_progress') `, clock.Now(), bookingID, userID) if err != nil { log.Printf("Failed to cancel booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } rowsAffected := res.RowsAffected() if rowsAffected == 0 { http.Error(w, "Booking not cancellable", http.StatusNotFound) return } // Acknowledge pending notification if exists if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } 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: %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 time_blocker: %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: %v", err) } // Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress) if originalStatus != "pending" { notificationQuery := ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) ` _, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID) if err != nil { log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit user cancel: %v, %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } 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) { http.Error(w, "Booking not found", http.StatusNotFound) 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 } // Fetch payment info before the transaction (read-only, no side effects). var refundResult *payments.RefundCalculationResult var refundFailed bool 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 calculatedRefund := totalPaid > 0 && !forgiveFees tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) // Get current status and user ID — use FOR UPDATE to lock the row so // the refund and status change are atomic. var originalStatus string var bookingUserID string if err := tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1 FOR UPDATE", bookingID).Scan(&originalStatus, &bookingUserID); err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Booking not cancellable", http.StatusNotFound) return } log.Printf("Failed to get booking status %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } res, err := tx.Exec(r.Context(), ` UPDATE bookings SET status = 'we_cancelled', updated_at = $1 WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress') `, clock.Now(), bookingID) if err != nil { log.Printf("Failed to admin cancel booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } rowsAffected := res.RowsAffected() if rowsAffected == 0 { http.Error(w, "Booking not cancellable", http.StatusNotFound) return } // Status update succeeded — now process the refund in the SAME transaction // so that a commit failure rolls back both the status change and the refund. if forgiveFees && totalPaid > 0 { refundResult = &payments.RefundCalculationResult{ TotalPrePaid: totalPaid, RefundableAmount: totalPaid, KeptAmount: 0, Tier: "admin_full_refund", } } if calculatedRefund { var calc *payments.RefundCalculationResult calc, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), "admin_cancelled", &adminID) if err == nil { refundResult = calc } else { refundFailed = true log.Printf("ALERT: AdminCancelBookingHandler — ProcessCancellationRefundTx failed for booking %s after status was updated to we_cancelled. Refund was NOT processed. The transaction WILL be committed (cancellation stands, no refund). Error: %v", bookingID, err) } } 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) return } // Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress) if originalStatus != "pending" { notificationQuery := ` INSERT INTO admin_notifications (reason, booking_id, user_id) SELECT 'cancelled_booking', $1, user_id FROM bookings WHERE id = $1 ` _, err = tx.Exec(r.Context(), notificationQuery, bookingID) if err != nil { log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // 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 } // Process pending Square refunds after the transaction commits successfully. // This ensures Square API calls only happen if the DB records persist. if calculatedRefund { payments.ProcessPendingSquareRefunds(r.Context(), bookingID, "admin_cancelled") } if refundFailed || (refundResult != nil && refundResult.RefundableAmount > 0) { resp := map[string]interface{}{ "message": "Booking cancelled", } if refundResult != nil && refundResult.RefundableAmount > 0 { resp["refund_calculation"] = refundResult } if refundFailed { resp["refund_failed"] = true resp["warning"] = "Booking was cancelled but refund processing failed — please process refund manually or retry" } json.NewEncoder(w).Encode(resp) return } w.WriteHeader(http.StatusNoContent) } // AdminListPendingBookingsHandler returns all bookings with status `pending` by delegating to the existing admin list handler. func AdminListPendingBookingsHandler(w http.ResponseWriter, r *http.Request) { r = r.Clone(r.Context()) q := r.URL.Query() q.Set("status", "pending") r.URL.RawQuery = q.Encode() GetAllAdminBookingsHandler(w, r) } // AdminGetInProgressBookingHandler returns the booking that is currently in progress. // It joins the bookings table with users to populate the UserSummary in the returned Booking. func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) { var b Booking var userID, fullName string err := db.Conn.QueryRow(r.Context(), ` SELECT b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, u.id, u.fn FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE b.status = 'in_progress' ORDER BY b.start_time LIMIT 1 `).Scan( &b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &b.CreatedBy, &userID, &fullName, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "No in-progress booking found", http.StatusNotFound) return } log.Printf("Failed to fetch in-progress booking: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Populate the UserSummary field b.User = &UserSummary{ ID: userID, FullName: fullName, FirstName: "", // not available here LastName: "", // not available here } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(b); err != nil { log.Printf("Failed to encode booking response: %v", err) } } 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"` OutOfHours bool `json:"out_of_hours"` } func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { // Admin identity (creator) adminID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || adminID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req AdminCreateBookingForUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Failed to decode request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Extract idempotency key from header idempotencyKey := r.Header.Get("Idempotency-Key") // If idempotency key provided, check for existing booking if idempotencyKey != "" { var existingID string err := db.Conn.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID) if err == nil { // Booking already exists with this key — fetch and return it var existingBooking Booking existingBooking.User = &UserSummary{} err := db.Conn.QueryRow(r.Context(), ` SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required FROM bookings b WHERE b.id = $1 `, existingID).Scan( &existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status, &existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy, &existingBooking.DepositRequired, ) if err == nil { // Fetch services for the response rows, err := db.Conn.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) } } // Get deposit info var depositRequired bool var preStartPaid float64 db.Conn.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired) db.Conn.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 } } } // Basic validation if req.UserID == "" { http.Error(w, "User ID is required", http.StatusBadRequest) return } if req.StartTime.IsZero() { http.Error(w, "Start time is required", http.StatusBadRequest) return } if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 { http.Error(w, "At least one service or custom service is required", http.StatusBadRequest) return } // Check patch test requirements for all regular services (custom services skip patch tests) if len(req.ServiceIDs) > 0 { patchTestRows, err := db.Conn.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 } 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.Conn.Query(r.Context(), ` SELECT patch_test_id, tested_at FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = ANY($2) `, req.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 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 } 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 } 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 } } } } // Enforce deposit checks if requested (default: true if not specified) enforceDeposits := true if req.EnforceDeposits != nil { enforceDeposits = *req.EnforceDeposits } if enforceDeposits { // Read live deposits_required from user var depositsRequired int if err := db.Conn.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, req.UserID).Scan(&depositsRequired); err != nil { log.Printf("Failed to fetch deposits_required for user %s: %v", req.UserID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Check one-active-booking limit when deposits are outstanding if depositsRequired > 0 { var activeCount int if err := db.Conn.QueryRow(r.Context(), ` SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status IN ('pending', 'confirmed') `, req.UserID).Scan(&activeCount); err != nil { log.Printf("Failed to check active bookings for user %s: %v", req.UserID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if activeCount > 0 { http.Error(w, "User already has an active booking. Cannot create another until deposit requirements are cleared.", http.StatusConflict) return } } } // Validate overrides if req.UserID == "" { http.Error(w, "User ID is required", http.StatusBadRequest) return } if req.StartTime.IsZero() { http.Error(w, "Start time is required", http.StatusBadRequest) return } if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 { http.Error(w, "At least one service or custom service is required", http.StatusBadRequest) return } // Validate overrides for _, override := range req.ServiceOverrides { if override.ServiceID == "" { http.Error(w, "Service ID is required for overrides", http.StatusBadRequest) return } if override.OverridePrice != nil && *override.OverridePrice < 0 { http.Error(w, "Override price cannot be negative", http.StatusBadRequest) return } if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 { http.Error(w, "Override duration must be positive", http.StatusBadRequest) return } } // Check if booking time falls within a closed exceptional hours period // Calculate the Monday of the week containing the booking date // DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert. localStart := req.StartTime.In(londonLocation) weekday := int((localStart.Weekday() + 6) % 7) daysToMonday := int(localStart.Weekday()) if daysToMonday == 0 { daysToMonday = 7 // Sunday -> next Monday } tm := localStart.AddDate(0, 0, -daysToMonday+1) // Use UTC midnight so the time.Time has Location=UTC at the London calendar date. // tm has Location=London (from .In(londonLocation) above), so tm.Year/Month/Day() // return London calendar values. Creating a UTC midnight of those values produces // a Location=UTC time at the correct London calendar Monday. pgx's DATE codec // extracts the calendar date from the time's own location — so this maps correctly // to ega.week_start (DATE column), regardless of BST/GMT. weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC) bookingTime := localStart.Format("15:04:05") if !req.OutOfHours { // Check if there's an exceptional hours entry that makes this time unavailable var isClosed bool var checkErr error checkErr = db.Conn.QueryRow(r.Context(), ` SELECT EXISTS ( SELECT 1 FROM exceptional_working_hours ewh JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id WHERE ega.week_start = $1 AND ewh.weekday = $2 AND ewh.is_open = false AND ewh.start_time <= $3 AND ewh.end_time >= $3 ) `, weekStart, weekday, bookingTime).Scan(&isClosed) if checkErr != nil { log.Printf("Failed to check exceptional hours: %v", checkErr) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if isClosed { http.Error(w, "Cannot book during holiday hours when the salon is closed", http.StatusConflict) return } } // Check for overlapping confirmed/in_progress/completed bookings allIDs := append(req.ServiceIDs, req.CustomServiceIDs...) var dur int err := db.Conn.QueryRow(r.Context(), ` SELECT COALESCE(SUM(dur), 0) FROM ( SELECT duration_minutes AS dur FROM services WHERE id = ANY($1) UNION ALL SELECT duration_minutes FROM custom_services WHERE id = ANY($1) ) combined `, allIDs).Scan(&dur) if err != nil { log.Printf("Failed to calculate duration: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute) // Check for time blocker overlap - admin can proceed with warning blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEnd) if err != nil { log.Printf("Failed to check time blocker overlap: %v", err) } tx, err := db.Conn.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 _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEnd); evictErr != nil { log.Printf("Failed to evict pending_release bookings for slot %s: %v", req.StartTime, evictErr) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Check for overlapping bookings (inside transaction) overlapRows, err := tx.Query(r.Context(), ` SELECT 1 FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND end_time > $1 FOR UPDATE `, req.StartTime, newEnd) if err != nil { log.Printf("Failed to check overlap: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } var overlapCount int for overlapRows.Next() { overlapCount++ } overlapRows.Close() if err := overlapRows.Err(); err != nil { log.Printf("Overlap row iteration error: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if overlapCount > 0 { http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict) return } // Create booking directly as confirmed bookingQuery := ` INSERT INTO bookings ( user_id, start_time, status, notes, created_by, idempotency_key, out_of_hours ) VALUES ($1, $2, 'confirmed', $3, $4, $5, $6) RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, out_of_hours ` var booking Booking booking.User = &UserSummary{} err = tx.QueryRow( r.Context(), bookingQuery, req.UserID, req.StartTime, req.Notes, adminID, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}, req.OutOfHours, ).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, &booking.OutOfHours, ) if err != nil { log.Printf("Failed to create admin booking: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Insert booking services 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 services: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } 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 services: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if _, err := tx.Exec(r.Context(), ` UPDATE custom_services SET usage_count = usage_count + 1, last_used_at = NOW() WHERE id = ANY($1) `, req.CustomServiceIDs); err != nil { log.Printf("ALERT: failed to update custom service usage: %v", err) } } // Apply overrides (optional) if len(req.ServiceOverrides) > 0 { // Ensure overrides only reference services in this booking serviceCheckQuery := ` SELECT COUNT(*) FROM booking_services WHERE booking_id = $1 AND service_id = ANY($2) ` overrideServiceIDs := make([]string, len(req.ServiceOverrides)) for i, o := range req.ServiceOverrides { overrideServiceIDs[i] = o.ServiceID } var count int err = tx.QueryRow( r.Context(), serviceCheckQuery, booking.ID, overrideServiceIDs, ).Scan(&count) if err != nil { log.Printf("Failed to verify service overrides: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if count != len(req.ServiceOverrides) { http.Error(w, "One or more service overrides do not belong to this booking", http.StatusBadRequest) return } overrideUpdateQuery := ` UPDATE booking_services SET override_price = $1, override_duration_minutes = $2 WHERE booking_id = $3 AND service_id = $4 ` for _, override := range req.ServiceOverrides { _, err := tx.Exec( r.Context(), overrideUpdateQuery, override.OverridePrice, override.OverrideDurationMinutes, booking.ID, override.ServiceID, ) if err != nil { log.Printf( "Failed to apply override (booking %s, service %s): %v", booking.ID, override.ServiceID, err, ) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } } if len(req.CustomOverrides) > 0 { customOverrideServiceIDs := make([]string, len(req.CustomOverrides)) for i, o := range req.CustomOverrides { customOverrideServiceIDs[i] = o.ServiceID } var customCount int err = tx.QueryRow(r.Context(), ` SELECT COUNT(*) FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = ANY($2) `, booking.ID, customOverrideServiceIDs).Scan(&customCount) if err != nil { log.Printf("Failed to verify custom service overrides: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if customCount != len(req.CustomOverrides) { http.Error(w, "One or more custom service overrides do not belong to this booking", http.StatusBadRequest) return } customOverrideUpdateQuery := ` UPDATE booking_custom_services SET override_price = $1, override_duration_minutes = $2 WHERE booking_id = $3 AND custom_service_id = $4 ` for _, override := range req.CustomOverrides { _, err := tx.Exec( r.Context(), customOverrideUpdateQuery, override.OverridePrice, override.OverrideDurationMinutes, booking.ID, override.ServiceID, ) if err != nil { log.Printf( "Failed to apply custom service override (booking %s, custom service %s): %v", booking.ID, override.ServiceID, err, ) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit admin booking creation: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Build response with warnings if any warnings := []string{} if blockerOverlap { warnings = append(warnings, fmt.Sprintf("Warning: This booking overlaps with a time blocker: %s", blockerDesc)) } response := map[string]interface{}{ "booking": booking, "warnings": warnings, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(response); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } } // ============================================================================= // Booking Edit Request Handlers // ============================================================================= // BookingEditRequest represents a user's request to edit a booking type BookingEditRequest struct { ID string `json:"id"` BookingID string `json:"booking_id"` RequestedBy string `json:"requested_by"` NewStartTime *time.Time `json:"new_start_time,omitempty"` NewServices []string `json:"new_services"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` HasOverrides bool `json:"has_overrides"` UpdatedAt time.Time `json:"updated_at"` // Joined fields Booking *Booking `json:"booking,omitempty"` User *UserSummary `json:"user,omitempty"` } // Enriched response types for edit request detail views type EditServiceDetail struct { ID string `json:"id"` Name string `json:"name"` Price float64 `json:"price"` DurationMinutes int `json:"duration_minutes"` } type EditSnapshot struct { StartTime *time.Time `json:"start_time"` EndTime *time.Time `json:"end_time"` Services []EditServiceDetail `json:"services"` Notes *string `json:"notes" validate:"omitempty,max=1000000"` } type EditUserSummary struct { ID string `json:"id"` FullName string `json:"full_name"` Email *string `json:"email,omitempty"` Phone *string `json:"phone,omitempty"` PreviousFirstName *string `json:"previous_first_name,omitempty"` PreviousLastName *string `json:"previous_last_name,omitempty"` } type EnrichedEditRequest struct { ID string `json:"id"` BookingID string `json:"booking_id"` RequestedBy string `json:"requested_by"` RequestedAt time.Time `json:"requested_at"` Notes *string `json:"notes" validate:"omitempty,max=1000000"` Original *EditSnapshot `json:"original"` Proposed *EditSnapshot `json:"proposed"` User *EditUserSummary `json:"user,omitempty"` } // buildEnrichedEditRequest builds a full enriched response from a pending edit request. // It queries the database for original booking details, services, and user info. func buildEnrichedEditRequest(ctx context.Context, editReq *BookingEditRequest) (*EnrichedEditRequest, error) { var bStartTime time.Time var bNotes *string err := db.Conn.QueryRow(ctx, ` SELECT start_time, notes FROM bookings WHERE id = $1 `, editReq.BookingID).Scan(&bStartTime, &bNotes) if err != nil { return nil, fmt.Errorf("failed to get booking %s: %w", editReq.BookingID, err) } origServices, err := queryBookingServicesWithDetails(ctx, editReq.BookingID) if err != nil { return nil, fmt.Errorf("failed to get booking services for %s: %w", editReq.BookingID, err) } var proposedServices []EditServiceDetail if len(editReq.NewServices) > 0 && !editReq.HasOverrides { proposedServices, err = queryServiceDetailsByIDs(ctx, editReq.NewServices) if err != nil { return nil, fmt.Errorf("failed to get service details: %w", err) } } else { proposedServices = origServices } var proposedStartTime *time.Time if editReq.NewStartTime != nil { proposedStartTime = editReq.NewStartTime } else { proposedStartTime = &bStartTime } var proposedNotes *string if editReq.Notes != nil { proposedNotes = editReq.Notes } else { proposedNotes = bNotes } origDuration := sumServiceDurations(origServices) proposedDuration := sumServiceDurations(proposedServices) origEndTime := bStartTime.Add(time.Duration(origDuration) * time.Minute) var proposedEndTime *time.Time if editReq.NewStartTime != nil { et := editReq.NewStartTime.Add(time.Duration(proposedDuration) * time.Minute) proposedEndTime = &et } else { proposedEndTime = &origEndTime } // Non-fatal: still return the request without user details userSummary, err := queryUserSummary(ctx, editReq.RequestedBy) if err != nil { // Non-fatal: still return the request without user details log.Printf("Failed to get user summary for %s: %v", editReq.RequestedBy, err) } result := &EnrichedEditRequest{ ID: editReq.ID, BookingID: editReq.BookingID, RequestedBy: editReq.RequestedBy, RequestedAt: editReq.UpdatedAt, Notes: editReq.Notes, Original: &EditSnapshot{ StartTime: &bStartTime, EndTime: &origEndTime, Services: origServices, Notes: bNotes, }, Proposed: &EditSnapshot{ StartTime: proposedStartTime, EndTime: proposedEndTime, Services: proposedServices, Notes: proposedNotes, }, User: userSummary, } return result, nil } // queryBookingServicesWithDetails returns service details for a booking, respecting overrides. func queryBookingServicesWithDetails(ctx context.Context, bookingID string) ([]EditServiceDetail, error) { rows, err := db.Conn.Query(ctx, ` SELECT s.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 JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1 UNION ALL SELECT cs.id, cs.name, COALESCE(bcs.override_price, cs.price), 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 ORDER BY name `, bookingID) if err != nil { return nil, err } defer rows.Close() var services []EditServiceDetail for rows.Next() { var svc EditServiceDetail if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil { return nil, err } services = append(services, svc) } if services == nil { services = []EditServiceDetail{} } return services, rows.Err() } // queryServiceDetailsByIDs returns service details for the given service IDs. func queryServiceDetailsByIDs(ctx context.Context, serviceIDs []string) ([]EditServiceDetail, error) { if len(serviceIDs) == 0 { return []EditServiceDetail{}, nil } rows, err := db.Conn.Query(ctx, ` SELECT id, name, price, duration_minutes FROM services WHERE id = ANY($1) ORDER BY name `, serviceIDs) if err != nil { return nil, err } defer rows.Close() var services []EditServiceDetail for rows.Next() { var svc EditServiceDetail if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil { return nil, err } services = append(services, svc) } if services == nil { services = []EditServiceDetail{} } return services, rows.Err() } // sumServiceDurations returns the total duration in minutes from a slice of EditServiceDetail. func sumServiceDurations(services []EditServiceDetail) int { total := 0 for _, s := range services { total += s.DurationMinutes } if total == 0 { return 60 // fallback } return total } // queryUserSummary fetches user details for the enriched edit request response. func queryUserSummary(ctx context.Context, userID string) (*EditUserSummary, error) { var summary EditUserSummary var prevFirstName, prevLastName sql.NullString err := db.Conn.QueryRow(ctx, ` SELECT u.id, u.fn, u.email, u.phone, nh.previous_first_name, nh.previous_last_name FROM users u LEFT JOIN LATERAL ( SELECT previous_first_name, previous_last_name FROM name_history WHERE user_id = u.id ORDER BY changed_at DESC LIMIT 1 ) nh ON true WHERE u.id = $1 `, userID).Scan(&summary.ID, &summary.FullName, &summary.Email, &summary.Phone, &prevFirstName, &prevLastName) if err != nil { return nil, err } if prevFirstName.Valid { summary.PreviousFirstName = &prevFirstName.String } if prevLastName.Valid { summary.PreviousLastName = &prevLastName.String } return &summary, nil } // DeleteEditRequestHandler allows a user to delete/cancel their pending edit request func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } // Verify user owns this booking var ownerID string err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to get booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if ownerID != userID { http.Error(w, "Access denied", http.StatusForbidden) return } // Use transaction to delete edit request and associated admin notification tx, err := db.Conn.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 the edit request for this booking res, err := tx.Exec(r.Context(), ` DELETE FROM booking_edit_requests WHERE booking_id = $1 AND requested_by = $2 `, bookingID, userID) if err != nil { log.Printf("Failed to delete edit request for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } rowsAffected := res.RowsAffected() if rowsAffected == 0 { http.Error(w, "No edit request found", http.StatusNotFound) return } 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 time_blocker: %v", err) } // Delete the admin notification for this edit request _, err = tx.Exec(r.Context(), ` DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' AND user_id = $2 `, bookingID, userID) if err != nil { log.Printf("Failed to delete admin notification for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit delete edit request: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // RequestEditHandler allows a user to request an edit to their booking func RequestEditHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req struct { NewStartTime *time.Time `json:"new_start_time,omitempty"` NewServices []string `json:"new_services"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) 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) return } // Verify user owns this booking var ownerID string err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to get booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if ownerID != userID { http.Error(w, "Access denied", http.StatusForbidden) return } // Check booking is not already completed/cancelled var currentStatus string var currentStartTime time.Time var depositRequired bool err = db.Conn.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) return } if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" { 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(clock.Now()).Hours() db.Conn.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.Conn.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.Conn.QueryRow(r.Context(), ` SELECT COUNT(*) FROM ( SELECT 1 FROM booking_services WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL) UNION ALL SELECT 1 FROM booking_custom_services WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL) ) overrides `, bookingID).Scan(&overrideCount) if err != nil { log.Printf("Failed to check overrides for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if overrideCount > 0 { http.Error(w, "Cannot change services on a booking that has overrides. Please contact the salon.", http.StatusForbidden) return } } tx, err := db.Conn.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 edit request for this booking (upsert behavior) _, err = tx.Exec(r.Context(), ` DELETE FROM booking_edit_requests WHERE booking_id = $1 AND requested_by = $2 `, bookingID, userID) if err != nil { log.Printf("Failed to delete existing edit request for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Create edit request - has_overrides is false since user can't override var editReq BookingEditRequest err = tx.QueryRow(r.Context(), ` INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at `, bookingID, userID, req.NewStartTime, req.NewServices, req.Notes, false).Scan( &editReq.ID, &editReq.BookingID, &editReq.RequestedBy, &editReq.NewStartTime, &editReq.NewServices, &editReq.Notes, &editReq.HasOverrides, &editReq.UpdatedAt, ) if err != nil { log.Printf("Failed to create edit request for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) 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 { if err := tx.QueryRow(r.Context(), ` SELECT COALESCE(SUM(s.duration_minutes), 60) FROM services s WHERE s.id = ANY($1) `, req.NewServices).Scan(&durMinutes); err != nil { log.Printf("Failed to get duration for new services: %v", err) durMinutes = 60 } } else { if err := tx.QueryRow(r.Context(), ` SELECT total_duration_minutes FROM bookings WHERE id = $1 `, bookingID).Scan(&durMinutes); err != nil { log.Printf("Failed to get duration for existing services: %v", err) durMinutes = 60 } } if durMinutes <= 0 { durMinutes = 60 } // Quick overlap check — block if slot is taken newEnd := req.NewStartTime.Add(time.Duration(durMinutes) * time.Minute) // Evict any pending_release bookings that overlap this slot. if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, *req.NewStartTime, newEnd); evictErr != nil { log.Printf("Failed to evict pending_release bookings for slot %s: %v", *req.NewStartTime, evictErr) http.Error(w, "Internal server error", http.StatusInternalServerError) return } 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 end_time > $2 `, bookingID, *req.NewStartTime, newEnd).Scan(&overlapCount); err != nil { log.Printf("Failed to check overlap: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } 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, clock.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 { 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 time_blocker: %v", err) } var durationMinutes int if len(req.NewServices) > 0 { _ = tx.QueryRow(r.Context(), ` SELECT COALESCE(SUM(dur), 60) FROM ( SELECT s.duration_minutes AS dur FROM services s WHERE s.id = ANY($1) UNION ALL SELECT cs.duration_minutes FROM custom_services cs WHERE cs.id = ANY($1) ) sub `, req.NewServices).Scan(&durationMinutes) } else { _ = tx.QueryRow(r.Context(), ` SELECT total_duration_minutes FROM bookings WHERE id = $1 `, bookingID).Scan(&durationMinutes) } _, err = tx.Exec(r.Context(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, $3, $4) `, *req.NewStartTime, durationMinutes, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), userID) if err != nil { log.Printf("Failed to create time_blocker reservation for edit request %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // Always create low-priority notification for edit requests _, err = tx.Exec(r.Context(), ` DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' `, bookingID) if err != nil { log.Printf("Failed to delete old admin notification for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(r.Context(), ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('edit_requested', $1, $2) `, bookingID, userID) if err != nil { log.Printf("Failed to create admin notification for edit request %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // If booking is pending, also create high-priority approval notification if currentStatus == "pending" { _, err = tx.Exec(r.Context(), ` UPDATE admin_notifications SET acknowledged_at = NOW() WHERE booking_id = $1 AND reason = 'pending_booking' AND acknowledged_at IS NULL `, bookingID) if err != nil { log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err) } _, err = tx.Exec(r.Context(), ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2) `, bookingID, userID) if err != nil { log.Printf("Failed to create pending booking notification for %s: %v", bookingID, err) } } if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit edit request: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(editReq) } // 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, 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 FROM booking_edit_requests ber JOIN bookings b ON ber.booking_id = b.id JOIN users u ON ber.requested_by = u.id ` var args []interface{} baseQuery += " ORDER BY ber.updated_at DESC" var total int // Count query (no ORDER BY needed). db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total) rows, err := db.Conn.Query(r.Context(), baseQuery, args...) if err != nil { log.Printf("Failed to fetch edit requests: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() var requests []BookingEditRequest for rows.Next() { var req BookingEditRequest var origStartTime time.Time var bookingStatus string var userName string var newServices []string err := rows.Scan( &req.ID, &req.BookingID, &req.RequestedBy, &req.NewStartTime, &newServices, &req.Notes, &req.HasOverrides, &req.UpdatedAt, &origStartTime, &bookingStatus, &userName, ) if err != nil { log.Printf("Failed to scan edit request: %v", err) continue } req.NewServices = newServices req.Booking = &Booking{ ID: req.BookingID, StartTime: origStartTime, Status: bookingStatus, } req.User = &UserSummary{ ID: req.RequestedBy, FullName: userName, } requests = append(requests, req) } if requests == nil { requests = []BookingEditRequest{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "requests": requests, "total": total, }) } // AdminApproveEditRequestHandler approves an edit request and updates the booking func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) { requestID := chi.URLParam(r, "request_id") if requestID == "" || !validators.IsValidID(requestID) { http.Error(w, "Edit request not found", http.StatusNotFound) return } tx, err := db.Conn.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 the edit request var bookingID string var newStartTime *time.Time var newServices []string var notes *string var hasOverrides bool err = tx.QueryRow(r.Context(), ` SELECT booking_id, new_start_time, new_services, notes, has_overrides FROM booking_edit_requests WHERE id = $1 `, requestID).Scan(&bookingID, &newStartTime, &newServices, ¬es, &hasOverrides) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Edit request not found", http.StatusNotFound) return } log.Printf("Failed to get edit request %s: %v", requestID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // If new_services provided and has_overrides is true, block with error if len(newServices) > 0 && hasOverrides { http.Error(w, "Cannot change services on a booking that has overrides. Please update services manually.", http.StatusForbidden) return } // Check for applied discounts (admin warning only — discounts remain locked in) var discountCount int if err := tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount); err != nil { log.Printf("ADMIN APPROVE EDIT: Failed to check discounts: %v", err) } 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 { // Use the existing booking_services with overrides err = tx.QueryRow(r.Context(), ` SELECT total_duration_minutes FROM bookings WHERE id = $1 `, bookingID).Scan(&durationMinutes) } else { // Use standard durations or new_services if provided if len(newServices) > 0 { // Use new services to calculate duration err = tx.QueryRow(r.Context(), ` SELECT COALESCE(SUM(s.duration_minutes), 60) FROM services s WHERE s.id = ANY($1) `, newServices).Scan(&durationMinutes) } else { // Use existing booking services err = tx.QueryRow(r.Context(), ` SELECT COALESCE(SUM(dur), 60) FROM ( SELECT 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 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(&durationMinutes) } } if err != nil { log.Printf("Failed to calculate duration: %v", err) durationMinutes = 60 // fallback } // Check for overlapping bookings if start time is being changed if newStartTime != nil { newEndTime := newStartTime.Add(time.Duration(durationMinutes) * time.Minute) // Evict any pending_release bookings that overlap this slot. if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, *newStartTime, newEndTime); evictErr != nil { log.Printf("Failed to evict pending_release bookings for slot %s: %v", *newStartTime, evictErr) http.Error(w, "Internal server error", http.StatusInternalServerError) return } var overlapCount int 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 end_time > $2 `, bookingID, *newStartTime, newEndTime).Scan(&overlapCount) if err != nil { log.Printf("Failed to check overlap: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if overlapCount > 0 { http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict) return } // Delete the edit request's reservation BEFORE checking time blockers. // The reservation (RESERVATION:edit_request:*) was created by RequestEditHandler // to temporarily hold the slot. If not removed first, it would show up as a // blocker and prevent the approve from succeeding. if _, delErr := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE description = $1 `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); delErr != nil { log.Printf("ALERT: failed to delete time_blocker: %v", delErr) } 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, "This edit would overlap with a time blocker", http.StatusConflict) return } // Check working hours (admin gets warning) // newStartTime from the DB is UTC; convert to London for weekday/daysToMonday // so BST dates (e.g. 00:30 BST = 23:30 UTC previous day) compute correctly. localStart := newStartTime.In(londonLocation) weekday := int((localStart.Weekday() + 6) % 7) bookingTime := localStart.Format("15:04:05") daysToMonday := int(localStart.Weekday()) if daysToMonday == 0 { daysToMonday = 7 // Sunday -> next Monday } tm := localStart.AddDate(0, 0, -daysToMonday+1) // Use UTC midnight for weekStart so PostgreSQL DATE comparison works // correctly with TIMESTAMPTZ. London-midnight during BST = 23:00 UTC // previous day, which would shift the DATE comparison by -1 day. weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC) var isClosed bool err = tx.QueryRow(r.Context(), ` SELECT EXISTS ( SELECT 1 FROM exceptional_working_hours ewh JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id WHERE ega.week_start = $1 AND ewh.weekday = $2 AND ewh.is_open = false AND ewh.start_time <= $3 AND ewh.end_time >= $3 ) `, weekStart, weekday, bookingTime).Scan(&isClosed) if err != nil { log.Printf("Failed to check exceptional hours: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if isClosed { http.Error(w, "Cannot approve: the proposed time falls during a period when the salon is closed", http.StatusConflict) return } } // 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. var setClauses []string var args []interface{} argNum := 1 if newStartTime != nil { setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum)) args = append(args, *newStartTime) argNum++ } if notes != nil { setClauses = append(setClauses, fmt.Sprintf("notes = $%d", argNum)) args = append(args, *notes) argNum++ } setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum)) args = append(args, clock.Now()) argNum++ args = append(args, bookingID) query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum) _, err = tx.Exec(r.Context(), query, args...) if err != nil { log.Printf("Failed to update booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // Handle new_services (only if has_overrides is false) if len(newServices) > 0 && !hasOverrides { // Delete existing booking_services _, err = tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID) if err != nil { log.Printf("Failed to delete existing services for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID) if err != nil { log.Printf("Failed to delete existing custom services for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Insert new services for _, serviceID := range newServices { _, err = tx.Exec(r.Context(), ` INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2) `, bookingID, serviceID) if err != nil { log.Printf("Failed to insert booking service %s: %v", serviceID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } } // Delete the edit request row (not update status) _, err = tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", requestID) if err != nil { log.Printf("Failed to delete edit request %s: %v", requestID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Acknowledge the admin notification for this edit request _, err = 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 != nil { log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // TODO: Notify user that their edit request was approved (blocked on E5 SMTP) if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // AdminRejectEditRequestHandler rejects an edit request by deleting it and acknowledging the admin notification func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) { requestID := chi.URLParam(r, "request_id") if requestID == "" || !validators.IsValidID(requestID) { http.Error(w, "Edit request not found", http.StatusNotFound) return } // First get the booking_id from the edit request before deleting var bookingID string err := db.Conn.QueryRow(r.Context(), ` SELECT booking_id FROM booking_edit_requests WHERE id = $1 `, requestID).Scan(&bookingID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Edit request not found", http.StatusNotFound) return } log.Printf("Failed to get edit request %s: %v", requestID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Use transaction to delete edit request and acknowledge associated admin notification tx, err := db.Conn.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 the edit request _, err = tx.Exec(r.Context(), ` DELETE FROM booking_edit_requests WHERE id = $1 `, requestID) if err != nil { log.Printf("Failed to reject edit request %s: %v", requestID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } 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 time_blocker: %v", err) } // Acknowledge the admin notification for this edit request _, err = 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 != nil { log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // TODO: Notify user that their edit request was denied with option to cancel (blocked on E5 SMTP) if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit reject edit request: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // GetMyEditRequestHandler returns the pending edit request for a specific booking the user owns. // GET /api/bookings/{id}/edit-request func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var ownerID string err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to get booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if ownerID != userID { http.Error(w, "Access denied", http.StatusForbidden) return } var editReq BookingEditRequest var newServices []string err = db.Conn.QueryRow(r.Context(), ` SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at FROM booking_edit_requests WHERE booking_id = $1 AND requested_by = $2 `, bookingID, userID).Scan( &editReq.ID, &editReq.BookingID, &editReq.RequestedBy, &editReq.NewStartTime, &newServices, &editReq.Notes, &editReq.HasOverrides, &editReq.UpdatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { 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) http.Error(w, "Internal server error", http.StatusInternalServerError) return } editReq.NewServices = newServices enriched, err := buildEnrichedEditRequest(r.Context(), &editReq) if err != nil { log.Printf("Failed to build enriched edit request: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "edit_request": enriched, }) } // GetMyEditRequestsHandler returns all pending edit requests for the current user across all bookings. // GET /api/bookings/edit-requests func GetMyEditRequestsHandler(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } rows, err := db.Conn.Query(r.Context(), ` SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at FROM booking_edit_requests WHERE requested_by = $1 ORDER BY updated_at DESC `, userID) if err != nil { log.Printf("Failed to fetch edit requests for user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() // Collect all edit requests first to avoid nested queries on the same tx connection. var editReqs []BookingEditRequest for rows.Next() { var editReq BookingEditRequest var newServices []string if err := rows.Scan( &editReq.ID, &editReq.BookingID, &editReq.RequestedBy, &editReq.NewStartTime, &newServices, &editReq.Notes, &editReq.HasOverrides, &editReq.UpdatedAt, ); err != nil { log.Printf("Failed to scan edit request: %v", err) continue } editReq.NewServices = newServices editReqs = append(editReqs, editReq) } var enrichedRequests []*EnrichedEditRequest for _, editReq := range editReqs { enriched, err := buildEnrichedEditRequest(r.Context(), &editReq) if err != nil { log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err) continue } enrichedRequests = append(enrichedRequests, enriched) } if enrichedRequests == nil { enrichedRequests = []*EnrichedEditRequest{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "edit_requests": enrichedRequests, }) } // AdminListAllEditRequestsHandler returns ALL pending edit requests across all bookings. // GET /api/admin/bookings/edit-requests func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) { rows, err := db.Conn.Query(r.Context(), ` SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at FROM booking_edit_requests ORDER BY updated_at DESC `) if err != nil { log.Printf("Failed to fetch all edit requests: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() // Collect all edit requests first to avoid nested queries on the same tx connection. var editReqs []BookingEditRequest for rows.Next() { var editReq BookingEditRequest var newServices []string if err := rows.Scan( &editReq.ID, &editReq.BookingID, &editReq.RequestedBy, &editReq.NewStartTime, &newServices, &editReq.Notes, &editReq.HasOverrides, &editReq.UpdatedAt, ); err != nil { log.Printf("Failed to scan edit request: %v", err) continue } editReq.NewServices = newServices editReqs = append(editReqs, editReq) } var enrichedRequests []*EnrichedEditRequest for _, editReq := range editReqs { enriched, err := buildEnrichedEditRequest(r.Context(), &editReq) if err != nil { log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err) continue } enrichedRequests = append(enrichedRequests, enriched) } if enrichedRequests == nil { enrichedRequests = []*EnrichedEditRequest{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "edit_requests": enrichedRequests, }) } // AdminGetBookingEditRequestHandler returns the pending edit request for a specific booking. // GET /api/admin/bookings/{id}/edit-request func AdminGetBookingEditRequestHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") if bookingID == "" || !validators.IsValidID(bookingID) { http.Error(w, "Booking not found", http.StatusNotFound) return } var editReq BookingEditRequest var newServices []string err := db.Conn.QueryRow(r.Context(), ` SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at FROM booking_edit_requests WHERE booking_id = $1 `, bookingID).Scan( &editReq.ID, &editReq.BookingID, &editReq.RequestedBy, &editReq.NewStartTime, &newServices, &editReq.Notes, &editReq.HasOverrides, &editReq.UpdatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "No edit request pending for this booking", http.StatusNotFound) return } log.Printf("Failed to get edit request for booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } editReq.NewServices = newServices enriched, err := buildEnrichedEditRequest(r.Context(), &editReq) if err != nil { log.Printf("Failed to build enriched edit request: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "edit_request": enriched, }) } // ======================================== // NO-SHOW HELPER FUNCTIONS // ======================================== // CountUnforgivenNoShows counts the number of no-shows in the last 6 months // that have not been forgiven (not in forgiven_no_shows table) func CountUnforgivenNoShows(ctx context.Context, userID string) (int, error) { var count int err := db.Conn.QueryRow(ctx, ` SELECT COUNT(*) FROM bookings b WHERE b.user_id = $1 AND b.status = 'no_show' AND b.start_time >= NOW() - INTERVAL '6 months' AND NOT EXISTS ( SELECT 1 FROM forgiven_no_shows WHERE booking_id = b.id ) `, userID).Scan(&count) return count, err } // ApplyDepositsIfNeeded checks if user has 2+ unforgiven no-shows // and applies 3 deposits if so. Returns true if deposits were applied. func ApplyDepositsIfNeeded(ctx context.Context, q db.Querier, userID string) (bool, error) { count, err := CountUnforgivenNoShows(ctx, userID) if err != nil { return false, err } if count >= 2 { // Apply 3 deposits _, err := q.Exec(ctx, ` UPDATE users SET deposits_required = 3 WHERE id = $1 `, userID) if err != nil { return false, err } return true, nil } return false, nil }