//! HTTP handlers for directive CRUD operations. use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, Json, }; use uuid::Uuid; use std::collections::HashMap; use crate::db::models::{ ChainStep, ChainStepWithContract, ChainWithSteps, CreateDirectiveRequest, Directive, DirectiveChain, DirectiveListResponse, DirectiveWithChains, EvaluationListResponse, StepContractSummary, UpdateDirectiveRequest, }; use crate::db::repository::{self, RepositoryError}; use crate::orchestration; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; /// List all directives for the authenticated user's owner. #[utoipa::path( get, path = "/api/v1/directives", responses( (status = 200, description = "List of directives", body = DirectiveListResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Directives" )] pub async fn list_directives( State(state): State, Authenticated(auth): Authenticated, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::list_directives_for_owner(pool, auth.owner_id).await { Ok(directives) => { let total = directives.len() as i64; Json(DirectiveListResponse { directives, total }).into_response() } Err(e) => { tracing::error!("Failed to list directives: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get a directive by ID with its chains. #[utoipa::path( get, path = "/api/v1/directives/{id}", params( ("id" = Uuid, Path, description = "Directive ID") ), responses( (status = 200, description = "Directive details with chains", body = DirectiveWithChains), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Directive not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Directives" )] pub async fn get_directive( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; let directive = match repository::get_directive_for_owner(pool, id, auth.owner_id).await { Ok(Some(d)) => d, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Directive not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get directive {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; let chains = match repository::list_chains_for_directive(pool, id).await { Ok(c) => c, Err(e) => { tracing::warn!("Failed to get chains for directive {}: {}", id, e); Vec::new() } }; // Build chains with steps let mut all_steps_by_chain = Vec::new(); for chain in &chains { let steps = match repository::list_steps_for_chain(pool, chain.id).await { Ok(s) => s, Err(e) => { tracing::warn!("Failed to get steps for chain {}: {}", chain.id, e); Vec::new() } }; all_steps_by_chain.push(steps); } // Collect all contract IDs (from steps + orchestrator) let mut contract_ids: Vec = all_steps_by_chain .iter() .flat_map(|steps| steps.iter().filter_map(|s| s.contract_id)) .collect(); if let Some(orch_id) = directive.orchestrator_contract_id { contract_ids.push(orch_id); } // Batch fetch contract summaries let mut summary_map: HashMap = if contract_ids.is_empty() { HashMap::new() } else { match repository::get_contract_summaries_batch(pool, &contract_ids).await { Ok(summaries) => summaries.into_iter().map(|s| (s.id, s)).collect(), Err(e) => { tracing::warn!("Failed to fetch contract summaries: {}", e); HashMap::new() } } }; // Build enriched chains let chains_with_steps: Vec = chains .into_iter() .zip(all_steps_by_chain.into_iter()) .map(|(chain, steps)| { let enriched_steps = steps .into_iter() .map(|step| { let contract_summary = step.contract_id.and_then(|id| summary_map.remove(&id)); ChainStepWithContract { step, contract_summary, } }) .collect(); ChainWithSteps { chain, steps: enriched_steps, } }) .collect(); let orchestrator_contract_summary = directive .orchestrator_contract_id .and_then(|id| summary_map.remove(&id)); Json(DirectiveWithChains { directive, orchestrator_contract_summary, chains: chains_with_steps, }) .into_response() } /// Create a new directive. #[utoipa::path( post, path = "/api/v1/directives", request_body = CreateDirectiveRequest, responses( (status = 201, description = "Directive created", body = Directive), (status = 400, description = "Invalid request", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Directives" )] pub async fn create_directive( State(state): State, Authenticated(auth): Authenticated, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::create_directive_for_owner(pool, auth.owner_id, req).await { Ok(directive) => (StatusCode::CREATED, Json(directive)).into_response(), Err(e) => { tracing::error!("Failed to create directive: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Update an existing directive. #[utoipa::path( put, path = "/api/v1/directives/{id}", params( ("id" = Uuid, Path, description = "Directive ID") ), request_body = UpdateDirectiveRequest, responses( (status = 200, description = "Directive updated", body = Directive), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Directive not found", body = ApiError), (status = 409, description = "Version conflict", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Directives" )] pub async fn update_directive( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::update_directive_for_owner(pool, id, auth.owner_id, req).await { Ok(Some(directive)) => Json(directive).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Directive not found")), ) .into_response(), Err(RepositoryError::VersionConflict { expected, actual }) => ( StatusCode::CONFLICT, Json(ApiError::new( "VERSION_CONFLICT", format!( "Version conflict: expected {}, actual {}", expected, actual ), )), ) .into_response(), Err(RepositoryError::Database(e)) => { tracing::error!("Failed to update directive {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Delete a directive. #[utoipa::path( delete, path = "/api/v1/directives/{id}", params( ("id" = Uuid, Path, description = "Directive ID") ), responses( (status = 204, description = "Directive deleted"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Directive not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Directives" )] pub async fn delete_directive( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::delete_directive_for_owner(pool, id, auth.owner_id).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Directive not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to delete directive {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// List chains for a directive. #[utoipa::path( get, path = "/api/v1/directives/{id}/chains", params( ("id" = Uuid, Path, description = "Directive ID") ), responses( (status = 200, description = "List of chains", body = Vec), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Directive not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Directives" )] pub async fn list_chains( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify directive exists and belongs to owner match repository::get_directive_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Directive not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get directive {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::list_chains_for_directive(pool, id).await { Ok(chains) => Json(chains).into_response(), Err(e) => { tracing::error!("Failed to list chains for directive {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get a chain with its steps. #[utoipa::path( get, path = "/api/v1/directives/{id}/chains/{chain_id}", params( ("id" = Uuid, Path, description = "Directive ID"), ("chain_id" = Uuid, Path, description = "Chain ID") ), responses( (status = 200, description = "Chain with steps", body = ChainWithSteps), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Chain not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Directives" )] pub async fn get_chain( State(state): State, Authenticated(auth): Authenticated, Path((id, chain_id)): Path<(Uuid, Uuid)>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify directive exists and belongs to owner match repository::get_directive_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Directive not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get directive {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } // Get the chain and verify it belongs to this directive let chains = match repository::list_chains_for_directive(pool, id).await { Ok(c) => c, Err(e) => { tracing::error!("Failed to list chains: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; let chain = match chains.into_iter().find(|c| c.id == chain_id) { Some(c) => c, None => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Chain not found")), ) .into_response(); } }; let steps = match repository::list_steps_for_chain(pool, chain_id).await { Ok(s) => s, Err(e) => { tracing::warn!("Failed to get steps for chain {}: {}", chain_id, e); Vec::new() } }; // Collect contract IDs from steps let contract_ids: Vec = steps.iter().filter_map(|s| s.contract_id).collect(); let mut summary_map: HashMap = if contract_ids.is_empty() { HashMap::new() } else { match repository::get_contract_summaries_batch(pool, &contract_ids).await { Ok(summaries) => summaries.into_iter().map(|s| (s.id, s)).collect(), Err(e) => { tracing::warn!("Failed to fetch contract summaries: {}", e); HashMap::new() } } }; let enriched_steps = steps .into_iter() .map(|step| { let contract_summary = step.contract_id.and_then(|id| summary_map.remove(&id)); ChainStepWithContract { step, contract_summary, } }) .collect(); Json(ChainWithSteps { chain, steps: enriched_steps, }) .into_response() } /// Start a directive: create a planning contract and begin orchestration. #[utoipa::path( post, path = "/api/v1/directives/{id}/start", params( ("id" = Uuid, Path, description = "Directive ID") ), responses( (status = 200, description = "Directive started", body = Directive), (status = 400, description = "Directive not in draft status", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Directive not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Directives" )] pub async fn start_directive( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match orchestration::directive::init_directive(pool, &state, auth.owner_id, id).await { Ok(directive) => Json(directive).into_response(), Err(e) if e.contains("not found") => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", e)), ) .into_response(), Err(e) if e.contains("must be in 'draft'") => ( StatusCode::BAD_REQUEST, Json(ApiError::new("INVALID_STATUS", e)), ) .into_response(), Err(e) => { tracing::error!("Failed to start directive {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("START_FAILED", e)), ) .into_response() } } } /// Trigger a manual evaluation for a step. #[utoipa::path( post, path = "/api/v1/directives/{id}/steps/{step_id}/evaluate", params( ("id" = Uuid, Path, description = "Directive ID"), ("step_id" = Uuid, Path, description = "Step ID") ), responses( (status = 200, description = "Evaluation triggered", body = ChainStep), (status = 400, description = "Step cannot be evaluated", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Directives" )] pub async fn evaluate_step( State(state): State, Authenticated(auth): Authenticated, Path((id, step_id)): Path<(Uuid, Uuid)>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match orchestration::directive::trigger_manual_evaluation(pool, &state, auth.owner_id, id, step_id).await { Ok(step) => Json(step).into_response(), Err(e) if e.contains("not found") || e.contains("Not found") => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", e)), ) .into_response(), Err(e) if e.contains("hasn't been executed") || e.contains("no active chain") => ( StatusCode::BAD_REQUEST, Json(ApiError::new("INVALID_STATE", e)), ) .into_response(), Err(e) => { tracing::error!("Failed to trigger evaluation for step {}: {}", step_id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("EVALUATION_FAILED", e)), ) .into_response() } } } /// List evaluations for a step. #[utoipa::path( get, path = "/api/v1/directives/{id}/steps/{step_id}/evaluations", params( ("id" = Uuid, Path, description = "Directive ID"), ("step_id" = Uuid, Path, description = "Step ID") ), responses( (status = 200, description = "List of evaluations", body = EvaluationListResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Directives" )] pub async fn list_evaluations( State(state): State, Authenticated(auth): Authenticated, Path((id, step_id)): Path<(Uuid, Uuid)>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify directive exists and belongs to owner match repository::get_directive_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Directive not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get directive {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::list_evaluations_for_step(pool, step_id).await { Ok(evaluations) => { let total = evaluations.len() as i64; Json(EvaluationListResponse { evaluations, total }).into_response() } Err(e) => { tracing::error!("Failed to list evaluations for step {}: {}", step_id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } }