From c58e212e3a6df82f009bc018984aab6b80317893 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 22:58:47 +0200 Subject: [PATCH] feat(repairs): automatic repair status emails --- .env.example | 9 + ARCHITECTURE.md | 13 +- README-DEV.md | 15 +- ROADMAP.md | 13 +- backend/hermes/.env.example | 7 + ...9a7c3d5b812_extend_repair_notifications.py | 29 +++ backend/hermes/app/api/dashboard.py | 3 + backend/hermes/app/api/repairs.py | 12 + backend/hermes/app/core/config.py | 7 + backend/hermes/app/models/repair.py | 2 + .../app/repositories/repair_repository.py | 31 +++ backend/hermes/app/schemas/repair.py | 2 + backend/hermes/app/services/audit_service.py | 6 +- .../services/repair_notification_service.py | 234 ++++++++++++++++-- .../services/repair_public_link_service.py | 26 +- backend/hermes/app/services/repair_service.py | 14 ++ .../app/services/repair_status_labels.py | 29 +++ docker-compose.yml | 7 + .../repairs/[id]/send-status-mail/route.ts | 21 ++ frontend/athena/app/repairs/[id]/page.tsx | 79 +++++- frontend/athena/types/repair.ts | 2 + 21 files changed, 525 insertions(+), 36 deletions(-) create mode 100644 backend/hermes/alembic/versions/e9a7c3d5b812_extend_repair_notifications.py create mode 100644 backend/hermes/app/services/repair_status_labels.py create mode 100644 frontend/athena/app/api/repairs/[id]/send-status-mail/route.ts diff --git a/.env.example b/.env.example index e7aca6c..86fc45b 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,12 @@ OLYMPUS_REPAIR_INTAKE_TOKEN= # Optionale Website-Basis-URL fuer oeffentliche Reparaturstatuslinks. # Beispiel: https://test.funktechnik-schubert.de/status PUBLIC_REPAIR_STATUS_BASE_URL= + +# Optionaler SMTP-Versand fuer automatische Reparaturstatusmails. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_EMAIL= +SMTP_FROM_NAME=Funktechnik Schubert +SMTP_USE_TLS=true diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d98233b..749cee0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -271,7 +271,7 @@ Die spaetere Website kann daraus einen Pfad wie `/status/` anbieten. Die ### Reparatur-Benachrichtigungen -`repair_notification_events` bereitet nachvollziehbare Kundenkommunikation vor. Der Versand wird noch nicht erzwungen. +`repair_notification_events` speichert jeden Versandversuch fuer Reparaturstatusmails. Der Statuswechsel selbst wird nie durch SMTP-Probleme blockiert. Vorbereitete Ereignisse: @@ -288,6 +288,14 @@ Vorbereitete Ereignisse: Vorlagen enthalten deutsche Betreffzeilen und Texte mit Platzhaltern fuer `repair_number`, `customer_name`, `device`, `status_label`, `public_status_url` und `company_name`. +Ab v0.8.3 sendet Hermes beim Statuswechsel automatisch eine Statusmail, wenn eine Kunden-E-Mail vorhanden ist. Fuer jede Mail wird ein frischer sicherer Statuslink erzeugt, weil Klartexttokens nicht gespeichert werden. Ist SMTP nicht konfiguriert oder schlaegt der Versand fehl, wird ein `repair_notification_events`-Eintrag mit `skipped` oder `failed` geschrieben und ein Audit-Eintrag erzeugt. + +Hermes-Endpunkt fuer manuellen Neuversand: + +- `POST /repairs/{id}/send-status-mail` + +Athena ruft diesen Endpunkt ausschliesslich ueber die BFF-Route `/api/repairs/[id]/send-status-mail` auf. + ### Website Repair Intake Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit. @@ -896,6 +904,9 @@ docker-compose.yml `PUBLIC_REPAIR_STATUS_BASE_URL` : Optionale Basis-URL fuer von Olympus erzeugte oeffentliche Reparaturstatuslinks, z. B. `https://test.funktechnik-schubert.de/status`. Wenn leer, gibt Hermes weiterhin relative Links im Format `/status/` zurueck. +`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME`, `SMTP_USE_TLS` +: Optionale SMTP-Konfiguration fuer automatische Reparaturstatusmails. `SMTP_PASSWORD` darf nicht geloggt oder committed werden. + ### Athena `HERMES_INTERNAL_URL` diff --git a/README-DEV.md b/README-DEV.md index f9c95d3..275140b 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -35,6 +35,13 @@ STORAGE_MAX_UPLOAD_MB=50 STORAGE_HOST_PATH=./storage OLYMPUS_REPAIR_INTAKE_TOKEN= PUBLIC_REPAIR_STATUS_BASE_URL= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_EMAIL= +SMTP_FROM_NAME=Funktechnik Schubert +SMTP_USE_TLS=true ``` Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git. @@ -149,6 +156,7 @@ Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen: - `/api/repairs/[id]/history` - `/api/repairs/[id]/public-link` - `/api/repairs/[id]/notifications` +- `/api/repairs/[id]/send-status-mail` Hermes stellt zusaetzlich `POST /public/repair-intake` fuer die spaetere Website-Anbindung bereit. Der Endpunkt ist fuer Server-zu-Server-Kommunikation gedacht und erwartet `X-Olympus-Intake-Token`. Der Token wird ueber `OLYMPUS_REPAIR_INTAKE_TOKEN` gesetzt und darf nicht im Browser verwendet werden. @@ -167,8 +175,11 @@ Statuslink-Konzept: Benachrichtigungen: - Vorlagen liegen in `backend/hermes/app/services/repair_notification_service.py`. -- `repair_notification_events` bereitet spaeteren E-Mail-Versand nachvollziehbar vor. -- Aktuell wird kein SMTP-Versand aus Olympus erzwungen. +- `repair_notification_events` dokumentiert jeden Versandversuch. +- Beim Statuswechsel wird automatisch eine Statusmail verarbeitet. +- Ohne SMTP-Konfiguration bleibt der Statuswechsel erfolgreich; der Versandversuch wird als `skipped` dokumentiert. +- SMTP wird ueber `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME` und `SMTP_USE_TLS` konfiguriert. +- `SMTP_PASSWORD` gehoert ausschliesslich in die Umgebung und nie ins Git oder in Logs. Vorbereitete Website-/Portal-Routen fuer spaeter: diff --git a/ROADMAP.md b/ROADMAP.md index 98f0b08..dbddbd3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,7 +102,18 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Kein Token im Log und keine unnoetigen Kundendaten in der oeffentlichen Antwort - Optionaler E-Mail-Versand mit sicherem Statuslink -## v0.8.3 - Kundenportal, geplant +## v0.8.3 - Automatische Reparaturstatusmails + +- Automatischer Statusmail-Versand bei Reparaturstatuswechseln +- Frische sichere Public-Statuslinks fuer Statusmails +- Professionelles HTML-Mailtemplate fuer Funktechnik Schubert +- Notification-Historie mit Erfolg, Fehler und Empfaenger +- Manueller Neuversand ueber `POST /repairs/{id}/send-status-mail` +- Athena-BFF-Route und Button "E-Mail erneut senden" +- Dashboard-Kennzahlen fuer Statusmails und Reparaturen ohne Kundenmail +- SMTP-Ausfall blockiert keine Reparaturprozesse + +## v0.8.4 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index eabbdf7..805a702 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -20,3 +20,10 @@ KNOWLEDGE_STORAGE_PATH=/data/knowledge KNOWLEDGE_MAX_UPLOAD_MB=50 OLYMPUS_REPAIR_INTAKE_TOKEN= PUBLIC_REPAIR_STATUS_BASE_URL= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_EMAIL= +SMTP_FROM_NAME=Funktechnik Schubert +SMTP_USE_TLS=true diff --git a/backend/hermes/alembic/versions/e9a7c3d5b812_extend_repair_notifications.py b/backend/hermes/alembic/versions/e9a7c3d5b812_extend_repair_notifications.py new file mode 100644 index 0000000..b8554ad --- /dev/null +++ b/backend/hermes/alembic/versions/e9a7c3d5b812_extend_repair_notifications.py @@ -0,0 +1,29 @@ +"""extend repair notifications + +Revision ID: e9a7c3d5b812 +Revises: d4f6a2b8c901 +Create Date: 2026-07-04 13:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "e9a7c3d5b812" +down_revision: Union[str, Sequence[str], None] = "d4f6a2b8c901" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("repair_notification_events", sa.Column("template", sa.Text(), server_default="", nullable=False)) + op.add_column("repair_notification_events", sa.Column("success", sa.Boolean(), server_default="false", nullable=False)) + op.create_index(op.f("ix_repair_notification_events_success"), "repair_notification_events", ["success"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_repair_notification_events_success"), table_name="repair_notification_events") + op.drop_column("repair_notification_events", "success") + op.drop_column("repair_notification_events", "template") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index a70dc6a..2f1371f 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -64,6 +64,9 @@ def get_dashboard_summary( MetricCard(label="In Reparatur", value=RepairRepository.count_by_status(db, "repair")), MetricCard(label="Endprüfung", value=RepairRepository.count_by_status(db, "final_test")), MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")), + MetricCard(label="Statusmails heute", value=RepairRepository.count_status_mails_sent_today(db)), + MetricCard(label="Fehlgeschlagene Mails", value=RepairRepository.count_failed_status_mails(db)), + MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)), ] logger.info("dashboard.summary", extra={"actor_user_id": current_user.id}) diff --git a/backend/hermes/app/api/repairs.py b/backend/hermes/app/api/repairs.py index ffdc53b..8e6d820 100644 --- a/backend/hermes/app/api/repairs.py +++ b/backend/hermes/app/api/repairs.py @@ -13,6 +13,7 @@ from app.schemas.repair import ( RepairCreate, RepairIntakePayload, RepairIntakeResponse, + RepairNotificationEventResponse, RepairListResponse, RepairNotificationOverviewResponse, RepairPublicLinkCreateResponse, @@ -171,6 +172,17 @@ def get_repair_notifications( return RepairNotificationService.overview(db, db_repair) +@router.post("/repairs/{repair_id}/send-status-mail", response_model=RepairNotificationEventResponse) +def send_repair_status_mail( + repair_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + return RepairNotificationService.send_status_mail(db, db_repair, actor=current_user, request=request, manual=True) + + @router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED) def create_repair_intake( payload: RepairIntakePayload, diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index ab59fa3..9590c60 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -25,6 +25,13 @@ class Settings(BaseSettings): knowledge_max_upload_mb: int = 50 olympus_repair_intake_token: str | None = None public_repair_status_base_url: str | None = None + smtp_host: str | None = None + smtp_port: int = 587 + smtp_username: str | None = None + smtp_password: str | None = None + smtp_from_email: str | None = None + smtp_from_name: str = "Funktechnik Schubert" + smtp_use_tls: bool = True model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/models/repair.py b/backend/hermes/app/models/repair.py index 5eae60b..a068d2c 100644 --- a/backend/hermes/app/models/repair.py +++ b/backend/hermes/app/models/repair.py @@ -120,7 +120,9 @@ class RepairNotificationEvent(Base): channel: Mapped[str] = mapped_column(String(40), index=True) recipient: Mapped[str] = mapped_column(String(255), default="", server_default="") subject: Mapped[str] = mapped_column(String(255)) + template: Mapped[str] = mapped_column(Text, default="", server_default="") status: Mapped[str] = mapped_column(String(40), index=True) + success: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/hermes/app/repositories/repair_repository.py b/backend/hermes/app/repositories/repair_repository.py index 51436f1..1390ee6 100644 --- a/backend/hermes/app/repositories/repair_repository.py +++ b/backend/hermes/app/repositories/repair_repository.py @@ -301,7 +301,9 @@ class RepairRepository: channel: str, recipient: str, subject: str, + template: str, status: str, + success: bool, error_message: str | None = None, sent_at: datetime | None = None, ) -> RepairNotificationEvent: @@ -311,7 +313,9 @@ class RepairRepository: channel=channel, recipient=recipient, subject=subject, + template=template, status=status, + success=success, error_message=error_message, sent_at=sent_at, ) @@ -320,6 +324,33 @@ class RepairRepository: db.refresh(event) return event + @staticmethod + def count_status_mails_sent_today(db: Session) -> int: + today = datetime.now(UTC).date() + return db.scalar( + select(func.count(RepairNotificationEvent.id)) + .where(RepairNotificationEvent.event_type == "repair_status_mail") + .where(RepairNotificationEvent.success.is_(True)) + .where(func.date(RepairNotificationEvent.created_at) == today) + ) or 0 + + @staticmethod + def count_failed_status_mails(db: Session) -> int: + return db.scalar( + select(func.count(RepairNotificationEvent.id)) + .where(RepairNotificationEvent.event_type == "repair_status_mail") + .where(RepairNotificationEvent.success.is_(False)) + .where(RepairNotificationEvent.status.in_(["failed", "skipped"])) + ) or 0 + + @staticmethod + def count_open_repairs_without_customer_email(db: Session) -> int: + return db.scalar( + select(func.count(Repair.id)) + .where(~Repair.status.in_(["completed", "cancelled"])) + .where((Repair.customer_email == "") | Repair.customer_email.is_(None)) + ) or 0 + @staticmethod def _payload_data(payload: RepairCreate | RepairUpdate) -> dict: data = payload.model_dump() diff --git a/backend/hermes/app/schemas/repair.py b/backend/hermes/app/schemas/repair.py index f086730..fcb3cfa 100644 --- a/backend/hermes/app/schemas/repair.py +++ b/backend/hermes/app/schemas/repair.py @@ -209,7 +209,9 @@ class RepairNotificationEventResponse(BaseModel): channel: str recipient: str subject: str + template: str status: str + success: bool error_message: str | None created_at: datetime sent_at: datetime | None diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index 7044759..d37f223 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -13,7 +13,7 @@ from app.repositories.audit_repository import AuditRepository logger = logging.getLogger(__name__) -SENSITIVE_KEYS = {"password", "password_hash", "token", "access_token", "secret", "secret_key"} +SENSITIVE_KEYS = {"password", "password_hash", "smtp_password", "token", "access_token", "secret", "secret_key"} def to_audit_data(value: Any, seen: set[int] | None = None) -> Any: @@ -165,6 +165,10 @@ def action_title(action: str) -> str: "repairs.intake_failed": "Reparatur-Intake fehlgeschlagen", "repairs.public_link.create": "Reparatur-Statuslink erstellt", "repairs.public_link.revoke": "Reparatur-Statuslink deaktiviert", + "repairs.public_link.auto_create": "Reparatur-Statuslink automatisch erstellt", + "repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt", + "repairs.status_mail.sent": "Statusmail versendet", + "repairs.status_mail.failed": "Statusmail fehlgeschlagen", } return labels.get(action, action) diff --git a/backend/hermes/app/services/repair_notification_service.py b/backend/hermes/app/services/repair_notification_service.py index 92020b4..1ce0d6e 100644 --- a/backend/hermes/app/services/repair_notification_service.py +++ b/backend/hermes/app/services/repair_notification_service.py @@ -1,26 +1,20 @@ +import smtplib from dataclasses import dataclass +from datetime import UTC, datetime +from email.message import EmailMessage +from html import escape from sqlalchemy.orm import Session +from starlette.requests import Request -from app.models.repair import Repair +from app.core.config import settings +from app.models.repair import Repair, RepairNotificationEvent +from app.models.user import User from app.repositories.repair_repository import RepairRepository from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotificationTemplateResponse, RepairStatus - - -STATUS_LABELS: dict[str, str] = { - "new": "Neu", - "accepted": "Angenommen", - "diagnosis": "Diagnose", - "estimate": "Kostenvoranschlag", - "waiting_for_customer": "Wartet auf Kunde", - "approved": "Freigegeben", - "repair": "Reparatur", - "final_test": "Endprüfung", - "ready_for_pickup": "Abholbereit", - "shipped": "Versand", - "completed": "Abgeschlossen", - "cancelled": "Storniert", -} +from app.services.audit_service import write_audit_log +from app.services.repair_public_link_service import RepairPublicLinkService +from app.services.repair_status_labels import MAIL_STATUS_LABELS, STATUS_LABELS @dataclass(frozen=True) @@ -96,13 +90,93 @@ TEMPLATES: tuple[RepairNotificationTemplate, ...] = ( ) +def _device_label(repair: Repair) -> str: + return f"{repair.device_manufacturer} {repair.device_model}".strip() + + +def _repair_label(repair: Repair) -> str: + return f"{repair.repair_number} · {repair.customer_name}" + + +def _smtp_configured() -> bool: + return bool(settings.smtp_host and settings.smtp_from_email) + + +def _from_header() -> str: + if settings.smtp_from_name: + return f"{settings.smtp_from_name} <{settings.smtp_from_email}>" + return str(settings.smtp_from_email) + + +def _plain_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str: + return ( + f"Hallo {repair.customer_name},\n\n" + "der Status Ihrer Reparatur wurde aktualisiert.\n\n" + f"Reparaturnummer:\n{repair.repair_number}\n\n" + f"Gerät:\n{_device_label(repair)}\n\n" + f"Neuer Status:\n{status_label}\n\n" + f"Status online ansehen:\n{public_status_url}\n\n" + "Hinweis:\nBitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.\n\n" + "Funktechnik Schubert" + ) + + +def _html_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str: + return f""" + + + + + + +
+ + + + + + + + + + +
+
Olympus CRM
+
Funktechnik Schubert
+
+

