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