summaryrefslogblamecommitdiff
path: root/makima/src/server/handlers/directive_documents.rs
blob: ed38ee4a3bebd75ac56c52e9891d7649e349b860 (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;
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}/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()
        }
    }
}