feat(sprint-6): Phase 4 — Immutable audit log
- V8 migration: audit_events table (JSONB metadata, immutable by design) - AuditEvent entity + AuditEventType enum (18 event types) - AuditService: log events, paginated query, PDF export - AuditController: GET /api/v1/audit (paginated, filtered), GET export - AuditEventRepository with JPQL filtered queries - Frontend: /audit-log page (read-only, filterable, timezone-aware) - PDF export button for Behörde inspections - Sidebar: 'Protokoll' under new Compliance section - PdfReportGenerator: generateAuditReport method added - 10-year retention, REVOKE DELETE documented - Full i18n (de/en) with 18 event type translations
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.AuditEvent;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.AuditEventType;
|
||||
import de.cannamanage.service.AuditService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/audit")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Audit", description = "Immutable audit log (KCanG compliance, 10-year retention)")
|
||||
public class AuditController {
|
||||
|
||||
private final AuditService auditService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Get paginated audit log",
|
||||
description = "Returns audit events with optional filters. Admin only.")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||
public ResponseEntity<Page<AuditEventResponse>> getAuditLog(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) AuditEventType eventType,
|
||||
@RequestParam(required = false) String entityType,
|
||||
@RequestParam(required = false) UUID actorId,
|
||||
@RequestParam(required = false) Instant from,
|
||||
@RequestParam(required = false) Instant to
|
||||
) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Page<AuditEvent> events = auditService.getEvents(
|
||||
tenantId, page, size, eventType, entityType, actorId, from, to
|
||||
);
|
||||
Page<AuditEventResponse> response = events.map(AuditEventResponse::from);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "Export audit log as PDF",
|
||||
description = "Generates a PDF audit report for the specified date range. Admin only.")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<byte[]> exportAuditPdf(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
|
||||
) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Instant fromInstant = from.atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
|
||||
Instant toInstant = to.plusDays(1).atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
|
||||
|
||||
byte[] pdf = auditService.exportPdf(tenantId, fromInstant, toInstant);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"audit-log-" + from + "-to-" + to + ".pdf\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(pdf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Response DTO for audit events (read-only projection).
|
||||
*/
|
||||
public record AuditEventResponse(
|
||||
UUID id,
|
||||
String eventType,
|
||||
String entityType,
|
||||
UUID entityId,
|
||||
UUID actorId,
|
||||
String actorName,
|
||||
String actorRole,
|
||||
String description,
|
||||
String metadata,
|
||||
String ipAddress,
|
||||
Instant timestamp
|
||||
) {
|
||||
public static AuditEventResponse from(AuditEvent event) {
|
||||
return new AuditEventResponse(
|
||||
event.getId(),
|
||||
event.getEventType().name(),
|
||||
event.getEntityType(),
|
||||
event.getEntityId(),
|
||||
event.getActorId(),
|
||||
event.getActorName(),
|
||||
event.getActorRole(),
|
||||
event.getDescription(),
|
||||
event.getMetadata(),
|
||||
event.getIpAddress(),
|
||||
event.getTimestamp()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
-- V8: Immutable Audit Log (KCanG §19 — 10-year retention)
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID,
|
||||
actor_id UUID NOT NULL,
|
||||
actor_name VARCHAR(255) NOT NULL,
|
||||
actor_role VARCHAR(20) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
metadata JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL
|
||||
);
|
||||
|
||||
-- Indexes for efficient querying
|
||||
CREATE INDEX idx_audit_timestamp ON audit_events(timestamp DESC);
|
||||
CREATE INDEX idx_audit_entity ON audit_events(entity_type, entity_id);
|
||||
CREATE INDEX idx_audit_actor ON audit_events(actor_id);
|
||||
CREATE INDEX idx_audit_tenant ON audit_events(tenant_id);
|
||||
CREATE INDEX idx_audit_type ON audit_events(event_type);
|
||||
|
||||
-- IMMUTABILITY: Revoke DELETE from application user
|
||||
-- (In production, run as DBA: REVOKE DELETE ON audit_events FROM cannamanage_app;)
|
||||
COMMENT ON TABLE audit_events IS 'Immutable audit log — 10-year retention (KCanG). Application role cannot DELETE.';
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.AuditEventType;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Immutable audit log entry.
|
||||
* No setters for fields post-persist — once written, never changed.
|
||||
* 10-year retention per KCanG compliance requirements.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "audit_events")
|
||||
public class AuditEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(name = "id", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "event_type", nullable = false, updatable = false, length = 50)
|
||||
private AuditEventType eventType;
|
||||
|
||||
@Column(name = "entity_type", nullable = false, updatable = false, length = 50)
|
||||
private String entityType;
|
||||
|
||||
@Column(name = "entity_id", updatable = false)
|
||||
private UUID entityId;
|
||||
|
||||
@Column(name = "actor_id", nullable = false, updatable = false)
|
||||
private UUID actorId;
|
||||
|
||||
@Column(name = "actor_name", nullable = false, updatable = false)
|
||||
private String actorName;
|
||||
|
||||
@Column(name = "actor_role", nullable = false, updatable = false, length = 20)
|
||||
private String actorRole;
|
||||
|
||||
@Column(name = "description", nullable = false, updatable = false, columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "metadata", updatable = false, columnDefinition = "jsonb")
|
||||
private String metadata;
|
||||
|
||||
@Column(name = "ip_address", updatable = false, length = 45)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "timestamp", nullable = false, updatable = false)
|
||||
private Instant timestamp;
|
||||
|
||||
@Column(name = "tenant_id", nullable = false, updatable = false)
|
||||
private UUID tenantId;
|
||||
|
||||
protected AuditEvent() {
|
||||
// JPA
|
||||
}
|
||||
|
||||
private AuditEvent(Builder builder) {
|
||||
this.eventType = builder.eventType;
|
||||
this.entityType = builder.entityType;
|
||||
this.entityId = builder.entityId;
|
||||
this.actorId = builder.actorId;
|
||||
this.actorName = builder.actorName;
|
||||
this.actorRole = builder.actorRole;
|
||||
this.description = builder.description;
|
||||
this.metadata = builder.metadata;
|
||||
this.ipAddress = builder.ipAddress;
|
||||
this.timestamp = Instant.now();
|
||||
this.tenantId = builder.tenantId;
|
||||
}
|
||||
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
// Read-only getters
|
||||
public UUID getId() { return id; }
|
||||
public AuditEventType getEventType() { return eventType; }
|
||||
public String getEntityType() { return entityType; }
|
||||
public UUID getEntityId() { return entityId; }
|
||||
public UUID getActorId() { return actorId; }
|
||||
public String getActorName() { return actorName; }
|
||||
public String getActorRole() { return actorRole; }
|
||||
public String getDescription() { return description; }
|
||||
public String getMetadata() { return metadata; }
|
||||
public String getIpAddress() { return ipAddress; }
|
||||
public Instant getTimestamp() { return timestamp; }
|
||||
public UUID getTenantId() { return tenantId; }
|
||||
|
||||
public static class Builder {
|
||||
private AuditEventType eventType;
|
||||
private String entityType;
|
||||
private UUID entityId;
|
||||
private UUID actorId;
|
||||
private String actorName;
|
||||
private String actorRole;
|
||||
private String description;
|
||||
private String metadata;
|
||||
private String ipAddress;
|
||||
private UUID tenantId;
|
||||
|
||||
public Builder eventType(AuditEventType eventType) { this.eventType = eventType; return this; }
|
||||
public Builder entityType(String entityType) { this.entityType = entityType; return this; }
|
||||
public Builder entityId(UUID entityId) { this.entityId = entityId; return this; }
|
||||
public Builder actorId(UUID actorId) { this.actorId = actorId; return this; }
|
||||
public Builder actorName(String actorName) { this.actorName = actorName; return this; }
|
||||
public Builder actorRole(String actorRole) { this.actorRole = actorRole; return this; }
|
||||
public Builder description(String description) { this.description = description; return this; }
|
||||
public Builder metadata(String metadata) { this.metadata = metadata; return this; }
|
||||
public Builder ipAddress(String ipAddress) { this.ipAddress = ipAddress; return this; }
|
||||
public Builder tenantId(UUID tenantId) { this.tenantId = tenantId; return this; }
|
||||
|
||||
public AuditEvent build() {
|
||||
return new AuditEvent(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.cannamanage.domain.enums;
|
||||
|
||||
/**
|
||||
* All auditable event types in the system.
|
||||
* Immutable audit trail for KCanG compliance (10-year retention).
|
||||
*/
|
||||
public enum AuditEventType {
|
||||
// Distribution events
|
||||
DISTRIBUTION_RECORDED,
|
||||
DISTRIBUTION_VOIDED,
|
||||
|
||||
// Member events
|
||||
MEMBER_CREATED,
|
||||
MEMBER_UPDATED,
|
||||
MEMBER_SUSPENDED,
|
||||
MEMBER_EXPELLED,
|
||||
|
||||
// Stock events
|
||||
BATCH_CREATED,
|
||||
BATCH_RECALLED,
|
||||
|
||||
// Auth events
|
||||
LOGIN_SUCCESS,
|
||||
LOGIN_FAILED,
|
||||
LOGOUT,
|
||||
PASSWORD_CHANGED,
|
||||
|
||||
// Staff events
|
||||
STAFF_INVITED,
|
||||
STAFF_PERMISSIONS_CHANGED,
|
||||
STAFF_REVOKED,
|
||||
|
||||
// Consent events
|
||||
CONSENT_GRANTED,
|
||||
CONSENT_REVOKED,
|
||||
DATA_EXPORTED,
|
||||
DATA_DELETED,
|
||||
|
||||
// Billing events
|
||||
SUBSCRIPTION_STARTED,
|
||||
SUBSCRIPTION_CANCELED,
|
||||
PAYMENT_RECEIVED,
|
||||
PAYMENT_FAILED
|
||||
}
|
||||
@@ -399,5 +399,50 @@
|
||||
"active": "Aktiv",
|
||||
"pastDue": "Zahlung ausstehend",
|
||||
"canceled": "Gekündigt"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit-Protokoll",
|
||||
"subtitle": "Unveränderliches Protokoll aller Vorgänge (10 Jahre Aufbewahrung)",
|
||||
"timestamp": "Zeitstempel",
|
||||
"type": "Typ",
|
||||
"description": "Beschreibung",
|
||||
"actor": "Akteur",
|
||||
"entity": "Objekt",
|
||||
"filterType": "Ereignistyp filtern",
|
||||
"filterDateFrom": "Von",
|
||||
"filterDateTo": "Bis",
|
||||
"filterActor": "Akteur suchen",
|
||||
"exportPdf": "Als PDF exportieren",
|
||||
"exporting": "PDF wird generiert...",
|
||||
"exported": "Audit-Protokoll exportiert.",
|
||||
"allTypes": "Alle Typen",
|
||||
"immutable": "Unveränderbar",
|
||||
"timezone": "Europe/Berlin",
|
||||
"retentionNote": "Aufbewahrungsfrist: 10 Jahre (KCanG-konform)",
|
||||
"types": {
|
||||
"DISTRIBUTION_RECORDED": "Ausgabe erfasst",
|
||||
"DISTRIBUTION_VOIDED": "Ausgabe storniert",
|
||||
"MEMBER_CREATED": "Mitglied angelegt",
|
||||
"MEMBER_UPDATED": "Mitglied aktualisiert",
|
||||
"MEMBER_SUSPENDED": "Mitglied gesperrt",
|
||||
"MEMBER_EXPELLED": "Mitglied ausgeschlossen",
|
||||
"BATCH_CREATED": "Charge angelegt",
|
||||
"BATCH_RECALLED": "Charge zurückgerufen",
|
||||
"LOGIN_SUCCESS": "Anmeldung",
|
||||
"LOGIN_FAILED": "Fehlgeschlagene Anmeldung",
|
||||
"LOGOUT": "Abmeldung",
|
||||
"PASSWORD_CHANGED": "Passwort geändert",
|
||||
"STAFF_INVITED": "Mitarbeiter eingeladen",
|
||||
"STAFF_PERMISSIONS_CHANGED": "Berechtigungen geändert",
|
||||
"STAFF_REVOKED": "Zugang entzogen",
|
||||
"CONSENT_GRANTED": "Einwilligung erteilt",
|
||||
"CONSENT_REVOKED": "Einwilligung widerrufen",
|
||||
"DATA_EXPORTED": "Daten exportiert",
|
||||
"DATA_DELETED": "Daten gelöscht",
|
||||
"SUBSCRIPTION_STARTED": "Abo gestartet",
|
||||
"SUBSCRIPTION_CANCELED": "Abo gekündigt",
|
||||
"PAYMENT_RECEIVED": "Zahlung erhalten",
|
||||
"PAYMENT_FAILED": "Zahlung fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,5 +399,50 @@
|
||||
"active": "Active",
|
||||
"pastDue": "Past due",
|
||||
"canceled": "Canceled"
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit Log",
|
||||
"subtitle": "Immutable log of all operations (10-year retention)",
|
||||
"timestamp": "Timestamp",
|
||||
"type": "Type",
|
||||
"description": "Description",
|
||||
"actor": "Actor",
|
||||
"entity": "Entity",
|
||||
"filterType": "Filter by event type",
|
||||
"filterDateFrom": "From",
|
||||
"filterDateTo": "To",
|
||||
"filterActor": "Search actor",
|
||||
"exportPdf": "Export as PDF",
|
||||
"exporting": "Generating PDF...",
|
||||
"exported": "Audit log exported.",
|
||||
"allTypes": "All types",
|
||||
"immutable": "Immutable",
|
||||
"timezone": "Europe/Berlin",
|
||||
"retentionNote": "Retention period: 10 years (KCanG-compliant)",
|
||||
"types": {
|
||||
"DISTRIBUTION_RECORDED": "Distribution recorded",
|
||||
"DISTRIBUTION_VOIDED": "Distribution voided",
|
||||
"MEMBER_CREATED": "Member created",
|
||||
"MEMBER_UPDATED": "Member updated",
|
||||
"MEMBER_SUSPENDED": "Member suspended",
|
||||
"MEMBER_EXPELLED": "Member expelled",
|
||||
"BATCH_CREATED": "Batch created",
|
||||
"BATCH_RECALLED": "Batch recalled",
|
||||
"LOGIN_SUCCESS": "Login",
|
||||
"LOGIN_FAILED": "Failed login",
|
||||
"LOGOUT": "Logout",
|
||||
"PASSWORD_CHANGED": "Password changed",
|
||||
"STAFF_INVITED": "Staff invited",
|
||||
"STAFF_PERMISSIONS_CHANGED": "Permissions changed",
|
||||
"STAFF_REVOKED": "Access revoked",
|
||||
"CONSENT_GRANTED": "Consent granted",
|
||||
"CONSENT_REVOKED": "Consent revoked",
|
||||
"DATA_EXPORTED": "Data exported",
|
||||
"DATA_DELETED": "Data deleted",
|
||||
"SUBSCRIPTION_STARTED": "Subscription started",
|
||||
"SUBSCRIPTION_CANCELED": "Subscription canceled",
|
||||
"PAYMENT_RECEIVED": "Payment received",
|
||||
"PAYMENT_FAILED": "Payment failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useAuditLogQuery, useExportAuditPdfMutation } from "@/services/audit"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Download, FileCheck, Lock, ScrollText, Search } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
const EVENT_TYPES = [
|
||||
"DISTRIBUTION_RECORDED",
|
||||
"DISTRIBUTION_VOIDED",
|
||||
"MEMBER_CREATED",
|
||||
"MEMBER_UPDATED",
|
||||
"MEMBER_SUSPENDED",
|
||||
"MEMBER_EXPELLED",
|
||||
"BATCH_CREATED",
|
||||
"BATCH_RECALLED",
|
||||
"LOGIN_SUCCESS",
|
||||
"LOGIN_FAILED",
|
||||
"LOGOUT",
|
||||
"PASSWORD_CHANGED",
|
||||
"STAFF_INVITED",
|
||||
"STAFF_PERMISSIONS_CHANGED",
|
||||
"STAFF_REVOKED",
|
||||
"CONSENT_GRANTED",
|
||||
"CONSENT_REVOKED",
|
||||
"DATA_EXPORTED",
|
||||
"DATA_DELETED",
|
||||
"SUBSCRIPTION_STARTED",
|
||||
"SUBSCRIPTION_CANCELED",
|
||||
"PAYMENT_RECEIVED",
|
||||
"PAYMENT_FAILED",
|
||||
]
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const t = useTranslations("audit")
|
||||
|
||||
// Filter state
|
||||
const [page, setPage] = useState(0)
|
||||
const [eventType, setEventType] = useState<string>("")
|
||||
const [dateFrom, setDateFrom] = useState("")
|
||||
const [dateTo, setDateTo] = useState("")
|
||||
|
||||
// Query
|
||||
const { data, isLoading } = useAuditLogQuery({
|
||||
page,
|
||||
size: 20,
|
||||
eventType: eventType || undefined,
|
||||
from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
|
||||
to: dateTo ? new Date(dateTo + "T23:59:59").toISOString() : undefined,
|
||||
})
|
||||
|
||||
// Export mutation
|
||||
const exportMutation = useExportAuditPdfMutation()
|
||||
|
||||
const handleExport = () => {
|
||||
if (!dateFrom || !dateTo) {
|
||||
toast.error("Bitte Zeitraum auswählen")
|
||||
return
|
||||
}
|
||||
exportMutation.mutate(
|
||||
{ from: dateFrom, to: dateTo },
|
||||
{
|
||||
onSuccess: () => toast.success(t("exported")),
|
||||
onError: () => toast.error("Export fehlgeschlagen"),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const formatTimestamp = (ts: string) => {
|
||||
return new Date(ts).toLocaleString("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
const getEventTypeLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
DISTRIBUTION_RECORDED: t("types.DISTRIBUTION_RECORDED"),
|
||||
MEMBER_CREATED: t("types.MEMBER_CREATED"),
|
||||
MEMBER_UPDATED: t("types.MEMBER_UPDATED"),
|
||||
MEMBER_SUSPENDED: t("types.MEMBER_SUSPENDED"),
|
||||
BATCH_CREATED: t("types.BATCH_CREATED"),
|
||||
BATCH_RECALLED: t("types.BATCH_RECALLED"),
|
||||
LOGIN_SUCCESS: t("types.LOGIN_SUCCESS"),
|
||||
LOGIN_FAILED: t("types.LOGIN_FAILED"),
|
||||
STAFF_INVITED: t("types.STAFF_INVITED"),
|
||||
CONSENT_GRANTED: t("types.CONSENT_GRANTED"),
|
||||
SUBSCRIPTION_STARTED: t("types.SUBSCRIPTION_STARTED"),
|
||||
PAYMENT_RECEIVED: t("types.PAYMENT_RECEIVED"),
|
||||
}
|
||||
return labels[type] || type.replace(/_/g, " ")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<ScrollText className="h-6 w-6" />
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-1 flex items-center gap-1 text-muted-foreground">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<FileCheck className="h-3 w-3" />
|
||||
{t("immutable")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Search className="h-4 w-4" />
|
||||
Filter
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{/* Event type filter */}
|
||||
<Select
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
>
|
||||
<option value="">{t("allTypes")}</option>
|
||||
{EVENT_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getEventTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Date from */}
|
||||
<Input
|
||||
type="date"
|
||||
placeholder={t("filterDateFrom")}
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Date to */}
|
||||
<Input
|
||||
type="date"
|
||||
placeholder={t("filterDateTo")}
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Export button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
disabled={exportMutation.isPending || !dateFrom || !dateTo}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{exportMutation.isPending ? t("exporting") : t("exportPdf")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Retention note */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("retentionNote")} · {t("timezone")}
|
||||
</p>
|
||||
|
||||
{/* Audit table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[160px]">{t("timestamp")}</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
{t("description")}
|
||||
</TableHead>
|
||||
<TableHead>{t("actor")}</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">
|
||||
{t("entity")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center">
|
||||
<div className="text-muted-foreground">Laden...</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data?.content && data.content.length > 0 ? (
|
||||
data.content.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getEventTypeLabel(event.eventType)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden max-w-[300px] truncate text-sm text-muted-foreground md:table-cell">
|
||||
{event.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{event.actorName}
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({event.actorRole})
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
|
||||
{event.entityType}
|
||||
{event.entityId && (
|
||||
<span className="ml-1 font-mono">
|
||||
{event.entityId.substring(0, 8)}…
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
Keine Audit-Einträge gefunden
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seite {data.number + 1} von {data.totalPages} ({data.totalElements}{" "}
|
||||
Einträge)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= data.totalPages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -36,4 +36,14 @@ export const navigationsData: NavigationType[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Compliance",
|
||||
items: [
|
||||
{
|
||||
title: "Protokoll",
|
||||
href: "/audit-log",
|
||||
iconName: "ScrollText",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
|
||||
import { apiClient, apiDownload } from "@/lib/api-client"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface AuditEventData {
|
||||
id: string
|
||||
eventType: string
|
||||
entityType: string
|
||||
entityId: string | null
|
||||
actorId: string
|
||||
actorName: string
|
||||
actorRole: string
|
||||
description: string
|
||||
metadata: string | null
|
||||
ipAddress: string | null
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface AuditLogPage {
|
||||
content: AuditEventData[]
|
||||
totalElements: number
|
||||
totalPages: number
|
||||
number: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
page?: number
|
||||
size?: number
|
||||
eventType?: string
|
||||
entityType?: string
|
||||
actorId?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
// --- Query Hooks ---
|
||||
|
||||
export function useAuditLogQuery(filters: AuditLogFilters = {}) {
|
||||
const {
|
||||
page = 0,
|
||||
size = 20,
|
||||
eventType,
|
||||
entityType,
|
||||
actorId,
|
||||
from,
|
||||
to,
|
||||
} = filters
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["audit", page, size, eventType, entityType, actorId, from, to],
|
||||
queryFn: () =>
|
||||
apiClient<AuditLogPage>("/audit", {
|
||||
params: {
|
||||
page: page.toString(),
|
||||
size: size.toString(),
|
||||
eventType: eventType || undefined,
|
||||
entityType: entityType || undefined,
|
||||
actorId: actorId || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mutation Hooks ---
|
||||
|
||||
export function useExportAuditPdfMutation() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ from, to }: { from: string; to: string }) => {
|
||||
const { blob, filename } = await apiDownload(
|
||||
`/audit/export?from=${from}&to=${to}`
|
||||
)
|
||||
// Trigger browser download
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename || `audit-log-${from}-to-${to}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.AuditEvent;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.AuditEventType;
|
||||
import de.cannamanage.service.repository.AuditEventRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Immutable audit log service.
|
||||
* Events can only be written (log) and read (query/export) — never updated or deleted.
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
public class AuditService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuditService.class);
|
||||
|
||||
private final AuditEventRepository auditEventRepository;
|
||||
private final PdfReportGenerator pdfReportGenerator;
|
||||
|
||||
public AuditService(AuditEventRepository auditEventRepository, PdfReportGenerator pdfReportGenerator) {
|
||||
this.auditEventRepository = auditEventRepository;
|
||||
this.pdfReportGenerator = pdfReportGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an immutable audit event. Once persisted, it cannot be modified or deleted.
|
||||
*/
|
||||
public AuditEvent log(
|
||||
AuditEventType eventType,
|
||||
String entityType,
|
||||
UUID entityId,
|
||||
UUID actorId,
|
||||
String actorName,
|
||||
String actorRole,
|
||||
String description,
|
||||
String metadata,
|
||||
String ipAddress
|
||||
) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
|
||||
AuditEvent event = AuditEvent.builder()
|
||||
.eventType(eventType)
|
||||
.entityType(entityType)
|
||||
.entityId(entityId)
|
||||
.actorId(actorId)
|
||||
.actorName(actorName)
|
||||
.actorRole(actorRole)
|
||||
.description(description)
|
||||
.metadata(metadata)
|
||||
.ipAddress(ipAddress)
|
||||
.tenantId(tenantId)
|
||||
.build();
|
||||
|
||||
AuditEvent saved = auditEventRepository.save(event);
|
||||
log.debug("Audit event logged: {} {} on {}:{}", eventType, actorName, entityType, entityId);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated, filtered query of audit events.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Page<AuditEvent> getEvents(
|
||||
UUID tenantId,
|
||||
int page,
|
||||
int size,
|
||||
AuditEventType eventType,
|
||||
String entityType,
|
||||
UUID actorId,
|
||||
Instant from,
|
||||
Instant to
|
||||
) {
|
||||
return auditEventRepository.findFiltered(
|
||||
tenantId, eventType, entityType, actorId, from, to,
|
||||
PageRequest.of(page, size)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit events as PDF bytes for a given date range.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public byte[] exportPdf(UUID tenantId, Instant from, Instant to) {
|
||||
List<AuditEvent> events = auditEventRepository.findByTenantIdAndTimestampRange(tenantId, from, to);
|
||||
return pdfReportGenerator.generateAuditReport(events, from, to);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.lowagie.text.*;
|
||||
import com.lowagie.text.pdf.PdfPCell;
|
||||
import com.lowagie.text.pdf.PdfPTable;
|
||||
import com.lowagie.text.pdf.PdfWriter;
|
||||
import de.cannamanage.domain.entity.AuditEvent;
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.service.model.report.MemberListReport;
|
||||
import de.cannamanage.service.model.report.MonthlyReport;
|
||||
@@ -12,8 +13,10 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Generates PDF reports using OpenPDF (librepdf fork of iText 2.x).
|
||||
@@ -239,4 +242,63 @@ public class PdfReportGenerator {
|
||||
valueCell.setPadding(4);
|
||||
table.addCell(valueCell);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a PDF audit report for compliance/Behörde export.
|
||||
*/
|
||||
public byte[] generateAuditReport(List<AuditEvent> events, Instant from, Instant to) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
Document document = new Document(PageSize.A4.rotate(), 30, 30, 40, 40);
|
||||
|
||||
try {
|
||||
PdfWriter writer = PdfWriter.getInstance(document, baos);
|
||||
writer.setPageEvent(new PdfFooterHandler());
|
||||
document.open();
|
||||
|
||||
// Title
|
||||
Paragraph title = new Paragraph("Audit-Protokoll", HEADER_FONT);
|
||||
title.setSpacingAfter(5);
|
||||
document.add(title);
|
||||
|
||||
// Date range subtitle
|
||||
String fromStr = DATETIME_FMT.format(from);
|
||||
String toStr = DATETIME_FMT.format(to);
|
||||
Paragraph subtitle = new Paragraph("Zeitraum: " + fromStr + " – " + toStr, NORMAL_FONT);
|
||||
subtitle.setSpacingAfter(5);
|
||||
document.add(subtitle);
|
||||
|
||||
Paragraph countPara = new Paragraph("Einträge: " + events.size(), NORMAL_FONT);
|
||||
countPara.setSpacingAfter(15);
|
||||
document.add(countPara);
|
||||
|
||||
// Immutability note
|
||||
Paragraph note = new Paragraph(
|
||||
"Unveränderliches Protokoll — 10 Jahre Aufbewahrungsfrist (KCanG-konform)", NORMAL_FONT);
|
||||
note.setSpacingAfter(15);
|
||||
document.add(note);
|
||||
|
||||
// Table
|
||||
PdfPTable table = new PdfPTable(new float[]{14, 12, 10, 24, 14, 10});
|
||||
table.setWidthPercentage(100);
|
||||
table.setSpacingBefore(10);
|
||||
|
||||
addTableHeader(table, "Zeitstempel", "Typ", "Objekt", "Beschreibung", "Akteur", "Rolle");
|
||||
|
||||
for (AuditEvent event : events) {
|
||||
addCell(table, DATETIME_FMT.format(event.getTimestamp()));
|
||||
addCell(table, event.getEventType().name());
|
||||
addCell(table, event.getEntityType());
|
||||
addCell(table, event.getDescription());
|
||||
addCell(table, event.getActorName());
|
||||
addCell(table, event.getActorRole());
|
||||
}
|
||||
|
||||
document.add(table);
|
||||
document.close();
|
||||
} catch (DocumentException e) {
|
||||
throw new RuntimeException("Failed to generate audit PDF", e);
|
||||
}
|
||||
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.AuditEvent;
|
||||
import de.cannamanage.domain.enums.AuditEventType;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface AuditEventRepository extends JpaRepository<AuditEvent, UUID> {
|
||||
|
||||
Page<AuditEvent> findByTenantIdOrderByTimestampDesc(UUID tenantId, Pageable pageable);
|
||||
|
||||
@Query("""
|
||||
SELECT a FROM AuditEvent a
|
||||
WHERE a.tenantId = :tenantId
|
||||
AND (:eventType IS NULL OR a.eventType = :eventType)
|
||||
AND (:entityType IS NULL OR a.entityType = :entityType)
|
||||
AND (:actorId IS NULL OR a.actorId = :actorId)
|
||||
AND (:from IS NULL OR a.timestamp >= :from)
|
||||
AND (:to IS NULL OR a.timestamp <= :to)
|
||||
ORDER BY a.timestamp DESC
|
||||
""")
|
||||
Page<AuditEvent> findFiltered(
|
||||
@Param("tenantId") UUID tenantId,
|
||||
@Param("eventType") AuditEventType eventType,
|
||||
@Param("entityType") String entityType,
|
||||
@Param("actorId") UUID actorId,
|
||||
@Param("from") Instant from,
|
||||
@Param("to") Instant to,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query("""
|
||||
SELECT a FROM AuditEvent a
|
||||
WHERE a.tenantId = :tenantId
|
||||
AND a.timestamp >= :from
|
||||
AND a.timestamp <= :to
|
||||
ORDER BY a.timestamp DESC
|
||||
""")
|
||||
java.util.List<AuditEvent> findByTenantIdAndTimestampRange(
|
||||
@Param("tenantId") UUID tenantId,
|
||||
@Param("from") Instant from,
|
||||
@Param("to") Instant to
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user