package bookings import ( "crussell/db" "crussell/handlers/notifications" "crussell/mw" "database/sql" "encoding/json" "log" "net/http" "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") 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 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") 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 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 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. // It validates the new start time and returns 404 if the booking does not exist. func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") 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 } 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 } 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 } 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 } // 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 } } 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 } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(booking); err != nil { log.Printf("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } }