diff options
Diffstat (limited to 'makima/src/server')
| -rw-r--r-- | makima/src/server/handlers/directive_documents.rs | 222 | ||||
| -rw-r--r-- | makima/src/server/handlers/files.rs | 5 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 5 | ||||
| -rw-r--r-- | makima/src/server/handlers/versions.rs | 5 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 16 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 5 |
6 files changed, 257 insertions, 1 deletions
diff --git a/makima/src/server/handlers/directive_documents.rs b/makima/src/server/handlers/directive_documents.rs index ed38ee4..23081b5 100644 --- a/makima/src/server/handlers/directive_documents.rs +++ b/makima/src/server/handlers/directive_documents.rs @@ -21,7 +21,7 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::db::models::{DirectiveStep, Task}; -use crate::db::repository; +use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; @@ -60,6 +60,16 @@ pub struct ReorderDirectiveDocumentRequest { 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")] @@ -656,3 +666,213 @@ pub async fn reorder_contract( } } } + +// ============================================================================= +// 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() +} diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs index 05e871c..711be41 100644 --- a/makima/src/server/handlers/files.rs +++ b/makima/src/server/handlers/files.rs @@ -277,6 +277,11 @@ pub async fn update_file( ) .into_response() } + Err(RepositoryError::Validation(msg)) => ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("VALIDATION", &msg)), + ) + .into_response(), } } diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index 63b1827..be5387e 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -467,6 +467,11 @@ pub async fn update_task( ) .into_response() } + Err(RepositoryError::Validation(msg)) => ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("VALIDATION", &msg)), + ) + .into_response(), } } diff --git a/makima/src/server/handlers/versions.rs b/makima/src/server/handlers/versions.rs index 15118d6..bb1b00c 100644 --- a/makima/src/server/handlers/versions.rs +++ b/makima/src/server/handlers/versions.rs @@ -203,5 +203,10 @@ pub async fn restore_version( ) .into_response() } + Err(RepositoryError::Validation(msg)) => ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("VALIDATION", &msg)), + ) + .into_response(), } } diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 68d3dea..a3a1886 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -242,6 +242,22 @@ pub fn make_router(state: SharedState) -> Router { post(directive_documents::reorder_contract), ) .route( + "/contracts/{document_id}/start", + post(directive_documents::start_contract), + ) + .route( + "/contracts/{document_id}/pause", + post(directive_documents::pause_contract), + ) + .route( + "/contracts/{document_id}/complete", + post(directive_documents::complete_contract), + ) + .route( + "/contracts/{document_id}/unlock", + post(directive_documents::unlock_contract), + ) + .route( "/contracts/{document_id}/tasks", get(directive_documents::list_document_tasks), ) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 7ddaf1b..184d12a 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -124,6 +124,10 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directive_documents::ship_document, directive_documents::archive_document, directive_documents::reorder_contract, + directive_documents::start_contract, + directive_documents::pause_contract, + directive_documents::complete_contract, + directive_documents::unlock_contract, directive_documents::list_document_tasks, // Order endpoints orders::list_orders, @@ -233,6 +237,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directive_documents::UpdateDirectiveDocumentRequest, directive_documents::ShipDirectiveDocumentRequest, directive_documents::ReorderDirectiveDocumentRequest, + directive_documents::CompleteContractRequest, directive_documents::DocumentTasksResponse, // Order schemas Order, |
