feat(sprint9): Phase 4 — DSGVO templates + Verein admin reports

Implements 7 report generators for Phase 4:

DSGVO templates:
- VvtReportGenerator: Art. 30 DSGVO Verarbeitungsverzeichnis (PDF)
- TomReportGenerator: Art. 32 DSGVO Technisch-organisatorische Maßnahmen (PDF)
- DsfaReportGenerator: Art. 35 DSGVO Datenschutz-Folgenabschätzung (PDF)
- LoeschkonzeptGenerator: Löschkonzept with retention rules (PDF + JSON)
- BreachNotificationGenerator: Art. 33/34 DSGVO 72h breach notification (PDF)

Verein administration:
- MemberListRegistryGenerator: §67 BGB Mitgliederliste for Amtsgericht (PDF)
- BoardChangeGenerator: §67 BGB Vorstandsänderung notification (PDF)

Also adds:
- BreachReportParameters record for breach notification input
- MemberRepository.findByClubIdOrderByLastNameAscFirstNameAsc()
This commit is contained in:
Patrick Plate
2026-06-15 13:22:46 +02:00
parent 3ca231dc9c
commit c3722ab726
9 changed files with 2295 additions and 0 deletions
@@ -0,0 +1,321 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.BoardMember;
import de.cannamanage.domain.entity.BoardPosition;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.BoardMemberRepository;
import de.cannamanage.service.repository.BoardPositionRepository;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.MemberRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
/**
* Generates the Board Change Notification document for the Vereinsregister (§67 BGB).
* Used to notify the Amtsgericht of board changes after elections.
*/
@Service
public class BoardChangeGenerator implements ReportGenerator<DateRangeReportParameters> {
private static final Logger log = LoggerFactory.getLogger(BoardChangeGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 14, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font SMALL_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.GRAY);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final ClubRepository clubRepository;
private final BoardMemberRepository boardMemberRepository;
private final BoardPositionRepository boardPositionRepository;
private final MemberRepository memberRepository;
public BoardChangeGenerator(ClubRepository clubRepository,
BoardMemberRepository boardMemberRepository,
BoardPositionRepository boardPositionRepository,
MemberRepository memberRepository) {
this.clubRepository = clubRepository;
this.boardMemberRepository = boardMemberRepository;
this.boardPositionRepository = boardPositionRepository;
this.memberRepository = memberRepository;
}
@Override
public ReportType getType() {
return ReportType.BOARD_CHANGE_NOTICE;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(DateRangeReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
List<BoardMember> currentBoard = boardMemberRepository.findByClubIdAndIsCurrentTrueOrderByCreatedAtAsc(clubId);
List<BoardMember> allBoardMembers = boardMemberRepository.findByClubIdOrderByCreatedAtDesc(clubId);
// Find previous board members (those who are not current)
List<BoardMember> previousBoard = allBoardMembers.stream()
.filter(bm -> !Boolean.TRUE.equals(bm.getIsCurrent()))
.toList();
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
addHeader(document, club);
addRecipientBlock(document, club);
addSubjectLine(document);
addPreviousBoard(document, previousBoard);
addNewBoard(document, currentBoard);
addElectionInfo(document, currentBoard);
addDeclaration(document);
addSignatureBlock(document);
document.close();
log.info("Generated Board Change Notification for club {} ({} current members)", clubId, currentBoard.size());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate Board Change Notification", e);
}
}
private void addHeader(Document document, Club club) throws DocumentException {
// Sender (club)
Paragraph sender = new Paragraph();
sender.add(new Chunk(club.getName(), SUBSECTION_FONT));
sender.add(Chunk.NEWLINE);
if (club.getAddressStreet() != null) {
sender.add(new Chunk(club.getAddressStreet(), NORMAL_FONT));
sender.add(Chunk.NEWLINE);
}
if (club.getAddressPostalCode() != null || club.getAddressCity() != null) {
String postalCity = (club.getAddressPostalCode() != null ? club.getAddressPostalCode() + " " : "") +
(club.getAddressCity() != null ? club.getAddressCity() : "");
sender.add(new Chunk(postalCity, NORMAL_FONT));
sender.add(Chunk.NEWLINE);
}
sender.add(Chunk.NEWLINE);
sender.add(new Chunk("Datum: " + LocalDate.now().format(DATE_FMT), NORMAL_FONT));
document.add(sender);
document.add(Chunk.NEWLINE);
}
private void addRecipientBlock(Document document, Club club) throws DocumentException {
Paragraph recipient = new Paragraph();
recipient.add(new Chunk("An das", NORMAL_FONT));
recipient.add(Chunk.NEWLINE);
recipient.add(new Chunk("Amtsgericht — Registergericht", SUBSECTION_FONT));
recipient.add(Chunk.NEWLINE);
recipient.add(new Chunk("[Zuständiges Amtsgericht]", NORMAL_FONT));
recipient.add(Chunk.NEWLINE);
recipient.add(Chunk.NEWLINE);
if (club.getRegistrationNumber() != null) {
recipient.add(new Chunk("Registernummer: ", SUBSECTION_FONT));
recipient.add(new Chunk(club.getRegistrationNumber(), NORMAL_FONT));
recipient.add(Chunk.NEWLINE);
}
document.add(recipient);
document.add(Chunk.NEWLINE);
}
private void addSubjectLine(Document document) throws DocumentException {
Paragraph subject = new Paragraph("ANMELDUNG ZUR EINTRAGUNG EINER ÄNDERUNG\nIM VEREINSREGISTER (§67 BGB)", HEADER_FONT);
subject.setAlignment(Element.ALIGN_CENTER);
document.add(subject);
document.add(Chunk.NEWLINE);
Paragraph betreff = new Paragraph("Betreff: Änderung der Vorstandsmitglieder gemäß §26 BGB", SECTION_FONT);
document.add(betreff);
document.add(Chunk.NEWLINE);
}
private void addPreviousBoard(Document document, List<BoardMember> previousBoard) throws DocumentException {
document.add(new Paragraph("Bisheriger Vorstand:", SUBSECTION_FONT));
document.add(Chunk.NEWLINE);
if (previousBoard.isEmpty()) {
document.add(new Paragraph("(Erstbesetzung — kein bisheriger Vorstand)", NORMAL_FONT));
} else {
PdfPTable table = createBoardTable();
for (BoardMember bm : previousBoard.stream().limit(10).toList()) {
addBoardMemberRow(table, bm);
}
document.add(table);
}
document.add(Chunk.NEWLINE);
}
private void addNewBoard(Document document, List<BoardMember> currentBoard) throws DocumentException {
document.add(new Paragraph("Neuer Vorstand:", SUBSECTION_FONT));
document.add(Chunk.NEWLINE);
if (currentBoard.isEmpty()) {
document.add(new Paragraph("(Keine aktuellen Vorstandsmitglieder vorhanden)", NORMAL_FONT));
} else {
PdfPTable table = createBoardTable();
for (BoardMember bm : currentBoard) {
addBoardMemberRow(table, bm);
}
document.add(table);
}
document.add(Chunk.NEWLINE);
}
private void addElectionInfo(Document document, List<BoardMember> currentBoard) throws DocumentException {
document.add(new Paragraph("Wahl / Bestellung:", SUBSECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph info = new Paragraph();
if (!currentBoard.isEmpty()) {
BoardMember first = currentBoard.get(0);
info.add(new Chunk("Gewählt am: ", TABLE_CELL_BOLD));
info.add(new Chunk(first.getElectedAt().format(DATE_FMT), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Amtsantritt: ", TABLE_CELL_BOLD));
info.add(new Chunk(first.getTermStart().format(DATE_FMT), NORMAL_FONT));
info.add(Chunk.NEWLINE);
if (first.getTermEnd() != null) {
info.add(new Chunk("Amtszeit bis: ", TABLE_CELL_BOLD));
info.add(new Chunk(first.getTermEnd().format(DATE_FMT), NORMAL_FONT));
info.add(Chunk.NEWLINE);
}
}
info.add(new Chunk("Beschlussorgan: ", TABLE_CELL_BOLD));
info.add(new Chunk("Mitgliederversammlung gemäß Satzung", NORMAL_FONT));
document.add(info);
document.add(Chunk.NEWLINE);
}
private void addDeclaration(Document document) throws DocumentException {
document.add(new Paragraph("Erklärung:", SUBSECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph declaration = new Paragraph("Wir versichern hiermit die Richtigkeit der vorstehenden Angaben " +
"und beantragen die Eintragung der Änderung im Vereinsregister. Die Wahl der neuen " +
"Vorstandsmitglieder erfolgte satzungsgemäß in einer ordentlichen Mitgliederversammlung. " +
"Die gewählten Personen haben die Wahl angenommen.", NORMAL_FONT);
document.add(declaration);
document.add(Chunk.NEWLINE);
Paragraph note = new Paragraph("Anlagen:\n" +
"• Protokoll der Mitgliederversammlung (beglaubigte Kopie)\n" +
"• Anwesenheitsliste der Mitgliederversammlung", NORMAL_FONT);
document.add(note);
document.add(Chunk.NEWLINE);
}
private void addSignatureBlock(Document document) throws DocumentException {
document.add(Chunk.NEWLINE);
PdfPTable sigTable = new PdfPTable(2);
sigTable.setWidthPercentage(90);
sigTable.setWidths(new float[]{50f, 50f});
PdfPCell sig1 = new PdfPCell();
sig1.setBorder(Rectangle.NO_BORDER);
sig1.setPadding(10);
sig1.addElement(new Paragraph("_________________________________", NORMAL_FONT));
sig1.addElement(new Paragraph("Unterschrift neuer Vorstand 1\n(mit notarieller Beglaubigung)", SMALL_FONT));
sigTable.addCell(sig1);
PdfPCell sig2 = new PdfPCell();
sig2.setBorder(Rectangle.NO_BORDER);
sig2.setPadding(10);
sig2.addElement(new Paragraph("_________________________________", NORMAL_FONT));
sig2.addElement(new Paragraph("Unterschrift neuer Vorstand 2\n(mit notarieller Beglaubigung)", SMALL_FONT));
sigTable.addCell(sig2);
document.add(sigTable);
document.add(Chunk.NEWLINE);
Paragraph legalNote = new Paragraph("Hinweis: Die Unterschriften bedürfen der notariellen Beglaubigung (§77 BGB).", SMALL_FONT);
document.add(legalNote);
}
// Helper methods
private PdfPTable createBoardTable() throws DocumentException {
PdfPTable table = new PdfPTable(4);
table.setWidthPercentage(100);
table.setWidths(new float[]{25f, 30f, 20f, 25f});
String[] headers = {"Position", "Name", "Gewählt am", "Amtszeit"};
for (String h : headers) {
PdfPCell cell = new PdfPCell(new Phrase(h, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
table.addCell(cell);
}
return table;
}
private void addBoardMemberRow(PdfPTable table, BoardMember bm) {
// Position name
Optional<BoardPosition> position = boardPositionRepository.findById(bm.getPositionId());
String positionName = position.map(BoardPosition::getTitle).orElse("");
// Member name
Optional<Member> member = memberRepository.findById(bm.getMemberId());
String memberName = member.map(m -> m.getFirstName() + " " + m.getLastName()).orElse("");
table.addCell(createCell(positionName, TABLE_CELL_BOLD));
table.addCell(createCell(memberName, TABLE_CELL_FONT));
table.addCell(createCell(bm.getElectedAt().format(DATE_FMT), TABLE_CELL_FONT));
String termInfo = bm.getTermStart().format(DATE_FMT);
if (bm.getTermEnd() != null) {
termInfo += " " + bm.getTermEnd().format(DATE_FMT);
} else {
termInfo += " unbefristet";
}
table.addCell(createCell(termInfo, TABLE_CELL_FONT));
}
private PdfPCell createCell(String text, Font font) {
PdfPCell cell = new PdfPCell(new Phrase(text, font));
cell.setPadding(4);
cell.setBorder(Rectangle.BOTTOM);
return cell;
}
}
@@ -0,0 +1,315 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.ClubRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
/**
* Generates the Breach Notification document per Art. 33/34 DSGVO.
* Mandatory 72-hour notification to the Aufsichtsbehörde.
*/
@Service
public class BreachNotificationGenerator implements ReportGenerator<BreachReportParameters> {
private static final Logger log = LoggerFactory.getLogger(BreachNotificationGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font SMALL_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.GRAY);
private static final Font URGENT_FONT = new Font(Font.HELVETICA, 11, Font.BOLD, new Color(180, 0, 0));
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Color HEADER_BG = new Color(139, 0, 0); // Dark red for urgency
private static final Color LIGHT_BG = new Color(255, 245, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
private final ClubRepository clubRepository;
public BreachNotificationGenerator(ClubRepository clubRepository) {
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.BREACH_NOTIFICATION;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(BreachReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
addHeader(document, club, params);
addSection1ArtDerVerletzung(document, params);
addSection2BetroffenePersonen(document, params);
addSection3KontaktDsb(document, club);
addSection4WahrscheinlicheFolgen(document, params);
addSection5Massnahmen(document, params);
addSection6Fristberechnung(document, params);
addFooter(document);
document.close();
log.info("Generated Breach Notification for club {} (discovered: {})", clubId, params.discoveredAt());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate Breach Notification", e);
}
}
private void addHeader(Document document, Club club, BreachReportParameters params) throws DocumentException {
Paragraph urgent = new Paragraph("DRINGEND — MELDEPFLICHTIGE DATENPANNE", URGENT_FONT);
urgent.setAlignment(Element.ALIGN_CENTER);
document.add(urgent);
document.add(Chunk.NEWLINE);
Paragraph header = new Paragraph("MELDUNG EINER VERLETZUNG DES SCHUTZES\nPERSONENBEZOGENER DATEN", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("gemäß Art. 33 DSGVO an die zuständige Aufsichtsbehörde", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
// Deadline banner
LocalDateTime deadline = params.discoveredAt().plusHours(72);
long hoursRemaining = ChronoUnit.HOURS.between(LocalDateTime.now(), deadline);
Paragraph deadlineBanner = new Paragraph();
deadlineBanner.add(new Chunk("72-Stunden-Frist: ", SUBSECTION_FONT));
if (hoursRemaining > 0) {
deadlineBanner.add(new Chunk("Meldung fällig bis " + deadline.format(DATETIME_FMT) +
" (" + hoursRemaining + " Stunden verbleibend)", URGENT_FONT));
} else {
deadlineBanner.add(new Chunk("FRIST ÜBERSCHRITTEN — sofortige Meldung erforderlich!", URGENT_FONT));
}
document.add(deadlineBanner);
document.add(Chunk.NEWLINE);
// Club info
PdfPTable infoTable = new PdfPTable(2);
infoTable.setWidthPercentage(100);
infoTable.setWidths(new float[]{30f, 70f});
addInfoRow(infoTable, "Verantwortlicher:", club.getName());
addInfoRow(infoTable, "Erlaubnisnummer:", club.getLicenseNumber());
addInfoRow(infoTable, "Kontakt:", club.getContactEmail() != null ? club.getContactEmail() : "");
addInfoRow(infoTable, "Datum der Entdeckung:", params.discoveredAt().format(DATETIME_FMT));
addInfoRow(infoTable, "Datum dieser Meldung:", LocalDateTime.now().format(DATETIME_FMT));
document.add(infoTable);
document.add(Chunk.NEWLINE);
}
private void addSection1ArtDerVerletzung(Document document, BreachReportParameters params) throws DocumentException {
document.add(new Paragraph("1. Art der Verletzung (Art. 33 Abs. 3 lit. a DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph();
p.add(new Chunk("Beschreibung der Verletzung:", SUBSECTION_FONT));
p.add(Chunk.NEWLINE);
p.add(new Chunk(params.description(), NORMAL_FONT));
p.add(Chunk.NEWLINE);
p.add(Chunk.NEWLINE);
p.add(new Chunk("Betroffene Datenkategorien:", SUBSECTION_FONT));
p.add(Chunk.NEWLINE);
p.add(new Chunk(params.dataCategories(), NORMAL_FONT));
document.add(p);
document.add(Chunk.NEWLINE);
}
private void addSection2BetroffenePersonen(Document document, BreachReportParameters params) throws DocumentException {
document.add(new Paragraph("2. Kategorien und Zahl betroffener Personen (Art. 33 Abs. 3 lit. a DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(2);
table.setWidthPercentage(100);
table.setWidths(new float[]{50f, 50f});
addInfoRow(table, "Anzahl betroffener Personen:", String.valueOf(params.affectedPersons()));
addInfoRow(table, "Kategorien:", "Mitglieder der Anbauvereinigung");
addInfoRow(table, "Art der Daten:", params.dataCategories());
document.add(table);
document.add(Chunk.NEWLINE);
if (params.dataCategories().toLowerCase().contains("gesundheit") ||
params.dataCategories().toLowerCase().contains("ausgabe") ||
params.dataCategories().toLowerCase().contains("thc")) {
Paragraph warning = new Paragraph("HINWEIS: Die Verletzung betrifft besondere Kategorien " +
"personenbezogener Daten (Gesundheitsdaten, Art. 9 DSGVO). Eine Benachrichtigung der " +
"betroffenen Personen nach Art. 34 DSGVO ist voraussichtlich erforderlich.", URGENT_FONT);
document.add(warning);
document.add(Chunk.NEWLINE);
}
}
private void addSection3KontaktDsb(Document document, Club club) throws DocumentException {
document.add(new Paragraph("3. Kontaktdaten Datenschutzbeauftragter (Art. 33 Abs. 3 lit. b DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(2);
table.setWidthPercentage(100);
table.setWidths(new float[]{30f, 70f});
addInfoRow(table, "Funktion:", "Datenschutzkoordinator / Vorstand");
addInfoRow(table, "Organisation:", club.getName());
addInfoRow(table, "E-Mail:", club.getContactEmail() != null ? club.getContactEmail() : "");
addInfoRow(table, "Telefon:", club.getContactPhone() != null ? club.getContactPhone() : "");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection4WahrscheinlicheFolgen(Document document, BreachReportParameters params) throws DocumentException {
document.add(new Paragraph("4. Wahrscheinliche Folgen (Art. 33 Abs. 3 lit. c DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph();
p.add(new Chunk("Mögliche Auswirkungen auf betroffene Personen:", SUBSECTION_FONT));
p.add(Chunk.NEWLINE);
p.add(new Chunk("• Verlust der Vertraulichkeit personenbezogener Daten", NORMAL_FONT)); p.add(Chunk.NEWLINE);
if (params.dataCategories().toLowerCase().contains("gesundheit") ||
params.dataCategories().toLowerCase().contains("cannabis") ||
params.dataCategories().toLowerCase().contains("ausgabe")) {
p.add(new Chunk("• Offenlegung von Gesundheitsdaten (Cannabis-Konsum)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Potenzielle soziale Stigmatisierung", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Risiko der Diskriminierung (Arbeitgeber, Versicherung)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
}
if (params.dataCategories().toLowerCase().contains("finanz") ||
params.dataCategories().toLowerCase().contains("zahlung")) {
p.add(new Chunk("• Finanzieller Schaden durch Missbrauch von Zahlungsdaten", NORMAL_FONT)); p.add(Chunk.NEWLINE);
}
p.add(new Chunk("• Möglicher Identitätsdiebstahl", NORMAL_FONT)); p.add(Chunk.NEWLINE);
document.add(p);
document.add(Chunk.NEWLINE);
}
private void addSection5Massnahmen(Document document, BreachReportParameters params) throws DocumentException {
document.add(new Paragraph("5. Ergriffene / vorgeschlagene Maßnahmen (Art. 33 Abs. 3 lit. d DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph();
p.add(new Chunk("Sofortmaßnahmen:", SUBSECTION_FONT));
p.add(Chunk.NEWLINE);
p.add(new Chunk(params.measures(), NORMAL_FONT));
p.add(Chunk.NEWLINE);
p.add(Chunk.NEWLINE);
p.add(new Chunk("Weitere geplante Maßnahmen:", SUBSECTION_FONT));
p.add(Chunk.NEWLINE);
p.add(new Chunk("• Forensische Analyse der Ursache", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Überprüfung und ggf. Anpassung der TOM", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Information der betroffenen Personen (sofern Art. 34 DSGVO anwendbar)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Dokumentation im Audit-Log (Kategorie: SECURITY_BREACH)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Ggf. Strafanzeige bei Cyberangriff", NORMAL_FONT)); p.add(Chunk.NEWLINE);
document.add(p);
document.add(Chunk.NEWLINE);
}
private void addSection6Fristberechnung(Document document, BreachReportParameters params) throws DocumentException {
document.add(new Paragraph("6. 72-Stunden-Frist (Art. 33 Abs. 1 DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
LocalDateTime deadline = params.discoveredAt().plusHours(72);
PdfPTable table = new PdfPTable(2);
table.setWidthPercentage(100);
table.setWidths(new float[]{40f, 60f});
addInfoRow(table, "Zeitpunkt der Kenntnisnahme:", params.discoveredAt().format(DATETIME_FMT));
addInfoRow(table, "72-Stunden-Frist endet:", deadline.format(DATETIME_FMT));
addInfoRow(table, "Zeitpunkt dieser Meldung:", LocalDateTime.now().format(DATETIME_FMT));
long hoursElapsed = ChronoUnit.HOURS.between(params.discoveredAt(), LocalDateTime.now());
boolean onTime = hoursElapsed <= 72;
addInfoRow(table, "Fristwahrung:", onTime ? "✓ Ja (innerhalb 72h)" : "✗ Nein — Begründung erforderlich");
document.add(table);
document.add(Chunk.NEWLINE);
if (!onTime) {
Paragraph late = new Paragraph("Die Meldung erfolgt verspätet. Gemäß Art. 33 Abs. 1 S. 2 DSGVO " +
"ist eine Begründung für die Verzögerung beizufügen:", URGENT_FONT);
document.add(late);
document.add(Chunk.NEWLINE);
Paragraph reason = new Paragraph("Begründung: _______________________________________________", NORMAL_FONT);
document.add(reason);
document.add(Chunk.NEWLINE);
}
}
private void addFooter(Document document) throws DocumentException {
document.add(Chunk.NEWLINE);
Paragraph footer = new Paragraph();
footer.add(new Chunk("Diese Meldung wird gemäß Art. 33 Abs. 5 DSGVO dokumentiert. " +
"Weitere Informationen werden bei Bekanntwerden nachgereicht.", SMALL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("_________________________________", NORMAL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("Ort, Datum, Unterschrift Vorstand", SMALL_FONT));
document.add(footer);
}
// Helper
private void addInfoRow(PdfPTable table, String label, String value) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, TABLE_CELL_BOLD));
labelCell.setBorder(Rectangle.BOTTOM);
labelCell.setPadding(4);
labelCell.setBackgroundColor(LIGHT_BG);
table.addCell(labelCell);
PdfPCell valueCell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
valueCell.setBorder(Rectangle.BOTTOM);
valueCell.setPadding(4);
table.addCell(valueCell);
}
}
@@ -0,0 +1,16 @@
package de.cannamanage.service.report;
import java.time.LocalDateTime;
/**
* Parameters for the Breach Notification report (Art. 33/34 DSGVO).
* Used to generate the mandatory 72-hour notification to the Aufsichtsbehörde.
*/
public record BreachReportParameters(
String description,
LocalDateTime discoveredAt,
int affectedPersons,
String dataCategories,
String measures
) implements ReportParameters {
}
@@ -0,0 +1,368 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.ClubRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
/**
* Generates the Datenschutz-Folgenabschätzung (DSFA / DPIA) per Art. 35 DSGVO.
* Required because cannabis distribution data qualifies as health data (Art. 9 DSGVO).
*/
@Service
public class DsfaReportGenerator implements ReportGenerator<YearReportParameters> {
private static final Logger log = LoggerFactory.getLogger(DsfaReportGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font SMALL_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.GRAY);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Font RISK_HIGH = new Font(Font.HELVETICA, 9, Font.BOLD, new Color(180, 0, 0));
private static final Font RISK_MEDIUM = new Font(Font.HELVETICA, 9, Font.BOLD, new Color(200, 130, 0));
private static final Font RISK_LOW = new Font(Font.HELVETICA, 9, Font.BOLD, new Color(0, 128, 0));
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final ClubRepository clubRepository;
public DsfaReportGenerator(ClubRepository clubRepository) {
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.DSFA;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(YearReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
addHeader(document, club, params.year());
addSection1Beschreibung(document);
addSection2ZweckRechtsgrundlage(document);
addSection3Notwendigkeit(document);
addSection4Risikobewertung(document);
addSection5Massnahmen(document);
addSection6Stellungnahme(document);
addSection7Ergebnis(document);
addFooter(document);
document.close();
log.info("Generated DSFA report for club {} (year {})", clubId, params.year());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate DSFA report", e);
}
}
private void addHeader(Document document, Club club, int year) throws DocumentException {
Paragraph header = new Paragraph("DATENSCHUTZ-FOLGENABSCHÄTZUNG", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("gemäß Art. 35 DSGVO", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
Paragraph info = new Paragraph();
info.add(new Chunk("Anbauvereinigung: ", SUBSECTION_FONT));
info.add(new Chunk(club.getName(), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Erstellt am: ", SUBSECTION_FONT));
info.add(new Chunk(LocalDate.now().format(DATE_FMT), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Berichtsjahr: ", SUBSECTION_FONT));
info.add(new Chunk(String.valueOf(year), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(Chunk.NEWLINE);
Paragraph reason = new Paragraph();
reason.add(new Chunk("Begründung der DSFA-Pflicht: ", SUBSECTION_FONT));
reason.add(new Chunk("Cannabis-Ausgabedaten qualifizieren als Gesundheitsdaten gemäß Art. 9 Abs. 1 DSGVO. " +
"Die systematische Verarbeitung besonderer Kategorien personenbezogener Daten erfordert " +
"eine Datenschutz-Folgenabschätzung nach Art. 35 Abs. 3 lit. b DSGVO.", NORMAL_FONT));
document.add(info);
document.add(reason);
document.add(Chunk.NEWLINE);
}
private void addSection1Beschreibung(Document document) throws DocumentException {
document.add(new Paragraph("1. Beschreibung der Verarbeitung (Art. 35 Abs. 7 lit. a DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph();
p.add(new Chunk("Die Anbauvereinigung verarbeitet im Rahmen der gesetzlichen Dokumentationspflichten " +
"nach dem Konsumcannabisgesetz (KCanG) folgende Gesundheitsdaten:", NORMAL_FONT));
document.add(p);
document.add(Chunk.NEWLINE);
Paragraph list = new Paragraph();
list.add(new Chunk("• Cannabis-Ausgabemengen je Mitglied (Gramm/Monat)", NORMAL_FONT)); list.add(Chunk.NEWLINE);
list.add(new Chunk("• THC-Gehalt der ausgegebenen Produkte (%)", NORMAL_FONT)); list.add(Chunk.NEWLINE);
list.add(new Chunk("• CBD-Gehalt der ausgegebenen Produkte (%)", NORMAL_FONT)); list.add(Chunk.NEWLINE);
list.add(new Chunk("• Sortenzuordnung pro Ausgabe", NORMAL_FONT)); list.add(Chunk.NEWLINE);
list.add(new Chunk("• Monatliches Kontingent (25g/50g) und Ausschöpfung", NORMAL_FONT)); list.add(Chunk.NEWLINE);
list.add(new Chunk("• Altersabhängige Mengenbegrenzung (U21: max. 30g, max. 10% THC)", NORMAL_FONT)); list.add(Chunk.NEWLINE);
document.add(list);
document.add(Chunk.NEWLINE);
Paragraph scope = new Paragraph();
scope.add(new Chunk("Umfang: ", SUBSECTION_FONT));
scope.add(new Chunk("Bis zu 500 Mitglieder, kontinuierliche Verarbeitung bei jeder Ausgabe, " +
"Aufbewahrung über 5 Jahre nach Mitgliedsaustritt (§24 KCanG).", NORMAL_FONT));
document.add(scope);
document.add(Chunk.NEWLINE);
}
private void addSection2ZweckRechtsgrundlage(Document document) throws DocumentException {
document.add(new Paragraph("2. Zweck und Rechtsgrundlage (Art. 35 Abs. 7 lit. b DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(2);
table.setWidthPercentage(100);
table.setWidths(new float[]{30f, 70f});
addInfoRow(table, "Primärzweck:", "Einhaltung der Dokumentationspflichten nach §2224 KCanG");
addInfoRow(table, "Sekundärzweck:", "Vereinsverwaltung (Mitgliedschaft, Beiträge)");
addInfoRow(table, "Rechtsgrundlage:", "Art. 6(1)(c) DSGVO i.V.m. §2224 KCanG (gesetzliche Pflicht)");
addInfoRow(table, "Art. 9 Grundlage:", "Art. 9(2)(g) DSGVO — erhebliches öffentliches Interesse (Drogenpolitik)");
addInfoRow(table, "Nationale Regelung:", "§22 KCanG (Dokumentationspflicht), §24 KCanG (Aufbewahrung)");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection3Notwendigkeit(Document document) throws DocumentException {
document.add(new Paragraph("3. Notwendigkeit und Verhältnismäßigkeit (Art. 35 Abs. 7 lit. b DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph();
p.add(new Chunk("Datenminimierung:", SUBSECTION_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Es werden ausschließlich die vom KCanG geforderten Daten erhoben", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Keine freiwilligen Gesundheitsdaten (kein Tracking von Konsumverhalten)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Pseudonymisierung in Reports (Mitgliedsnummer statt Klarname)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(Chunk.NEWLINE);
p.add(new Chunk("Zweckbindung:", SUBSECTION_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Ausgabedaten werden ausschließlich für KCanG-Compliance und Behördenberichte verwendet", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Keine Profil-Bildung, keine Werbung, keine Weitergabe an Dritte", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(Chunk.NEWLINE);
p.add(new Chunk("Speicherbegrenzung:", SUBSECTION_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Automatische Löschung 5 Jahre nach Mitgliedsaustritt", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Löschkonzept mit technischer Umsetzung (RetentionService)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
document.add(p);
document.add(Chunk.NEWLINE);
}
private void addSection4Risikobewertung(Document document) throws DocumentException {
document.add(new Paragraph("4. Risikobewertung (Art. 35 Abs. 7 lit. c DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(5);
table.setWidthPercentage(100);
table.setWidths(new float[]{22f, 18f, 18f, 18f, 24f});
addTableHeader(table, new String[]{"Risiko", "Wahrscheinlichkeit", "Schwere", "Risikostufe", "Maßnahme"});
addRiskRow(table, "Unbefugter Zugriff auf Ausgabedaten", "Niedrig", "Hoch", "MITTEL", "RBAC + Tenant-Isolation");
addRiskRow(table, "Datenverlust (Backup-Fehler)", "Niedrig", "Hoch", "MITTEL", "Tägliche Backups + Test");
addRiskRow(table, "SQL-Injection / Datenexfiltration", "Sehr niedrig", "Sehr hoch", "NIEDRIG", "JPA/Prepared Statements");
addRiskRow(table, "Insider-Missbrauch (Admin)", "Niedrig", "Hoch", "MITTEL", "Audit-Log + Minimalprinzip");
addRiskRow(table, "Behördliche Offenlegung", "Mittel", "Mittel", "MITTEL", "Nur auf Rechtsgrundlage");
addRiskRow(table, "Ransomware / Verschlüsselung", "Niedrig", "Hoch", "MITTEL", "Isolierte Backups");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection5Massnahmen(Document document) throws DocumentException {
document.add(new Paragraph("5. Abhilfemaßnahmen (Art. 35 Abs. 7 lit. d DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(3);
table.setWidthPercentage(100);
table.setWidths(new float[]{25f, 50f, 25f});
addTableHeader(table, new String[]{"Maßnahme", "Umsetzung", "Status"});
addMeasureRow(table, "Verschlüsselung", "TLS 1.3 (Transport), AES-256 (Backups), BCrypt (Passwörter)", "Umgesetzt");
addMeasureRow(table, "Pseudonymisierung", "Mitgliedsnummer statt Klarname in Ausgabe-Reports", "Umgesetzt");
addMeasureRow(table, "Löschfristen", "Automatisierte Löschung per RetentionService nach §24 KCanG", "Umgesetzt");
addMeasureRow(table, "Zugriffskontrolle", "RBAC mit 23+ granularen Berechtigungen (StaffPermission)", "Umgesetzt");
addMeasureRow(table, "Audit-Trail", "Unveränderliches Log aller Zugriffe auf Gesundheitsdaten", "Umgesetzt");
addMeasureRow(table, "Datensparsamkeit", "Nur KCanG-Pflichtdaten, keine zusätzlichen Gesundheitsdaten", "Umgesetzt");
addMeasureRow(table, "Tenant-Isolation", "Technische Trennung per club_id, kein Cross-Club-Zugriff", "Umgesetzt");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection6Stellungnahme(Document document) throws DocumentException {
document.add(new Paragraph("6. Stellungnahme der Betroffenen (Art. 35 Abs. 9 DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph();
p.add(new Chunk("Mitglieder werden über die Verarbeitung ihrer Daten informiert durch:", NORMAL_FONT));
p.add(Chunk.NEWLINE);
p.add(new Chunk("• Datenschutzerklärung bei Beitritt (Consent-Management)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Transparenz-Information im Mitglieder-Portal", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Auskunftsrecht (Art. 15 DSGVO) über Self-Service-Funktion", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Jährliche Erinnerung an Verarbeitungszwecke", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(Chunk.NEWLINE);
p.add(new Chunk("Widerspruchsrecht: ", SUBSECTION_FONT));
p.add(new Chunk("Ein Widerspruch gegen die Verarbeitung der Ausgabedaten ist nicht möglich, " +
"da die Verarbeitung auf gesetzlicher Pflicht beruht (§22 KCanG). " +
"Mitglieder können jederzeit austreten; die Löschung erfolgt dann nach Ablauf der " +
"gesetzlichen Aufbewahrungsfrist.", NORMAL_FONT));
document.add(p);
document.add(Chunk.NEWLINE);
}
private void addSection7Ergebnis(Document document) throws DocumentException {
document.add(new Paragraph("7. Ergebnis der DSFA", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph result = new Paragraph();
result.add(new Chunk("Gesamtbewertung: ", SUBSECTION_FONT));
result.add(new Chunk("RESTRISIKO VERTRETBAR", new Font(Font.HELVETICA, 10, Font.BOLD, new Color(0, 128, 0))));
result.add(Chunk.NEWLINE);
result.add(Chunk.NEWLINE);
result.add(new Chunk("Begründung: ", SUBSECTION_FONT));
result.add(new Chunk("Die identifizierten Risiken werden durch die implementierten technischen und " +
"organisatorischen Maßnahmen auf ein vertretbares Niveau reduziert. Die Verarbeitung ist " +
"gesetzlich vorgeschrieben (KCanG) und dient einem erheblichen öffentlichen Interesse. " +
"Die Grundsätze der Datenminimierung und Zweckbindung werden konsequent umgesetzt.", NORMAL_FONT));
result.add(Chunk.NEWLINE);
result.add(Chunk.NEWLINE);
result.add(new Chunk("Empfehlung: ", SUBSECTION_FONT));
result.add(new Chunk("Die Verarbeitung kann unter Einhaltung der beschriebenen Maßnahmen fortgesetzt werden. " +
"Eine erneute Überprüfung ist bei wesentlichen Änderungen der Verarbeitung oder " +
"spätestens nach 12 Monaten durchzuführen.", NORMAL_FONT));
document.add(result);
document.add(Chunk.NEWLINE);
}
private void addFooter(Document document) throws DocumentException {
document.add(Chunk.NEWLINE);
Paragraph footer = new Paragraph();
footer.add(new Chunk("Nächste Überprüfung: ", SUBSECTION_FONT));
footer.add(new Chunk(LocalDate.now().plusYears(1).format(DATE_FMT), NORMAL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("_________________________________", NORMAL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("Ort, Datum, Unterschrift Vorstand", SMALL_FONT));
document.add(footer);
}
// Helper methods
private void addTableHeader(PdfPTable table, String[] headers) {
for (String h : headers) {
PdfPCell cell = new PdfPCell(new Phrase(h, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
table.addCell(cell);
}
}
private void addInfoRow(PdfPTable table, String label, String value) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, TABLE_CELL_BOLD));
labelCell.setBorder(Rectangle.BOTTOM);
labelCell.setPadding(4);
labelCell.setBackgroundColor(LIGHT_BG);
table.addCell(labelCell);
PdfPCell valueCell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
valueCell.setBorder(Rectangle.BOTTOM);
valueCell.setPadding(4);
table.addCell(valueCell);
}
private void addRiskRow(PdfPTable table, String risk, String probability, String severity,
String riskLevel, String measure) {
table.addCell(createCell(risk, TABLE_CELL_FONT));
table.addCell(createCell(probability, TABLE_CELL_FONT));
table.addCell(createCell(severity, TABLE_CELL_FONT));
Font riskFont = switch (riskLevel) {
case "HOCH" -> RISK_HIGH;
case "MITTEL" -> RISK_MEDIUM;
default -> RISK_LOW;
};
PdfPCell riskCell = new PdfPCell(new Phrase(riskLevel, riskFont));
riskCell.setPadding(4);
riskCell.setBorder(Rectangle.BOTTOM);
riskCell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(riskCell);
table.addCell(createCell(measure, TABLE_CELL_FONT));
}
private void addMeasureRow(PdfPTable table, String measure, String implementation, String status) {
PdfPCell measureCell = new PdfPCell(new Phrase(measure, TABLE_CELL_BOLD));
measureCell.setPadding(4);
measureCell.setBorder(Rectangle.BOTTOM);
measureCell.setBackgroundColor(LIGHT_BG);
table.addCell(measureCell);
table.addCell(createCell(implementation, TABLE_CELL_FONT));
Font statusFont = "Umgesetzt".equals(status) ? RISK_LOW : RISK_MEDIUM;
PdfPCell statusCell = new PdfPCell(new Phrase(status, statusFont));
statusCell.setPadding(4);
statusCell.setBorder(Rectangle.BOTTOM);
statusCell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(statusCell);
}
private PdfPCell createCell(String text, Font font) {
PdfPCell cell = new PdfPCell(new Phrase(text, font));
cell.setPadding(4);
cell.setBorder(Rectangle.BOTTOM);
return cell;
}
}
@@ -0,0 +1,319 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.ClubRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
/**
* Generates the Löschkonzept (Deletion Concept) document.
* Documents when each data type is deleted/anonymized.
* Supports PDF (formal document) and JSON (for automated RetentionService in Phase 6).
*/
@Service
public class LoeschkonzeptGenerator implements ReportGenerator<YearReportParameters> {
private static final Logger log = LoggerFactory.getLogger(LoeschkonzeptGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font SMALL_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.GRAY);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final ClubRepository clubRepository;
// Retention rules as structured data (used by both PDF and JSON)
private static final List<RetentionRule> RETENTION_RULES = List.of(
new RetentionRule("MEMBER_DATA", "Mitgliederdaten",
"Name, E-Mail, Geburtsdatum, Anschrift, Telefon",
"5 Jahre", 5, "YEARS_AFTER_LEAVE", "§24 KCanG",
"Anonymisierung (Pseudonymisierung nicht ausreichend)", "ACTIVE"),
new RetentionRule("FINANCIAL_DATA", "Finanzdaten",
"Zahlungen, Beiträge, Kontoverbindung, Stripe-Referenzen",
"10 Jahre", 10, "YEARS_AFTER_FISCAL_YEAR_END", "§147 AO",
"Löschung nach Ablauf steuerlicher Aufbewahrungsfrist", "ACTIVE"),
new RetentionRule("DISTRIBUTION_DATA", "Ausgabedaten",
"Ausgabemenge, THC/CBD-Gehalt, Sorte, Zeitpunkt",
"5 Jahre", 5, "YEARS_AFTER_LEAVE", "§24 KCanG",
"Vollständige Löschung (Gesundheitsdaten)", "ACTIVE"),
new RetentionRule("COMMUNICATION_DATA", "Kommunikationsdaten",
"Forum-Beiträge, Nachrichten, Benachrichtigungen",
"2 Jahre", 2, "YEARS_AFTER_INACTIVITY", "Art. 6(1)(f) DSGVO",
"Anonymisierung (Autor → 'Gelöschter Nutzer')", "ACTIVE"),
new RetentionRule("AUDIT_LOG", "Audit-Log",
"Alle Systemereignisse, Zugriffsprotokolle",
"10 Jahre", 10, "YEARS_AFTER_CREATION", "§8 AO",
"Keine Löschung vor Ablauf (append-only)", "ACTIVE"),
new RetentionRule("GROW_DATA", "Anbauprotokoll",
"Anbaumengen, Erntedaten, Sorten, Vernichtungen",
"5 Jahre", 5, "YEARS_AFTER_HARVEST", "§22 KCanG",
"Vollständige Löschung nach Fristablauf", "ACTIVE")
);
public LoeschkonzeptGenerator(ClubRepository clubRepository) {
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.DELETION_CONCEPT;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF, ExportFormat.JSON);
}
@Override
public byte[] generatePdf(YearReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
addHeader(document, club, params.year());
addRetentionTable(document);
addDeletionProcedures(document);
addAutomationSection(document);
addFooter(document);
document.close();
log.info("Generated Löschkonzept PDF for club {} (year {})", clubId, params.year());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate Löschkonzept PDF", e);
}
}
@Override
public byte[] generateJson(YearReportParameters params, UUID clubId) {
clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
StringBuilder json = new StringBuilder();
json.append("{\n");
json.append(" \"version\": \"1.0\",\n");
json.append(" \"generatedAt\": \"").append(LocalDate.now()).append("\",\n");
json.append(" \"clubId\": \"").append(clubId).append("\",\n");
json.append(" \"year\": ").append(params.year()).append(",\n");
json.append(" \"retentionRules\": [\n");
for (int i = 0; i < RETENTION_RULES.size(); i++) {
RetentionRule rule = RETENTION_RULES.get(i);
json.append(" {\n");
json.append(" \"id\": \"").append(rule.id()).append("\",\n");
json.append(" \"category\": \"").append(rule.category()).append("\",\n");
json.append(" \"description\": \"").append(rule.dataDescription()).append("\",\n");
json.append(" \"retentionPeriod\": \"").append(rule.retentionPeriod()).append("\",\n");
json.append(" \"retentionYears\": ").append(rule.retentionYears()).append(",\n");
json.append(" \"triggerEvent\": \"").append(rule.triggerEvent()).append("\",\n");
json.append(" \"legalBasis\": \"").append(rule.legalBasis()).append("\",\n");
json.append(" \"deletionMethod\": \"").append(rule.deletionMethod()).append("\",\n");
json.append(" \"status\": \"").append(rule.status()).append("\"\n");
json.append(" }");
if (i < RETENTION_RULES.size() - 1) json.append(",");
json.append("\n");
}
json.append(" ]\n");
json.append("}\n");
log.info("Generated Löschkonzept JSON for club {} (year {})", clubId, params.year());
return json.toString().getBytes(StandardCharsets.UTF_8);
}
private void addHeader(Document document, Club club, int year) throws DocumentException {
Paragraph header = new Paragraph("LÖSCHKONZEPT", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("gemäß Art. 17 DSGVO / §35 BDSG", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
Paragraph info = new Paragraph();
info.add(new Chunk("Anbauvereinigung: ", SUBSECTION_FONT));
info.add(new Chunk(club.getName(), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Stand: ", SUBSECTION_FONT));
info.add(new Chunk(LocalDate.now().format(DATE_FMT), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Berichtsjahr: ", SUBSECTION_FONT));
info.add(new Chunk(String.valueOf(year), NORMAL_FONT));
document.add(info);
document.add(Chunk.NEWLINE);
Paragraph intro = new Paragraph("Dieses Löschkonzept definiert die Aufbewahrungsfristen für alle " +
"personenbezogenen Daten der Anbauvereinigung und beschreibt die technischen Verfahren " +
"zur fristgerechten Löschung bzw. Anonymisierung.", NORMAL_FONT);
document.add(intro);
document.add(Chunk.NEWLINE);
}
private void addRetentionTable(Document document) throws DocumentException {
document.add(new Paragraph("Löschfristen-Matrix", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(6);
table.setWidthPercentage(100);
table.setWidths(new float[]{16f, 20f, 14f, 16f, 18f, 16f});
addTableHeader(table, new String[]{"Datenkategorie", "Daten", "Löschfrist", "Rechtsgrundlage", "Verfahren", "Status"});
for (RetentionRule rule : RETENTION_RULES) {
PdfPCell catCell = new PdfPCell(new Phrase(rule.category(), TABLE_CELL_BOLD));
catCell.setPadding(4);
catCell.setBorder(Rectangle.BOTTOM);
catCell.setBackgroundColor(LIGHT_BG);
table.addCell(catCell);
table.addCell(createCell(rule.dataDescription()));
table.addCell(createCell(rule.retentionPeriod()));
table.addCell(createCell(rule.legalBasis()));
table.addCell(createCell(rule.deletionMethod()));
PdfPCell statusCell = new PdfPCell(new Phrase("" + rule.status(),
new Font(Font.HELVETICA, 9, Font.NORMAL, new Color(0, 128, 0))));
statusCell.setPadding(4);
statusCell.setBorder(Rectangle.BOTTOM);
table.addCell(statusCell);
}
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addDeletionProcedures(Document document) throws DocumentException {
document.add(new Paragraph("Löschverfahren", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph();
p.add(new Chunk("Vollständige Löschung:", SUBSECTION_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• DELETE FROM <table> WHERE Fristablauf erreicht", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Kaskadierende Löschung aller abhängigen Datensätze", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Verifizierung durch Count-Abfrage nach Löschung", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(Chunk.NEWLINE);
p.add(new Chunk("Anonymisierung:", SUBSECTION_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Personenbezogene Felder → 'ANONYMISIERT' / NULL", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Aggregierte Statistik bleibt erhalten (Vereinszweck)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• UUID-Referenzen werden entfernt (kein Re-Linking möglich)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(Chunk.NEWLINE);
p.add(new Chunk("Auslöser (Trigger Events):", SUBSECTION_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• YEARS_AFTER_LEAVE: Frist beginnt mit Mitgliedsaustritt", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• YEARS_AFTER_FISCAL_YEAR_END: Frist beginnt am 01.01. des Folgejahres", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• YEARS_AFTER_INACTIVITY: Frist beginnt nach letzter Nutzeraktivität", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• YEARS_AFTER_CREATION: Frist beginnt mit Erzeugung des Datensatzes", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• YEARS_AFTER_HARVEST: Frist beginnt mit Ernte/Vernichtung", NORMAL_FONT)); p.add(Chunk.NEWLINE);
document.add(p);
document.add(Chunk.NEWLINE);
}
private void addAutomationSection(Document document) throws DocumentException {
document.add(new Paragraph("Automatisierung (RetentionService)", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph();
p.add(new Chunk("Die Löschung erfolgt automatisiert durch den RetentionService:", NORMAL_FONT));
p.add(Chunk.NEWLINE);
p.add(Chunk.NEWLINE);
p.add(new Chunk("• Täglicher Scheduled-Task prüft fällige Datensätze", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Löschprotokoll im Audit-Log (Kategorie: DATA_RETENTION)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Benachrichtigung an Vorstand bei Löschvorgängen", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• JSON-Export dieses Konzepts dient als Konfigurationsquelle", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Manuelle Auslösung über Admin-UI möglich (Dry-Run + Execute)", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(Chunk.NEWLINE);
p.add(new Chunk("Hinweis: ", SUBSECTION_FONT));
p.add(new Chunk("Vor jeder automatischen Löschung wird ein Backup-Snapshot erstellt. " +
"Löschungen sind irreversibel und werden im Audit-Log protokolliert.", NORMAL_FONT));
document.add(p);
document.add(Chunk.NEWLINE);
}
private void addFooter(Document document) throws DocumentException {
document.add(Chunk.NEWLINE);
Paragraph footer = new Paragraph();
footer.add(new Chunk("Dieses Löschkonzept ist Bestandteil der Datenschutz-Dokumentation und wird " +
"mindestens jährlich überprüft.", SMALL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("_________________________________", NORMAL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("Ort, Datum, Unterschrift Vorstand", SMALL_FONT));
document.add(footer);
}
// Helper methods
private void addTableHeader(PdfPTable table, String[] headers) {
for (String h : headers) {
PdfPCell cell = new PdfPCell(new Phrase(h, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
table.addCell(cell);
}
}
private PdfPCell createCell(String text) {
PdfPCell cell = new PdfPCell(new Phrase(text, TABLE_CELL_FONT));
cell.setPadding(4);
cell.setBorder(Rectangle.BOTTOM);
return cell;
}
/**
* Internal record representing a retention rule.
* Used by both PDF generation and JSON export.
*/
private record RetentionRule(
String id,
String category,
String dataDescription,
String retentionPeriod,
int retentionYears,
String triggerEvent,
String legalBasis,
String deletionMethod,
String status
) {}
}
@@ -0,0 +1,246 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.ClubRepository;
import de.cannamanage.service.repository.MemberRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Generates the Member List for Vereinsregister (§67 BGB).
* Formatted specifically for the Amtsgericht registry requirements.
*/
@Service
public class MemberListRegistryGenerator implements ReportGenerator<DateRangeReportParameters> {
private static final Logger log = LoggerFactory.getLogger(MemberListRegistryGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 14, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font SMALL_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.GRAY);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final ClubRepository clubRepository;
private final MemberRepository memberRepository;
public MemberListRegistryGenerator(ClubRepository clubRepository, MemberRepository memberRepository) {
this.clubRepository = clubRepository;
this.memberRepository = memberRepository;
}
@Override
public ReportType getType() {
return ReportType.MEMBER_LIST_REGISTRY;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(DateRangeReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
List<Member> members = memberRepository.findByClubIdOrderByLastNameAscFirstNameAsc(clubId);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4.rotate(), 40, 40, 50, 50); // Landscape for wide table
PdfWriter.getInstance(document, baos);
document.open();
addHeader(document, club);
addMemberTable(document, members);
addSummary(document, members);
addFooter(document, club);
document.close();
log.info("Generated Member List Registry for club {} ({} members)", clubId, members.size());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate Member List Registry", e);
}
}
private void addHeader(Document document, Club club) throws DocumentException {
Paragraph header = new Paragraph("MITGLIEDERLISTE FÜR DAS VEREINSREGISTER", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("gemäß §67 BGB", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
PdfPTable infoTable = new PdfPTable(2);
infoTable.setWidthPercentage(60);
infoTable.setWidths(new float[]{35f, 65f});
infoTable.setHorizontalAlignment(Element.ALIGN_LEFT);
addInfoRow(infoTable, "Vereinsname:", club.getName());
addInfoRow(infoTable, "Registernummer:", club.getRegistrationNumber() != null ? club.getRegistrationNumber() : "");
addInfoRow(infoTable, "Sitz:", formatAddress(club));
addInfoRow(infoTable, "Erstellt am:", LocalDate.now().format(DATE_FMT));
document.add(infoTable);
document.add(Chunk.NEWLINE);
}
private void addMemberTable(Document document, List<Member> members) throws DocumentException {
PdfPTable table = new PdfPTable(7);
table.setWidthPercentage(100);
table.setWidths(new float[]{6f, 18f, 18f, 12f, 14f, 14f, 18f});
// Header
String[] headers = {"Nr.", "Nachname", "Vorname", "Mitgl.-Nr.", "Eintrittsdatum", "Austrittsdatum", "Status"};
for (String h : headers) {
PdfPCell cell = new PdfPCell(new Phrase(h, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
// Data rows
AtomicInteger counter = new AtomicInteger(1);
for (Member member : members) {
int rowNum = counter.getAndIncrement();
Color rowBg = (rowNum % 2 == 0) ? LIGHT_BG : null;
addMemberRow(table, rowNum, member, rowBg);
}
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addMemberRow(PdfPTable table, int number, Member member, Color bg) {
table.addCell(createCell(String.valueOf(number), TABLE_CELL_FONT, bg, Element.ALIGN_CENTER));
table.addCell(createCell(member.getLastName(), TABLE_CELL_BOLD, bg, Element.ALIGN_LEFT));
table.addCell(createCell(member.getFirstName(), TABLE_CELL_FONT, bg, Element.ALIGN_LEFT));
table.addCell(createCell(member.getMembershipNumber(), TABLE_CELL_FONT, bg, Element.ALIGN_CENTER));
table.addCell(createCell(member.getMembershipDate().format(DATE_FMT), TABLE_CELL_FONT, bg, Element.ALIGN_CENTER));
table.addCell(createCell("", TABLE_CELL_FONT, bg, Element.ALIGN_CENTER)); // Austrittsdatum - needs exit date field
table.addCell(createCell(formatStatus(member.getStatus()), TABLE_CELL_FONT, bg, Element.ALIGN_CENTER));
}
private String formatStatus(MemberStatus status) {
return switch (status) {
case ACTIVE -> "Aktiv";
case SUSPENDED -> "Gesperrt";
case RESIGNED -> "Ausgetreten";
case EXPELLED -> "Ausgeschlossen";
case PENDING_APPROVAL -> "Aufnahme ausstehend";
};
}
private void addSummary(Document document, List<Member> members) throws DocumentException {
long active = members.stream().filter(m -> m.getStatus() == MemberStatus.ACTIVE).count();
long inactive = members.stream().filter(m -> m.getStatus() != MemberStatus.ACTIVE).count();
Paragraph summary = new Paragraph();
summary.add(new Chunk("Zusammenfassung: ", SUBSECTION_FONT));
summary.add(new Chunk("Gesamt: " + members.size() + " | Aktiv: " + active + " | Inaktiv/Ausgetreten: " + inactive, NORMAL_FONT));
document.add(summary);
document.add(Chunk.NEWLINE);
}
private void addFooter(Document document, Club club) throws DocumentException {
Paragraph footer = new Paragraph();
footer.add(new Chunk("Diese Mitgliederliste wird dem Amtsgericht gemäß §67 BGB auf Verlangen vorgelegt. " +
"Die Liste gibt den Stand zum " + LocalDate.now().format(DATE_FMT) + " wieder.", SMALL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(Chunk.NEWLINE);
// Signature section
PdfPTable sigTable = new PdfPTable(2);
sigTable.setWidthPercentage(80);
sigTable.setWidths(new float[]{50f, 50f});
PdfPCell sig1 = new PdfPCell();
sig1.setBorder(Rectangle.NO_BORDER);
sig1.addElement(new Paragraph("_________________________________", NORMAL_FONT));
sig1.addElement(new Paragraph("Vorstand (Vertretungsberechtigter)", SMALL_FONT));
sigTable.addCell(sig1);
PdfPCell sig2 = new PdfPCell();
sig2.setBorder(Rectangle.NO_BORDER);
sig2.addElement(new Paragraph("_________________________________", NORMAL_FONT));
sig2.addElement(new Paragraph("Ort, Datum", SMALL_FONT));
sigTable.addCell(sig2);
document.add(footer);
document.add(sigTable);
}
// Helper methods
private String formatAddress(Club club) {
StringBuilder sb = new StringBuilder();
if (club.getAddressStreet() != null) sb.append(club.getAddressStreet());
if (club.getAddressPostalCode() != null || club.getAddressCity() != null) {
if (sb.length() > 0) sb.append(", ");
if (club.getAddressPostalCode() != null) sb.append(club.getAddressPostalCode()).append(" ");
if (club.getAddressCity() != null) sb.append(club.getAddressCity());
}
return sb.length() > 0 ? sb.toString() : "";
}
private void addInfoRow(PdfPTable table, String label, String value) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, TABLE_CELL_BOLD));
labelCell.setBorder(Rectangle.BOTTOM);
labelCell.setPadding(4);
table.addCell(labelCell);
PdfPCell valueCell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
valueCell.setBorder(Rectangle.BOTTOM);
valueCell.setPadding(4);
table.addCell(valueCell);
}
private PdfPCell createCell(String text, Font font, Color bg, int alignment) {
PdfPCell cell = new PdfPCell(new Phrase(text, font));
cell.setPadding(4);
cell.setBorder(Rectangle.BOTTOM);
cell.setHorizontalAlignment(alignment);
if (bg != null) cell.setBackgroundColor(bg);
return cell;
}
}
@@ -0,0 +1,304 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.ClubRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
/**
* Generates the TOM document (Technisch-organisatorische Maßnahmen) per Art. 32 DSGVO.
* Documents all security measures implemented by the Anbauvereinigung.
*/
@Service
public class TomReportGenerator implements ReportGenerator<YearReportParameters> {
private static final Logger log = LoggerFactory.getLogger(TomReportGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font SMALL_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.GRAY);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Font CHECK_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL, new Color(0, 128, 0));
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final ClubRepository clubRepository;
public TomReportGenerator(ClubRepository clubRepository) {
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.TOM;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(YearReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
addHeader(document, club, params.year());
addSection1Zutrittskontrolle(document);
addSection2Zugangskontrolle(document);
addSection3Zugriffskontrolle(document);
addSection4Weitergabekontrolle(document);
addSection5Eingabekontrolle(document);
addSection6Auftragskontrolle(document);
addSection7Verfuegbarkeitskontrolle(document);
addSection8Trennungsgebot(document);
addFooter(document);
document.close();
log.info("Generated TOM report for club {} (year {})", clubId, params.year());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate TOM report", e);
}
}
private void addHeader(Document document, Club club, int year) throws DocumentException {
Paragraph header = new Paragraph("TECHNISCH-ORGANISATORISCHE MASSNAHMEN", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("gemäß Art. 32 DSGVO", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
Paragraph info = new Paragraph();
info.add(new Chunk("Anbauvereinigung: ", SUBSECTION_FONT));
info.add(new Chunk(club.getName(), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Stand: ", SUBSECTION_FONT));
info.add(new Chunk(LocalDate.now().format(DATE_FMT), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Version: ", SUBSECTION_FONT));
info.add(new Chunk(year + ".1", NORMAL_FONT));
document.add(info);
document.add(Chunk.NEWLINE);
}
private void addSection1Zutrittskontrolle(Document document) throws DocumentException {
document.add(new Paragraph("1. Zutrittskontrolle", SECTION_FONT));
Paragraph desc = new Paragraph("Maßnahmen, die Unbefugten den Zutritt zu Datenverarbeitungsanlagen verwehren.", SMALL_FONT);
document.add(desc);
document.add(Chunk.NEWLINE);
PdfPTable table = createMeasureTable();
addMeasure(table, "Serverstandort", "Rechenzentrum IONOS SE, Deutschland (ISO 27001 zertifiziert)", "");
addMeasure(table, "Physischer Zugang", "Kein eigener Serverraum — vollständig cloud-basiert", "");
addMeasure(table, "RZ-Sicherheit", "Biometrische Zugangskontrollen, 24/7 Überwachung (IONOS)", "");
addMeasure(table, "Standort DE", "Server ausschließlich in deutschen Rechenzentren", "");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection2Zugangskontrolle(Document document) throws DocumentException {
document.add(new Paragraph("2. Zugangskontrolle", SECTION_FONT));
Paragraph desc = new Paragraph("Maßnahmen, die verhindern, dass Datenverarbeitungssysteme von Unbefugten genutzt werden.", SMALL_FONT);
document.add(desc);
document.add(Chunk.NEWLINE);
PdfPTable table = createMeasureTable();
addMeasure(table, "Authentifizierung", "JWT-basierte Authentifizierung mit Session-Management", "");
addMeasure(table, "Passwort-Hashing", "BCrypt mit Kostenfaktor 12 (brute-force-resistent)", "");
addMeasure(table, "Token-Revocation", "Sofortige Invalidierung von Sessions bei Abmeldung/Sperrung", "");
addMeasure(table, "2FA-Readiness", "Infrastruktur für Zwei-Faktor-Authentifizierung vorbereitet", "");
addMeasure(table, "Login-Schutz", "Rate-Limiting gegen Brute-Force-Angriffe", "");
addMeasure(table, "Passwort-Richtlinie", "Mindestens 8 Zeichen, Komplexitätsanforderungen", "");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection3Zugriffskontrolle(Document document) throws DocumentException {
document.add(new Paragraph("3. Zugriffskontrolle", SECTION_FONT));
Paragraph desc = new Paragraph("Maßnahmen, die gewährleisten, dass Berechtigte nur auf die ihrer Berechtigung unterliegenden Daten zugreifen.", SMALL_FONT);
document.add(desc);
document.add(Chunk.NEWLINE);
PdfPTable table = createMeasureTable();
addMeasure(table, "RBAC", "Rollenbasierte Zugriffskontrolle (StaffPermission-Enum, 23+ Berechtigungen)", "");
addMeasure(table, "Tenant-Isolation", "Strikte Datentrennung per club_id auf Datenbankebene", "");
addMeasure(table, "Berechtigungsprüfung", "Jeder API-Endpunkt prüft Permission vor Datenzugriff", "");
addMeasure(table, "Minimalprinzip", "Mitglieder sehen nur eigene Daten im Portal", "");
addMeasure(table, "Admin-Separation", "Trennung zwischen Vereins-Admin und Mitglieder-Portal", "");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection4Weitergabekontrolle(Document document) throws DocumentException {
document.add(new Paragraph("4. Weitergabekontrolle", SECTION_FONT));
Paragraph desc = new Paragraph("Maßnahmen, die gewährleisten, dass Daten bei Übertragung nicht unbefugt gelesen/kopiert werden.", SMALL_FONT);
document.add(desc);
document.add(Chunk.NEWLINE);
PdfPTable table = createMeasureTable();
addMeasure(table, "Transportverschlüsselung", "TLS 1.3 für alle HTTP-Verbindungen (HTTPS-only)", "");
addMeasure(table, "DB-Verschlüsselung", "Verschlüsselte PostgreSQL-Verbindung (SSL)", "");
addMeasure(table, "API-Security", "Alle APIs erfordern gültiges JWT-Token", "");
addMeasure(table, "CORS-Policy", "Strikte Origin-Beschränkung auf eigene Domain", "");
addMeasure(table, "Kein FTP", "Keine unverschlüsselten Datenübertragungen", "");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection5Eingabekontrolle(Document document) throws DocumentException {
document.add(new Paragraph("5. Eingabekontrolle", SECTION_FONT));
Paragraph desc = new Paragraph("Maßnahmen, die gewährleisten, dass nachträglich überprüft werden kann, wer Daten eingegeben/verändert hat.", SMALL_FONT);
document.add(desc);
document.add(Chunk.NEWLINE);
PdfPTable table = createMeasureTable();
addMeasure(table, "Audit-Log", "Unveränderliches (append-only) Audit-Log aller Datenänderungen", "");
addMeasure(table, "Aufbewahrung", "10 Jahre Aufbewahrung der Audit-Einträge (§8 AO)", "");
addMeasure(table, "Benutzer-Zuordnung", "Jede Aktion enthält User-ID und Zeitstempel", "");
addMeasure(table, "Event-Typen", "50+ Audit-Event-Typen für granulare Nachvollziehbarkeit", "");
addMeasure(table, "Unveränderlichkeit", "Audit-Einträge können nicht gelöscht oder bearbeitet werden", "");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection6Auftragskontrolle(Document document) throws DocumentException {
document.add(new Paragraph("6. Auftragskontrolle", SECTION_FONT));
Paragraph desc = new Paragraph("Maßnahmen, die gewährleisten, dass Auftragsverarbeiter Daten nur gemäß Weisung verarbeiten.", SMALL_FONT);
document.add(desc);
document.add(Chunk.NEWLINE);
PdfPTable table = createMeasureTable();
addMeasure(table, "AVV Stripe", "Auftragsverarbeitungsvertrag mit Stripe Inc. (Zahlungsabwicklung)", "");
addMeasure(table, "AVV IONOS", "Auftragsverarbeitungsvertrag mit IONOS SE (Hosting)", "");
addMeasure(table, "Datenminimierung", "An Auftragsverarbeiter werden nur notwendige Daten übermittelt", "");
addMeasure(table, "Regelmäßige Prüfung", "Jährliche Überprüfung der AVV-Konformität", "");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection7Verfuegbarkeitskontrolle(Document document) throws DocumentException {
document.add(new Paragraph("7. Verfügbarkeitskontrolle", SECTION_FONT));
Paragraph desc = new Paragraph("Maßnahmen gegen zufällige Zerstörung oder Verlust von Daten.", SMALL_FONT);
document.add(desc);
document.add(Chunk.NEWLINE);
PdfPTable table = createMeasureTable();
addMeasure(table, "Tägliche Backups", "Automatisierte tägliche Datenbank-Sicherungen", "");
addMeasure(table, "Docker Health-Checks", "Automatische Neustart-Policy bei Container-Ausfall", "");
addMeasure(table, "Monitoring", "Container-Überwachung und Alarmierung", "");
addMeasure(table, "Disaster Recovery", "Backup-Wiederherstellung getestet und dokumentiert", "");
addMeasure(table, "Redundanz", "Datenbank-Replikation auf IONOS-Infrastruktur", "");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection8Trennungsgebot(Document document) throws DocumentException {
document.add(new Paragraph("8. Trennungsgebot", SECTION_FONT));
Paragraph desc = new Paragraph("Maßnahmen, die gewährleisten, dass zu unterschiedlichen Zwecken erhobene Daten getrennt verarbeitet werden.", SMALL_FONT);
document.add(desc);
document.add(Chunk.NEWLINE);
PdfPTable table = createMeasureTable();
addMeasure(table, "Multi-Tenant", "Strikte Datentrennung durch club_id auf jeder Entität", "");
addMeasure(table, "Keine Cross-Club-Daten", "Technisch unmöglich, Daten anderer Vereine einzusehen", "");
addMeasure(table, "Logische Trennung", "Separate Datenräume für Mitglieder-, Finanz- und Anbaustammdaten", "");
addMeasure(table, "Zweckbindung", "Jede Verarbeitungstätigkeit ist einem konkreten Zweck zugeordnet", "");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addFooter(Document document) throws DocumentException {
document.add(Chunk.NEWLINE);
Paragraph legend = new Paragraph();
legend.add(new Chunk("Legende: ", SUBSECTION_FONT));
legend.add(new Chunk("✓ = umgesetzt | ◯ = in Planung | ✗ = nicht umgesetzt", NORMAL_FONT));
document.add(legend);
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
Paragraph footer = new Paragraph();
footer.add(new Chunk("Dieses Dokument wird mindestens jährlich überprüft und bei wesentlichen Änderungen " +
"der technischen Infrastruktur aktualisiert.", SMALL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("_________________________________", NORMAL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("Ort, Datum, Unterschrift Vorstand", SMALL_FONT));
document.add(footer);
}
// Helper methods
private PdfPTable createMeasureTable() throws DocumentException {
PdfPTable table = new PdfPTable(3);
table.setWidthPercentage(100);
table.setWidths(new float[]{25f, 60f, 15f});
String[] headers = {"Maßnahme", "Beschreibung", "Status"};
for (String h : headers) {
PdfPCell cell = new PdfPCell(new Phrase(h, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
table.addCell(cell);
}
return table;
}
private void addMeasure(PdfPTable table, String measure, String description, String status) {
PdfPCell measureCell = new PdfPCell(new Phrase(measure, TABLE_CELL_BOLD));
measureCell.setPadding(4);
measureCell.setBorder(Rectangle.BOTTOM);
measureCell.setBackgroundColor(LIGHT_BG);
table.addCell(measureCell);
PdfPCell descCell = new PdfPCell(new Phrase(description, TABLE_CELL_FONT));
descCell.setPadding(4);
descCell.setBorder(Rectangle.BOTTOM);
table.addCell(descCell);
PdfPCell statusCell = new PdfPCell(new Phrase(status, CHECK_FONT));
statusCell.setPadding(4);
statusCell.setBorder(Rectangle.BOTTOM);
statusCell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(statusCell);
}
}
@@ -0,0 +1,393 @@
package de.cannamanage.service.report;
import com.lowagie.text.Chunk;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.domain.enums.ExportFormat;
import de.cannamanage.domain.enums.ReportType;
import de.cannamanage.service.repository.ClubRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.UUID;
/**
* Generates the Verzeichnis von Verarbeitungstätigkeiten (VVT) per Art. 30 DSGVO.
* Auto-generates the complete record of processing activities for the Anbauvereinigung.
*/
@Service
public class VvtReportGenerator implements ReportGenerator<YearReportParameters> {
private static final Logger log = LoggerFactory.getLogger(VvtReportGenerator.class);
private static final Font HEADER_FONT = new Font(Font.HELVETICA, 16, Font.BOLD);
private static final Font SECTION_FONT = new Font(Font.HELVETICA, 12, Font.BOLD);
private static final Font SUBSECTION_FONT = new Font(Font.HELVETICA, 10, Font.BOLD);
private static final Font NORMAL_FONT = new Font(Font.HELVETICA, 10, Font.NORMAL);
private static final Font SMALL_FONT = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.GRAY);
private static final Font TABLE_HEADER_FONT = new Font(Font.HELVETICA, 9, Font.BOLD, Color.WHITE);
private static final Font TABLE_CELL_FONT = new Font(Font.HELVETICA, 9, Font.NORMAL);
private static final Font TABLE_CELL_BOLD = new Font(Font.HELVETICA, 9, Font.BOLD);
private static final Color HEADER_BG = new Color(34, 87, 58);
private static final Color LIGHT_BG = new Color(245, 248, 245);
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final ClubRepository clubRepository;
public VvtReportGenerator(ClubRepository clubRepository) {
this.clubRepository = clubRepository;
}
@Override
public ReportType getType() {
return ReportType.VVT;
}
@Override
public Set<ExportFormat> supportedFormats() {
return Set.of(ExportFormat.PDF);
}
@Override
public byte[] generatePdf(YearReportParameters params, UUID clubId) {
Club club = clubRepository.findById(clubId)
.orElseThrow(() -> new NoSuchElementException("Club not found: " + clubId));
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document document = new Document(PageSize.A4, 50, 50, 50, 50);
PdfWriter.getInstance(document, baos);
document.open();
addHeader(document, club, params.year());
addSection1Verantwortlicher(document, club);
addSection2Dsb(document);
addSection3Verarbeitungstaetigkeiten(document);
addSection4BetroffenePersonen(document);
addSection5DatenKategorien(document);
addSection6Empfaenger(document);
addSection7Tom(document);
addSection8Loeschfristen(document);
addFooter(document);
document.close();
log.info("Generated VVT report for club {} (year {})", clubId, params.year());
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Failed to generate VVT report", e);
}
}
private void addHeader(Document document, Club club, int year) throws DocumentException {
Paragraph header = new Paragraph("VERZEICHNIS VON VERARBEITUNGSTÄTIGKEITEN", HEADER_FONT);
header.setAlignment(Element.ALIGN_CENTER);
document.add(header);
Paragraph subtitle = new Paragraph("gemäß Art. 30 DSGVO", NORMAL_FONT);
subtitle.setAlignment(Element.ALIGN_CENTER);
document.add(subtitle);
document.add(Chunk.NEWLINE);
Paragraph info = new Paragraph();
info.add(new Chunk("Anbauvereinigung: ", SUBSECTION_FONT));
info.add(new Chunk(club.getName(), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Erlaubnisnummer: ", SUBSECTION_FONT));
info.add(new Chunk(club.getLicenseNumber(), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Berichtsjahr: ", SUBSECTION_FONT));
info.add(new Chunk(String.valueOf(year), NORMAL_FONT));
info.add(Chunk.NEWLINE);
info.add(new Chunk("Erstellt am: ", SUBSECTION_FONT));
info.add(new Chunk(LocalDate.now().format(DATE_FMT), NORMAL_FONT));
document.add(info);
document.add(Chunk.NEWLINE);
}
private void addSection1Verantwortlicher(Document document, Club club) throws DocumentException {
document.add(new Paragraph("1. Verantwortlicher (Art. 30 Abs. 1 lit. a DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(2);
table.setWidthPercentage(100);
table.setWidths(new float[]{30f, 70f});
addInfoRow(table, "Name der Organisation:", club.getName());
addInfoRow(table, "Anschrift:", formatAddress(club));
addInfoRow(table, "Kontakt E-Mail:", club.getContactEmail() != null ? club.getContactEmail() : "");
addInfoRow(table, "Kontakt Telefon:", club.getContactPhone() != null ? club.getContactPhone() : "");
addInfoRow(table, "Vertretungsberechtigter:", "Vorstand gemäß §26 BGB");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection2Dsb(Document document) throws DocumentException {
document.add(new Paragraph("2. Datenschutzbeauftragter (Art. 30 Abs. 1 lit. a DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph dsb = new Paragraph();
dsb.add(new Chunk("Bestellungspflicht: ", SUBSECTION_FONT));
dsb.add(new Chunk("Anbauvereinigungen mit weniger als 20 Personen, die ständig mit der Verarbeitung " +
"personenbezogener Daten beschäftigt sind, benötigen keinen Datenschutzbeauftragten (§38 BDSG).", NORMAL_FONT));
dsb.add(Chunk.NEWLINE);
dsb.add(new Chunk("Empfehlung: ", SUBSECTION_FONT));
dsb.add(new Chunk("Freiwillige Benennung eines Datenschutzkoordinators im Vorstand.", NORMAL_FONT));
document.add(dsb);
document.add(Chunk.NEWLINE);
}
private void addSection3Verarbeitungstaetigkeiten(Document document) throws DocumentException {
document.add(new Paragraph("3. Verarbeitungstätigkeiten (Art. 30 Abs. 1 lit. b-d DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(5);
table.setWidthPercentage(100);
table.setWidths(new float[]{20f, 22f, 18f, 20f, 20f});
addTableHeader(table, new String[]{"Verarbeitungstätigkeit", "Datenkategorien", "Rechtsgrundlage", "Zweck", "Löschfrist"});
addProcessingActivity(table, "Mitgliederverwaltung",
"Name, E-Mail, Geburtsdatum, Telefon, Anschrift",
"Art. 6(1)(b) DSGVO",
"Vertragsdurchführung (Mitgliedschaft)",
"5 Jahre nach Austritt (§24 KCanG)");
addProcessingActivity(table, "Ausgabe-Dokumentation",
"Ausgabemenge, THC-Gehalt, Zeitpunkt, Mitgliedsnummer",
"Art. 6(1)(c) DSGVO",
"Gesetzliche Dokumentationspflicht",
"5 Jahre nach Austritt (§24 KCanG)");
addProcessingActivity(table, "Finanzverwaltung",
"Zahlungsdaten, Beiträge, Kontoverbindung",
"Art. 6(1)(b)+(c) DSGVO",
"Beitragsverwaltung, Buchführungspflicht",
"10 Jahre (§147 AO)");
addProcessingActivity(table, "Kommunikation",
"Nachrichten, Forum-Beiträge, Benachrichtigungen",
"Art. 6(1)(f) DSGVO",
"Berechtigtes Interesse (Vereinskommunikation)",
"2 Jahre nach Inaktivität");
addProcessingActivity(table, "Anbauprotokoll",
"Anbaumengen, Sorten, Erntedaten, Vernichtungen",
"Art. 6(1)(c) DSGVO",
"Gesetzliche Dokumentationspflicht (§22 KCanG)",
"5 Jahre (§22 KCanG)");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection4BetroffenePersonen(Document document) throws DocumentException {
document.add(new Paragraph("4. Kategorien betroffener Personen (Art. 30 Abs. 1 lit. c DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph();
p.add(new Chunk("• Mitglieder", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk(" (aktive Vereinsmitglieder, max. 500 je Anbauvereinigung)", SMALL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Personal / Ehrenamt", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk(" (Vorstand, Suchtpräventionsbeauftragte, Mitarbeiter)", SMALL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk("• Vorstand", NORMAL_FONT)); p.add(Chunk.NEWLINE);
p.add(new Chunk(" (vertretungsberechtigte Personen gemäß §26 BGB)", SMALL_FONT)); p.add(Chunk.NEWLINE);
document.add(p);
document.add(Chunk.NEWLINE);
}
private void addSection5DatenKategorien(Document document) throws DocumentException {
document.add(new Paragraph("5. Kategorien personenbezogener Daten (Art. 30 Abs. 1 lit. c DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(3);
table.setWidthPercentage(100);
table.setWidths(new float[]{30f, 40f, 30f});
addTableHeader(table, new String[]{"Kategorie", "Beispiele", "Besonderer Schutz"});
addDataCategory(table, "Stammdaten", "Name, Anschrift, Geburtsdatum, Mitgliedsnummer", "Nein");
addDataCategory(table, "Kontaktdaten", "E-Mail, Telefon", "Nein");
addDataCategory(table, "Finanzdaten", "Beiträge, Zahlungsstatus, Stripe-Referenz", "Nein");
addDataCategory(table, "Gesundheitsdaten", "Cannabis-Ausgabemengen, THC-Gehalt", "Ja (Art. 9 DSGVO)");
addDataCategory(table, "Kommunikationsdaten", "Forum-Beiträge, Chat-Nachrichten", "Nein");
document.add(table);
document.add(Chunk.NEWLINE);
Paragraph note = new Paragraph("Hinweis: Cannabis-Konsummengen sind als Gesundheitsdaten im Sinne von " +
"Art. 9 DSGVO einzustufen. Die Verarbeitung erfolgt auf Basis von Art. 9(2)(g) DSGVO i.V.m. §24 KCanG " +
"(erhebliches öffentliches Interesse — Drogenpolitik/Gesundheitsschutz).", SMALL_FONT);
document.add(note);
document.add(Chunk.NEWLINE);
}
private void addSection6Empfaenger(Document document) throws DocumentException {
document.add(new Paragraph("6. Empfänger / Auftragsverarbeiter (Art. 30 Abs. 1 lit. d DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(4);
table.setWidthPercentage(100);
table.setWidths(new float[]{25f, 25f, 25f, 25f});
addTableHeader(table, new String[]{"Empfänger", "Zweck", "Datenkategorien", "Rechtsgrundlage"});
addRecipient(table, "Stripe Inc.", "Zahlungsabwicklung", "Zahlungsdaten", "AVV (Art. 28 DSGVO)");
addRecipient(table, "IONOS SE", "Hosting / Infrastruktur", "Alle Daten (verschlüsselt)", "AVV (Art. 28 DSGVO)");
addRecipient(table, "Zuständige Behörde", "Behördliche Auskunft", "Mitgliederdaten (auf Verlangen)", "Art. 6(1)(c) DSGVO");
document.add(table);
document.add(Chunk.NEWLINE);
}
private void addSection7Tom(Document document) throws DocumentException {
document.add(new Paragraph("7. Technische und organisatorische Maßnahmen (Art. 30 Abs. 1 lit. g DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
Paragraph p = new Paragraph("Detaillierte Beschreibung der Maßnahmen gemäß Art. 32 DSGVO " +
"— siehe separates TOM-Dokument (Technisch-organisatorische Maßnahmen).", NORMAL_FONT);
document.add(p);
document.add(Chunk.NEWLINE);
Paragraph summary = new Paragraph();
summary.add(new Chunk("Zusammenfassung: ", SUBSECTION_FONT));
summary.add(new Chunk("JWT-Authentifizierung, BCrypt-Passwort-Hashing, rollenbasierte Zugriffskontrolle, " +
"TLS-Verschlüsselung, Multi-Tenant-Isolation, unveränderliches Audit-Log, " +
"tägliche Backups, Löschfristen-Automatisierung.", NORMAL_FONT));
document.add(summary);
document.add(Chunk.NEWLINE);
}
private void addSection8Loeschfristen(Document document) throws DocumentException {
document.add(new Paragraph("8. Löschfristen-Matrix (Art. 30 Abs. 1 lit. f DSGVO)", SECTION_FONT));
document.add(Chunk.NEWLINE);
PdfPTable table = new PdfPTable(4);
table.setWidthPercentage(100);
table.setWidths(new float[]{25f, 25f, 25f, 25f});
addTableHeader(table, new String[]{"Datenkategorie", "Löschfrist", "Rechtsgrundlage", "Auslöser"});
addRetentionRow(table, "Mitgliederdaten", "5 Jahre", "§24 KCanG", "Austritt");
addRetentionRow(table, "Finanzdaten", "10 Jahre", "§147 AO", "Geschäftsjahresende");
addRetentionRow(table, "Ausgabedaten", "5 Jahre", "§24 KCanG", "Austritt");
addRetentionRow(table, "Kommunikation", "2 Jahre", "Art. 6(1)(f) DSGVO", "Letzte Aktivität");
addRetentionRow(table, "Audit-Log", "10 Jahre", "§8 AO", "Erzeugung");
addRetentionRow(table, "Anbauprotokoll", "5 Jahre", "§22 KCanG", "Ernte/Vernichtung");
document.add(table);
document.add(Chunk.NEWLINE);
Paragraph ref = new Paragraph("Details zur Umsetzung: siehe separates Löschkonzept.", SMALL_FONT);
document.add(ref);
}
private void addFooter(Document document) throws DocumentException {
document.add(Chunk.NEWLINE);
document.add(Chunk.NEWLINE);
Paragraph footer = new Paragraph();
footer.add(new Chunk("Dieses Verzeichnis wird gemäß Art. 30 Abs. 4 DSGVO der zuständigen Aufsichtsbehörde " +
"auf Anfrage zur Verfügung gestellt.", SMALL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("_________________________________", NORMAL_FONT));
footer.add(Chunk.NEWLINE);
footer.add(new Chunk("Ort, Datum, Unterschrift Vorstand", SMALL_FONT));
document.add(footer);
}
// Helper methods
private String formatAddress(Club club) {
StringBuilder sb = new StringBuilder();
if (club.getAddressStreet() != null) sb.append(club.getAddressStreet());
if (club.getAddressPostalCode() != null || club.getAddressCity() != null) {
if (sb.length() > 0) sb.append(", ");
if (club.getAddressPostalCode() != null) sb.append(club.getAddressPostalCode()).append(" ");
if (club.getAddressCity() != null) sb.append(club.getAddressCity());
}
return sb.length() > 0 ? sb.toString() : "";
}
private void addTableHeader(PdfPTable table, String[] headers) {
for (String h : headers) {
PdfPCell cell = new PdfPCell(new Phrase(h, TABLE_HEADER_FONT));
cell.setBackgroundColor(HEADER_BG);
cell.setPadding(5);
cell.setHorizontalAlignment(Element.ALIGN_LEFT);
table.addCell(cell);
}
}
private void addInfoRow(PdfPTable table, String label, String value) {
PdfPCell labelCell = new PdfPCell(new Phrase(label, TABLE_CELL_BOLD));
labelCell.setBorder(Rectangle.BOTTOM);
labelCell.setPadding(4);
table.addCell(labelCell);
PdfPCell valueCell = new PdfPCell(new Phrase(value, TABLE_CELL_FONT));
valueCell.setBorder(Rectangle.BOTTOM);
valueCell.setPadding(4);
table.addCell(valueCell);
}
private void addProcessingActivity(PdfPTable table, String activity, String categories,
String legalBasis, String purpose, String retention) {
table.addCell(createCell(activity, TABLE_CELL_BOLD, LIGHT_BG));
table.addCell(createCell(categories, TABLE_CELL_FONT, null));
table.addCell(createCell(legalBasis, TABLE_CELL_FONT, null));
table.addCell(createCell(purpose, TABLE_CELL_FONT, null));
table.addCell(createCell(retention, TABLE_CELL_FONT, null));
}
private void addDataCategory(PdfPTable table, String category, String examples, String specialProtection) {
table.addCell(createCell(category, TABLE_CELL_BOLD, LIGHT_BG));
table.addCell(createCell(examples, TABLE_CELL_FONT, null));
PdfPCell protCell = createCell(specialProtection, TABLE_CELL_FONT, null);
if ("Ja (Art. 9 DSGVO)".equals(specialProtection)) {
protCell.setPhrase(new Phrase(specialProtection, new Font(Font.HELVETICA, 9, Font.BOLD, new Color(180, 0, 0))));
}
table.addCell(protCell);
}
private void addRecipient(PdfPTable table, String recipient, String purpose, String data, String basis) {
table.addCell(createCell(recipient, TABLE_CELL_BOLD, LIGHT_BG));
table.addCell(createCell(purpose, TABLE_CELL_FONT, null));
table.addCell(createCell(data, TABLE_CELL_FONT, null));
table.addCell(createCell(basis, TABLE_CELL_FONT, null));
}
private void addRetentionRow(PdfPTable table, String category, String period, String basis, String trigger) {
table.addCell(createCell(category, TABLE_CELL_BOLD, LIGHT_BG));
table.addCell(createCell(period, TABLE_CELL_FONT, null));
table.addCell(createCell(basis, TABLE_CELL_FONT, null));
table.addCell(createCell(trigger, TABLE_CELL_FONT, null));
}
private PdfPCell createCell(String text, Font font, Color bg) {
PdfPCell cell = new PdfPCell(new Phrase(text, font));
cell.setPadding(4);
cell.setBorder(Rectangle.BOTTOM);
if (bg != null) cell.setBackgroundColor(bg);
return cell;
}
}
@@ -44,6 +44,19 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
return findByTenantId(clubId); return findByTenantId(clubId);
} }
/**
* Find all members for a club, ordered by last name and first name.
* Used for official registry lists (§67 BGB).
*/
default List<Member> findByClubIdOrderByLastNameAscFirstNameAsc(UUID clubId) {
return findByTenantId(clubId).stream()
.sorted((a, b) -> {
int cmp = a.getLastName().compareToIgnoreCase(b.getLastName());
return cmp != 0 ? cmp : a.getFirstName().compareToIgnoreCase(b.getFirstName());
})
.toList();
}
/** /**
* Get all active member user IDs (for broadcast notifications). * Get all active member user IDs (for broadcast notifications).
* Uses the Hibernate tenant filter, so no explicit tenantId parameter needed. * Uses the Hibernate tenant filter, so no explicit tenantId parameter needed.