//! HTTP handlers for daemon-to-contract interaction.
//!
//! These endpoints allow tasks running in daemons to interact with their
//! associated contracts via the contract.sh script. Authentication is via
//! tool keys registered by the daemon when starting a task.
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::db::{models::FileSummary, repository};
use crate::llm::phase_guidance::{self, PhaseChecklist, TaskInfo};
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
use crate::server::state::SharedState;
// =============================================================================
// Request/Response Types
// =============================================================================
/// Contract status response for daemon.
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContractStatusResponse {
pub id: Uuid,
pub name: String,
pub phase: String,
pub status: String,
pub description: Option<String>,
}
/// Contract goals response.
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ContractGoalsResponse {
/// Description serves as goals for the contract
pub description: Option<String>,
pub phase: String,
pub phase_guidance: String,
}
/// Progress report request from daemon.
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProgressReportRequest {
pub message: String,
#[serde(default)]
pub task_id: Option<Uuid>,
}
/// Suggested action from server.
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SuggestedActionResponse {
pub action: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
/// Completion action request.
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CompletionActionRequest {
#[serde(default)]
pub task_id: Option<Uuid>,
#[serde(default)]
pub files_modified: Vec<String>,
#[serde(default)]
pub lines_added: i32,
#[serde(default)]
pub lines_removed: i32,
#[serde(default)]
pub has_code_changes: bool,
}
/// Recommended completion action.
#[derive(Debug, Clone, Serialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum CompletionAction {
Branch,
Merge,
Pr,
None,
}
impl std::fmt::Display for CompletionAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CompletionAction::Branch => write!(f, "branch"),
CompletionAction::Merge => write!(f, "merge"),
CompletionAction::Pr => write!(f, "pr"),
CompletionAction::None => write!(f, "none"),
}
}
}
/// Completion action response.
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CompletionActionResponse {
pub action: String,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch_name: Option<String>,
}
/// Create file request from daemon.
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateFileRequest {
pub name: String,
pub content: String,
#[serde(default)]
pub template_id: Option<String>,
}
/// Update file request from daemon.
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct DaemonUpdateFileRequest {
/// Content to update in the file (as markdown body element)
pub content: String,
}
// =============================================================================
// Handlers
// =============================================================================
/// Get contract status for daemon.
#[utoipa::path(
get,
path = "/api/v1/contracts/{id}/daemon/status",
params(
("id" = Uuid, Path, description = "Contract ID")
),
responses(
(status = 200, description = "Contract status", body = ContractStatusResponse),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(
("tool_key" = []),
("api_key" = [])
),
tag = "Contract Daemon"
)]
pub async fn get_contract_status(
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::get_contract_for_owner(pool, id, auth.owner_id).await {
Ok(Some(contract)) => Json(ContractStatusResponse {
id: contract.id,
name: contract.name,
phase: contract.phase,
status: contract.status,
description: contract.description,
})
.into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Contract not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to get contract {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Get phase deliverables checklist.
#[utoipa::path(
get,
path = "/api/v1/contracts/{id}/daemon/checklist",
params(
("id" = Uuid, Path, description = "Contract ID")
),
responses(
(status = 200, description = "Phase checklist", body = PhaseChecklist),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(
("tool_key" = []),
("api_key" = [])
),
tag = "Contract Daemon"
)]
pub async fn get_contract_checklist(
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 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 completed deliverables for the current phase
let completed_deliverables = contract.get_completed_deliverables(&contract.phase);
// Get tasks for this contract
let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await {
Ok(t) => t
.into_iter()
.map(|t| TaskInfo {
name: t.name,
status: t.status,
})
.collect::<Vec<_>>(),
Err(e) => {
tracing::warn!("Failed to get tasks for contract {}: {}", id, e);
Vec::new()
}
};
// Check if repository is configured
let has_repository = match repository::list_contract_repositories(pool, id).await {
Ok(repos) => !repos.is_empty(),
Err(_) => false,
};
let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type);
Json(checklist).into_response()
}
/// Get contract goals.
#[utoipa::path(
get,
path = "/api/v1/contracts/{id}/daemon/goals",
params(
("id" = Uuid, Path, description = "Contract ID")
),
responses(
(status = 200, description = "Contract goals", body = ContractGoalsResponse),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(
("tool_key" = []),
("api_key" = [])
),
tag = "Contract Daemon"
)]
pub async fn get_contract_goals(
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::get_contract_for_owner(pool, id, auth.owner_id).await {
Ok(Some(contract)) => {
let deliverables = phase_guidance::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type);
Json(ContractGoalsResponse {
description: contract.description,
phase: contract.phase,
phase_guidance: deliverables.guidance,
})
.into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Contract not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to get contract {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Post progress report to contract.
#[utoipa::path(
post,
path = "/api/v1/contracts/{id}/daemon/report",
params(
("id" = Uuid, Path, description = "Contract ID")
),
request_body = ProgressReportRequest,
responses(
(status = 200, description = "Report received"),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(
("tool_key" = []),
("api_key" = [])
),
tag = "Contract Daemon"
)]
pub async fn post_progress_report(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<ProgressReportRequest>,
) -> 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
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();
}
}
// Log the report as a contract event
let event_type = "progress_report";
let payload = serde_json::json!({
"message": req.message,
"task_id": req.task_id,
});
if let Err(e) = repository::record_contract_event(pool, id, event_type, Some(payload)).await {
tracing::warn!("Failed to create contract event: {}", e);
}
Json(serde_json::json!({"status": "received"})).into_response()
}
/// Get suggested action based on contract state.
#[utoipa::path(
post,
path = "/api/v1/contracts/{id}/daemon/suggest-action",
params(
("id" = Uuid, Path, description = "Contract ID")
),
responses(
(status = 200, description = "Suggested action", body = SuggestedActionResponse),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(
("tool_key" = []),
("api_key" = [])
),
tag = "Contract Daemon"
)]
pub async fn get_suggest_action(
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 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 completed deliverables and tasks for checklist
let completed_deliverables = contract.get_completed_deliverables(&contract.phase);
let tasks = repository::list_tasks_in_contract(pool, id, auth.owner_id)
.await
.unwrap_or_default()
.into_iter()
.map(|t| TaskInfo {
name: t.name,
status: t.status,
})
.collect::<Vec<_>>();
let has_repository = repository::list_contract_repositories(pool, id)
.await
.map(|r| !r.is_empty())
.unwrap_or(false);
let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type);
// Determine suggested action based on checklist
let (action, description) = if !checklist.suggestions.is_empty() {
("follow_suggestion", checklist.suggestions.first().unwrap().clone())
} else if checklist.completion_percentage >= 100 {
("advance_phase", format!("Phase {} is complete, consider advancing to next phase", contract.phase))
} else {
("continue", format!("Continue working on {} phase ({}% complete)", contract.phase, checklist.completion_percentage))
};
Json(SuggestedActionResponse {
action: action.to_string(),
description,
data: None,
})
.into_response()
}
/// Get recommended completion action.
#[utoipa::path(
post,
path = "/api/v1/contracts/{id}/daemon/completion-action",
params(
("id" = Uuid, Path, description = "Contract ID")
),
request_body = CompletionActionRequest,
responses(
(status = 200, description = "Recommended completion action", body = CompletionActionResponse),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(
("tool_key" = []),
("api_key" = [])
),
tag = "Contract Daemon"
)]
pub async fn get_completion_action(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<CompletionActionRequest>,
) -> 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 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();
}
};
// Determine completion action based on phase and changes
let has_changes = !req.files_modified.is_empty() || req.lines_added > 0 || req.lines_removed > 0;
let has_significant_changes = req.lines_added + req.lines_removed > 50;
let (action, reason) = match contract.phase.as_str() {
"research" | "specify" => {
if has_changes {
(CompletionAction::Merge, "Early phase changes can be merged directly".to_string())
} else {
(CompletionAction::None, "No changes to commit".to_string())
}
}
"plan" => {
if has_significant_changes {
(CompletionAction::Pr, "Significant planning changes require review".to_string())
} else if has_changes {
(CompletionAction::Merge, "Minor planning changes can be merged".to_string())
} else {
(CompletionAction::None, "No changes to commit".to_string())
}
}
"execute" => {
if req.has_code_changes {
(CompletionAction::Pr, "Code changes in execute phase require review".to_string())
} else if has_changes {
(CompletionAction::Branch, "Documentation changes can be branched".to_string())
} else {
(CompletionAction::None, "No changes to commit".to_string())
}
}
"review" => {
if has_changes {
(CompletionAction::Pr, "Review phase changes should be reviewed".to_string())
} else {
(CompletionAction::None, "No changes to commit".to_string())
}
}
_ => (CompletionAction::None, "Unknown phase".to_string()),
};
// Generate branch name based on contract
let branch_name = if matches!(action, CompletionAction::Branch | CompletionAction::Pr) {
let slug = contract.name.to_lowercase().replace(' ', "-");
Some(format!("contract/{}", slug))
} else {
None
};
Json(CompletionActionResponse {
action: action.to_string(),
reason,
branch_name,
})
.into_response()
}
/// List contract files for daemon.
#[utoipa::path(
get,
path = "/api/v1/contracts/{id}/daemon/files",
params(
("id" = Uuid, Path, description = "Contract ID")
),
responses(
(status = 200, description = "List of contract files", body = Vec<FileSummary>),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(
("tool_key" = []),
("api_key" = [])
),
tag = "Contract Daemon"
)]
pub async fn list_contract_files(
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
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_files_in_contract(pool, id, auth.owner_id).await {
Ok(files) => Json(files).into_response(),
Err(e) => {
tracing::error!("Failed to list files for contract {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Get a specific contract file.
#[utoipa::path(
get,
path = "/api/v1/contracts/{id}/daemon/files/{file_id}",
params(
("id" = Uuid, Path, description = "Contract ID"),
("file_id" = Uuid, Path, description = "File ID")
),
responses(
(status = 200, description = "File content"),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract or file not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(
("tool_key" = []),
("api_key" = [])
),
tag = "Contract Daemon"
)]
pub async fn get_contract_file(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((id, file_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
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();
}
}
// Get file and verify it belongs to this contract
match repository::get_file_for_owner(pool, file_id, auth.owner_id).await {
Ok(Some(file)) => {
if file.contract_id != Some(id) {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "File not found in this contract")),
)
.into_response();
}
Json(file).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "File not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to get file {}: {}", file_id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Update a contract file.
#[utoipa::path(
put,
path = "/api/v1/contracts/{id}/daemon/files/{file_id}",
params(
("id" = Uuid, Path, description = "Contract ID"),
("file_id" = Uuid, Path, description = "File ID")
),
request_body = DaemonUpdateFileRequest,
responses(
(status = 200, description = "File updated"),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract or file not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(
("tool_key" = []),
("api_key" = [])
),
tag = "Contract Daemon"
)]
pub async fn update_contract_file(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((id, file_id)): Path<(Uuid, Uuid)>,
Json(req): Json<DaemonUpdateFileRequest>,
) -> 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
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();
}
}
// Get file and verify it belongs to this contract
let file = match repository::get_file_for_owner(pool, file_id, auth.owner_id).await {
Ok(Some(f)) => f,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "File not found")),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to get file {}: {}", file_id, e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
};
if file.contract_id != Some(id) {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "File not found in this contract")),
)
.into_response();
}
// Update the file with content parsed as markdown
let body = crate::llm::markdown_to_body(&req.content);
let update_req = crate::db::models::UpdateFileRequest {
name: None,
description: None,
transcript: None,
summary: None,
body: Some(body),
version: None,
repo_file_path: None,
};
match repository::update_file_for_owner(pool, file_id, auth.owner_id, update_req).await {
Ok(Some(updated)) => Json(updated).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "File not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to update file {}: {}", file_id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", format!("{}", e))),
)
.into_response()
}
}
}
/// Create a new contract file.
#[utoipa::path(
post,
path = "/api/v1/contracts/{id}/daemon/files",
params(
("id" = Uuid, Path, description = "Contract ID")
),
request_body = CreateFileRequest,
responses(
(status = 201, description = "File created"),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(
("tool_key" = []),
("api_key" = [])
),
tag = "Contract Daemon"
)]
pub async fn create_contract_file(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<CreateFileRequest>,
) -> 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
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();
}
};
// Create the file with content parsed as markdown
let body = crate::llm::markdown_to_body(&req.content);
let create_req = crate::db::models::CreateFileRequest {
contract_id: id,
name: Some(req.name),
description: None,
transcript: vec![],
location: None,
body,
repo_file_path: None,
contract_phase: None, // Will be looked up from contract's current phase
};
match repository::create_file_for_owner(pool, auth.owner_id, create_req).await {
Ok(file) => (StatusCode::CREATED, Json(file)).into_response(),
Err(e) => {
tracing::error!("Failed to create file for contract {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}