diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/BoardChangeGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/BoardChangeGenerator.java new file mode 100644 index 0000000..e54006a --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/BoardChangeGenerator.java @@ -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 { + + 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 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 currentBoard = boardMemberRepository.findByClubIdAndIsCurrentTrueOrderByCreatedAtAsc(clubId); + List allBoardMembers = boardMemberRepository.findByClubIdOrderByCreatedAtDesc(clubId); + + // Find previous board members (those who are not current) + List 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 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 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 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 position = boardPositionRepository.findById(bm.getPositionId()); + String positionName = position.map(BoardPosition::getTitle).orElse("—"); + + // Member name + Optional 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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/BreachNotificationGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/BreachNotificationGenerator.java new file mode 100644 index 0000000..584384c --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/BreachNotificationGenerator.java @@ -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 { + + 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 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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/BreachReportParameters.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/BreachReportParameters.java new file mode 100644 index 0000000..20340f1 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/BreachReportParameters.java @@ -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 { +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/DsfaReportGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/DsfaReportGenerator.java new file mode 100644 index 0000000..4376ade --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/DsfaReportGenerator.java @@ -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 { + + 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 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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/LoeschkonzeptGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/LoeschkonzeptGenerator.java new file mode 100644 index 0000000..70afc01 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/LoeschkonzeptGenerator.java @@ -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 { + + 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 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 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 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 + ) {} +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/MemberListRegistryGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/MemberListRegistryGenerator.java new file mode 100644 index 0000000..b01646f --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/MemberListRegistryGenerator.java @@ -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 { + + 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 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 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 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 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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/TomReportGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/TomReportGenerator.java new file mode 100644 index 0000000..49c17a6 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/TomReportGenerator.java @@ -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 { + + 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 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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/report/VvtReportGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/report/VvtReportGenerator.java new file mode 100644 index 0000000..6d423ad --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/report/VvtReportGenerator.java @@ -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 { + + 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 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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java index 27f6e75..0ee7935 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java @@ -44,6 +44,19 @@ public interface MemberRepository extends JpaRepository { 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 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). * Uses the Hibernate tenant filter, so no explicit tenantId parameter needed.