summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/directive_documents.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server/handlers/directive_documents.rs')
-rw-r--r--makima/src/server/handlers/directive_documents.rs572
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()
+}