summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-13 02:37:35 +0000
committersoryu <soryu@soryu.co>2026-02-13 02:37:35 +0000
commitb79f3c414006816f744067d4abda1e277597a6ab (patch)
tree917379209c614bf1b737c7228c41c8f63e28d9c3
parent5edaf1228b4e48a441b98c49f58de312b7924ed6 (diff)
downloadsoryu-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.sql24
-rw-r--r--makima/src/db/models.rs86
-rw-r--r--makima/src/db/repository.rs280
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/handlers/orders.rs396
-rw-r--r--makima/src/server/mod.rs15
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)