Hallo {escape(repair.customer_name)},

+

der Status Ihrer Reparatur wurde aktualisiert.

+ + + + + + + + + + + + + +
Reparaturnummer{escape(repair.repair_number)}
Gerät{escape(_device_label(repair))}
Neuer Status{escape(status_label)}
+

+ Reparaturstatus ansehen +

+

Bitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.

+
Funktechnik Schubert
+
+ +""" + + class RepairNotificationService: @staticmethod def render_templates(repair: Repair, *, public_status_url: str = "") -> list[RepairNotificationTemplateResponse]: placeholders = { "repair_number": repair.repair_number, "customer_name": repair.customer_name, - "device": f"{repair.device_manufacturer} {repair.device_model}".strip(), + "device": _device_label(repair), "status_label": STATUS_LABELS.get(repair.status, repair.status), "public_status_url": public_status_url or "wird später bereitgestellt", "company_name": "Olympus CRM", @@ -124,3 +198,127 @@ class RepairNotificationService: templates=RepairNotificationService.render_templates(repair, public_status_url=public_status_url), events=RepairRepository.list_notification_events(db, repair.id), ) + + @staticmethod + def send_status_mail( + db: Session, + repair: Repair, + *, + actor: User | None, + request: Request | None, + manual: bool = False, + ) -> RepairNotificationEvent: + recipient = repair.customer_email.strip() + subject = f"Reparaturstatus {repair.repair_number} wurde aktualisiert" + status_label = MAIL_STATUS_LABELS.get(repair.status, repair.status) + template = "repair_status_update" + + if not recipient: + event = RepairRepository.create_notification_event( + db, + repair_id=repair.id, + event_type="repair_status_mail", + channel="email", + recipient="", + subject=subject, + template=template, + status="skipped", + success=False, + error_message="Keine Kunden-E-Mail hinterlegt", + ) + RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="missing_recipient") + return event + + link_action = "repairs.public_link.regenerate" if manual else "repairs.public_link.auto_create" + public_link = RepairPublicLinkService.create_with_audit(db, repair, actor=actor, request=request, audit_action=link_action) + public_status_url = public_link.public_status_path + + text = _plain_status_mail(repair, status_label=status_label, public_status_url=public_status_url) + html = _html_status_mail(repair, status_label=status_label, public_status_url=public_status_url) + + if not _smtp_configured(): + event = RepairRepository.create_notification_event( + db, + repair_id=repair.id, + event_type="repair_status_mail", + channel="email", + recipient=recipient, + subject=subject, + template=template, + status="skipped", + success=False, + error_message="SMTP nicht konfiguriert", + ) + RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_not_configured") + return event + + try: + RepairNotificationService._send_email(recipient=recipient, subject=subject, text=text, html=html) + except Exception: + event = RepairRepository.create_notification_event( + db, + repair_id=repair.id, + event_type="repair_status_mail", + channel="email", + recipient=recipient, + subject=subject, + template=template, + status="failed", + success=False, + error_message="Statusmail konnte nicht versendet werden", + ) + RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_send_failed") + return event + + event = RepairRepository.create_notification_event( + db, + repair_id=repair.id, + event_type="repair_status_mail", + channel="email", + recipient=recipient, + subject=subject, + template=template, + status="sent", + success=True, + sent_at=datetime.now(UTC), + ) + write_audit_log( + db, + action="repairs.status_mail.sent", + entity_type="repairs", + entity_id=repair.id, + entity_label=_repair_label(repair), + actor=actor, + request=request, + metadata={"notification_event_id": event.id}, + ) + return event + + @staticmethod + def _send_email(*, recipient: str, subject: str, text: str, html: str) -> None: + message = EmailMessage() + message["Subject"] = subject + message["From"] = _from_header() + message["To"] = recipient + message.set_content(text) + message.add_alternative(html, subtype="html") + + with smtplib.SMTP(str(settings.smtp_host), settings.smtp_port, timeout=15) as smtp: + if settings.smtp_use_tls: + smtp.starttls() + if settings.smtp_username and settings.smtp_password: + smtp.login(settings.smtp_username, settings.smtp_password) + smtp.send_message(message) + + @staticmethod + def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None: + write_audit_log( + db, + action="repairs.status_mail.failed", + entity_type="repairs", + entity_id=repair.id, + entity_label=_repair_label(repair), + actor=actor, + request=request, + metadata={"reason": reason}, + ) diff --git a/backend/hermes/app/services/repair_public_link_service.py b/backend/hermes/app/services/repair_public_link_service.py index c0d9bfc..3b0e499 100644 --- a/backend/hermes/app/services/repair_public_link_service.py +++ b/backend/hermes/app/services/repair_public_link_service.py @@ -18,14 +18,17 @@ from app.schemas.repair import ( RepairPublicStatusResponse, ) from app.services.audit_service import write_audit_log -from app.services.repair_notification_service import STATUS_LABELS -from app.services.repair_service import repair_label +from app.services.repair_status_labels import STATUS_LABELS def normalize_token(token: str) -> str: return token.strip() +def repair_label(repair: Repair) -> str: + return f"{repair.repair_number} · {repair.customer_name}" + + def public_status_path(token: str) -> str: normalized_token = normalize_token(token) path = f"/status/{normalized_token}" @@ -64,6 +67,23 @@ class RepairPublicLinkService: @staticmethod def create(db: Session, repair: Repair, *, actor: User, request: Request) -> RepairPublicLinkCreateResponse: + return RepairPublicLinkService.create_with_audit( + db, + repair, + actor=actor, + request=request, + audit_action="repairs.public_link.create", + ) + + @staticmethod + def create_with_audit( + db: Session, + repair: Repair, + *, + actor: User | None, + request: Request | None, + audit_action: str, + ) -> RepairPublicLinkCreateResponse: token = RepairPublicLinkService.create_token() public_link = RepairRepository.create_public_link( db, @@ -73,7 +93,7 @@ class RepairPublicLinkService: ) write_audit_log( db, - action="repairs.public_link.create", + action=audit_action, entity_type="repairs", entity_id=repair.id, entity_label=repair_label(repair), diff --git a/backend/hermes/app/services/repair_service.py b/backend/hermes/app/services/repair_service.py index d84c9f4..5806831 100644 --- a/backend/hermes/app/services/repair_service.py +++ b/backend/hermes/app/services/repair_service.py @@ -11,6 +11,7 @@ from app.repositories.customer_repository import CustomerRepository from app.repositories.repair_repository import RepairRepository from app.schemas.repair import RepairCreate, RepairIntakePayload, RepairStatusUpdate, RepairUpdate from app.services.audit_service import sanitize, write_audit_log +from app.services.repair_notification_service import RepairNotificationService def repair_label(repair: Repair) -> str: @@ -74,6 +75,19 @@ class RepairService: after_data=updated, metadata={"old_status": before_data.get("status") if isinstance(before_data, dict) else None, "new_status": updated.status}, ) + try: + RepairNotificationService.send_status_mail(db, updated, actor=actor, request=request) + except Exception: + write_audit_log( + db, + action="repairs.status_mail.failed", + entity_type="repairs", + entity_id=updated.id, + entity_label=repair_label(updated), + actor=actor, + request=request, + metadata={"reason": "unexpected_notification_error"}, + ) return updated @staticmethod diff --git a/backend/hermes/app/services/repair_status_labels.py b/backend/hermes/app/services/repair_status_labels.py new file mode 100644 index 0000000..cfd98c8 --- /dev/null +++ b/backend/hermes/app/services/repair_status_labels.py @@ -0,0 +1,29 @@ +STATUS_LABELS: dict[str, str] = { + "new": "Neu", + "accepted": "Angenommen", + "diagnosis": "Diagnose", + "estimate": "Kostenvoranschlag", + "waiting_for_customer": "Wartet auf Kunde", + "approved": "Freigegeben", + "repair": "Reparatur", + "final_test": "Endprüfung", + "ready_for_pickup": "Abholbereit", + "shipped": "Versand", + "completed": "Abgeschlossen", + "cancelled": "Storniert", +} + +MAIL_STATUS_LABELS: dict[str, str] = { + "new": "Neu eingegangen", + "accepted": "Reparatur angenommen", + "diagnosis": "Gerät in Diagnose", + "estimate": "Kostenvoranschlag erstellt", + "waiting_for_customer": "Wartet auf Ihre Rückmeldung", + "approved": "Reparatur freigegeben", + "repair": "Reparatur wird durchgeführt", + "final_test": "Gerät wird geprüft", + "ready_for_pickup": "Gerät ist abholbereit", + "shipped": "Gerät wurde versendet", + "completed": "Reparatur abgeschlossen", + "cancelled": "Reparatur wurde storniert", +} diff --git a/docker-compose.yml b/docker-compose.yml index 65426d1..8f660ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,13 @@ services: KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-} PUBLIC_REPAIR_STATUS_BASE_URL: ${PUBLIC_REPAIR_STATUS_BASE_URL:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} + SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Funktechnik Schubert} + SMTP_USE_TLS: ${SMTP_USE_TLS:-true} volumes: - ${STORAGE_HOST_PATH:-./storage}:/data/storage diff --git a/frontend/athena/app/api/repairs/[id]/send-status-mail/route.ts b/frontend/athena/app/api/repairs/[id]/send-status-mail/route.ts new file mode 100644 index 0000000..143b2d1 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/send-status-mail/route.ts @@ -0,0 +1,21 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/send-status-mail`); +} diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx index 4bcd88f..ebff51b 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; -import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, ShieldCheck, Wrench, XCircle } from "lucide-react"; +import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wrench, XCircle } from "lucide-react"; import DetailSection from "@/components/common/DetailSection"; import { useToast } from "@/components/common/ToastProvider"; @@ -137,11 +137,15 @@ export default function RepairDetailPage({ params }: Params) { setStatusError(""); try { const response = await api.put(`/repairs/${repair.id}/status`, { status, note }); - const historyResponse = await api.get(`/repairs/${repair.id}/history`); - const notificationResponse = await api.get(`/repairs/${repair.id}/notifications`); + const [historyResponse, notificationResponse, publicLinkResponse] = await Promise.all([ + api.get(`/repairs/${repair.id}/history`), + api.get(`/repairs/${repair.id}/notifications`), + canManagePublicLink ? api.get(`/repairs/${repair.id}/public-link`) : Promise.resolve(null), + ]); setRepair(response.data); setHistory(historyResponse.data); setNotifications(notificationResponse.data); + if (publicLinkResponse) setPublicLink(publicLinkResponse.data); setStatusOpen(false); showToast({ type: "success", title: "Status aktualisiert", description: response.data.repair_number }); } catch (err) { @@ -197,6 +201,27 @@ export default function RepairDetailPage({ params }: Params) { } } + async function sendStatusMail() { + if (!repair || !canUpdate) return; + setCommunicationPending(true); + try { + await api.post(`/repairs/${repair.id}/send-status-mail`); + const [notificationResponse, publicLinkResponse] = await Promise.all([ + api.get(`/repairs/${repair.id}/notifications`), + canManagePublicLink ? api.get(`/repairs/${repair.id}/public-link`) : Promise.resolve(null), + ]); + setNotifications(notificationResponse.data); + if (publicLinkResponse) setPublicLink(publicLinkResponse.data); + setLastCreatedStatusPath(""); + showToast({ type: "success", title: "Statusmail verarbeitet", description: "Der Versandversuch wurde dokumentiert." }); + } catch (err) { + const message = getErrorMessage(err); + showToast({ type: "error", title: "Statusmail konnte nicht verarbeitet werden", description: message }); + } finally { + setCommunicationPending(false); + } + } + async function copyPublicLink() { if (!lastCreatedStatusPath) { showToast({ type: "error", title: "Kein Klartextlink verfügbar", description: "Der sichere Link wird nur direkt nach der Erstellung angezeigt." }); @@ -307,6 +332,9 @@ export default function RepairDetailPage({ params }: Params) { + {(() => { + const lastMail = notifications?.events[0] ?? null; + return (
@@ -322,6 +350,12 @@ export default function RepairDetailPage({ params }: Params) { + + + + + + {lastCreatedStatusPath && (
@@ -329,13 +363,17 @@ export default function RepairDetailPage({ params }: Params) {

{lastCreatedStatusPath}

)} - {canManagePublicLink ? ( -
- +
+ {canUpdate && } + {canManagePublicLink && ( + <> + -
- ) : ( + + )} +
+ {!canManagePublicLink && (

Für die Verwaltung des Statuslinks ist `repairs.public_link.manage` erforderlich.

)}
@@ -343,7 +381,26 @@ export default function RepairDetailPage({ params }: Params) {
- Vorbereitete Benachrichtigungen + Benachrichtigungen +
+
+

Versandhistorie

+ {notifications?.events.length ? ( +
+ {notifications.events.slice(0, 5).map((event) => ( +
+
+

{event.subject}

+ {event.success ? "Erfolgreich" : event.status} +
+

{formatDate(event.created_at)} · {event.recipient || "Kein Empfänger"}

+ {event.error_message &&

{event.error_message}

} +
+ ))} +
+ ) : ( +

Noch kein Versandversuch dokumentiert.

+ )}
{notifications?.templates.map((template) => ( @@ -360,11 +417,13 @@ export default function RepairDetailPage({ params }: Params) {
-

Notification Events werden gespeichert, sobald später echter E-Mail-Versand angebunden wird. Interne Notizen werden nicht öffentlich ausgegeben.

+

Statusmails werden beim Statuswechsel automatisch verarbeitet. Interne Notizen werden nicht öffentlich ausgegeben.

+ ); + })()} diff --git a/frontend/athena/types/repair.ts b/frontend/athena/types/repair.ts index 8b41c60..584a4d5 100644 --- a/frontend/athena/types/repair.ts +++ b/frontend/athena/types/repair.ts @@ -120,7 +120,9 @@ export interface RepairNotificationEvent { channel: string; recipient: string; subject: string; + template: string; status: "pending" | "sent" | "failed" | "skipped"; + success: boolean; error_message: string | null; created_at: string; sent_at: string | null;