//! 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::{
ConvertToStepRequest, CreateOrderRequest, DirectiveStep, LinkContractRequest,
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<String>, Query, description = "Filter by status"),
("type" = Option<String>, Query, description = "Filter by order type"),
("priority" = Option<String>, Query, description = "Filter by priority"),
("directive_id" = Option<Uuid>, Query, description = "Filter by directive ID"),
("contract_id" = Option<Uuid>, Query, description = "Filter by contract ID"),
),
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<SharedState>,
Authenticated(auth): Authenticated,
Query(query): Query<OrderListQuery>,
) -> 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.contract_id,
)
.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.
#[utoipa::path(
post,
path = "/api/v1/orders",
request_body = CreateOrderRequest,
responses(
(status = 201, description = "Order created", body = Order),
(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<SharedState>,
Authenticated(auth): Authenticated,
Json(req): Json<CreateOrderRequest>,
) -> 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::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<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> 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<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<UpdateOrderRequest>,
) -> 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<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> 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<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<LinkDirectiveRequest>,
) -> 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()
}
}
}
/// Link an order to a contract.
#[utoipa::path(
post,
path = "/api/v1/orders/{id}/link-contract",
params(("id" = Uuid, Path, description = "Order ID")),
request_body = LinkContractRequest,
responses(
(status = 200, description = "Order linked to contract", 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_contract(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<LinkContractRequest>,
) -> 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 contract exists and belongs to this owner
match repository::get_contract_for_owner(pool, auth.owner_id, req.contract_id).await {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Contract 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_contract(pool, auth.owner_id, id, req.contract_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 contract: {}", 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 specified directive using the order's title/description,
/// and links the order to both the directive and the new step.
#[utoipa::path(
post,
path = "/api/v1/orders/{id}/convert-to-step",
params(("id" = Uuid, Path, description = "Order ID")),
request_body = ConvertToStepRequest,
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<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<ConvertToStepRequest>,
) -> 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, req.directive_id).await {
Ok(Some(step)) => (StatusCode::CREATED, Json(step)).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Order or directive not found")),
)
.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()
}
}
}