diff options
| author | soryu <soryu@soryu.co> | 2026-01-11 05:52:14 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 00:21:16 +0000 |
| commit | 87044a747b47bd83249d61a45842c7f7b2eae56d (patch) | |
| tree | ef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/src/server/handlers/files.rs | |
| parent | 077820c4167c168072d217a1b01df840463a12a8 (diff) | |
| download | soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip | |
Contract system
Diffstat (limited to 'makima/src/server/handlers/files.rs')
| -rw-r--r-- | makima/src/server/handlers/files.rs | 219 |
1 files changed, 213 insertions, 6 deletions
diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs index 9634b73..05e871c 100644 --- a/makima/src/server/handlers/files.rs +++ b/makima/src/server/handlers/files.rs @@ -8,11 +8,11 @@ use axum::{ }; use uuid::Uuid; -use crate::db::models::{CreateFileRequest, FileListResponse, FileSummary, UpdateFileRequest}; +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::{FileUpdateNotification, SharedState}; +use crate::server::state::{DaemonCommand, FileUpdateNotification, SharedState}; /// List all files for the authenticated user's owner. #[utoipa::path( @@ -42,9 +42,8 @@ pub async fn list_files( .into_response(); }; - match repository::list_files_for_owner(pool, auth.owner_id).await { - Ok(files) => { - let summaries: Vec<FileSummary> = files.into_iter().map(FileSummary::from).collect(); + match repository::list_file_summaries_for_owner(pool, auth.owner_id).await { + Ok(summaries) => { let total = summaries.len() as i64; Json(FileListResponse { files: summaries, @@ -114,7 +113,7 @@ pub async fn get_file( } } -/// Create a new file. +/// Create a new file. Files must belong to a contract. #[utoipa::path( post, path = "/api/v1/files", @@ -123,6 +122,7 @@ pub async fn get_file( (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), ), @@ -145,6 +145,26 @@ pub async fn create_file( .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) => { @@ -310,3 +330,190 @@ pub async fn delete_file( } } } + +/// 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<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 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() +} |
