summaryrefslogtreecommitdiff
path: root/makima/src/server
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-29 02:56:44 +0000
committersoryu <soryu@soryu.co>2026-01-29 02:56:44 +0000
commitf19acd400cc5bbe1fe51c004c50ee90d704240d8 (patch)
treeb7dcfd6926efcafd6eac33e713ebd321ec4284d0 /makima/src/server
parent7af8561677cfdcfd23d099a25783c7fef51d1ba6 (diff)
downloadsoryu-f19acd400cc5bbe1fe51c004c50ee90d704240d8.tar.gz
soryu-f19acd400cc5bbe1fe51c004c50ee90d704240d8.zip
Fix contract type selection
Diffstat (limited to 'makima/src/server')
-rw-r--r--makima/src/server/handlers/contract_chat.rs1
-rw-r--r--makima/src/server/handlers/templates.rs420
-rw-r--r--makima/src/server/handlers/transcript_analysis.rs1
-rw-r--r--makima/src/server/mod.rs11
4 files changed, 428 insertions, 5 deletions
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs
index c1ca3ed..a066595 100644
--- a/makima/src/server/handlers/contract_chat.rs
+++ b/makima/src/server/handlers/contract_chat.rs
@@ -2595,6 +2595,7 @@ async fn handle_contract_request(
local_only: None,
red_team_enabled: None,
red_team_prompt: None,
+ template_id: None,
};
let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await {
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,
+ }
+}
diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs
index 0a6ac7f..920851c 100644
--- a/makima/src/server/handlers/transcript_analysis.rs
+++ b/makima/src/server/handlers/transcript_analysis.rs
@@ -281,6 +281,7 @@ pub async fn create_contract_from_analysis(
local_only: None,
red_team_enabled: None,
red_team_prompt: None,
+ template_id: None,
};
let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await {
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index 7c13f08..8456006 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -213,7 +213,16 @@ pub fn make_router(state: SharedState) -> Router {
// Timeline endpoint (unified history for user)
.route("/timeline", get(history::get_timeline))
// Contract type templates (workflow definitions)
- .route("/contract-types", get(templates::list_contract_types))
+ .route(
+ "/contract-types",
+ get(templates::list_contract_types).post(templates::create_template),
+ )
+ .route(
+ "/contract-types/{id}",
+ get(templates::get_template)
+ .put(templates::update_template)
+ .delete(templates::delete_template),
+ )
// Settings endpoints
.route(
"/settings/repository-history",