diff options
Diffstat (limited to 'makima/src/server/handlers/contracts.rs')
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 1284 |
1 files changed, 1284 insertions, 0 deletions
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs new file mode 100644 index 0000000..3d726df --- /dev/null +++ b/makima/src/server/handlers/contracts.rs @@ -0,0 +1,1284 @@ +//! HTTP handlers for contract CRUD operations. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{ + AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest, + ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, + CreateContractRequest, CreateManagedRepositoryRequest, UpdateContractRequest, + UpdateTaskRequest, +}; +use crate::db::repository::{self, RepositoryError}; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// Helper function to update the supervisor task with repository info when a primary repo is added. +/// This ensures the supervisor has access to the repository when it starts. +async fn update_supervisor_with_repo_if_needed( + pool: &sqlx::PgPool, + contract_id: uuid::Uuid, + owner_id: uuid::Uuid, + repo: &ContractRepository, +) { + // Only update for primary repositories + if !repo.is_primary { + return; + } + + // Get the supervisor task + let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await { + Ok(Some(s)) => s, + Ok(None) => { + tracing::debug!(contract_id = %contract_id, "No supervisor task found"); + return; + } + Err(e) => { + tracing::warn!(contract_id = %contract_id, error = %e, "Failed to get supervisor task"); + return; + } + }; + + // Only update if supervisor doesn't have a repository URL yet + if supervisor.repository_url.is_some() { + tracing::debug!( + supervisor_id = %supervisor.id, + "Supervisor already has repository URL" + ); + return; + } + + // Get repository URL (for remote repos) or local path (for local repos) + let repo_url = repo.repository_url.clone().or_else(|| repo.local_path.clone()); + + if repo_url.is_none() && repo.source_type != "managed" { + tracing::debug!( + supervisor_id = %supervisor.id, + "Repository has no URL or path to assign" + ); + return; + } + + // Update supervisor task with repository info + let update_req = UpdateTaskRequest { + repository_url: repo_url, + version: Some(supervisor.version), + ..Default::default() + }; + + match repository::update_task_for_owner(pool, supervisor.id, owner_id, update_req).await { + Ok(Some(updated)) => { + tracing::info!( + supervisor_id = %updated.id, + repository_url = ?updated.repository_url, + "Updated supervisor task with repository URL" + ); + } + Ok(None) => { + tracing::warn!(supervisor_id = %supervisor.id, "Supervisor task not found during update"); + } + Err(e) => { + tracing::warn!( + supervisor_id = %supervisor.id, + error = %e, + "Failed to update supervisor with repository URL" + ); + } + } +} + +/// List all root contracts (no parent) for the authenticated user's owner. +#[utoipa::path( + get, + path = "/api/v1/contracts", + responses( + (status = 200, description = "List of root contracts", body = ContractListResponse), + (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 = "Contracts" +)] +pub async fn list_contracts( + State(state): State<SharedState>, + 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_contracts_for_owner(pool, auth.owner_id).await { + Ok(contracts) => { + let total = contracts.len() as i64; + Json(ContractListResponse { contracts, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list contracts: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a contract by ID with all its relations (repositories, files, tasks). +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Contract details with relations", body = ContractWithRelations), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract 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 = "Contracts" +)] +pub async fn get_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<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(); + }; + + // Get the contract + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Get repositories + let repositories = match repository::list_contract_repositories(pool, id).await { + Ok(r) => r, + Err(e) => { + tracing::warn!("Failed to get repositories for {}: {}", id, e); + Vec::new() + } + }; + + // Get files + let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { + Ok(f) => f, + Err(e) => { + tracing::warn!("Failed to get files for contract {}: {}", id, e); + Vec::new() + } + }; + + // Get tasks + let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { + Ok(t) => t, + Err(e) => { + tracing::warn!("Failed to get tasks for contract {}: {}", id, e); + Vec::new() + } + }; + + Json(ContractWithRelations { + contract, + repositories, + files, + tasks, + }) + .into_response() +} + +/// Create a new contract. +#[utoipa::path( + post, + path = "/api/v1/contracts", + request_body = CreateContractRequest, + responses( + (status = 201, description = "Contract created", body = ContractSummary), + (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 = "Contracts" +)] +pub async fn create_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateContractRequest>, +) -> 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_contract_for_owner(pool, auth.owner_id, req.clone()).await { + Ok(contract) => { + // Create supervisor task for this contract + let supervisor_name = format!("{} Supervisor", contract.name); + let supervisor_plan = format!( + "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", + contract.name, + contract.description.as_deref().unwrap_or("No description provided.") + ); + + // Get repository info from contract if available + let repo_url = { + // Try to get the first repository associated with this contract + match repository::list_contract_repositories(pool, contract.id).await { + Ok(repos) if !repos.is_empty() => { + let repo = &repos[0]; + repo.repository_url.clone() + } + _ => None, + } + }; + + let supervisor_req = crate::db::models::CreateTaskRequest { + name: supervisor_name, + description: None, + plan: supervisor_plan, + repository_url: repo_url, + base_branch: None, + target_branch: None, + parent_task_id: None, + contract_id: contract.id, + target_repo_path: None, + completion_action: None, + continue_from_task_id: None, + copy_files: None, + is_supervisor: true, + checkpoint_sha: None, + priority: 0, + merge_mode: None, + }; + + match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { + Ok(supervisor_task) => { + tracing::info!( + contract_id = %contract.id, + supervisor_task_id = %supervisor_task.id, + is_supervisor = supervisor_task.is_supervisor, + "Created supervisor task for contract" + ); + + // Update contract with supervisor_task_id + let update_req = crate::db::models::UpdateContractRequest { + supervisor_task_id: Some(supervisor_task.id), + version: Some(contract.version), + ..Default::default() + }; + if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await { + tracing::warn!( + contract_id = %contract.id, + error = %e, + "Failed to link supervisor task to contract" + ); + } + } + Err(e) => { + tracing::warn!( + contract_id = %contract.id, + error = %e, + "Failed to create supervisor task for contract" + ); + } + } + + // Get the summary version with counts + match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await + { + Ok(Some(summary)) => (StatusCode::CREATED, Json(summary)).into_response(), + Ok(None) => { + // Shouldn't happen, but return basic info if it does + ( + StatusCode::CREATED, + Json(ContractSummary { + id: contract.id, + name: contract.name, + description: contract.description, + phase: contract.phase, + status: contract.status, + file_count: 0, + task_count: 0, + repository_count: 0, + version: contract.version, + created_at: contract.created_at, + }), + ) + .into_response() + } + Err(e) => { + tracing::warn!("Failed to get contract summary: {}", e); + ( + StatusCode::CREATED, + Json(ContractSummary { + id: contract.id, + name: contract.name, + description: contract.description, + phase: contract.phase, + status: contract.status, + file_count: 0, + task_count: 0, + repository_count: 0, + version: contract.version, + created_at: contract.created_at, + }), + ) + .into_response() + } + } + } + Err(e) => { + tracing::error!("Failed to create contract: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Update a contract. +#[utoipa::path( + put, + path = "/api/v1/contracts/{id}", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = UpdateContractRequest, + responses( + (status = 200, description = "Contract updated", body = ContractSummary), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract 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 = "Contracts" +)] +pub async fn update_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateContractRequest>, +) -> 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_contract_for_owner(pool, id, auth.owner_id, req).await { + Ok(Some(contract)) => { + // If contract is completed, stop the supervisor task + if contract.status == "completed" { + if let Some(supervisor_task_id) = contract.supervisor_task_id { + // Get the supervisor task to find its daemon + if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { + if let Some(daemon_id) = supervisor.daemon_id { + let state_clone = state.clone(); + tokio::spawn(async move { + // Gracefully interrupt the supervisor + let cmd = crate::server::state::DaemonCommand::InterruptTask { + task_id: supervisor_task_id, + graceful: true, + }; + if let Err(e) = state_clone.send_daemon_command(daemon_id, cmd).await { + tracing::warn!( + supervisor_task_id = %supervisor_task_id, + daemon_id = %daemon_id, + error = %e, + "Failed to stop supervisor task on contract completion" + ); + } else { + tracing::info!( + supervisor_task_id = %supervisor_task_id, + contract_id = %id, + "Stopped supervisor task on contract completion" + ); + } + }); + } + } + } + } + + // Get summary with counts + match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await + { + Ok(Some(summary)) => Json(summary).into_response(), + _ => Json(ContractSummary { + id: contract.id, + name: contract.name, + description: contract.description, + phase: contract.phase, + status: contract.status, + file_count: 0, + task_count: 0, + repository_count: 0, + version: contract.version, + created_at: contract.created_at, + }) + .into_response(), + } + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(), + Err(RepositoryError::VersionConflict { expected, actual }) => { + tracing::info!( + "Version conflict on contract {}: expected {}, actual {}", + id, + expected, + actual + ); + ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "VERSION_CONFLICT", + "message": format!( + "Contract was modified. Expected version {}, actual version {}", + expected, actual + ), + "expectedVersion": expected, + "actualVersion": actual, + })), + ) + .into_response() + } + Err(RepositoryError::Database(e)) => { + tracing::error!("Failed to update contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a contract. +#[utoipa::path( + delete, + path = "/api/v1/contracts/{id}", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 204, description = "Contract deleted"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract 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 = "Contracts" +)] +pub async fn delete_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<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 repository::delete_contract_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", "Contract not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Repository Management +// ============================================================================= + +/// Add a remote repository to a contract. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/repositories/remote", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = AddRemoteRepositoryRequest, + responses( + (status = 201, description = "Repository added", body = ContractRepository), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract 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 = "Contracts" +)] +pub async fn add_remote_repository( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<AddRemoteRepositoryRequest>, +) -> 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 contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::add_remote_repository(pool, id, &req.name, &req.repository_url, req.is_primary) + .await + { + Ok(repo) => { + // Update supervisor task with repository info if this is a primary repo + update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; + (StatusCode::CREATED, Json(repo)).into_response() + } + Err(e) => { + tracing::error!("Failed to add remote repository to contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Add a local repository to a contract. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/repositories/local", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = AddLocalRepositoryRequest, + responses( + (status = 201, description = "Repository added", body = ContractRepository), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract 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 = "Contracts" +)] +pub async fn add_local_repository( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<AddLocalRepositoryRequest>, +) -> 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 contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::add_local_repository(pool, id, &req.name, &req.local_path, req.is_primary) + .await + { + Ok(repo) => { + // Update supervisor task with repository info if this is a primary repo + update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; + (StatusCode::CREATED, Json(repo)).into_response() + } + Err(e) => { + tracing::error!("Failed to add local repository to contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Create a managed repository (daemon will create it). +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/repositories/managed", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = CreateManagedRepositoryRequest, + responses( + (status = 201, description = "Repository creation requested", body = ContractRepository), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract 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 = "Contracts" +)] +pub async fn create_managed_repository( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateManagedRepositoryRequest>, +) -> 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 contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::create_managed_repository(pool, id, &req.name, req.is_primary).await { + Ok(repo) => { + // For managed repos, the daemon will create the repo and we'll update later + // For now, just mark that this is a managed repo configuration + // The helper handles the case where repo has no URL yet + update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; + (StatusCode::CREATED, Json(repo)).into_response() + } + Err(e) => { + tracing::error!( + "Failed to create managed repository for contract {}: {}", + id, + e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a repository from a contract. +#[utoipa::path( + delete, + path = "/api/v1/contracts/{id}/repositories/{repo_id}", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("repo_id" = Uuid, Path, description = "Repository ID") + ), + responses( + (status = 204, description = "Repository removed"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or repository 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 = "Contracts" +)] +pub async fn delete_repository( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, repo_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 contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::delete_contract_repository(pool, repo_id, id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Repository not found")), + ) + .into_response(), + Err(e) => { + tracing::error!( + "Failed to delete repository {} from contract {}: {}", + repo_id, + id, + e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Set a repository as primary for a contract. +#[utoipa::path( + put, + path = "/api/v1/contracts/{id}/repositories/{repo_id}/primary", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("repo_id" = Uuid, Path, description = "Repository ID") + ), + responses( + (status = 204, description = "Repository set as primary"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or repository 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 = "Contracts" +)] +pub async fn set_repository_primary( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, repo_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 contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::set_repository_primary(pool, repo_id, id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Repository not found")), + ) + .into_response(), + Err(e) => { + tracing::error!( + "Failed to set repository {} as primary for contract {}: {}", + repo_id, + id, + e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Task Association +// ============================================================================= + +/// Add a task to a contract. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/tasks/{task_id}", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("task_id" = Uuid, Path, description = "Task ID") + ), + responses( + (status = 204, description = "Task added to contract"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or task 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 = "Contracts" +)] +pub async fn add_task_to_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, task_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 contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Verify task exists and belongs to owner + match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get task {}: {}", task_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::add_task_to_contract(pool, id, task_id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to add task {} to contract {}: {}", task_id, id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Remove a task from a contract. +#[utoipa::path( + delete, + path = "/api/v1/contracts/{id}/tasks/{task_id}", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("task_id" = Uuid, Path, description = "Task ID") + ), + responses( + (status = 204, description = "Task removed from contract"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or task 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 = "Contracts" +)] +pub async fn remove_task_from_contract( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, task_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 contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::remove_task_from_contract(pool, id, task_id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Task not found in this contract")), + ) + .into_response(), + Err(e) => { + tracing::error!( + "Failed to remove task {} from contract {}: {}", + task_id, + id, + e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Phase Management +// ============================================================================= + +/// Change contract phase. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/phase", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = ChangePhaseRequest, + responses( + (status = 200, description = "Phase changed", body = ContractSummary), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract 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 = "Contracts" +)] +pub async fn change_phase( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<ChangePhaseRequest>, +) -> 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::change_contract_phase_for_owner(pool, id, auth.owner_id, &req.phase).await { + Ok(Some(contract)) => { + // Notify supervisor of phase change + if let Some(supervisor_task_id) = contract.supervisor_task_id { + if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { + let state_clone = state.clone(); + let contract_id = contract.id; + let new_phase = contract.phase.clone(); + tokio::spawn(async move { + state_clone.notify_supervisor_of_phase_change( + supervisor.id, + supervisor.daemon_id, + contract_id, + &new_phase, + ).await; + }); + } + } + + // Get summary with counts + match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await + { + Ok(Some(summary)) => Json(summary).into_response(), + _ => Json(ContractSummary { + id: contract.id, + name: contract.name, + description: contract.description, + phase: contract.phase, + status: contract.status, + file_count: 0, + task_count: 0, + repository_count: 0, + version: contract.version, + created_at: contract.created_at, + }) + .into_response(), + } + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to change phase for contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Events +// ============================================================================= + +/// Get contract event history. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/events", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Event history", body = Vec<crate::db::models::ContractEvent>), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract 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 = "Contracts" +)] +pub async fn get_events( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<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 contract exists and belongs to owner + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::list_contract_events(pool, id).await { + Ok(events) => Json(events).into_response(), + Err(e) => { + tracing::error!("Failed to get events for contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} |
