summaryrefslogblamecommitdiff
path: root/makima/src/server/handlers/files.rs
blob: 711be418920414902f9345dbbd51c08f98d3cdb3 (plain) (tree)
1
2
3
4
5
6
7
8
9
10









                                           
                                                                                
                                                   
                                       
                                      
                                                                               
 
                                                      




                                                                               
                                                                      


                                                                                 



                             

                 



                                       







                                                                             

                                                                                

















                                                               
                                              







                                                                                     
                                                                      



                                                                                 



                             



                                     
                                       









                                                                             
                                                                         
















                                                                
                                                       






                                                                                     
                                                                      
                                                                            


                                                                                 



                             



                                     
                                       









                                                                             



















                                                                                          
                                                                             











                                                                      
                                              








                                                                                     
                                                                      
                                                                        
                                                                          


                                                                                 



                             



                                     
                                       










                                                                             

















                                                           
                                                                                 









                                                                




                                                               





















                                                                                                    






                                                                   




                                                    


     
                                    







                                                     
                                                                      



                                                                                 



                             



                                     
                                       









                                                                             
                                                                            















                                                                   


























































































































































































                                                                                              
//! 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<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_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<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_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<SharedState>,
    Authenticated(auth): Authenticated,
    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 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<SharedState>,
    Authenticated(auth): Authenticated,
    Path(id): Path<Uuid>,
    Json(req): Json<UpdateFileRequest>,
) -> 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<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_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<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()
}