feat(sprint9): Phase 3 — KCanG compliance reports + Behörden-Export

Implemented 6 KCanG compliance report generators and the hero
Behörden-Export feature:

- AnnualAuthorityReportGenerator: Multi-section §22 KCanG annual report
  (9 sections: Vereinsdaten, Mitgliederstatistik, Anbauübersicht,
  Weitergabe-Statistik, Bestandsführung, Vernichtung, Transport,
  Prävention, Jugendschutz)
- DistributionLogGenerator: §19(4) distribution log (PDF+CSV,
  anonymized member data per DSGVO)
- DestructionProtocolGenerator: §22 destruction protocol with
  signature lines and sequential numbering
- TransportCertificateGenerator: §22(4) transport documentation
- BestandsfuehrungGenerator: Stock flow report (PDF+CSV) with
  per-batch breakdown
- PreventionActivityReportGenerator: §23 prevention activities

Authority Export (Behörden-Export) — THE HERO FEATURE:
- AuthorityExportService: Streaming ZIP generation via ZipOutputStream
- Re-authentication required (password re-entry + BCrypt verification)
- Mandatory reason field stored in audit trail
- Rate limited: max 1 export per hour per tenant
- ZIP contains all compliance PDFs + anonymized member JSON + manifest
- Memory-efficient: PDFs generated and streamed sequentially

Endpoint: POST /api/v1/reports/authority-export
Request: { year, password, reason }
Response: StreamingResponseBody (application/zip)

Also enhanced repositories:
- DestructionRecordRepository: date-range queries + sum aggregation
- TransportRecordRepository: date-range queries
This commit is contained in:
Patrick Plate
2026-06-15 12:53:12 +02:00
parent a29c38756c
commit 3ca231dc9c
12 changed files with 2422 additions and 1 deletions
@@ -1,10 +1,12 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.report.AuthorityExportRequest;
import de.cannamanage.api.dto.report.MemberListResponse;
import de.cannamanage.api.dto.report.MonthlyReportResponse;
import de.cannamanage.api.dto.report.RecallReportResponse;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.domain.enums.ReportType;
@@ -15,12 +17,18 @@ import de.cannamanage.service.ReportService;
import de.cannamanage.service.model.report.MemberListReport;
import de.cannamanage.service.model.report.MonthlyReport;
import de.cannamanage.service.model.report.RecallReport;
import de.cannamanage.service.report.AuthorityExportService;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.UserRepository;
import jakarta.validation.Valid;
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.time.YearMonth;
import java.util.*;
@@ -38,17 +46,26 @@ public class ReportController {
private final CsvReportGenerator csvGenerator;
private final ClubRepository clubRepository;
private final ReportGeneratorService reportGeneratorService;
private final AuthorityExportService authorityExportService;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public ReportController(ReportService reportService,
PdfReportGenerator pdfGenerator,
CsvReportGenerator csvGenerator,
ClubRepository clubRepository,
ReportGeneratorService reportGeneratorService) {
ReportGeneratorService reportGeneratorService,
AuthorityExportService authorityExportService,
UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.reportService = reportService;
this.pdfGenerator = pdfGenerator;
this.csvGenerator = csvGenerator;
this.clubRepository = clubRepository;
this.reportGeneratorService = reportGeneratorService;
this.authorityExportService = authorityExportService;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
/**
@@ -211,6 +228,49 @@ public class ReportController {
);
}
/**
* Full Authority Export (Behörden-Export) — THE HERO FEATURE.
* Generates a streaming ZIP containing all compliance documents.
* Requires re-authentication (password re-entry) + mandatory reason.
* Rate limited: max 1 export per hour per tenant.
*
* POST /api/v1/reports/authority-export
*/
@PostMapping("/authority-export")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<StreamingResponseBody> authorityExport(
@Valid @RequestBody AuthorityExportRequest request,
@AuthenticationPrincipal UUID userId) {
UUID tenantId = TenantContext.getCurrentTenant();
// Rate limit check
if (authorityExportService.isRateLimited(tenantId)) {
return ResponseEntity.status(429)
.header("Retry-After", "3600")
.build();
}
// Re-authentication: verify password against BCrypt hash
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalStateException("Authenticated user not found"));
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
return ResponseEntity.status(403).build();
}
// Stream the ZIP
StreamingResponseBody responseBody = outputStream ->
authorityExportService.streamAuthorityExport(
outputStream, tenantId, request.year(), userId, request.reason());
String filename = "Behoerden_Export_" + request.year() + "_" + tenantId.toString().substring(0, 8) + ".zip";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("application/zip"))
.body(responseBody);
}
private RecallReportResponse toRecallResponse(RecallReport r) {
return new RecallReportResponse(
r.getBatchId(),
@@ -0,0 +1,16 @@
package de.cannamanage.api.dto.report;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* Request body for the authority export endpoint.
* Requires re-authentication (password) and a mandatory reason for the audit trail.
*/
public record AuthorityExportRequest(
@NotNull Integer year,
@NotBlank @Size(min = 1, max = 500) String password,
@NotBlank @Size(min = 10, max = 500) String reason
) {
}