summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/files.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-11 05:52:14 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:21:16 +0000
commit87044a747b47bd83249d61a45842c7f7b2eae56d (patch)
treeef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/src/server/handlers/files.rs
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-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.rs219
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()
+}