From 9aadbc7958d39d181c0dd0600e2b7c30bb6c391a Mon Sep 17 00:00:00 2001 From: soryu Date: Sat, 14 Feb 2026 21:29:26 +0000 Subject: Makima system improvements: Orders, directive questions, PR creation fix, bug fixes (#62) * feat: soryu-co/soryu - makima: Fix directive goal update bug - stale closure issue * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Create Orders database schema and backend API * feat: soryu-co/soryu - makima: Fix task Claude instance not receiving user inputs from input box * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Build Orders frontend page replacing the Board page * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Fix directive PR creation system --- makima/src/server/handlers/orders.rs | 443 +++++++++++++++++++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 makima/src/server/handlers/orders.rs (limited to 'makima/src/server/handlers/orders.rs') diff --git a/makima/src/server/handlers/orders.rs b/makima/src/server/handlers/orders.rs new file mode 100644 index 0000000..c43c406 --- /dev/null +++ b/makima/src/server/handlers/orders.rs @@ -0,0 +1,443 @@ +//! 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, 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"), + ("contract_id" = Option, 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, + 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.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, + 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(); + }; + + 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() + } + } +} + +/// 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, + 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 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, + 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::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() + } + } +} -- cgit v1.2.3