diff options
Diffstat (limited to 'makima/src/server')
| -rw-r--r-- | makima/src/server/handlers/directive_documents.rs | 572 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 25 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 19 |
4 files changed, 614 insertions, 3 deletions
diff --git a/makima/src/server/handlers/directive_documents.rs b/makima/src/server/handlers/directive_documents.rs new file mode 100644 index 0000000..48f314f --- /dev/null +++ b/makima/src/server/handlers/directive_documents.rs @@ -0,0 +1,572 @@ +//! HTTP handlers for directive_documents. +//! +//! A directive_document (the user-facing name is "directive contract") is one +//! of N markdown documents owned by a directive. Each document has its own +//! lifecycle (draft → active → shipped → archived) and may be attached to a +//! PR. Multiple documents can be active under the same directive at once; +//! the next step's UI work will let users pick between them. +//! +//! Auth/owner-scoping conventions mirror `handlers::directives`: every handler +//! looks the parent directive up via `get_directive_for_owner` to enforce the +//! caller actually owns the directive before any document operation. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::db::models::{DirectiveStep, Task}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +// ============================================================================= +// Request payloads +// ============================================================================= + +/// Body for `POST /api/v1/directives/{directive_id}/documents`. +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveDocumentRequest { + /// Optional document title. Defaults to empty string. + pub title: Option<String>, + /// Optional document body (markdown). Defaults to empty string. + pub body: Option<String>, +} + +/// Body for `PATCH /api/v1/directive-documents/{document_id}`. +#[derive(Debug, Default, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateDirectiveDocumentRequest { + pub title: Option<String>, + pub body: Option<String>, +} + +/// Body for `POST /api/v1/directive-documents/{document_id}/ship`. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ShipDirectiveDocumentRequest { + pub pr_url: String, + pub pr_branch: Option<String>, +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/// Load a directive document and verify the caller owns the parent directive. +/// +/// Returns: +/// * `Ok(Some(doc))` — caller owns the parent directive and the document exists. +/// * `Ok(None)` — document does not exist OR caller does not own the directive. +/// Both cases are folded into 404 to avoid leaking existence to non-owners. +async fn load_owned_document( + pool: &sqlx::PgPool, + owner_id: Uuid, + document_id: Uuid, +) -> Result<Option<crate::db::models::DirectiveDocument>, sqlx::Error> { + let doc = match repository::get_directive_document(pool, document_id).await? { + Some(d) => d, + None => return Ok(None), + }; + match repository::get_directive_for_owner(pool, owner_id, doc.directive_id).await? { + Some(_) => Ok(Some(doc)), + None => Ok(None), + } +} + +// ============================================================================= +// Directive-scoped endpoints (parent = /directives/{directive_id}) +// ============================================================================= + +/// List all documents under a directive. +#[utoipa::path( + get, + path = "/api/v1/directives/{directive_id}/documents", + params(("directive_id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 200, description = "List of directive documents", body = Vec<crate::db::models::DirectiveDocument>), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn list_documents( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(directive_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(); + }; + + // Verify directive ownership before exposing any documents. + match repository::get_directive_for_owner(pool, auth.owner_id, 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::list_directive_documents(pool, directive_id).await { + Ok(docs) => Json(docs).into_response(), + Err(e) => { + tracing::error!("Failed to list directive documents: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Create a new directive document. The new document starts in `draft` status. +#[utoipa::path( + post, + path = "/api/v1/directives/{directive_id}/documents", + params(("directive_id" = Uuid, Path, description = "Directive ID")), + request_body = CreateDirectiveDocumentRequest, + responses( + (status = 201, description = "Document created", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn create_document( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(directive_id): Path<Uuid>, + Json(req): Json<CreateDirectiveDocumentRequest>, +) -> 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, 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(); + } + } + + let title = req.title.as_deref().unwrap_or(""); + let body = req.body.as_deref().unwrap_or(""); + + match repository::create_directive_document(pool, directive_id, title, body).await { + Ok(doc) => (StatusCode::CREATED, Json(doc)).into_response(), + Err(e) => { + tracing::error!("Failed to create directive document: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CREATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Document-scoped endpoints (parent = /directive-documents/{document_id}) +// ============================================================================= + +/// Get a single directive document by ID. +#[utoipa::path( + get, + path = "/api/v1/directive-documents/{document_id}", + params(("document_id" = Uuid, Path, description = "Directive document ID")), + responses( + (status = 200, description = "Directive document", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn get_document( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_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 load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(doc)) => Json(doc).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get directive document: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a directive document's title and/or body. +/// +/// Calls `repository::update_directive_document`, which already encodes the +/// "editing a shipped contract reactivates it" behaviour: if the doc was in +/// `shipped` status and the body actually changed, the underlying SQL flips +/// status back to `active` and clears `shipped_at`. The handler simply +/// returns the updated row — the reactivation is automatic. +#[utoipa::path( + patch, + path = "/api/v1/directive-documents/{document_id}", + params(("document_id" = Uuid, Path, description = "Directive document ID")), + request_body = UpdateDirectiveDocumentRequest, + responses( + (status = 200, description = "Document updated", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn update_document( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_id): Path<Uuid>, + Json(req): Json<UpdateDirectiveDocumentRequest>, +) -> 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 caller owns the parent directive before mutating. + match load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::update_directive_document( + pool, + document_id, + req.title.as_deref(), + req.body.as_deref(), + ) + .await + { + Ok(Some(doc)) => Json(doc).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update directive document: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("UPDATE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Mark a directive document as shipped — records pr_url and optional +/// pr_branch, sets status='shipped', and stamps shipped_at. +#[utoipa::path( + post, + path = "/api/v1/directive-documents/{document_id}/ship", + params(("document_id" = Uuid, Path, description = "Directive document ID")), + request_body = ShipDirectiveDocumentRequest, + responses( + (status = 200, description = "Document shipped", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn ship_document( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_id): Path<Uuid>, + Json(req): Json<ShipDirectiveDocumentRequest>, +) -> 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 caller owns the parent directive. + match load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::mark_directive_document_shipped( + pool, + document_id, + &req.pr_url, + req.pr_branch.as_deref(), + ) + .await + { + Ok(Some(doc)) => Json(doc).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to ship directive document: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("SHIP_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Archive a directive document. +#[utoipa::path( + post, + path = "/api/v1/directive-documents/{document_id}/archive", + params(("document_id" = Uuid, Path, description = "Directive document ID")), + responses( + (status = 200, description = "Document archived", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn archive_document( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_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(); + }; + + // Verify the caller owns the parent directive. + match load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::archive_directive_document(pool, document_id).await { + Ok(Some(doc)) => Json(doc).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to archive directive document: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("ARCHIVE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Document tasks endpoint — returns the steps + ephemeral tasks attached to +// a single document. Used by the sidebar to render a per-document tasks/ +// subfolder beside the document row, and to keep that subfolder pinned to +// the document when it ships (because the rows are FK-linked to the doc, +// not to the directive). +// ============================================================================= + +/// Response body for `GET /api/v1/directive-documents/{document_id}/tasks`. +/// +/// We return BOTH steps and tasks. Steps are the planned units of work in the +/// directive's DAG; tasks are the actual execution records (orchestrator, +/// completion, ephemeral). The sidebar renders both under the document's +/// tasks/ subfolder so the user sees the full task history of a document at +/// a glance. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocumentTasksResponse { + pub steps: Vec<DirectiveStep>, + pub tasks: Vec<Task>, +} + +/// List the steps and ephemeral tasks attached to a directive document. +/// +/// This drives the sidebar's per-document `tasks/` subfolder. The tasks here +/// are *artifacts* of the document — they were created while that document +/// was the "active" doc — so when the document ships, its tasks visually +/// move with it under the shipped/ subfolder. +/// +/// Auth: same scope check as the other document endpoints — caller must own +/// the parent directive. +#[utoipa::path( + get, + path = "/api/v1/directive-documents/{document_id}/tasks", + params(("document_id" = Uuid, Path, description = "Directive document ID")), + responses( + (status = 200, description = "Steps and tasks attached to the document", body = DocumentTasksResponse), + (status = 404, description = "Document not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn list_document_tasks( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(document_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(); + }; + + // Ownership check via the existing helper — folds "doesn't exist" and + // "not yours" into 404. + match load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive document not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + let steps = match repository::list_directive_document_steps(pool, document_id).await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to list directive document steps: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_STEPS_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + let tasks = match repository::list_directive_document_tasks(pool, document_id).await { + Ok(t) => t, + Err(e) => { + tracing::error!("Failed to list directive document tasks: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_TASKS_FAILED", &e.to_string())), + ) + .into_response(); + } + }; + + Json(DocumentTasksResponse { steps, tasks }).into_response() +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index c761dcc..a39a4c0 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod api_keys; pub mod chat; pub mod daemon_download; +pub mod directive_documents; pub mod directives; pub mod file_ws; pub mod files; diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 59eff2e..dd79ddf 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, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, users, versions}; +use crate::server::handlers::{api_keys, chat, daemon_download, directive_documents, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -214,6 +214,29 @@ pub fn make_router(state: SharedState) -> Router { ) .route("/directives/{id}/dogs/{dog_id}/orders", get(directives::list_dog_orders)) .route("/directives/{id}/dogs/{dog_id}/pick-up-orders", post(directives::pick_up_dog_orders)) + // Directive document endpoints (multi-document directive contracts). + .route( + "/directives/{directive_id}/documents", + get(directive_documents::list_documents) + .post(directive_documents::create_document), + ) + .route( + "/directive-documents/{document_id}", + get(directive_documents::get_document) + .patch(directive_documents::update_document), + ) + .route( + "/directive-documents/{document_id}/ship", + post(directive_documents::ship_document), + ) + .route( + "/directive-documents/{document_id}/archive", + post(directive_documents::archive_document), + ) + .route( + "/directive-documents/{document_id}/tasks", + get(directive_documents::list_document_tasks), + ) // Order endpoints .route( "/orders", diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 51a1c0d..ad7837a 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -12,7 +12,7 @@ use crate::db::models::{ CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateOrderRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, - DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, + DaemonDirectory, DaemonListResponse, Directive, DirectiveDocument, DirectiveListResponse, DirectiveRevision, DirectiveStep, DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, LinkDirectiveRequest, @@ -31,7 +31,7 @@ use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; +use crate::server::handlers::{api_keys, directive_documents, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -116,6 +116,14 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::list_directive_tasks, directives::cleanup_directive, directives::create_pr, + // Directive document endpoints + directive_documents::list_documents, + directive_documents::create_document, + directive_documents::get_document, + directive_documents::update_document, + directive_documents::ship_document, + directive_documents::archive_document, + directive_documents::list_document_tasks, // Order endpoints orders::list_orders, orders::create_order, @@ -218,6 +226,12 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage CreateDirectiveStepRequest, UpdateDirectiveStepRequest, CleanupResponse, + // Directive document schemas + DirectiveDocument, + directive_documents::CreateDirectiveDocumentRequest, + directive_documents::UpdateDirectiveDocumentRequest, + directive_documents::ShipDirectiveDocumentRequest, + directive_documents::DocumentTasksResponse, // Order schemas Order, OrderListResponse, @@ -239,6 +253,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage (name = "API Keys", description = "API key management for programmatic access"), (name = "Users", description = "User account management"), (name = "Directives", description = "Directive management with DAG-based step progression"), + (name = "Directive Documents", description = "Directive contracts — multi-document markdown contracts owned by a directive"), (name = "Orders", description = "Order management — card-based issue tracking for planned work items"), (name = "Settings", description = "User settings including repository history"), ) |
