//! 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();
};
// Legacy contract scope removed; files are owner-scoped only now.
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()
}
}
}