gradatum-server 0.4.0

Stateless HTTP/MCP façade :19090 — handles read/search + enqueues writes
//! Handlers MCP F-40 — Copy-on-Write history v1.
//!
//! 4 endpoints synchrones (200 OK) pour l'historique des notes :
//! - `vault_history`     — liste les timestamps des snapshots CoW.
//! - `vault_history_get` — lit le contenu d'un snapshot précis.
//! - `vault_restore`     — restaure une note depuis un snapshot (déclenche un CoW).
//! - `vault_diff`        — diff brut ligne-à-ligne entre deux versions.
//!
//! Chaque handler :
//! 1. Vérifie l'authentification via [`TrustContext::is_authenticated`].
//! 2. Évalue l'ACL via [`AclEngine::evaluate`] (Read pour history/get/diff,
//!    Write pour restore).
//! 3. Délègue à `state.vault` (`Arc<dyn Registry>`) — T2 P2.0c.
//!
//! # Endpoints
//!
//! | Méthode | Path | ACL | Codes |
//! |---------|------|-----|-------|
//! | POST | `/api/v1/vault_history`     | Read  | 200 / 401 / 403 / 404 / 500 |
//! | POST | `/api/v1/vault_history_get` | Read  | 200 / 401 / 403 / 404 / 500 |
//! | POST | `/api/v1/vault_restore`     | Write | 200 / 401 / 403 / 404 / 500 |
//! | POST | `/api/v1/vault_diff`        | Read  | 200 / 401 / 403 / 400 / 404 / 500 |

use axum::{extract::State, http::StatusCode, Extension, Json};
use gradatum_acl_policy::{AclDecision, AclOp};
use gradatum_core::error::GradatumError;
use gradatum_core::trust::TrustContext;

use crate::state::AppState;

// ── Re-exports DTOs (depuis gradatum-dto) ─────────────────────────────────────
pub use gradatum_dto::{
    VaultDiffRequest, VaultDiffResponse, VaultHistoryGetRequest, VaultHistoryGetResponse,
    VaultHistoryRequest, VaultHistoryResponse, VaultRestoreRequest, VaultRestoreResponse,
};

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Construit le locus ACL : `{tenant_id}/main` (section par défaut).
fn locus_for_tenant(tenant_id: &str) -> String {
    format!("{}/main", tenant_id)
}

/// Mappe une erreur GradatumError en StatusCode HTTP.
///
/// - `NoteNotFound` → 404
/// - `Storage` avec "introuvable" ou "Not found" → 404
/// - Autres → 500
fn map_err_to_status(e: &GradatumError) -> StatusCode {
    match e {
        GradatumError::NoteNotFound(_) => StatusCode::NOT_FOUND,
        GradatumError::Storage(msg) if msg.contains("introuvable") || msg.contains("Not found") => {
            StatusCode::NOT_FOUND
        }
        _ => StatusCode::INTERNAL_SERVER_ERROR,
    }
}

// ── Handlers ──────────────────────────────────────────────────────────────────

/// `POST /api/v1/vault_history`
///
/// Liste les timestamps (ms Unix) des snapshots CoW d'une note.
///
/// ## Réponse
///
/// ```json
/// { "versions": [1700000000000, 1700000001000], "count": 2 }
/// ```
///
/// Retourne `versions: []` si la note n'a pas d'historique (jamais modifiée
/// avec un body différent) ou si la note est inconnue.
///
/// ## Codes d'erreur
///
/// - `401` : bearer absent ou invalide.
/// - `403` : ACL Read refusée.
/// - `500` : erreur inattendue (log émis).
pub async fn vault_history(
    State(state): State<AppState>,
    Extension(trust): Extension<TrustContext>,
    Json(req): Json<VaultHistoryRequest>,
) -> Result<Json<VaultHistoryResponse>, StatusCode> {
    if !trust.is_authenticated() {
        return Err(StatusCode::UNAUTHORIZED);
    }
    let locus = locus_for_tenant(&req.tenant_id);
    if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
        return Err(StatusCode::FORBIDDEN);
    }

    let versions = state
        .vault
        .history_versions(&req.note_id)
        .await
        .map_err(|e| {
            tracing::error!(
                err = %e,
                note_id = %req.note_id,
                "vault_history: history_versions failed"
            );
            map_err_to_status(&e)
        })?;

    let count = versions.len();
    Ok(Json(VaultHistoryResponse { versions, count }))
}

/// `POST /api/v1/vault_history_get`
///
/// Lit le contenu d'un snapshot historique précis.
///
/// ## Réponse
///
/// ```json
/// {
///   "note_id": "01JTEXAMPLE",
///   "ts_ms": 1700000000000,
///   "body": "# Titre\n\ncorps de la note...",
///   "section": "decisions"
/// }
/// ```
///
/// ## Codes d'erreur
///
/// - `401` : bearer absent ou invalide.
/// - `403` : ACL Read refusée.
/// - `404` : snapshot ou note introuvable.
/// - `500` : erreur inattendue (log émis).
pub async fn vault_history_get(
    State(state): State<AppState>,
    Extension(trust): Extension<TrustContext>,
    Json(req): Json<VaultHistoryGetRequest>,
) -> Result<Json<VaultHistoryGetResponse>, StatusCode> {
    if !trust.is_authenticated() {
        return Err(StatusCode::UNAUTHORIZED);
    }
    let locus = locus_for_tenant(&req.tenant_id);
    if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
        return Err(StatusCode::FORBIDDEN);
    }

    let snapshot = state
        .vault
        .history_get(&req.note_id, req.ts_ms)
        .await
        .map_err(|e| {
            if !matches!(e, GradatumError::NoteNotFound(_)) {
                tracing::error!(
                    err = %e,
                    note_id = %req.note_id,
                    ts_ms = req.ts_ms,
                    "vault_history_get: history_get failed"
                );
            }
            map_err_to_status(&e)
        })?;

    Ok(Json(VaultHistoryGetResponse {
        note_id: req.note_id,
        ts_ms: req.ts_ms,
        body: snapshot.body.markdown,
        section: snapshot.frontmatter.section.to_string(),
    }))
}

