summaryrefslogblamecommitdiff
path: root/makima/src/server/handlers/templates.rs
blob: 0cc5657aa9f95d2d8b5ec520cf39152915ef09ad (plain) (tree)
1
2
3
4
5
6
7
8
9
                               
 





                           
                     
                     
               
 




                                                                                   
                          
                                                

                                                             
 










                                                                                







                                                                  







                                                                                                               


















                                                                               






                                                           






















































































































































































































































































































































































                                                                                                         
//! 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,
    }
}