summaryrefslogblamecommitdiff
path: root/makima/src/server/handlers/versions.rs
blob: 15118d6f4a95cdee4f13fdb50e8ec47bcb6e17e9 (plain) (tree)














































































































































































































                                                                                                           
//! 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()
        }
    }
}