//! 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::{self, RepositoryError};
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/contracts/{document_id}`.
#[derive(Debug, Default, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateDirectiveDocumentRequest {
pub title: Option<String>,
pub body: Option<String>,
/// Per-contract merge mode. `shared` lands commits on the directive's
/// branch; `own_pr` carves out a contract-specific branch + PR. The
/// queue scheduler reads this when activating the contract.
pub merge_mode: Option<String>,
}
/// Body for `POST /api/v1/contracts/{document_id}/reorder`.
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ReorderDirectiveDocumentRequest {
/// New 0-indexed queue position within the parent directive.
pub position: i32,
}
/// Body for `POST /api/v1/contracts/{document_id}/complete`. Both fields
/// are optional — supplying them records PR provenance; leaving them off
/// just marks the contract done.
#[derive(Debug, Default, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CompleteContractRequest {
pub pr_url: Option<String>,
pub pr_branch: Option<String>,
}
/// Body for `POST /api/v1/contracts/{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}/contracts",
params(("directive_id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 200, description = "List of contracts under the directive", 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}/contracts",
params(("directive_id" = Uuid, Path, description = "Directive ID")),
request_body = CreateDirectiveDocumentRequest,
responses(
(status = 201, description = "Contract 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/contracts/{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/contracts/{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(),
req.merge_mode.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/contracts/{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/contracts/{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/contracts/{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/contracts/{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()
}
// =============================================================================
// Reorder a contract within its parent directive's queue.
//
// Drag-to-reorder in the sidebar lands here. The `position` field on each
// contract drives the ORDER BY in `list_directive_documents`, so the
// repository function does the bookkeeping (shift siblings, set new
// position) inside a single transaction. The handler only owns auth.
// =============================================================================
/// Move a contract to a new queue position within its parent directive.
#[utoipa::path(
post,
path = "/api/v1/contracts/{document_id}/reorder",
params(("document_id" = Uuid, Path, description = "Contract ID")),
request_body = ReorderDirectiveDocumentRequest,
responses(
(status = 200, description = "Contract reordered", 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 reorder_contract(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(document_id): Path<Uuid>,
Json(req): Json<ReorderDirectiveDocumentRequest>,
) -> 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(_)) => {}
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::reorder_directive_document_position(pool, document_id, req.position).await {
Ok(Some(doc)) => Json(doc).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Contract not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to reorder contract: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("REORDER_FAILED", &e.to_string())),
)
.into_response()
}
}
}
// =============================================================================
// Contract lifecycle: start / pause / complete / unlock
//
// State machine: `draft → queued → active → shipped → archived`. The
// repository functions enforce transition validity and handle queue
// auto-promotion when the active slot frees. Handlers here own auth
// and mapping to HTTP status codes.
// =============================================================================
/// Common path: load + ownership-check, then dispatch a state-transition
/// closure. Cuts the boilerplate from start/pause/complete/unlock down
/// to a couple of lines each.
async fn run_contract_transition<F, Fut>(
pool: sqlx::PgPool,
owner_id: Uuid,
contract_id: Uuid,
f: F,
) -> impl IntoResponse
where
F: FnOnce(sqlx::PgPool, Uuid) -> Fut,
Fut: std::future::Future<
Output = Result<Option<crate::db::models::DirectiveDocument>, RepositoryError>,
>,
{
match load_owned_document(&pool, owner_id, 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 f(pool, contract_id).await {
Ok(Some(doc)) => Json(doc).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Contract not found")),
)
.into_response(),
Err(RepositoryError::Validation(msg)) => (
StatusCode::BAD_REQUEST,
Json(ApiError::new("VALIDATION", &msg)),
)
.into_response(),
Err(RepositoryError::VersionConflict { expected, actual }) => (
StatusCode::CONFLICT,
Json(ApiError::new(
"VERSION_CONFLICT",
&format!("expected version {}, actual {}", expected, actual),
)),
)
.into_response(),
Err(RepositoryError::Database(e)) => {
tracing::error!("Contract transition failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("TRANSITION_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Lock & start a draft contract. If a sibling is already `active`,
/// this contract goes to `queued`; otherwise it activates immediately.
#[utoipa::path(
post,
path = "/api/v1/contracts/{document_id}/start",
params(("document_id" = Uuid, Path, description = "Contract ID")),
responses(
(status = 200, description = "Contract started", body = crate::db::models::DirectiveDocument),
(status = 400, description = "Invalid state transition", body = ApiError),
(status = 404, description = "Not found", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directive Documents"
)]
pub async fn start_contract(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(document_id): Path<Uuid>,
) -> impl IntoResponse {
let Some(pool) = state.db_pool.clone() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move {
repository::start_contract(&pool, id).await
})
.await
.into_response()
}
/// Pause an active contract — moves it back to `queued` and lets the
/// next queued sibling take the active slot. The orchestrator daemon
/// stop is the caller's responsibility.
#[utoipa::path(
post,
path = "/api/v1/contracts/{document_id}/pause",
params(("document_id" = Uuid, Path, description = "Contract ID")),
responses(
(status = 200, description = "Contract paused", body = crate::db::models::DirectiveDocument),
(status = 400, description = "Invalid state transition", body = ApiError),
(status = 404, description = "Not found", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directive Documents"
)]
pub async fn pause_contract(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(document_id): Path<Uuid>,
) -> impl IntoResponse {
let Some(pool) = state.db_pool.clone() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move {
repository::pause_contract(&pool, id).await
})
.await
.into_response()
}
/// Mark an active contract as `shipped` (work done). PR url + branch
/// are optional — pass them to record provenance, leave them off to
/// just close out the contract. Auto-promotes the next queued sibling.
#[utoipa::path(
post,
path = "/api/v1/contracts/{document_id}/complete",
params(("document_id" = Uuid, Path, description = "Contract ID")),
request_body = CompleteContractRequest,
responses(
(status = 200, description = "Contract completed", body = crate::db::models::DirectiveDocument),
(status = 400, description = "Invalid state transition", body = ApiError),
(status = 404, description = "Not found", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directive Documents"
)]
pub async fn complete_contract(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(document_id): Path<Uuid>,
Json(req): Json<CompleteContractRequest>,
) -> impl IntoResponse {
let Some(pool) = state.db_pool.clone() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
run_contract_transition(pool, auth.owner_id, document_id, move |pool, id| async move {
repository::complete_contract(&pool, id, req.pr_url.as_deref(), req.pr_branch.as_deref()).await
})
.await
.into_response()
}
/// Unlock a queued or active contract back to `draft` so its spec is
/// editable again. If the contract was active, the slot frees and the
/// next queued sibling auto-promotes.
#[utoipa::path(
post,
path = "/api/v1/contracts/{document_id}/unlock",
params(("document_id" = Uuid, Path, description = "Contract ID")),
responses(
(status = 200, description = "Contract unlocked", body = crate::db::models::DirectiveDocument),
(status = 400, description = "Invalid state transition", body = ApiError),
(status = 404, description = "Not found", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directive Documents"
)]
pub async fn unlock_contract(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(document_id): Path<Uuid>,
) -> impl IntoResponse {
let Some(pool) = state.db_pool.clone() else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move {
repository::unlock_contract(&pool, id).await
})
.await
.into_response()
}