//! HTTP handlers for file version history operations.
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use uuid::Uuid;
use crate::db::models::{FileVersionListResponse, FileVersionSummary, RestoreVersionRequest};
use crate::db::repository::{self, RepositoryError};
use crate::server::messages::ApiError;
use crate::server::state::{FileUpdateNotification, SharedState};
/// List all versions of a file.
#[utoipa::path(
get,
path = "/api/v1/files/{id}/versions",
params(
("id" = Uuid, Path, description = "File ID")
),
responses(
(status = 200, description = "List of file versions", body = FileVersionListResponse),
(status = 404, description = "File not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
tag = "Versions"
)]
pub async fn list_versions(
State(state): State<SharedState>,
Path(file_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();
};
// Check if file exists
match repository::get_file(pool, file_id).await {
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "File not found")),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to check file {}: {}", file_id, e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
Ok(Some(_)) => {}
}
match repository::list_file_versions(pool, file_id).await {
Ok(versions) => {
let summaries: Vec<FileVersionSummary> =
versions.into_iter().map(FileVersionSummary::from).collect();
let total = summaries.len() as i64;
Json(FileVersionListResponse {
versions: summaries,
total,
})
.into_response()
}
Err(e) => {
tracing::error!("Failed to list versions for file {}: {}", file_id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Get a specific version of a file.
#[utoipa::path(
get,
path = "/api/v1/files/{id}/versions/{version}",
params(
("id" = Uuid, Path, description = "File ID"),
("version" = i32, Path, description = "Version number")
),
responses(
(status = 200, description = "Version details", body = crate::db::models::FileVersion),
(status = 404, description = "Version not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
tag = "Versions"
)]
pub async fn get_version(
State(state): State<SharedState>,
Path((file_id, version)): Path<(Uuid, i32)>,
) -> 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_version(pool, file_id, version).await {
Ok(Some(version)) => Json(version).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Version not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to get version {} for file {}: {}", version, file_id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Restore a file to a previous version.
#[utoipa::path(
post,
path = "/api/v1/files/{id}/versions/restore",
params(
("id" = Uuid, Path, description = "File ID")
),
request_body = RestoreVersionRequest,
responses(
(status = 200, description = "File restored to previous version", body = crate::db::models::File),
(status = 404, description = "File or version 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),
),
tag = "Versions"
)]
pub async fn restore_version(
State(state): State<SharedState>,
Path(file_id): Path<Uuid>,
Json(req): Json<RestoreVersionRequest>,
) -> 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::restore_file_version(pool, file_id, req.target_version, req.current_version).await {
Ok(Some(file)) => {
// Broadcast update notification
state.broadcast_file_update(FileUpdateNotification {
file_id,
version: file.version,
updated_fields: vec!["body".to_string(), "summary".to_string()],
updated_by: "system".to_string(),
});
Json(file).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "File or version not found")),
)
.into_response(),
Err(RepositoryError::VersionConflict { expected, actual }) => {
tracing::info!(
"Version conflict on file {} restore: expected {}, actual {}",
file_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 restore file {} to version {}: {}", file_id, req.target_version, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}