diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
index a28d508..17d0e02 100644
--- a/backend/app/api/admin.py
+++ b/backend/app/api/admin.py
@@ -1,5 +1,6 @@
"""Admin-only API endpoints: user CRUD, invitation management, audit log."""
+import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -17,13 +18,77 @@ from app.schemas.disclosure import (
DisclosureCountResponse, DisclosureRequestResponse, DisclosureRequestUpdate,
)
from app.schemas.user import UserCreate, UserResponse, UserUpdate
+from app.config import get_settings
from app.services.audit_service import log_action
from app.services.disclosure_service import (
admin_delete_disclosure, get_pending_count, reactivate_disclosure,
review_disclosure_request, revoke_disclosure,
)
+from app.services.notification_service import send_email
+from app.services.pdf_guide import generate_guide_pdf
router = APIRouter()
+logger = logging.getLogger(__name__)
+
+_settings = get_settings()
+
+
+# ---------------------------------------------------------------------------
+# Invitation email helper
+# ---------------------------------------------------------------------------
+
+def _send_invitation_email(
+ email: str, token: str, expires_at: datetime
+) -> None:
+ """Send an invitation email with a PDF onboarding guide attached."""
+ try:
+ invite_url = f"{_settings.FRONTEND_BASE_URL}/register?token={token}"
+ pdf_bytes = generate_guide_pdf(invite_url)
+
+ expires_str = expires_at.strftime("%d.%m.%Y")
+
+ plain_body = (
+ "Guten Tag,\n\n"
+ "Sie wurden zum DAK Zweitmeinungs-Portal eingeladen.\n\n"
+ "Bitte registrieren Sie sich unter folgendem Link:\n"
+ f"{invite_url}\n\n"
+ f"Der Link ist gueltig bis zum {expires_str}.\n\n"
+ "Im Anhang finden Sie eine Anleitung (PDF) mit allen "
+ "wichtigen Informationen zur Registrierung und Nutzung "
+ "des Portals.\n\n"
+ "Mit freundlichen Gruessen\n"
+ "Complex Care Solutions GmbH"
+ )
+
+ html_body = (
+ "
"
+ "
DAK Zweitmeinungs-Portal
"
+ "
Guten Tag,
"
+ "
Sie wurden zum DAK Zweitmeinungs-Portal eingeladen.
"
+ "
Bitte registrieren Sie sich unter folgendem Link:
"
+ f"
{invite_url}
"
+ f"
Der Link ist gültig bis zum {expires_str}.
"
+ "
Im Anhang finden Sie eine Anleitung (PDF) mit allen "
+ "wichtigen Informationen zur Registrierung und Nutzung des Portals.
"
+ "
"
+ "
"
+ "Complex Care Solutions GmbH
"
+ "https://dak.complexcaresolutions.de
"
+ "
"
+ )
+
+ send_email(
+ to=email,
+ subject="[DAK Portal] Ihre Einladung zum Zweitmeinungs-Portal",
+ body=plain_body,
+ html_body=html_body,
+ attachments=[
+ ("DAK-Portal-Anleitung.pdf", pdf_bytes, "application/pdf")
+ ],
+ )
+ except Exception:
+ logger.exception("Failed to send invitation email to %s", email)
# ---------------------------------------------------------------------------
@@ -250,6 +315,10 @@ def create_invitation(
user_agent=request.headers.get("user-agent"),
)
+ # Send invitation email with PDF guide (fire-and-forget)
+ if payload.email:
+ _send_invitation_email(payload.email, token, expires_at)
+
return invitation
diff --git a/backend/app/assets/LiberationSans-Bold.ttf b/backend/app/assets/LiberationSans-Bold.ttf
new file mode 100644
index 0000000..31ddaef
Binary files /dev/null and b/backend/app/assets/LiberationSans-Bold.ttf differ
diff --git a/backend/app/assets/LiberationSans-Italic.ttf b/backend/app/assets/LiberationSans-Italic.ttf
new file mode 100644
index 0000000..ebf48bc
Binary files /dev/null and b/backend/app/assets/LiberationSans-Italic.ttf differ
diff --git a/backend/app/assets/LiberationSans-Regular.ttf b/backend/app/assets/LiberationSans-Regular.ttf
new file mode 100644
index 0000000..f524339
Binary files /dev/null and b/backend/app/assets/LiberationSans-Regular.ttf differ
diff --git a/backend/app/assets/dak_logo.png b/backend/app/assets/dak_logo.png
new file mode 100644
index 0000000..a1036f7
Binary files /dev/null and b/backend/app/assets/dak_logo.png differ
diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py
index 6f7bbe3..50049cb 100644
--- a/backend/app/services/notification_service.py
+++ b/backend/app/services/notification_service.py
@@ -5,8 +5,10 @@ from __future__ import annotations
import logging
import smtplib
from datetime import datetime, timezone
+from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
+from email import encoders
from typing import Optional
from sqlalchemy.orm import Session
@@ -28,21 +30,45 @@ def send_email(
subject: str,
body: str,
html_body: str | None = None,
+ attachments: list[tuple[str, bytes, str]] | None = None,
) -> bool:
"""Send an email via SMTP SSL.
+ Parameters
+ ----------
+ attachments:
+ Optional list of ``(filename, data, mime_type)`` tuples.
+
Returns *True* on success, *False* on failure (the error is logged).
"""
try:
- msg = MIMEMultipart("alternative")
+ if attachments:
+ msg = MIMEMultipart("mixed")
+ text_part = MIMEMultipart("alternative")
+ text_part.attach(MIMEText(body, "plain"))
+ if html_body:
+ text_part.attach(MIMEText(html_body, "html"))
+ msg.attach(text_part)
+
+ for filename, data, mime_type in attachments:
+ maintype, subtype = mime_type.split("/", 1)
+ part = MIMEBase(maintype, subtype)
+ part.set_payload(data)
+ encoders.encode_base64(part)
+ part.add_header(
+ "Content-Disposition", "attachment", filename=filename
+ )
+ msg.attach(part)
+ else:
+ msg = MIMEMultipart("alternative")
+ msg.attach(MIMEText(body, "plain"))
+ if html_body:
+ msg.attach(MIMEText(html_body, "html"))
+
msg["From"] = settings.SMTP_FROM
msg["To"] = to
msg["Subject"] = subject
- msg.attach(MIMEText(body, "plain"))
- if html_body:
- msg.attach(MIMEText(html_body, "html"))
-
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as server:
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.send_message(msg)
diff --git a/backend/app/services/pdf_guide.py b/backend/app/services/pdf_guide.py
new file mode 100644
index 0000000..903b525
--- /dev/null
+++ b/backend/app/services/pdf_guide.py
@@ -0,0 +1,239 @@
+"""PDF guide generator for DAK onboarding invitations."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from fpdf import FPDF
+
+ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
+LOGO_PATH = ASSETS_DIR / "dak_logo.png"
+FONT_DIR = ASSETS_DIR
+
+# Font family name used throughout the PDF
+_FONT = "LiberationSans"
+
+# Colours (DAK blue tones)
+DAK_BLUE = (0, 82, 147)
+DAK_DARK = (33, 37, 41)
+DAK_GREY = (108, 117, 125)
+DAK_LIGHT_BG = (240, 244, 248)
+
+
+class _GuidePDF(FPDF):
+ """Custom FPDF subclass with shared header/footer styling."""
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ # Register Liberation Sans (Unicode TTF) in Regular, Bold, Italic
+ self.add_font(_FONT, "", str(FONT_DIR / "LiberationSans-Regular.ttf"))
+ self.add_font(_FONT, "B", str(FONT_DIR / "LiberationSans-Bold.ttf"))
+ self.add_font(_FONT, "I", str(FONT_DIR / "LiberationSans-Italic.ttf"))
+
+ def header(self):
+ if self.page_no() == 1:
+ return # Page 1 has its own header with logo
+ self.set_font(_FONT, "I", 8)
+ self.set_text_color(*DAK_GREY)
+ self.cell(0, 8, "DAK Zweitmeinungs-Portal \u2014 Anleitung", align="R")
+ self.ln(12)
+
+ def footer(self):
+ self.set_y(-15)
+ self.set_font(_FONT, "I", 8)
+ self.set_text_color(*DAK_GREY)
+ self.cell(0, 10, f"Seite {self.page_no()}/{{nb}}", align="C")
+
+ # -- helpers ---------------------------------------------------------------
+
+ def section_title(self, text: str) -> None:
+ self.set_font(_FONT, "B", 14)
+ self.set_text_color(*DAK_BLUE)
+ self.cell(0, 10, text)
+ self.ln(8)
+ # blue underline
+ self.set_draw_color(*DAK_BLUE)
+ self.set_line_width(0.5)
+ self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y())
+ self.ln(6)
+
+ def body_text(self, text: str) -> None:
+ self.set_font(_FONT, "", 10)
+ self.set_text_color(*DAK_DARK)
+ self.multi_cell(0, 6, text)
+ self.ln(2)
+
+ def numbered_step(self, number: int, text: str) -> None:
+ self.set_font(_FONT, "B", 10)
+ self.set_text_color(*DAK_BLUE)
+ self.cell(8, 6, f"{number}.")
+ self.set_font(_FONT, "", 10)
+ self.set_text_color(*DAK_DARK)
+ self.multi_cell(0, 6, text)
+ self.ln(1)
+
+ def bullet_item(self, title: str, description: str) -> None:
+ self.set_font(_FONT, "B", 10)
+ self.set_text_color(*DAK_DARK)
+ self.cell(4, 6, "\u2022 ")
+ self.cell(0, 6, title)
+ self.ln(6)
+ if description:
+ self.set_font(_FONT, "", 9)
+ self.set_text_color(*DAK_GREY)
+ self.set_x(self.l_margin + 6)
+ self.multi_cell(0, 5, description)
+ self.ln(2)
+
+ def info_box(self, text: str) -> None:
+ self.set_fill_color(*DAK_LIGHT_BG)
+ self.set_draw_color(*DAK_BLUE)
+ x = self.l_margin
+ y = self.get_y()
+ w = self.w - self.l_margin - self.r_margin
+ self.set_font(_FONT, "", 9)
+ self.set_text_color(*DAK_DARK)
+ # calculate height
+ self.set_xy(x + 4, y + 4)
+ self.multi_cell(w - 8, 5, text)
+ h = self.get_y() - y + 4
+ # draw box behind
+ self.rect(x, y, w, h, style="DF")
+ # re-render text on top
+ self.set_xy(x + 4, y + 4)
+ self.multi_cell(w - 8, 5, text)
+ self.ln(4)
+
+
+def generate_guide_pdf(invite_url: str) -> bytes:
+ """Generate the DAK onboarding guide as PDF bytes.
+
+ Parameters
+ ----------
+ invite_url:
+ The personalised invitation/registration URL to embed in the document.
+
+ Returns
+ -------
+ bytes
+ The raw PDF content (no disk I/O).
+ """
+ pdf = _GuidePDF(orientation="P", unit="mm", format="A4")
+ pdf.alias_nb_pages()
+ pdf.set_auto_page_break(auto=True, margin=20)
+ pdf.set_left_margin(20)
+ pdf.set_right_margin(20)
+
+ # ── Page 1: Welcome & Registration ────────────────────────────────────
+ pdf.add_page()
+
+ # Logo
+ if LOGO_PATH.exists():
+ pdf.image(str(LOGO_PATH), x=75, y=15, w=60)
+ pdf.ln(50)
+ else:
+ pdf.ln(15)
+
+ # Title
+ pdf.set_font(_FONT, "B", 20)
+ pdf.set_text_color(*DAK_BLUE)
+ pdf.cell(0, 12, "Willkommen im", align="C")
+ pdf.ln(12)
+ pdf.cell(0, 12, "DAK Zweitmeinungs-Portal", align="C")
+ pdf.ln(20)
+
+ # Registration section
+ pdf.section_title("Konto erstellen")
+ pdf.body_text(
+ "Um Ihr Konto zu erstellen, folgen Sie bitte diesen Schritten:"
+ )
+
+ pdf.numbered_step(1, "Registrierungslink im Browser öffnen (siehe unten)")
+ pdf.numbered_step(2, "Benutzername festlegen (mindestens 3 Zeichen)")
+ pdf.numbered_step(3, "E-Mail-Adresse eingeben")
+ pdf.numbered_step(4, "Passwort setzen (mindestens 8 Zeichen)")
+ pdf.numbered_step(5, "\u201eRegistrieren\u201c anklicken")
+ pdf.ln(2)
+
+ # Invite URL box
+ pdf.set_font(_FONT, "B", 10)
+ pdf.set_text_color(*DAK_BLUE)
+ pdf.cell(0, 6, "Ihr Registrierungslink:")
+ pdf.ln(7)
+ pdf.info_box(invite_url)
+
+ # First login
+ pdf.section_title("Erste Anmeldung")
+ pdf.body_text(
+ "Melden Sie sich mit Ihrem Benutzernamen (oder E-Mail-Adresse) "
+ "und dem gewählten Passwort an."
+ )
+ pdf.body_text(
+ "Empfehlung: Aktivieren Sie die Zwei-Faktor-Authentifizierung "
+ "unter Kontoverwaltung für zusätzliche Sicherheit."
+ )
+
+ # ── Page 2: Feature overview ──────────────────────────────────────────
+ pdf.add_page()
+ pdf.section_title("Funktionsübersicht")
+
+ pdf.body_text(
+ "Das DAK Zweitmeinungs-Portal bietet Ihnen folgende Funktionen:"
+ )
+ pdf.ln(2)
+
+ features = [
+ ("Dashboard", "Übersicht über KPIs, Diagramme und Jahresvergleich."),
+ ("Fälle", "Fallübersicht mit Such- und Filterfunktionen."),
+ ("ICD-Eingabe", "ICD-Codes für Fälle erfassen und bearbeiten."),
+ ("Berichte", "Wochenberichte als Excel-Datei herunterladen."),
+ (
+ "Wochenübersicht",
+ "Datenübersicht pro Kalenderwoche und ICD-Upload per Excel.",
+ ),
+ (
+ "Freigaben",
+ "Personenbezogene Daten bei Bedarf anfordern (24 Stunden Gültigkeit).",
+ ),
+ (
+ "Kontoverwaltung",
+ "Profil bearbeiten, Passwort ändern, Zwei-Faktor-Authentifizierung.",
+ ),
+ ]
+ for title, desc in features:
+ pdf.bullet_item(title, desc)
+
+ # ── Page 3: Closing notes ─────────────────────────────────────────────
+ pdf.add_page()
+ pdf.section_title("Weitere Informationen")
+
+ pdf.body_text(
+ "Ausführliche Anleitungen zu allen Funktionen finden Sie direkt "
+ "im Portal unter dem Menüpunkt \u201eAnleitung\u201c."
+ )
+ pdf.ln(4)
+
+ pdf.body_text(
+ "Bei Fragen oder Problemen wenden Sie sich bitte an Ihren "
+ "Ansprechpartner bei Complex Care Solutions:"
+ )
+ pdf.ln(2)
+
+ pdf.set_font(_FONT, "B", 10)
+ pdf.set_text_color(*DAK_DARK)
+ pdf.cell(0, 6, "Complex Care Solutions GmbH")
+ pdf.ln(6)
+ pdf.set_font(_FONT, "", 10)
+ pdf.cell(0, 6, "E-Mail: info@complexcaresolutions.de")
+ pdf.ln(6)
+ pdf.cell(0, 6, "Portal: https://dak.complexcaresolutions.de")
+ pdf.ln(16)
+
+ # Final info box
+ pdf.info_box(
+ "Dieses Dokument wurde automatisch generiert und enthält "
+ "Ihren persönlichen Registrierungslink. "
+ "Bitte geben Sie diesen Link nicht an Dritte weiter."
+ )
+
+ return pdf.output()
diff --git a/backend/requirements.txt b/backend/requirements.txt
index d36e458..072ab50 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -16,6 +16,7 @@ pydantic==2.10.4
pydantic-settings==2.7.1
python-dotenv==1.0.1
email-validator==2.2.0
+fpdf2==2.8.3
httpx==0.28.1
# Dev/Test