//! 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()
}