/// `POST /api/v1/vault_restore`
///
/// Restaure une note depuis un snapshot historique.
///
/// Écrit le snapshot comme nouvelle version courante (déclenche un CoW :
/// l'ancienne version courante est sauvegardée dans `.history/`).
///
/// ## Réponse
///
/// ```json
/// {
///   "note_id": "01JTEXAMPLE",
///   "ts_ms": 1700000000000,
///   "content_hash": "a3f1c2d4..."
/// }
/// ```
///
/// ## Codes d'erreur
///
/// - `401` : bearer absent ou invalide.
/// - `403` : ACL Write refusée.
/// - `404` : snapshot ou note introuvable.
/// - `500` : erreur inattendue (log émis).
pub async fn vault_restore(
    State(state): State<AppState>,
    Extension(trust): Extension<TrustContext>,
    Json(req): Json<VaultRestoreRequest>,
) -> Result<Json<VaultRestoreResponse>, StatusCode> {
    if !trust.is_authenticated() {
        return Err(StatusCode::UNAUTHORIZED);
    }
    // Restauration = opération d'écriture → ACL Write.
    let locus = locus_for_tenant(&req.tenant_id);
    if state.acl.evaluate(&trust, AclOp::Write, &locus) != AclDecision::Allow {
        return Err(StatusCode::FORBIDDEN);
    }

    let content_hash = state
        .vault
        .history_restore(&req.note_id, req.ts_ms)
        .await
        .map_err(|e| {
            if !matches!(e, GradatumError::NoteNotFound(_)) {
                tracing::error!(
                    err = %e,
                    note_id = %req.note_id,
                    ts_ms = req.ts_ms,
                    "vault_restore: history_restore failed"
                );
            }
            map_err_to_status(&e)
        })?;

    Ok(Json(VaultRestoreResponse {
        note_id: req.note_id,
        ts_ms: req.ts_ms,
        content_hash,
    }))
}

/// `POST /api/v1/vault_diff`
///
/// Diff brut ligne-à-ligne entre deux versions d'une note.
///
/// `a` et `b` sont des timestamps ms Unix (issus de `vault_history`) ou la
/// chaîne littérale `"current"` pour la version courante.
///
/// ## Réponse
///
/// ```json
/// {
///   "lines": [" ligne commune", "-ligne retirée", "+ligne ajoutée"],
///   "count": 3
/// }
/// ```
///
/// ## Codes d'erreur
///
/// - `400` : sélecteur `a` ou `b` invalide (ni timestamp ni `"current"`).
/// - `401` : bearer absent ou invalide.
/// - `403` : ACL Read refusée.
/// - `404` : note ou snapshot introuvable.
/// - `500` : erreur inattendue (log émis).
pub async fn vault_diff(
    State(state): State<AppState>,
    Extension(trust): Extension<TrustContext>,
    Json(req): Json<VaultDiffRequest>,
) -> Result<Json<VaultDiffResponse>, StatusCode> {
    if !trust.is_authenticated() {
        return Err(StatusCode::UNAUTHORIZED);
    }
    let locus = locus_for_tenant(&req.tenant_id);
    if state.acl.evaluate(&trust, AclOp::Read, &locus) != AclDecision::Allow {
        return Err(StatusCode::FORBIDDEN);
    }

    // Validation préalable des sélecteurs (400 avant tout appel vault).
    let is_valid_selector = |s: &str| -> bool { s == "current" || s.parse::<i64>().is_ok() };
    if !is_valid_selector(&req.a) || !is_valid_selector(&req.b) {
        tracing::warn!(
            a = %req.a,
            b = %req.b,
            note_id = %req.note_id,
            "vault_diff: sélecteur invalide (attendu 'current' ou timestamp ms)"
        );
        return Err(StatusCode::BAD_REQUEST);
    }

    let lines = state
        .vault
        .history_diff(&req.note_id, &req.a, &req.b)
        .await
        .map_err(|e| {
            if !matches!(e, GradatumError::NoteNotFound(_)) {
                let status = map_err_to_status(&e);
                if status == StatusCode::INTERNAL_SERVER_ERROR {
                    tracing::error!(
                        err = %e,
                        note_id = %req.note_id,
                        a = %req.a,
                        b = %req.b,
                        "vault_diff: history_diff failed"
                    );
                }
            }
            map_err_to_status(&e)
        })?;

    let count = lines.len();
    Ok(Json(VaultDiffResponse { lines, count }))
}