feat(dsms): version chain history + diff endpoint + Audit Timeline UI

DSMS Stufe 3 — making the parent_cid chain useful end-to-end.

Gateway (dsms-gateway):
- /api/v1/documents/{cid}/history alias added next to the legacy
  /documents/{cid}/history (history endpoint itself was already there,
  just under an inconsistent prefix).
- NEW /api/v1/documents/{cid_a}/diff/{cid_b}: fetches both packages from
  IPFS, computes a metadata diff (per-field old/new), and renders a
  unified text diff for utf-8 payloads. Binary payloads return only
  metadata diff with a "binary — compare via rendered export" note.
- 4 new pytest cases (mocking ipfs_cat): text diff, binary fallback,
  fetch error, history chain depth — all green.

Frontend (admin-compliance):
- CIDHistoryModal: lazy-loads /dsms/documents/:cid/history, renders the
  version chain as a vertical timeline, marks the AKTUELL entry, and
  per-step exposes a "Diff zu V<n>" button that loads + renders the diff
  inline (metadata table + unified text diff in a monospace panel).
- AuditTimelinePage: existing CID badge now sits next to a "Verlauf
  anzeigen" link that opens the modal. Handles both Python's plain-CID
  audit values and the Go techfile flow's JSON envelope {cid, filename,
  size} via extractCID() helper.

This makes "show me how this CE-Akte changed between V2 and V3"
self-service in the UI instead of a curl-against-IPFS workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-22 10:10:07 +02:00
parent e2be51b0aa
commit 299375e486
4 changed files with 446 additions and 4 deletions
@@ -1,6 +1,8 @@
'use client'
import { useState } from 'react'
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
import CIDHistoryModal from './_components/CIDHistoryModal'
const ENTITY_LABELS: Record<string, string> = {
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
@@ -16,8 +18,24 @@ const ACTION_COLORS: Record<string, string> = {
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
// new_value may be a plain CID (from Python evidence flow) or a JSON envelope
// {"cid":"X","filename":"...","size":"..."} (from the Go IACE tech-file flow).
function extractCID(value: string): string {
const trimmed = value.trim()
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed)
if (typeof parsed.cid === 'string') return parsed.cid
} catch {
// fall through
}
}
return trimmed
}
export default function AuditTimelinePage() {
const { entries, loading, filter, setFilter } = useAuditTimeline()
const [historyCID, setHistoryCID] = useState<string | null>(null)
return (
<div className="max-w-4xl mx-auto space-y-6">
@@ -58,16 +76,18 @@ export default function AuditTimelinePage() {
<div className="space-y-4">
{entries.map((entry) => (
<TimelineEntry key={entry.id} entry={entry} />
<TimelineEntry key={entry.id} entry={entry} onShowHistory={setHistoryCID} />
))}
</div>
</div>
)}
{historyCID && <CIDHistoryModal cid={historyCID} onClose={() => setHistoryCID(null)} />}
</div>
)
}
function TimelineEntry({ entry }: { entry: AuditEntry }) {
function TimelineEntry({ entry, onShowHistory }: { entry: AuditEntry; onShowHistory: (cid: string) => void }) {
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
const date = new Date(entry.performed_at)
@@ -94,7 +114,7 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
)}
{isCID && entry.new_value && (
<div className="mt-2 flex items-center gap-2">
<div className="mt-2 flex items-center gap-2 flex-wrap">
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
@@ -102,6 +122,16 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
</code>
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
<button
onClick={(e) => {
e.stopPropagation()
if (entry.new_value) onShowHistory(extractCID(entry.new_value))
}}
className="text-[10px] text-purple-600 hover:text-purple-800 dark:text-purple-400 underline-offset-2 hover:underline"
title="DSMS-Versionsverlauf und Diff zur Vorversion anzeigen"
>
Verlauf anzeigen
</button>
</div>
)}
</div>