feat(iace): DSMS-CID-Badge im Tech-File-Export + aggregierter Bulk-Diff
CI / detect-changes (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / build-sha-integrity (push) Failing after 4s
CI / validate-canonical-controls (push) Successful in 10s
CI / loc-budget (push) Successful in 14s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m21s
CI / test-go (push) Failing after 37s
CI / iace-gt-coverage (push) Successful in 23s
CI / test-python-backend (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Successful in 17s

Punkt 1 — UI-CID-Badge nach erfolgreichem Tech-File-Export:
- archiveTechFile setzt X-DSMS-CID / X-DSMS-Filename / X-DSMS-Size response
  headers + Access-Control-Expose-Headers, sobald DSMS-Archive durchlief
- Split iace_handler_techfile.go (war ueber 500 LOC) → archiveTechFile lebt
  jetzt in iace_handler_techfile_archive.go, setDSMSResponseHeaders als
  pure Helper mit 3 unit tests
- Next.js IACE-Proxy forwarded die X-DSMS-* Header und erkennt jetzt auch
  XLSX/DOCX/MD als Binary-Response (vorher nur PDF/ZIP/octet-stream)
- ExportCIDBadge.tsx zeigt CID, Filename, Groesse + Kopieren-Button +
  "Verlauf anzeigen" (oeffnet CIDHistoryModal)

Punkt 2 — Bulk-Diff Report V1 → V_latest:
- Neuer Endpoint GET /api/v1/documents/{cid}/bulk-diff im dsms-gateway:
  laeuft parent_cid-Kette ab, berechnet chronologische Step-Diffs,
  aggregiert Totals (added/removed lines, metadata_fields_changed,
  binary_steps). Edge-Cases: einzelne Version, binaere Steps, abgebrochene
  Kette
- BulkDiffPanel.tsx zeigt 4-Stat-Header + Step-Tabelle
- CIDHistoryModal bekommt Toggle-Button "Bulk-Diff V1 → V_latest anzeigen"
  neben dem Versions-Counter; damit auch vom IACE-Export-Badge erreichbar

Tests: 3 neue Go-Tests, 4 neue pytest-Tests, alle gruen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-06-09 09:07:20 +02:00
parent d3ac33d53a
commit 216c7b8eca
10 changed files with 684 additions and 42 deletions
@@ -66,18 +66,31 @@ async function proxyRequest(
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (PDF exports, ZIP CE technical file)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/zip') ||
responseContentType?.includes('application/octet-stream')) {
// Handle non-JSON responses (PDF/ZIP CE technical file, XLSX/DOCX/MD exports).
const responseContentType = response.headers.get('content-type') || ''
const isBinary =
responseContentType.includes('application/pdf') ||
responseContentType.includes('application/zip') ||
responseContentType.includes('application/octet-stream') ||
responseContentType.includes('application/vnd.openxmlformats-officedocument') ||
responseContentType.includes('application/vnd.ms-excel') ||
responseContentType.includes('application/msword') ||
responseContentType.includes('text/markdown')
if (isBinary) {
const blob = await response.blob()
const forwardedHeaders: Record<string, string> = {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
}
// Forward DSMS archive metadata so the frontend can render the CID badge
// (set by archiveTechFile when the backend persisted the export to DSMS).
for (const h of ['x-dsms-cid', 'x-dsms-filename', 'x-dsms-size']) {
const v = response.headers.get(h)
if (v) forwardedHeaders[h] = v
}
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
headers: forwardedHeaders,
})
}
@@ -0,0 +1,175 @@
'use client'
import { useEffect, useState } from 'react'
interface BulkDiffStep {
from: string
from_version: string | null
to: string
to_version: string | null
created_at: string | null
kind: 'text' | 'binary'
added_lines: number
removed_lines: number
metadata_diff_fields: string[]
}
interface BulkDiffResponse {
cid_latest: string
cid_baseline: string
versions: number
steps: BulkDiffStep[]
totals: {
added_lines: number
removed_lines: number
metadata_fields_changed: number
binary_steps: number
}
note?: string
}
interface Props {
cid: string
onClose: () => void
}
function shorten(cid: string): string {
if (cid.length <= 14) return cid
return cid.slice(0, 8) + '…' + cid.slice(-6)
}
export default function BulkDiffPanel({ cid, onClose }: Props) {
const [data, setData] = useState<BulkDiffResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancel = false
setLoading(true)
setError(null)
fetch(`/api/sdk/v1/dsms/documents/${encodeURIComponent(cid)}/bulk-diff`)
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const json = (await r.json()) as BulkDiffResponse
if (!cancel) setData(json)
})
.catch((e) => {
if (!cancel) setError(e?.message || 'Fehler beim Laden')
})
.finally(() => {
if (!cancel) setLoading(false)
})
return () => {
cancel = true
}
}, [cid])
return (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Aggregierter Diff: V1 V_latest
</h3>
<button
onClick={onClose}
className="text-[11px] text-gray-500 hover:text-gray-700"
aria-label="Bulk-Diff schliessen"
>
Schliessen
</button>
</div>
{loading && <div className="text-xs text-gray-500">Bulk-Diff wird berechnet</div>}
{error && <div className="text-xs text-red-600 dark:text-red-400">{error}</div>}
{!loading && !error && data && (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-center">
<Stat label="Versionen" value={data.versions} tone="neutral" />
<Stat label="Zeilen +" value={data.totals.added_lines} tone="positive" />
<Stat label="Zeilen " value={data.totals.removed_lines} tone="negative" />
<Stat label="Metadaten-Felder" value={data.totals.metadata_fields_changed} tone="neutral" />
</div>
{data.totals.binary_steps > 0 && (
<div className="text-[11px] text-amber-700 dark:text-amber-400 italic">
{data.totals.binary_steps} von {data.steps.length} Schritten binaer Text-Diff nicht moeglich.
</div>
)}
{data.steps.length === 0 ? (
<div className="text-xs text-gray-500 italic">{data.note || 'Keine Vorgaengerversion vorhanden.'}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-200 dark:border-gray-700">
<th className="py-1 pr-2 font-medium">Schritt</th>
<th className="py-1 pr-2 font-medium">Datum</th>
<th className="py-1 pr-2 font-medium">Typ</th>
<th className="py-1 pr-2 font-medium text-right">+</th>
<th className="py-1 pr-2 font-medium text-right"></th>
<th className="py-1 font-medium">Metadaten-Felder</th>
</tr>
</thead>
<tbody>
{data.steps.map((step, i) => (
<tr key={`${step.from}-${step.to}`} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-1 pr-2 text-gray-700 dark:text-gray-300">
V{step.from_version || '?'} V{step.to_version || '?'}
<div className="text-[9px] font-mono text-gray-400">
{shorten(step.from)} {shorten(step.to)}
</div>
</td>
<td className="py-1 pr-2 text-gray-500">
{step.created_at ? new Date(step.created_at).toLocaleDateString('de-DE') : '—'}
</td>
<td className="py-1 pr-2">
<span
className={
step.kind === 'binary'
? 'text-amber-700 dark:text-amber-400'
: 'text-gray-700 dark:text-gray-300'
}
>
{step.kind === 'binary' ? 'binaer' : 'text'}
</span>
</td>
<td className="py-1 pr-2 text-right text-emerald-700 dark:text-emerald-400">
{step.kind === 'binary' ? '—' : step.added_lines}
</td>
<td className="py-1 pr-2 text-right text-red-700 dark:text-red-400">
{step.kind === 'binary' ? '—' : step.removed_lines}
</td>
<td className="py-1 text-gray-600 dark:text-gray-400">
{step.metadata_diff_fields.length === 0
? '—'
: step.metadata_diff_fields.slice(0, 3).join(', ') +
(step.metadata_diff_fields.length > 3 ? ` (+${step.metadata_diff_fields.length - 3})` : '')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
)
}
function Stat({ label, value, tone }: { label: string; value: number; tone: 'positive' | 'negative' | 'neutral' }) {
const color =
tone === 'positive'
? 'text-emerald-700 dark:text-emerald-400'
: tone === 'negative'
? 'text-red-700 dark:text-red-400'
: 'text-gray-800 dark:text-gray-200'
return (
<div className="bg-gray-50 dark:bg-gray-900/40 rounded p-2 border border-gray-200 dark:border-gray-700">
<div className={`text-base font-semibold ${color}`}>{value.toLocaleString('de-DE')}</div>
<div className="text-[10px] uppercase tracking-wide text-gray-500">{label}</div>
</div>
)
}
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import BulkDiffPanel from './BulkDiffPanel'
interface HistoryEntry {
cid: string
@@ -40,6 +41,7 @@ export default function CIDHistoryModal({ cid, onClose }: Props) {
const [diffPair, setDiffPair] = useState<{ a: string; b: string } | null>(null)
const [diff, setDiff] = useState<DiffResponse | null>(null)
const [diffLoading, setDiffLoading] = useState(false)
const [showBulkDiff, setShowBulkDiff] = useState(false)
useEffect(() => {
let cancel = false
@@ -109,9 +111,22 @@ export default function CIDHistoryModal({ cid, onClose }: Props) {
{!loading && !error && history.length > 0 && (
<>
<div className="text-xs text-gray-500 dark:text-gray-400">
{history.length} Version{history.length > 1 ? 'en' : ''} in der Kette (neueste oben).
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="text-xs text-gray-500 dark:text-gray-400">
{history.length} Version{history.length > 1 ? 'en' : ''} in der Kette (neueste oben).
</div>
{history.length > 1 && (
<button
onClick={() => setShowBulkDiff((v) => !v)}
className="text-[11px] px-2 py-1 rounded border border-purple-300 text-purple-700 hover:bg-purple-50 dark:border-purple-700 dark:text-purple-300 dark:hover:bg-purple-900/30"
title="Aggregierter Diff ueber alle Versionen"
>
{showBulkDiff ? 'Bulk-Diff ausblenden' : `Bulk-Diff V1 → V${history[0].version || '?'} anzeigen`}
</button>
)}
</div>
{showBulkDiff && <BulkDiffPanel cid={cid} onClose={() => setShowBulkDiff(false)} />}
<ol className="relative border-l-2 border-emerald-500/40 pl-4 space-y-3">
{history.map((entry, idx) => {
const next = history[idx + 1]
@@ -0,0 +1,95 @@
'use client'
import { useState } from 'react'
import CIDHistoryModal from '@/app/sdk/audit-timeline/_components/CIDHistoryModal'
export interface LastExport {
cid: string
filename: string
size: number
format: string
}
interface Props {
lastExport: LastExport | null
onDismiss: () => void
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(2)} MB`
}
export function ExportCIDBadge({ lastExport, onDismiss }: Props) {
const [showHistory, setShowHistory] = useState(false)
const [copied, setCopied] = useState(false)
if (!lastExport) return null
async function copyToClipboard() {
if (!lastExport) return
try {
await navigator.clipboard.writeText(lastExport.cid)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard not available — silent
}
}
return (
<>
<div className="rounded-xl border border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900/20 p-4">
<div className="flex items-start gap-3">
<div className="rounded-full bg-emerald-500 p-1 flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-emerald-900 dark:text-emerald-200">
CE-Akte exportiert und in DSMS archiviert
</div>
<div className="mt-1 text-xs text-emerald-800 dark:text-emerald-300">
{lastExport.filename} · {formatBytes(lastExport.size)} · Format {lastExport.format.toUpperCase()}
</div>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-wide text-emerald-700 dark:text-emerald-400 font-semibold">
CID
</span>
<code className="font-mono text-xs text-emerald-900 dark:text-emerald-100 bg-white/60 dark:bg-black/20 px-2 py-0.5 rounded select-all break-all">
{lastExport.cid}
</code>
<button
onClick={copyToClipboard}
className="text-[11px] text-emerald-700 hover:text-emerald-900 dark:text-emerald-400 dark:hover:text-emerald-200 underline"
title="CID in Zwischenablage kopieren"
>
{copied ? '✓ Kopiert' : 'Kopieren'}
</button>
<button
onClick={() => setShowHistory(true)}
className="text-[11px] text-emerald-700 hover:text-emerald-900 dark:text-emerald-400 dark:hover:text-emerald-200 underline"
title="DSMS-Versionsverlauf und Diffs anzeigen"
>
Verlauf anzeigen
</button>
</div>
</div>
<button
onClick={onDismiss}
className="text-emerald-600 hover:text-emerald-800 dark:text-emerald-400 dark:hover:text-emerald-200 p-1 flex-shrink-0"
title="Hinweis schliessen"
aria-label="Hinweis schliessen"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{showHistory && <CIDHistoryModal cid={lastExport.cid} onClose={() => setShowHistory(false)} />}
</>
)
}
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useRef } from 'react'
import { useParams } from 'next/navigation'
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
import { ReportGenerator } from './_components/ReportGenerator'
import { ExportCIDBadge, type LastExport } from './_components/ExportCIDBadge'
import { SECTION_TYPES, STATUS_CONFIG, EXPORT_FORMATS } from './_constants'
interface TechFileSection {
@@ -116,6 +117,7 @@ export default function TechFilePage() {
const [viewingSection, setViewingSection] = useState<TechFileSection | null>(null)
const [exporting, setExporting] = useState(false)
const [showExportMenu, setShowExportMenu] = useState(false)
const [lastExport, setLastExport] = useState<LastExport | null>(null)
const exportMenuRef = useRef<HTMLDivElement>(null)
// Close export menu when clicking outside
@@ -224,6 +226,19 @@ export default function TechFilePage() {
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
// DSMS archive metadata is forwarded by the backend in X-DSMS-* headers
// when archiving succeeded. If headers are absent (DSMS gateway down)
// the export still works but no badge is shown.
const cid = res.headers.get('x-dsms-cid')
if (cid) {
setLastExport({
cid,
filename: res.headers.get('x-dsms-filename') || `CE-Akte-${projectId}${extension}`,
size: parseInt(res.headers.get('x-dsms-size') || '0', 10) || blob.size,
format,
})
}
}
} catch (err) {
console.error('Failed to export:', err)
@@ -305,6 +320,9 @@ export default function TechFilePage() {
</div>
</div>
{/* DSMS-CID badge nach erfolgreichem Export */}
<ExportCIDBadge lastExport={lastExport} onDismiss={() => setLastExport(null)} />
{/* Progress */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between mb-2">