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:
@@ -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(),
|
||||
|
||||
+16
@@ -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
|
||||
) {
|
||||
}
|
||||
Reference in New Issue
Block a user