//! HTTP handlers for order CRUD operations. //! Orders are card-based work items (features, bugs, spikes) similar to //! GitHub Issues or Linear cards. use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Json, }; use uuid::Uuid; use crate::db::models::{ CreateOrderRequest, DirectiveStep, LinkDirectiveRequest, Order, OrderListQuery, OrderListResponse, UpdateOrderRequest, }; use crate::db::repository; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; // ============================================================================= // Order CRUD // ============================================================================= /// List all orders for the authenticated user. #[utoipa::path( get, path = "/api/v1/orders", params( ("status" = Option, Query, description = "Filter by status"), ("type" = Option, Query, description = "Filter by order type"), ("priority" = Option, Query, description = "Filter by priority"), ("directive_id" = Option, Query, description = "Filter by directive ID"), ("search" = Option, Query, description = "Text search across title, description, and directive name"), ), responses( (status = 200, description = "List of orders", body = OrderListResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security(("bearer_auth" = []), ("api_key" = [])), tag = "Orders" )] pub async fn list_orders( State(state): State, Authenticated(auth): Authenticated, Query(query): Query, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::list_orders( pool, auth.owner_id, query.status.as_deref(), query.order_type.as_deref(), query.priority.as_deref(), query.directive_id, query.search.as_deref(), ) .await { Ok(orders) => { let total = orders.len() as i64; Json(OrderListResponse { orders, total }).into_response() } Err(e) => { tracing::error!("Failed to list orders: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("LIST_FAILED", &e.to_string())), ) .into_response() } } } /// Create a new order. A valid directive_id is required. #[utoipa::path( post, path = "/api/v1/orders", request_body = CreateOrderRequest, responses( (status = 201, description = "Order created", body = Order), (status = 400, description = "Invalid directive_id", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security(("bearer_auth" = []), ("api_key" = [])), tag = "Orders" )] pub async fn create_order( State(state): State, Authenticated(auth): Authenticated, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Validate the directive exists and belongs to this owner. // directive_id is required by the CreateOrderRequest struct (Uuid, not Option). match repository::get_directive_for_owner(pool, auth.owner_id, req.directive_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new( "INVALID_DIRECTIVE", "directive_id is required and must reference a valid directive owned by you", )), ) .into_response(); } Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("VALIDATION_FAILED", &e.to_string())), ) .into_response(); } } match repository::create_order(pool, auth.owner_id, req).await { Ok(order) => (StatusCode::CREATED, Json(order)).into_response(), Err(e) => { tracing::error!("Failed to create order: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("CREATE_FAILED", &e.to_string())), ) .into_response() } } } /// Get an order by ID. #[utoipa::path( get, path = "/api/v1/orders/{id}", params(("id" = Uuid, Path, description = "Order ID")), responses( (status = 200, description = "Order details", body = Order), (status = 404, description = "Not found", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security(("bearer_auth" = []), ("api_key" = [])), tag = "Orders" )] pub async fn get_order( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::get_order(pool, auth.owner_id, id).await { Ok(Some(order)) => Json(order).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Order not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to get order: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("GET_FAILED", &e.to_string())), ) .into_response() } } } /// Update an order. #[utoipa::path( patch, path = "/api/v1/orders/{id}", params(("id" = Uuid, Path, description = "Order ID")), request_body = UpdateOrderRequest, responses( (status = 200, description = "Order updated", body = Order), (status = 404, description = "Not found", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security(("bearer_auth" = []), ("api_key" = [])), tag = "Orders" )] pub async fn update_order( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::update_order(pool, auth.owner_id, id, req).await { Ok(Some(order)) => Json(order).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Order not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to update order: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("UPDATE_FAILED", &e.to_string())), ) .into_response() } } } /// Delete an order. #[utoipa::path( delete, path = "/api/v1/orders/{id}", params(("id" = Uuid, Path, description = "Order ID")), responses( (status = 204, description = "Deleted"), (status = 404, description = "Not found", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security(("bearer_auth" = []), ("api_key" = [])), tag = "Orders" )] pub async fn delete_order( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::delete_order(pool, auth.owner_id, id).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Order not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to delete order: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DELETE_FAILED", &e.to_string())), ) .into_response() } } } // ============================================================================= // Order Linking & Conversion // ============================================================================= /// Link an order to a directive. #[utoipa::path( post, path = "/api/v1/orders/{id}/link-directive", params(("id" = Uuid, Path, description = "Order ID")), request_body = LinkDirectiveRequest, responses( (status = 200, description = "Order linked to directive", body = Order), (status = 404, description = "Not found", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security(("bearer_auth" = []), ("api_key" = [])), tag = "Orders" )] pub async fn link_to_directive( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify the directive exists and belongs to this owner match repository::get_directive_for_owner(pool, auth.owner_id, req.directive_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Directive not found")), ) .into_response(); } Err(e) => { return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("GET_FAILED", &e.to_string())), ) .into_response(); } } match repository::link_order_to_directive(pool, auth.owner_id, id, req.directive_id).await { Ok(Some(order)) => Json(order).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Order not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to link order to directive: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("LINK_FAILED", &e.to_string())), ) .into_response() } } } /// Convert an order to a directive step. /// Creates a new step in the order's linked directive using the order's title/description, /// and links the order to the new step. The order must have a directive_id set. #[utoipa::path( post, path = "/api/v1/orders/{id}/convert-to-step", params(("id" = Uuid, Path, description = "Order ID")), responses( (status = 201, description = "Directive step created from order", body = DirectiveStep), (status = 404, description = "Order or directive not found", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security(("bearer_auth" = []), ("api_key" = [])), tag = "Orders" )] pub async fn convert_to_step( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::convert_order_to_step(pool, auth.owner_id, id).await { Ok(Some(step)) => (StatusCode::CREATED, Json(step)).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new( "NOT_FOUND", "Order not found or has no linked directive", )), ) .into_response(), Err(e) => { tracing::error!("Failed to convert order to step: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("CONVERT_FAILED", &e.to_string())), ) .into_response() } } }