diff options
| author | soryu <soryu@soryu.co> | 2026-02-13 02:37:35 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-13 02:37:35 +0000 |
| commit | b79f3c414006816f744067d4abda1e277597a6ab (patch) | |
| tree | 917379209c614bf1b737c7228c41c8f63e28d9c3 | |
| parent | 5edaf1228b4e48a441b98c49f58de312b7924ed6 (diff) | |
| download | soryu-makima/makima-jp--create-orders-database-schema-and-backe-3e6f96e4.tar.gz soryu-makima/makima-jp--create-orders-database-schema-and-backe-3e6f96e4.zip | |
feat: makima.jp: Create Orders database schema and backend CRUDmakima/makima-jp--create-orders-database-schema-and-backe-3e6f96e4
| -rw-r--r-- | makima/migrations/20260213000000_create_orders.sql | 24 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 86 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 280 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/orders.rs | 396 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 15 |
6 files changed, 800 insertions, 2 deletions
diff --git a/makima/migrations/20260213000000_create_orders.sql b/makima/migrations/20260213000000_create_orders.sql new file mode 100644 index 0000000..2568192 --- /dev/null +++ b/makima/migrations/20260213000000_create_orders.sql @@ -0,0 +1,24 @@ +-- Create orders table for work item tracking (issues, tickets, cards). +-- Orders can be standalone or linked to directives/contracts. + +CREATE TABLE orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + description TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'backlog' + CHECK (status IN ('backlog', 'ready', 'in_progress', 'done', 'cancelled')), + priority VARCHAR(32) NOT NULL DEFAULT 'medium' + CHECK (priority IN ('low', 'medium', 'high', 'critical')), + labels JSONB NOT NULL DEFAULT '[]'::jsonb, + linked_directive_id UUID REFERENCES directives(id) ON DELETE SET NULL, + linked_directive_step_id UUID REFERENCES directive_steps(id) ON DELETE SET NULL, + linked_contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_orders_owner_id ON orders(owner_id); +CREATE INDEX idx_orders_status ON orders(status); +CREATE INDEX idx_orders_linked_directive ON orders(linked_directive_id); +CREATE INDEX idx_orders_linked_contract ON orders(linked_contract_id); diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 66c0a30..4967c3b 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2893,3 +2893,89 @@ pub struct DirectiveMemoryListResponse { pub memories: Vec<DirectiveMemory>, pub total: i64, } + +// ============================================================================= +// Order Types +// ============================================================================= + +/// An order — a work item (issue/ticket/card) that can be linked to directives or contracts. +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Order { + pub id: Uuid, + pub owner_id: Uuid, + pub title: String, + pub description: Option<String>, + /// Status: backlog, ready, in_progress, done, cancelled + pub status: String, + /// Priority: low, medium, high, critical + pub priority: String, + pub labels: serde_json::Value, + pub linked_directive_id: Option<Uuid>, + pub linked_directive_step_id: Option<Uuid>, + pub linked_contract_id: Option<Uuid>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Summary for order list views. +#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct OrderSummary { + pub id: Uuid, + pub owner_id: Uuid, + pub title: String, + pub status: String, + pub priority: String, + pub labels: serde_json::Value, + pub linked_directive_id: Option<Uuid>, + pub linked_directive_step_id: Option<Uuid>, + pub linked_contract_id: Option<Uuid>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// List response for orders. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct OrderListResponse { + pub orders: Vec<OrderSummary>, + pub total: i64, +} + +/// Request to create a new order. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateOrderRequest { + pub title: String, + pub description: Option<String>, + pub status: Option<String>, + pub priority: Option<String>, + #[serde(default)] + pub labels: Vec<String>, +} + +/// Request to update an order. +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateOrderRequest { + pub title: Option<String>, + pub description: Option<String>, + pub status: Option<String>, + pub priority: Option<String>, + pub labels: Option<Vec<String>>, +} + +/// Request to link an order to a directive. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LinkOrderToDirectiveRequest { + pub directive_id: Uuid, +} + +/// Request to link an order to a contract. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LinkOrderToContractRequest { + pub contract_id: Uuid, +} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 51f49cd..49687de 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -16,7 +16,9 @@ use super::models::{ UpdateDirectiveStepRequest, SetDirectiveMemoryRequest, BatchSetDirectiveMemoryRequest, DirectiveMemoryListResponse, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, - MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, + MeshChatConversation, MeshChatMessageRecord, Order, OrderSummary, + CreateOrderRequest, UpdateOrderRequest, + PhaseChangeResult, PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest, @@ -6020,3 +6022,279 @@ pub async fn clear_directive_memories( .await?; Ok(result.rows_affected()) } + +// ============================================================================= +// Order CRUD +// ============================================================================= + +/// Create a new order for an owner. +pub async fn create_order_for_owner( + pool: &PgPool, + owner_id: Uuid, + req: CreateOrderRequest, +) -> Result<Order, sqlx::Error> { + let status = req.status.unwrap_or_else(|| "backlog".to_string()); + let priority = req.priority.unwrap_or_else(|| "medium".to_string()); + let labels = serde_json::to_value(&req.labels).unwrap_or_else(|_| serde_json::json!([])); + + sqlx::query_as::<_, Order>( + r#" + INSERT INTO orders (owner_id, title, description, status, priority, labels) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + "#, + ) + .bind(owner_id) + .bind(&req.title) + .bind(&req.description) + .bind(&status) + .bind(&priority) + .bind(&labels) + .fetch_one(pool) + .await +} + +/// List orders for an owner with optional filters. +pub async fn list_orders_for_owner( + pool: &PgPool, + owner_id: Uuid, + status_filter: Option<&str>, + priority_filter: Option<&str>, + directive_id_filter: Option<Uuid>, + contract_id_filter: Option<Uuid>, +) -> Result<Vec<OrderSummary>, sqlx::Error> { + // Build dynamic query with optional filters + let mut query = String::from( + r#" + SELECT id, owner_id, title, status, priority, labels, + linked_directive_id, linked_directive_step_id, linked_contract_id, + created_at, updated_at + FROM orders + WHERE owner_id = $1 + "#, + ); + + let mut param_idx = 2u32; + let mut conditions = Vec::new(); + + if status_filter.is_some() { + conditions.push(format!("status = ${}", param_idx)); + param_idx += 1; + } + if priority_filter.is_some() { + conditions.push(format!("priority = ${}", param_idx)); + param_idx += 1; + } + if directive_id_filter.is_some() { + conditions.push(format!("linked_directive_id = ${}", param_idx)); + param_idx += 1; + } + if contract_id_filter.is_some() { + conditions.push(format!("linked_contract_id = ${}", param_idx)); + // param_idx += 1; // last param + } + + for condition in &conditions { + query.push_str(&format!(" AND {}", condition)); + } + + query.push_str(" ORDER BY created_at DESC"); + + let mut q = sqlx::query_as::<_, OrderSummary>(&query).bind(owner_id); + + if let Some(status) = status_filter { + q = q.bind(status.to_string()); + } + if let Some(priority) = priority_filter { + q = q.bind(priority.to_string()); + } + if let Some(directive_id) = directive_id_filter { + q = q.bind(directive_id); + } + if let Some(contract_id) = contract_id_filter { + q = q.bind(contract_id); + } + + q.fetch_all(pool).await +} + +/// Get a single order for an owner. +pub async fn get_order_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<Option<Order>, sqlx::Error> { + sqlx::query_as::<_, Order>( + r#"SELECT * FROM orders WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await +} + +/// Update an order for an owner. +pub async fn update_order_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, + req: UpdateOrderRequest, +) -> Result<Option<Order>, sqlx::Error> { + // Get current order first + let current = match get_order_for_owner(pool, id, owner_id).await? { + Some(o) => o, + None => return Ok(None), + }; + + let title = req.title.unwrap_or(current.title); + let description = req.description.or(current.description); + let status = req.status.unwrap_or(current.status); + let priority = req.priority.unwrap_or(current.priority); + let labels = req + .labels + .map(|l| serde_json::to_value(&l).unwrap_or_else(|_| serde_json::json!([]))) + .unwrap_or(current.labels); + + sqlx::query_as::<_, Order>( + r#" + UPDATE orders + SET title = $3, description = $4, status = $5, priority = $6, + labels = $7, updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(id) + .bind(owner_id) + .bind(&title) + .bind(&description) + .bind(&status) + .bind(&priority) + .bind(&labels) + .fetch_optional(pool) + .await +} + +/// Delete an order for an owner. +pub async fn delete_order_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#"DELETE FROM orders WHERE id = $1 AND owner_id = $2"#, + ) + .bind(id) + .bind(owner_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Link an order to a directive (sets linked_directive_id and status to in_progress). +pub async fn link_order_to_directive( + pool: &PgPool, + order_id: Uuid, + directive_id: Uuid, + owner_id: Uuid, +) -> Result<Option<Order>, sqlx::Error> { + sqlx::query_as::<_, Order>( + r#" + UPDATE orders + SET linked_directive_id = $3, status = 'in_progress', updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(order_id) + .bind(owner_id) + .bind(directive_id) + .fetch_optional(pool) + .await +} + +/// Link an order to a contract (sets linked_contract_id and status to in_progress). +pub async fn link_order_to_contract( + pool: &PgPool, + order_id: Uuid, + contract_id: Uuid, + owner_id: Uuid, +) -> Result<Option<Order>, sqlx::Error> { + sqlx::query_as::<_, Order>( + r#" + UPDATE orders + SET linked_contract_id = $3, status = 'in_progress', updated_at = NOW() + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(order_id) + .bind(owner_id) + .bind(contract_id) + .fetch_optional(pool) + .await +} + +/// Get all orders linked to a specific directive. +pub async fn get_orders_by_directive( + pool: &PgPool, + directive_id: Uuid, + owner_id: Uuid, +) -> Result<Vec<OrderSummary>, sqlx::Error> { + sqlx::query_as::<_, OrderSummary>( + r#" + SELECT id, owner_id, title, status, priority, labels, + linked_directive_id, linked_directive_step_id, linked_contract_id, + created_at, updated_at + FROM orders + WHERE linked_directive_id = $1 AND owner_id = $2 + ORDER BY created_at DESC + "#, + ) + .bind(directive_id) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Get all orders linked to a specific contract. +pub async fn get_orders_by_contract( + pool: &PgPool, + contract_id: Uuid, + owner_id: Uuid, +) -> Result<Vec<OrderSummary>, sqlx::Error> { + sqlx::query_as::<_, OrderSummary>( + r#" + SELECT id, owner_id, title, status, priority, labels, + linked_directive_id, linked_directive_step_id, linked_contract_id, + created_at, updated_at + FROM orders + WHERE linked_contract_id = $1 AND owner_id = $2 + ORDER BY created_at DESC + "#, + ) + .bind(contract_id) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Update the status of an order (used by lifecycle hooks). +pub async fn update_order_status( + pool: &PgPool, + id: Uuid, + status: &str, +) -> Result<Option<Order>, sqlx::Error> { + sqlx::query_as::<_, Order>( + r#" + UPDATE orders + SET status = $2, updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(id) + .bind(status) + .fetch_optional(pool) + .await +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 29cd09f..8b06a28 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -13,6 +13,7 @@ pub mod history; pub mod listen; pub mod mesh; pub mod mesh_chat; +pub mod orders; pub mod mesh_daemon; pub mod mesh_merge; pub mod mesh_supervisor; diff --git a/makima/src/server/handlers/orders.rs b/makima/src/server/handlers/orders.rs new file mode 100644 index 0000000..eaa79f5 --- /dev/null +++ b/makima/src/server/handlers/orders.rs @@ -0,0 +1,396 @@ +//! HTTP handlers for order CRUD (work items / issues / tickets). + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::db::models::{ + CreateOrderRequest, LinkOrderToContractRequest, LinkOrderToDirectiveRequest, Order, + OrderListResponse, UpdateOrderRequest, +}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// Query parameters for listing orders. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListOrdersQuery { + pub status: Option<String>, + pub priority: Option<String>, + pub directive_id: Option<Uuid>, + pub contract_id: Option<Uuid>, +} + +// ============================================================================= +// Order CRUD +// ============================================================================= + +/// 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_for_owner(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() + } + } +} + +/// List all orders for the authenticated user, with optional filters. +#[utoipa::path( + get, + path = "/api/v1/orders", + params( + ("status" = Option<String>, Query, description = "Filter by status"), + ("priority" = Option<String>, Query, description = "Filter by priority"), + ("directiveId" = Option<Uuid>, Query, description = "Filter by linked directive"), + ("contractId" = Option<Uuid>, Query, description = "Filter by linked contract"), + ), + 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<ListOrdersQuery>, +) -> 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_for_owner( + pool, + auth.owner_id, + query.status.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() + } + } +} + +/// Get a single 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 = 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_for_owner(pool, id, auth.owner_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( + put, + 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 = 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_for_owner(pool, id, auth.owner_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 = 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_for_owner(pool, id, auth.owner_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 +// ============================================================================= + +/// 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 = LinkOrderToDirectiveRequest, + responses( + (status = 200, description = "Order linked to directive", body = Order), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Orders" +)] +pub async fn link_order_to_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<LinkOrderToDirectiveRequest>, +) -> 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 directive ownership + 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, id, req.directive_id, auth.owner_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 = LinkOrderToContractRequest, + responses( + (status = 200, description = "Order linked to contract", body = Order), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Orders" +)] +pub async fn link_order_to_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<LinkOrderToContractRequest>, +) -> 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 contract ownership + 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, id, req.contract_id, auth.owner_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() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 7110ef8..4d1aaba 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -242,6 +242,19 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/memories", get(directives::list_memories).post(directives::set_memory).delete(directives::clear_memories)) .route("/directives/{id}/memories/batch", post(directives::batch_set_memories)) .route("/directives/{id}/memories/{key}", get(directives::get_memory).delete(directives::delete_memory)) + // Order endpoints + .route( + "/orders", + get(orders::list_orders).post(orders::create_order), + ) + .route( + "/orders/{id}", + get(orders::get_order) + .put(orders::update_order) + .delete(orders::delete_order), + ) + .route("/orders/{id}/link-directive", post(orders::link_order_to_directive)) + .route("/orders/{id}/link-contract", post(orders::link_order_to_contract)) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) // Contract type templates (built-in only) |
