Files
2026-09-22 10:29:26 +02:00

101 lines
3.6 KiB
TypeScript

// Duenner Client der AUD-03-Export-API (internal/audit.ExportHandler) —
// keine eigene Aggregations-/Filterlogik im Frontend (Ticket-Vorgabe:
// "reiner Konsument der Export-API"). Die JSON-Lines-Antwort wird nur
// dekodiert, nicht neu berechnet oder gefiltert.
export type AuditRecord = {
occurred_at: string;
tenant_slug: string;
actor: string;
action: string;
target: string;
metadata: unknown;
};
export type AuditFilter = {
tenant: string;
actor: string;
action: string;
from: string; // RFC3339, leer = kein Filter
to: string;
};
// QA-15 F-09: das Admin-Token geht seither als Header, nicht mehr als
// Query-Parameter (Zugriffsprotokolle, Browserverlauf, Proxy-Logs) — muss
// mit internal/audit.ExportTokenHeader (= internal/svcauth.Header)
// uebereinstimmen.
const TOKEN_HEADER = "X-Service-Token";
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_AUDITLOG_API_URL;
if (!base) {
throw new Error(
"NEXT_PUBLIC_AUDITLOG_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
);
}
return base;
}
function buildParams(filter: AuditFilter, format: "json" | "csv"): URLSearchParams {
const params = new URLSearchParams({ format });
if (filter.tenant) params.set("tenant", filter.tenant);
if (filter.actor) params.set("actor", filter.actor);
if (filter.action) params.set("action", filter.action);
if (filter.from) params.set("from", filter.from);
if (filter.to) params.set("to", filter.to);
return params;
}
// exportURL liefert die Adresse desselben Endpunkts, den auch die
// Export-Datei-Funktion (Akzeptanzkriterium 2) verwendet — Liste und Export
// sind bewusst DERSELBE API-Aufruf mit unterschiedlichem "format". Enthaelt
// (seit QA-15 F-09) KEIN Token mehr — das geht ausschliesslich per Header.
function exportURL(filter: AuditFilter, format: "json" | "csv"): string {
return `${apiBase()}/audit/export?${buildParams(filter, format)}`;
}
async function fetchExport(caller: string, filter: AuditFilter, format: "json" | "csv"): Promise<Response> {
const res = await fetch(exportURL(filter, format), {
cache: "no-store",
headers: { [TOKEN_HEADER]: caller },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Anfrage fehlgeschlagen (${res.status})`);
}
return res;
}
// fetchRecords laedt die Liste als JSON-Lines und dekodiert Zeile fuer
// Zeile — dieselben Filterparameter wie ein direkter API-Aufruf
// (Akzeptanzkriterium 1 / Pruefung 1).
export async function fetchRecords(caller: string, filter: AuditFilter): Promise<AuditRecord[]> {
const res = await fetchExport(caller, filter, "json");
const text = await res.text();
return text
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => JSON.parse(line) as AuditRecord);
}
// downloadExport laedt die Exportdatei per Header-authentifiziertem Abruf
// und loest ueber einen temporaeren Objekt-URL denselben Datei-Download aus
// wie zuvor eine direkte Browser-Navigation — die brauchte QA-15 F-09 nicht
// mehr moeglich zu sein, weil ein <a href>/window.open()-Aufruf keinen
// eigenen Header setzen kann (Akzeptanzkriterium 2: Export weiterhin direkt
// als Datei ausloesbar).
export async function downloadExport(caller: string, filter: AuditFilter, format: "csv" | "json"): Promise<void> {
const res = await fetchExport(caller, filter, format);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = `audit-export.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
} finally {
URL.revokeObjectURL(url);
}
}