package bookings import ( "context" "crussell/db" "crussell/handlers/notifications" "crussell/internal/validators" "crussell/handlers/scheduling" "crussell/mw" "database/sql" "encoding/json" "errors" "fmt" "log" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/lib/pq" ) // 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.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) // Get current status before updating var originalStatus string err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not 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') `, time.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 } // 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) } // AdminCancelBookingHandler allows an admin to cancel any booking. // The update uses a status filter and checks RowsAffected for existence. 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 } tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) // Get current status before updating var originalStatus string err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus) if err != nil { if errors.Is(err, sql.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') `, time.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 } // 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 } } 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 } 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.DB.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, sql.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) } } // AdminEditBookingHandler allows an admin to modify the start time of any booking. // Admin can edit any booking EXCEPT completed or cancelled bookings. // Admin can create/edit bookings outside working hours (with warning). // Admin can create/edit bookings that overlap with existing bookings (with warning). func AdminEditBookingHandler(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 EditBookingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Basic validation: ensure the new time is not in the past if time.Now().After(req.StartTime) { http.Error(w, "Start time must be in the future", http.StatusBadRequest) return } // Check if booking exists and is not completed/cancelled var currentStatus string err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to get booking status %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Block edits on completed or cancelled bookings if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" { http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden) return } // Get booking duration for overlap check var durationMinutes int err = db.DB.QueryRow(r.Context(), ` SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1 `, bookingID).Scan(&durationMinutes) if err != nil { log.Printf("Failed to get booking duration %s: %v", bookingID, err) durationMinutes = 60 // fallback } // Check for overlapping bookings (excluding the current booking) var overlapCount int newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute) err = db.DB.QueryRow(r.Context(), ` SELECT COUNT(*) FROM bookings WHERE id != $1 AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled') AND start_time < $3 AND start_time + (INTERVAL '1 minute' * ( SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id )) > $2 `, bookingID, req.StartTime, newEndTime).Scan(&overlapCount) if err != nil { log.Printf("Failed to check overlap %s: %v", bookingID, err) } // Check if salon is closed (exceptional hours) - admin gets warning but can proceed weekday := int(req.StartTime.Weekday()) bookingTime := req.StartTime.Format("15:04:05") daysToMonday := weekday if daysToMonday == 0 { daysToMonday = 7 } weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) // Check if salon is closed (exceptional hours) var isClosed bool err = db.DB.QueryRow(r.Context(), ` SELECT EXISTS ( SELECT 1 FROM exceptional_working_hours ewh JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id WHERE ega.week_start = $1 AND ewh.weekday = $2 AND ewh.is_open = false AND ewh.start_time <= $3 AND ewh.end_time >= $3 ) `, weekStart, weekday, bookingTime).Scan(&isClosed) if err != nil { log.Printf("Failed to check exceptional hours: %v", err) } isOutsideWorkingHours := isClosed // Prevent overlap - block admin if overlapCount > 0 { http.Error(w, "This booking overlaps with an existing booking", http.StatusConflict) return } // Build warning for outside working hours (admin can proceed with warning) var warnings []string if isOutsideWorkingHours { warnings = append(warnings, "Warning: This booking is outside standard working hours") } // Check for time blocker overlap - admin can proceed with warning blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime) if err != nil { log.Printf("Failed to check time blocker overlap: %v", err) } if blockerOverlap { warnings = append(warnings, fmt.Sprintf("Warning: This booking overlaps with a time blocker: %s", blockerDesc)) } // Perform the update res, err := db.DB.Exec(r.Context(), ` UPDATE bookings SET start_time = $1, updated_at = $2 WHERE id = $3 `, req.StartTime, time.Now(), bookingID) if err != nil { log.Printf("Failed to edit 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 found", http.StatusNotFound) return } // Clear any pending edit requests for this booking (admin edit takes priority) _, err = db.DB.Exec(r.Context(), ` DELETE FROM booking_edit_requests WHERE booking_id = $1 `, bookingID) if err != nil { log.Printf("Failed to clear edit requests for booking %s: %v", bookingID, err) // Don't fail the request, just log the error } // Return warnings if any if len(warnings) > 0 { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Booking updated", "warnings": warnings, }) return } w.WriteHeader(http.StatusNoContent) } type AdminCreateBookingForUserRequest struct { UserID string `json:"user_id" validate:"required"` StartTime time.Time `json:"start_time" validate:"required"` ServiceIDs []string `json:"service_ids" validate:"required,min=1"` ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"` Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff EnforceDeposits *bool `json:"enforce_deposits,omitempty"` // Optional: if true, enforce outstanding deposit checks; if false or omitted, bypass checks } 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 } // 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 { http.Error(w, "At least one service is required", http.StatusBadRequest) return } // Check patch test requirements for all services for _, serviceID := range req.ServiceIDs { // Find patch test for this service var patchTestID string var noticeHours int err := db.DB.QueryRow(r.Context(), ` SELECT id, notice_duration_hours FROM patch_tests WHERE $1 = ANY(service_ids) `, serviceID).Scan(&patchTestID, ¬iceHours) if err == nil { // Service requires a patch test - check if user has valid record var testedAt time.Time err = db.DB.QueryRow(r.Context(), ` SELECT tested_at FROM user_patch_tests WHERE user_id = $1 AND patch_test_id = $2 `, req.UserID, patchTestID).Scan(&testedAt) if err != nil { // No valid patch test record http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest) return } // Check if notice period has passed eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour) if 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 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.DB.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.DB.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 { http.Error(w, "At least one 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 weekday := int(req.StartTime.Weekday()) daysToMonday := weekday if daysToMonday == 0 { daysToMonday = 7 // Sunday -> next Monday } weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour) bookingTime := req.StartTime.Format("15:04:05") // Check if there's an exceptional hours entry that makes this time unavailable var isClosed bool var checkErr error checkErr = db.DB.QueryRow(r.Context(), ` SELECT EXISTS ( SELECT 1 FROM exceptional_working_hours ewh JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id WHERE ega.week_start = $1 AND ewh.weekday = $2 AND ewh.is_open = false AND ewh.start_time <= $3 AND ewh.end_time >= $3 ) `, weekStart, weekday, bookingTime).Scan(&isClosed) if 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 var dur int db.DB.QueryRow(r.Context(), ` SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1) `, req.ServiceIDs).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(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60) FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id)) > $1 `, req.StartTime, newEnd).Scan(&cnt) if cnt > 0 { http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict) return } // 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.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()) // Create booking directly as confirmed bookingQuery := ` INSERT INTO bookings ( user_id, start_time, status, notes, created_by ) VALUES ($1, $2, 'confirmed', $3, $4) RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by ` var booking Booking booking.User = &UserSummary{} err = tx.QueryRow( r.Context(), bookingQuery, req.UserID, req.StartTime, req.Notes, adminID, ).Scan( &booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy, ) if err != nil { log.Printf("Failed to create admin booking: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // 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 err != nil { log.Printf("Failed to insert booking service %s: %v", serviceID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // 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 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"` HasOverrides bool `json:"has_overrides"` UpdatedAt time.Time `json:"updated_at"` // Joined fields Booking *Booking `json:"booking,omitempty"` User *UserSummary `json:"user,omitempty"` } // 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.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to get booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if ownerID != userID { http.Error(w, "Access denied", http.StatusForbidden) return } // Use transaction to delete edit request and associated admin notification tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) // 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 } // Delete the admin notification for this edit request _, err = tx.Exec(r.Context(), ` DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_request' 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"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // 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.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Booking not found", http.StatusNotFound) return } log.Printf("Failed to get booking %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if ownerID != userID { http.Error(w, "Access denied", http.StatusForbidden) return } // 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) 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 } if len(req.NewServices) > 0 { var overrideCount int err = db.DB.QueryRow(r.Context(), ` SELECT COUNT(*) FROM booking_services WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL) `, 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.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 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, pq.Array(req.NewServices), req.Notes, false).Scan( &editReq.ID, &editReq.BookingID, &editReq.RequestedBy, &editReq.NewStartTime, pq.Array(&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 } // Delete existing admin notification for edit_request before creating new one (refreshes timestamp) _, err = tx.Exec(r.Context(), ` DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_request' `, 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 } // Create admin notification with reason 'edit_request' _, err = tx.Exec(r.Context(), ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) `, "edit_request", 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 status is 'pending', acknowledge existing pending_booking notification and create new one if currentStatus == "pending" { // Acknowledge existing pending_booking notification _, 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) } // Create new pending_booking notification (admin will see the edit request) _, err = tx.Exec(r.Context(), ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3) `, "pending_booking", 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 ` 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 } rows, err := db.DB.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, pq.Array(&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.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 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, pq.Array(&newServices), ¬es, &hasOverrides) if err != nil { if errors.Is(err, sql.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 } // 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 COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1 `, bookingID).Scan(&durationMinutes) } 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(s.duration_minutes), 60) FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1 `, bookingID).Scan(&durationMinutes) } } if err != nil { log.Printf("Failed to 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) 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(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bookings.id )) > $2 `, bookingID, *newStartTime, newEndTime).Scan(&overlapCount) if err != nil { log.Printf("Failed to check overlap: %v", err) } if overlapCount > 0 { http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict) return } } // Build update query for bookings table if newStartTime != nil || notes != nil { 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, time.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 } // 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_request' 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 } 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.DB.QueryRow(r.Context(), ` SELECT booking_id FROM booking_edit_requests WHERE id = $1 `, requestID).Scan(&bookingID) if err != nil { if errors.Is(err, sql.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.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 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 } // 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_request' 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 } 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) } // ======================================== // 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.DB.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, userID string) (bool, error) { count, err := CountUnforgivenNoShows(ctx, userID) if err != nil { return false, err } if count >= 2 { // Apply 3 deposits _, err := db.DB.Exec(ctx, ` UPDATE users SET deposits_required = 3 WHERE id = $1 `, userID) if err != nil { return false, err } return true, nil } return false, nil }