//! HTTP handlers for file CRUD operations. use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, Json, }; use uuid::Uuid; use crate::db::models::{CreateFileRequest, FileListResponse, UpdateFileRequest}; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::{DaemonCommand, FileUpdateNotification, SharedState}; /// List all files for the authenticated user's owner. #[utoipa::path( get, path = "/api/v1/files", responses( (status = 200, description = "List of files", body = FileListResponse), (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 = "Files" )] pub async fn list_files( 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_file_summaries_for_owner(pool, auth.owner_id).await { Ok(summaries) => { let total = summaries.len() as i64; Json(FileListResponse { files: summaries, total, }) .into_response() } Err(e) => { tracing::error!("Failed to list files: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get a single file by ID (scoped by owner). #[utoipa::path( get, path = "/api/v1/files/{id}", params( ("id" = Uuid, Path, description = "File ID") ), responses( (status = 200, description = "File details", body = crate::db::models::File), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "File 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 = "Files" )] pub async fn get_file( 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::get_file_for_owner(pool, id, auth.owner_id).await { Ok(Some(file)) => 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 {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Create a new file. Files must belong to a contract. #[utoipa::path( post, path = "/api/v1/files", request_body = CreateFileRequest, responses( (status = 201, description = "File created", body = crate::db::models::File), (status = 400, description = "Invalid request", body = ApiError), (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 = "Files" )] pub async fn create_file( 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(); }; // Verify the contract exists and belongs to the owner match repository::get_contract_for_owner(pool, req.contract_id, auth.owner_id).await { Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to verify contract: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } Ok(Some(_)) => {} // Contract exists, proceed } match repository::create_file_for_owner(pool, auth.owner_id, req).await { Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), Err(e) => { tracing::error!("Failed to create file: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Update an existing file (scoped by owner). #[utoipa::path( put, path = "/api/v1/files/{id}", params( ("id" = Uuid, Path, description = "File ID") ), request_body = UpdateFileRequest, responses( (status = 200, description = "File updated", body = crate::db::models::File), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "File 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 = "Files" )] pub async fn update_file( 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(); }; // Collect which fields are being updated for broadcast let mut updated_fields = Vec::new(); if req.name.is_some() { updated_fields.push("name".to_string()); } if req.description.is_some() { updated_fields.push("description".to_string()); } if req.transcript.is_some() { updated_fields.push("transcript".to_string()); } if req.summary.is_some() { updated_fields.push("summary".to_string()); } if req.body.is_some() { updated_fields.push("body".to_string()); } match repository::update_file_for_owner(pool, id, auth.owner_id, req).await { Ok(Some(file)) => { // Broadcast update notification state.broadcast_file_update(FileUpdateNotification { file_id: id, version: file.version, updated_fields, updated_by: "user".to_string(), }); Json(file).into_response() } Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "File not found")), ) .into_response(), Err(RepositoryError::VersionConflict { expected, actual }) => { tracing::info!( "Version conflict on file {}: expected {}, actual {}", id, expected, actual ); ( StatusCode::CONFLICT, Json(serde_json::json!({ "code": "VERSION_CONFLICT", "message": format!( "File was modified by another user. Expected version {}, actual version {}", expected, actual ), "expectedVersion": expected, "actualVersion": actual, })), ) .into_response() } Err(RepositoryError::Database(e)) => { tracing::error!("Failed to update file {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } Err(RepositoryError::Validation(msg)) => ( StatusCode::BAD_REQUEST, Json(ApiError::new("VALIDATION", &msg)), ) .into_response(), } } /// Delete a file (scoped by owner). #[utoipa::path( delete, path = "/api/v1/files/{id}", params( ("id" = Uuid, Path, description = "File ID") ), responses( (status = 204, description = "File deleted"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "File 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 = "Files" )] pub async fn delete_file( 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_file_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", "File not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to delete file {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Sync a file from its linked repository file. /// /// This endpoint triggers an async sync operation. The file must have a /// repo_file_path set, and its contract must have a linked repository. /// A connected daemon will read the file and update the file content. #[utoipa::path( post, path = "/api/v1/files/{id}/sync-from-repo", params( ("id" = Uuid, Path, description = "File ID") ), responses( (status = 202, description = "Sync operation started"), (status = 400, description = "File not linked to repository", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "File not found", body = ApiError), (status = 503, description = "No daemon available", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Files" )] pub async fn sync_file_from_repo( 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(); }; // Get the file and verify it has a repo_file_path let file = match repository::get_file_for_owner(pool, 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 {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // Check if file has a repo path and contract_id let contract_id = match file.contract_id { Some(id) => id, None => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new( "NO_CONTRACT", "File is not associated with a contract", )), ) .into_response(); } }; let repo_file_path = match file.repo_file_path { Some(ref path) if !path.is_empty() => path.clone(), _ => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new( "NOT_LINKED", "File is not linked to a repository file", )), ) .into_response(); } }; // Get contract repositories let repositories = match repository::list_contract_repositories(pool, contract_id).await { Ok(repos) => repos, Err(e) => { tracing::error!("Failed to get contract repositories: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // Check if contract has repositories if repositories.is_empty() { return ( StatusCode::BAD_REQUEST, Json(ApiError::new( "NO_REPOSITORY", "Contract has no linked repositories", )), ) .into_response(); } // Use the first repository's local path let repo = &repositories[0]; let repo_local_path = match &repo.local_path { Some(path) if !path.is_empty() => path.clone(), _ => { return ( StatusCode::BAD_REQUEST, Json(ApiError::new( "NO_LOCAL_PATH", "Repository has no local path configured", )), ) .into_response(); } }; // Find a connected daemon for this owner let daemon_id = state .daemon_connections .iter() .find(|entry| entry.value().owner_id == auth.owner_id) .map(|entry| entry.value().id); let daemon_id = match daemon_id { Some(id) => id, None => { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new( "NO_DAEMON", "No daemon connected. Start a daemon to sync files from repository.", )), ) .into_response(); } }; // Send ReadRepoFile command to daemon // Use the file ID as the request_id so we can match the response let command = DaemonCommand::ReadRepoFile { request_id: id, contract_id, file_path: repo_file_path, repo_path: repo_local_path, }; if let Err(e) = state.send_daemon_command(daemon_id, command).await { tracing::error!("Failed to send ReadRepoFile command: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DAEMON_ERROR", e)), ) .into_response(); } // Update status to indicate sync in progress if let Err(e) = sqlx::query("UPDATE files SET repo_sync_status = 'syncing' WHERE id = $1") .bind(id) .execute(pool) .await { tracing::warn!("Failed to update repo_sync_status: {}", e); } // Return 202 Accepted - the sync happens asynchronously ( StatusCode::ACCEPTED, Json(serde_json::json!({ "message": "Sync operation started", "fileId": id, })), ) .into_response() }