//! Contract types API handler.
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)
// =============================================================================
/// Response for listing contract types
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ListContractTypesResponse {
pub contract_types: Vec<ContractTypeTemplate>,
}
/// 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",
responses(
(status = 200, description = "Contract types retrieved successfully", body = ListContractTypesResponse)
),
tag = "templates"
)]
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,
Json(ListContractTypesResponse { contract_types }),
)
.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,
}
}