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",
|
"active": "Aktiv",
|
||||||
"pastDue": "Zahlung ausstehend",
|
"pastDue": "Zahlung ausstehend",
|
||||||
"canceled": "Gekündigt"
|
"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",
|
"active": "Active",
|
||||||
"pastDue": "Past due",
|
"pastDue": "Past due",
|
||||||
"canceled": "Canceled"
|
"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.PdfPCell;
|
||||||
import com.lowagie.text.pdf.PdfPTable;
|
import com.lowagie.text.pdf.PdfPTable;
|
||||||
import com.lowagie.text.pdf.PdfWriter;
|
import com.lowagie.text.pdf.PdfWriter;
|
||||||
|
import de.cannamanage.domain.entity.AuditEvent;
|
||||||
import de.cannamanage.domain.entity.Club;
|
import de.cannamanage.domain.entity.Club;
|
||||||
import de.cannamanage.service.model.report.MemberListReport;
|
import de.cannamanage.service.model.report.MemberListReport;
|
||||||
import de.cannamanage.service.model.report.MonthlyReport;
|
import de.cannamanage.service.model.report.MonthlyReport;
|
||||||
@@ -12,8 +13,10 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
import java.awt.Color;
|
import java.awt.Color;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates PDF reports using OpenPDF (librepdf fork of iText 2.x).
|
* Generates PDF reports using OpenPDF (librepdf fork of iText 2.x).
|
||||||
@@ -239,4 +242,63 @@ public class PdfReportGenerator {
|
|||||||
valueCell.setPadding(4);
|
valueCell.setPadding(4);
|
||||||
table.addCell(valueCell);
|
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