//! 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, /// Optional document body (markdown). Defaults to empty string. pub body: Option, } /// Body for `PATCH /api/v1/contracts/{document_id}`. #[derive(Debug, Default, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateDirectiveDocumentRequest { pub title: Option, pub body: Option, /// 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, } /// 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, pub pr_branch: Option, } /// 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, } // ============================================================================= // 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, 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), (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, Authenticated(auth): Authenticated, Path(directive_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(directive_id): Path, Json(req): Json, ) -> 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, Authenticated(auth): Authenticated, Path(document_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(document_id): Path, Json(req): Json, ) -> 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, Authenticated(auth): Authenticated, Path(document_id): Path, Json(req): Json, ) -> 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, Authenticated(auth): Authenticated, Path(document_id): Path, ) -> 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, pub tasks: Vec, } /// 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, Authenticated(auth): Authenticated, Path(document_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(document_id): Path, Json(req): Json, ) -> 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( 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, 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, Authenticated(auth): Authenticated, Path(document_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(document_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(document_id): Path, Json(req): Json, ) -> 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, Authenticated(auth): Authenticated, Path(document_id): Path, ) -> 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() }