summaryrefslogblamecommitdiff
path: root/makima/src/server/handlers/directive_documents.rs
blob: 23081b58d4d9adf34383c3bc76b0b94f18672381 (plain) (tree)






















                                                                                  
                                                   

















                                                                                
                                                     




                                                



                                                                          

 







                                                                 









                                                                           
                                                         






































                                                                                        
                                                         

                                                                        
                                                                                                                                





















































                                                                                        
                                                         


                                                                        
                                                                                                      





























































                                                                                        
                                             
















































                                                                                                        
                                             















































                                                                                                      
                                  























                                                                             
                                                  






































































                                                                                                      
                                                     


































































                                                                                                       
                                                                  























                                                                              
                                                   



































































                                                                                                               








































































                                                                                                        

















































































































































































































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