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:
+321
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+315
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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 {
|
||||||
|
}
|
||||||
+368
@@ -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 §22–24 KCanG");
|
||||||
|
addInfoRow(table, "Sekundärzweck:", "Vereinsverwaltung (Mitgliedschaft, Beiträge)");
|
||||||
|
addInfoRow(table, "Rechtsgrundlage:", "Art. 6(1)(c) DSGVO i.V.m. §22–24 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+319
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+246
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+304
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+393
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user