feat(repairs): automatic repair status emails
This commit is contained in:
parent
3ed6596bf0
commit
c58e212e3a
21 changed files with 525 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ Die spaetere Website kann daraus einen Pfad wie `/status/<token>` 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/<token>` 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`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
13
ROADMAP.md
13
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""<!doctype html>
|
||||
<html lang="de">
|
||||
<body style="margin:0;background:#f4f7fb;font-family:Arial,Helvetica,sans-serif;color:#172033;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border:1px solid #dce5ef;border-radius:8px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:#082a60;color:#ffffff;padding:24px 28px;">
|
||||
<div style="font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;">Olympus CRM</div>
|
||||
<div style="font-size:24px;font-weight:800;margin-top:6px;">Funktechnik Schubert</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px;">
|
||||
<p style="margin:0 0 18px;font-size:16px;">Hallo {escape(repair.customer_name)},</p>
|
||||
<p style="margin:0 0 22px;font-size:16px;line-height:1.55;">der Status Ihrer Reparatur wurde aktualisiert.</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;margin:0 0 24px;">
|
||||
<tr>
|
||||
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;">Reparaturnummer</td>
|
||||
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;">{escape(repair.repair_number)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;border-top:1px solid #e7edf5;">Gerät</td>
|
||||
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;border-top:1px solid #e7edf5;">{escape(_device_label(repair))}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 0;color:#657184;font-size:13px;font-weight:700;text-transform:uppercase;border-top:1px solid #e7edf5;">Neuer Status</td>
|
||||
<td style="padding:10px 0;text-align:right;font-size:16px;font-weight:800;border-top:1px solid #e7edf5;">{escape(status_label)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 24px;">
|
||||
<a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#ffffff;text-decoration:none;font-weight:800;border-radius:8px;padding:13px 18px;">Reparaturstatus ansehen</a>
|
||||
</p>
|
||||
<p style="margin:0;color:#657184;font-size:14px;line-height:1.5;">Bitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:18px 28px;background:#f8fafc;color:#657184;font-size:13px;">Funktechnik Schubert</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
29
backend/hermes/app/services/repair_status_labels.py
Normal file
29
backend/hermes/app/services/repair_status_labels.py
Normal file
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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<Repair>(`/repairs/${repair.id}/status`, { status, note });
|
||||
const historyResponse = await api.get<RepairStatusHistory[]>(`/repairs/${repair.id}/history`);
|
||||
const notificationResponse = await api.get<RepairNotificationOverview>(`/repairs/${repair.id}/notifications`);
|
||||
const [historyResponse, notificationResponse, publicLinkResponse] = await Promise.all([
|
||||
api.get<RepairStatusHistory[]>(`/repairs/${repair.id}/history`),
|
||||
api.get<RepairNotificationOverview>(`/repairs/${repair.id}/notifications`),
|
||||
canManagePublicLink ? api.get<RepairPublicLink>(`/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<RepairNotificationOverview>(`/repairs/${repair.id}/notifications`),
|
||||
canManagePublicLink ? api.get<RepairPublicLink>(`/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) {
|
|||
</DetailSection>
|
||||
|
||||
<DetailSection title="Kundenkommunikation">
|
||||
{(() => {
|
||||
const lastMail = notifications?.events[0] ?? null;
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_1.3fr]">
|
||||
<div className="space-y-4 rounded-lg border bg-slate-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -322,6 +350,12 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
<DetailItem label="Token-Hinweis" value={publicLink?.token_hint ? `...${publicLink.token_hint}` : "-"} />
|
||||
<DetailItem label="Zuletzt verwendet" value={formatDate(publicLink?.last_used_at)} />
|
||||
<DetailItem label="Erstellt" value={formatDate(publicLink?.created_at)} />
|
||||
<DetailItem label="Letzter Versand" value={formatDate(lastMail?.created_at)} />
|
||||
<DetailItem label="Letzte Mail" value={lastMail?.subject} />
|
||||
<DetailItem label="Empfänger" value={lastMail?.recipient} />
|
||||
<DetailItem label="Mail-Status" value={lastMail?.status} />
|
||||
<DetailItem label="Versand erfolgreich" value={lastMail ? lastMail.success : null} />
|
||||
<DetailItem label="Fehlermeldung" value={lastMail?.error_message} />
|
||||
</dl>
|
||||
{lastCreatedStatusPath && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
|
|
@ -329,13 +363,17 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
<p className="mt-1 break-all">{lastCreatedStatusPath}</p>
|
||||
</div>
|
||||
)}
|
||||
{canManagePublicLink ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={createPublicLink} disabled={communicationPending}><Link2 size={16} />Statuslink erstellen</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canUpdate && <Button type="button" onClick={sendStatusMail} disabled={communicationPending || !repair.customer_email}><Send size={16} />E-Mail erneut senden</Button>}
|
||||
{canManagePublicLink && (
|
||||
<>
|
||||
<Button type="button" onClick={createPublicLink} disabled={communicationPending}><Link2 size={16} />Statuslink neu erzeugen</Button>
|
||||
<Button type="button" variant="outline" onClick={copyPublicLink} disabled={!lastCreatedStatusPath || communicationPending}><Copy size={16} />Link kopieren</Button>
|
||||
<Button type="button" variant="destructive" onClick={revokePublicLink} disabled={!publicLink?.is_active || communicationPending}>Statuslink deaktivieren</Button>
|
||||
</div>
|
||||
) : (
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!canManagePublicLink && (
|
||||
<p className="text-sm text-slate-500">Für die Verwaltung des Statuslinks ist `repairs.public_link.manage` erforderlich.</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -343,7 +381,26 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-950">
|
||||
<Mail size={16} />
|
||||
Vorbereitete Benachrichtigungen
|
||||
Benachrichtigungen
|
||||
</div>
|
||||
<div className="rounded-lg border bg-white p-3">
|
||||
<p className="font-medium text-slate-950">Versandhistorie</p>
|
||||
{notifications?.events.length ? (
|
||||
<div className="mt-3 grid gap-2">
|
||||
{notifications.events.slice(0, 5).map((event) => (
|
||||
<div key={event.id} className="rounded-lg border bg-slate-50 p-3 text-sm">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-medium text-slate-950">{event.subject}</p>
|
||||
<span className={event.success ? "text-emerald-700" : "text-amber-700"}>{event.success ? "Erfolgreich" : event.status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-slate-500">{formatDate(event.created_at)} · {event.recipient || "Kein Empfänger"}</p>
|
||||
{event.error_message && <p className="mt-1 text-red-600">{event.error_message}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-500">Noch kein Versandversuch dokumentiert.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{notifications?.templates.map((template) => (
|
||||
|
|
@ -360,11 +417,13 @@ export default function RepairDetailPage({ params }: Params) {
|
|||
<div className="rounded-lg border bg-slate-50 p-3 text-sm text-slate-500">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldCheck className="mt-0.5 text-slate-500" size={16} />
|
||||
<p>Notification Events werden gespeichert, sobald später echter E-Mail-Versand angebunden wird. Interne Notizen werden nicht öffentlich ausgegeben.</p>
|
||||
<p>Statusmails werden beim Statuswechsel automatisch verarbeitet. Interne Notizen werden nicht öffentlich ausgegeben.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Audit und Aktivität vorbereitet">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue