diff options
Diffstat (limited to 'makima/src/server/handlers/directive_documents.rs')
| -rw-r--r-- | makima/src/server/handlers/directive_documents.rs | 572 |
1 files changed, 572 insertions, 0 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() +} |
