diff options
Diffstat (limited to 'makima/src/server/handlers/templates.rs')
| -rw-r--r-- | makima/src/server/handlers/templates.rs | 420 |
1 files changed, 416 insertions, 4 deletions
diff --git a/makima/src/server/handlers/templates.rs b/makima/src/server/handlers/templates.rs index c73007e..0cc5657 100644 --- a/makima/src/server/handlers/templates.rs +++ b/makima/src/server/handlers/templates.rs @@ -1,11 +1,24 @@ //! Contract types API handler. -use axum::{http::StatusCode, response::IntoResponse, Json}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; use serde::Serialize; use utoipa::ToSchema; +use uuid::Uuid; +use crate::db::models::{ + ContractTypeTemplateRecord, ContractTypeTemplateSummary, CreateTemplateRequest, + UpdateTemplateRequest, +}; +use crate::db::repository; use crate::llm::templates; use crate::llm::templates::ContractTypeTemplate; +use crate::server::auth::{Authenticated, MaybeAuthenticated}; +use crate::server::state::SharedState; // ============================================================================= // Contract Type Templates (Workflow Definitions) @@ -18,7 +31,14 @@ pub struct ListContractTypesResponse { pub contract_types: Vec<ContractTypeTemplate>, } -/// List all available contract type templates +/// Response for a single custom template +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TemplateResponse { + pub template: ContractTypeTemplateSummary, +} + +/// List all available contract type templates (built-in + custom) #[utoipa::path( get, path = "/api/v1/contract-types", @@ -27,8 +47,25 @@ pub struct ListContractTypesResponse { ), tag = "templates" )] -pub async fn list_contract_types() -> impl IntoResponse { - let contract_types = templates::all_contract_types(); +pub async fn list_contract_types( + State(state): State<SharedState>, + MaybeAuthenticated(auth): MaybeAuthenticated, +) -> impl IntoResponse { + // Start with built-in types + let mut contract_types = templates::all_contract_types(); + + // If authenticated, also fetch custom templates for this owner + if let Some(user) = auth { + if let Some(ref pool) = state.db_pool { + if let Ok(custom_templates) = + repository::list_templates_for_owner(pool, user.owner_id).await + { + for template in custom_templates { + contract_types.push(template_record_to_api(&template)); + } + } + } + } ( StatusCode::OK, @@ -36,3 +73,378 @@ pub async fn list_contract_types() -> impl IntoResponse { ) .into_response() } + +/// Create a new custom contract type template +#[utoipa::path( + post, + path = "/api/v1/contract-types", + request_body = CreateTemplateRequest, + responses( + (status = 201, description = "Template created successfully", body = TemplateResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 409, description = "Template with this name already exists") + ), + tag = "templates" +)] +pub async fn create_template( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateTemplateRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": "Database not configured" + })), + ) + .into_response(); + }; + + // Validate request + if req.name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "code": "INVALID_REQUEST", + "message": "Template name cannot be empty" + })), + ) + .into_response(); + } + + if req.phases.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "code": "INVALID_REQUEST", + "message": "Template must have at least one phase" + })), + ) + .into_response(); + } + + // Validate default_phase is in the phases list + if !req.phases.iter().any(|p| p.id == req.default_phase) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "code": "INVALID_REQUEST", + "message": format!("Default phase '{}' is not in the phases list", req.default_phase) + })), + ) + .into_response(); + } + + // Check that template name doesn't conflict with built-in types + let builtin_names = ["simple", "specification", "execute"]; + if builtin_names.contains(&req.name.to_lowercase().as_str()) { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "NAME_CONFLICT", + "message": "Cannot create a template with the same name as a built-in type" + })), + ) + .into_response(); + } + + match repository::create_template_for_owner(pool, auth.owner_id, req).await { + Ok(template) => ( + StatusCode::CREATED, + Json(serde_json::json!({ + "template": template_record_to_summary(&template) + })), + ) + .into_response(), + Err(e) => { + // Check for unique constraint violation + let error_str = e.to_string(); + if error_str.contains("unique") || error_str.contains("duplicate") { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "NAME_CONFLICT", + "message": "A template with this name already exists" + })), + ) + .into_response(); + } + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": format!("Failed to create template: {}", e) + })), + ) + .into_response() + } + } +} + +/// Get a specific contract type template by ID +#[utoipa::path( + get, + path = "/api/v1/contract-types/{id}", + params( + ("id" = Uuid, Path, description = "Template ID") + ), + responses( + (status = 200, description = "Template retrieved successfully", body = TemplateResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Template not found") + ), + tag = "templates" +)] +pub async fn get_template( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": "Database not configured" + })), + ) + .into_response(); + }; + + match repository::get_template_for_owner(pool, id, auth.owner_id).await { + Ok(Some(template)) => ( + StatusCode::OK, + Json(serde_json::json!({ + "template": template_record_to_summary(&template) + })), + ) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "code": "NOT_FOUND", + "message": "Template not found" + })), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": format!("Failed to get template: {}", e) + })), + ) + .into_response(), + } +} + +/// Update a contract type template +#[utoipa::path( + put, + path = "/api/v1/contract-types/{id}", + params( + ("id" = Uuid, Path, description = "Template ID") + ), + request_body = UpdateTemplateRequest, + responses( + (status = 200, description = "Template updated successfully", body = TemplateResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Template not found"), + (status = 409, description = "Version conflict") + ), + tag = "templates" +)] +pub async fn update_template( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateTemplateRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": "Database not configured" + })), + ) + .into_response(); + }; + + // Validate phases if provided + if let Some(ref phases) = req.phases { + if phases.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "code": "INVALID_REQUEST", + "message": "Template must have at least one phase" + })), + ) + .into_response(); + } + + // If default_phase is also provided, validate it's in the phases + if let Some(ref default_phase) = req.default_phase { + if !phases.iter().any(|p| &p.id == default_phase) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "code": "INVALID_REQUEST", + "message": format!("Default phase '{}' is not in the phases list", default_phase) + })), + ) + .into_response(); + } + } + } + + // Check that template name doesn't conflict with built-in types + if let Some(ref name) = req.name { + let builtin_names = ["simple", "specification", "execute"]; + if builtin_names.contains(&name.to_lowercase().as_str()) { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "NAME_CONFLICT", + "message": "Cannot rename template to a built-in type name" + })), + ) + .into_response(); + } + } + + match repository::update_template_for_owner(pool, id, auth.owner_id, req).await { + Ok(Some(template)) => ( + StatusCode::OK, + Json(serde_json::json!({ + "template": template_record_to_summary(&template) + })), + ) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "code": "NOT_FOUND", + "message": "Template not found" + })), + ) + .into_response(), + Err(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "VERSION_CONFLICT", + "message": format!("Version conflict: expected {}, found {}", expected, actual), + "expectedVersion": expected, + "actualVersion": actual + })), + ) + .into_response(), + Err(e) => { + let error_str = e.to_string(); + if error_str.contains("unique") || error_str.contains("duplicate") { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "NAME_CONFLICT", + "message": "A template with this name already exists" + })), + ) + .into_response(); + } + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": format!("Failed to update template: {}", e) + })), + ) + .into_response() + } + } +} + +/// Delete a contract type template +#[utoipa::path( + delete, + path = "/api/v1/contract-types/{id}", + params( + ("id" = Uuid, Path, description = "Template ID") + ), + responses( + (status = 204, description = "Template deleted successfully"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Template not found") + ), + tag = "templates" +)] +pub async fn delete_template( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": "Database not configured" + })), + ) + .into_response(); + }; + + match repository::delete_template_for_owner(pool, id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "code": "NOT_FOUND", + "message": "Template not found" + })), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": format!("Failed to delete template: {}", e) + })), + ) + .into_response(), + } +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Convert a database template record to the API template format +fn template_record_to_api(template: &ContractTypeTemplateRecord) -> ContractTypeTemplate { + ContractTypeTemplate { + id: template.id.to_string(), + name: template.name.clone(), + description: template.description.clone().unwrap_or_default(), + phases: template.phases.iter().map(|p| p.id.clone()).collect(), + default_phase: template.default_phase.clone(), + is_builtin: false, + } +} + +/// Convert a database template record to the summary format +fn template_record_to_summary(template: &ContractTypeTemplateRecord) -> ContractTypeTemplateSummary { + ContractTypeTemplateSummary { + id: template.id, + name: template.name.clone(), + description: template.description.clone(), + phases: template.phases.clone(), + default_phase: template.default_phase.clone(), + is_builtin: false, + version: template.version, + created_at: template.created_at, + } +} |
