feat: append-only audit trail for all mutations (P0)
- Database: kanzlai.audit_log table with RLS, append-only policies (no UPDATE/DELETE), indexes for entity, user, and time queries - Backend: AuditService.Log() with context-based tenant/user/IP/UA extraction, wired into all 7 services (case, deadline, appointment, document, note, party, tenant) - API: GET /api/audit-log with entity_type, entity_id, user_id, from/to date, and pagination filters - Frontend: Protokoll tab on case detail page with chronological audit entries, diff preview, and pagination Required by § 50 BRAO and DSGVO Art. 5(2).
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
Users,
|
||||
StickyNote,
|
||||
AlertTriangle,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
@@ -44,6 +45,7 @@ const TABS = [
|
||||
{ segment: "dokumente", label: "Dokumente", icon: FileText },
|
||||
{ segment: "parteien", label: "Parteien", icon: Users },
|
||||
{ segment: "notizen", label: "Notizen", icon: StickyNote },
|
||||
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
|
||||
] as const;
|
||||
|
||||
const TAB_LABELS: Record<string, string> = {
|
||||
@@ -52,6 +54,7 @@ const TAB_LABELS: Record<string, string> = {
|
||||
dokumente: "Dokumente",
|
||||
parteien: "Parteien",
|
||||
notizen: "Notizen",
|
||||
protokoll: "Protokoll",
|
||||
};
|
||||
|
||||
function CaseDetailSkeleton() {
|
||||
|
||||
178
frontend/src/app/(app)/cases/[id]/protokoll/page.tsx
Normal file
178
frontend/src/app/(app)/cases/[id]/protokoll/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import type { AuditLogResponse } from "@/lib/types";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Loader2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: "Erstellt",
|
||||
update: "Aktualisiert",
|
||||
delete: "Geloescht",
|
||||
};
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
create: "bg-emerald-50 text-emerald-700",
|
||||
update: "bg-blue-50 text-blue-700",
|
||||
delete: "bg-red-50 text-red-700",
|
||||
};
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
case: "Akte",
|
||||
deadline: "Frist",
|
||||
appointment: "Termin",
|
||||
document: "Dokument",
|
||||
party: "Partei",
|
||||
note: "Notiz",
|
||||
settings: "Einstellungen",
|
||||
membership: "Mitgliedschaft",
|
||||
};
|
||||
|
||||
function DiffPreview({
|
||||
oldValues,
|
||||
newValues,
|
||||
}: {
|
||||
oldValues?: Record<string, unknown>;
|
||||
newValues?: Record<string, unknown>;
|
||||
}) {
|
||||
if (!oldValues && !newValues) return null;
|
||||
|
||||
const allKeys = new Set([
|
||||
...Object.keys(oldValues ?? {}),
|
||||
...Object.keys(newValues ?? {}),
|
||||
]);
|
||||
|
||||
const changes: { key: string; from?: unknown; to?: unknown }[] = [];
|
||||
for (const key of allKeys) {
|
||||
const oldVal = oldValues?.[key];
|
||||
const newVal = newValues?.[key];
|
||||
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
||||
changes.push({ key, from: oldVal, to: newVal });
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
{changes.slice(0, 5).map((c) => (
|
||||
<div key={c.key} className="flex items-baseline gap-2 text-xs">
|
||||
<span className="font-medium text-neutral-500">{c.key}:</span>
|
||||
{c.from !== undefined && (
|
||||
<span className="rounded bg-red-50 px-1 text-red-600 line-through">
|
||||
{String(c.from)}
|
||||
</span>
|
||||
)}
|
||||
{c.to !== undefined && (
|
||||
<span className="rounded bg-emerald-50 px-1 text-emerald-600">
|
||||
{String(c.to)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{changes.length > 5 && (
|
||||
<span className="text-xs text-neutral-400">
|
||||
+{changes.length - 5} weitere Aenderungen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProtokollPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const page = Number(searchParams.get("page")) || 1;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["audit-log", id, page],
|
||||
queryFn: () =>
|
||||
api.get<AuditLogResponse>(
|
||||
`/audit-log?entity_id=${id}&page=${page}&limit=50`,
|
||||
),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = data?.entries ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / 50);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="py-8 text-center text-sm text-neutral-400">
|
||||
Keine Protokolleintraege vorhanden.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-md border border-neutral-100 bg-white px-4 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${ACTION_COLORS[entry.action] ?? "bg-neutral-100 text-neutral-600"}`}
|
||||
>
|
||||
{ACTION_LABELS[entry.action] ?? entry.action}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{ENTITY_LABELS[entry.entity_type] ?? entry.entity_type}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-neutral-400">
|
||||
{format(new Date(entry.created_at), "d. MMM yyyy, HH:mm", {
|
||||
locale: de,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<DiffPreview
|
||||
oldValues={entry.old_values}
|
||||
newValues={entry.new_values}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-400">
|
||||
{total} Eintraege, Seite {page} von {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{page > 1 && (
|
||||
<a
|
||||
href={`?page=${page - 1}`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2 py-1 text-xs text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" /> Zurueck
|
||||
</a>
|
||||
)}
|
||||
{page < totalPages && (
|
||||
<a
|
||||
href={`?page=${page + 1}`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-neutral-200 px-2 py-1 text-xs text-neutral-600 hover:bg-neutral-50"
|
||||
>
|
||||
Weiter <ChevronRight className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -189,6 +189,27 @@ export interface Note {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
tenant_id: string;
|
||||
user_id?: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id?: string;
|
||||
old_values?: Record<string, unknown>;
|
||||
new_values?: Record<string, unknown>;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
entries: AuditLogEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
status: number;
|
||||
|
||||
Reference in New Issue
Block a user