From 3ed6596bf094eaa676169a136bf9585fbb03896f Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 22:19:28 +0200 Subject: [PATCH 01/15] fix(repairs): normalize public status tokens --- .DS_Store | Bin 10244 -> 10244 bytes .env.example | 4 +++ ARCHITECTURE.md | 3 +++ README-DEV.md | 2 ++ backend/.DS_Store | Bin 6148 -> 6148 bytes backend/hermes/.env.example | 1 + backend/hermes/app/core/config.py | 1 + .../app/repositories/repair_repository.py | 2 ++ .../services/repair_public_link_service.py | 25 ++++++++++++++---- docker-compose.yml | 1 + funktechnik-schubert-website | 2 +- 11 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.DS_Store b/.DS_Store index 3ff066b0858b464c49971b4718339d80c3ee2cab..93d72f91b40a1d3fb738c840f185876e103de066 100644 GIT binary patch delta 1359 zcmaKsyGz4R6vj_AnogP)1rdA{#KA#u5qwusP!|hzbg&Pt#Wo3T6m+?zI;#-f-JBdG zAc$gdQFQR}59lOr;;asyo7&{GX&UGayyyGvbTHkfdf z<6vHhW1NOR4wi)#io^~0I6M?8aQWDC`)xv1@!43WkGUAjDpm{?5KO^JWvc*L8U=}* zTOdTY0l41vPih$|g7sc0p!Z2WMnk8PAFXB;Rw08(GFHnxJQ{xZI92~z+ML5@L)7vS zmIj!62!j6BGHT=`kEi*+nR#W}2R}Pdm@syHdyOrWp^$$%&0(wZX(-sCRFFstbOx8r z5Vjf4$SFlm;QDxbpb2k?4162BB{Y0*;7MpWW~w$wO+Q5(zp&1T&S8!aitjSXF^BGq zR|+2y@nZ;`1QqcsT1o0JIhbvswE3%{cd>Z)D;A=7?yhp6PzgxGCgPP%ShZv-*C1T@ t!jVT23k4cmwEm17F8jFMU1dPwXET%!XWRZdL+t(*^yAhUW8QyV_Xm;6ReAsb delta 104 zcmZn(XbG6$FUrQiz`)4BAi%(o%8=O%IHnS`IVwrqjT57Yn#7ias DNt_*- diff --git a/.env.example b/.env.example index e6e06b3..e7aca6c 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,7 @@ KNOWLEDGE_MAX_UPLOAD_MB=50 # Server-zu-Server Token fuer spaetere Website-Reparaturannahme. # Produktiv als langen Zufallswert setzen. Nicht im Frontend verwenden. OLYMPUS_REPAIR_INTAKE_TOKEN= + +# Optionale Website-Basis-URL fuer oeffentliche Reparaturstatuslinks. +# Beispiel: https://test.funktechnik-schubert.de/status +PUBLIC_REPAIR_STATUS_BASE_URL= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 65f6edf..d98233b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -893,6 +893,9 @@ docker-compose.yml `OLYMPUS_REPAIR_INTAKE_TOKEN` : Server-zu-Server Token fuer `POST /public/repair-intake`. Dieser Wert darf nicht im Frontend oder in Logs erscheinen. +`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. + ### Athena `HERMES_INTERNAL_URL` diff --git a/README-DEV.md b/README-DEV.md index 0ad90a3..f9c95d3 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -34,6 +34,7 @@ STORAGE_BASE_PATH=/data/storage STORAGE_MAX_UPLOAD_MB=50 STORAGE_HOST_PATH=./storage OLYMPUS_REPAIR_INTAKE_TOKEN= +PUBLIC_REPAIR_STATUS_BASE_URL= ``` Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git. @@ -159,6 +160,7 @@ Statuslink-Konzept: - RBAC-Permission: `repairs.public_link.manage`. - Tokens sind lang, zufaellig und werden nur gehasht gespeichert. - Der Klartexttoken wird nur einmal bei Erstellung zurueckgegeben. +- `PUBLIC_REPAIR_STATUS_BASE_URL` kann auf die Website-Route zeigen, z. B. `https://test.funktechnik-schubert.de/status`. - Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`. - Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen. diff --git a/backend/.DS_Store b/backend/.DS_Store index 7ff34d41d8fdf143294a2b3adebef521fa5b7ed9..90bfa15157e6684f07ea491ff927cf1a5f690936 100644 GIT binary patch delta 203 zcmZoMXfc=|#>B`mu~2NHo+2ab!~pAw2O5BMk4!xqLk2@CLlHwRkSxwgHw;eB&n;j8 z0v79yg+K~LJ~!XRB`GIA2`J9-tEQgg-|yp&m}*i8swv2T*~1F7Xlla7jnQnI**W+* hfR5a_@jLTmei2Vtn|(xfFi&h?0RW~HG~)mO delta 73 zcmZoMXfc=|#>CJzu~2NHo+2aT!~knX#>qU4G7~cvZ$7~2&Ni{3bTc~#KL=3F=8cTs cnJ4p$=yEbLFfbedVur~!JkpzEM3yiE0Q)c#djJ3c diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index 33eff2d..eabbdf7 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -19,3 +19,4 @@ STORAGE_MAX_UPLOAD_MB=50 KNOWLEDGE_STORAGE_PATH=/data/knowledge KNOWLEDGE_MAX_UPLOAD_MB=50 OLYMPUS_REPAIR_INTAKE_TOKEN= +PUBLIC_REPAIR_STATUS_BASE_URL= diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index ba364cf..ab59fa3 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -24,6 +24,7 @@ class Settings(BaseSettings): knowledge_storage_path: str = "/data/knowledge" knowledge_max_upload_mb: int = 50 olympus_repair_intake_token: str | None = None + public_repair_status_base_url: str | None = None model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/repositories/repair_repository.py b/backend/hermes/app/repositories/repair_repository.py index 6ec0e2d..51436f1 100644 --- a/backend/hermes/app/repositories/repair_repository.py +++ b/backend/hermes/app/repositories/repair_repository.py @@ -221,6 +221,7 @@ class RepairRepository: select(RepairPublicAccessToken) .where(RepairPublicAccessToken.repair_id == repair_id) .where(RepairPublicAccessToken.is_active.is_(True)) + .where(RepairPublicAccessToken.revoked_at.is_(None)) .where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now)) .order_by(RepairPublicAccessToken.created_at.desc(), RepairPublicAccessToken.id.desc()) .limit(1) @@ -234,6 +235,7 @@ class RepairRepository: .options(selectinload(RepairPublicAccessToken.repair)) .where(RepairPublicAccessToken.token_hash == token_hash) .where(RepairPublicAccessToken.is_active.is_(True)) + .where(RepairPublicAccessToken.revoked_at.is_(None)) .where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now)) .limit(1) ) diff --git a/backend/hermes/app/services/repair_public_link_service.py b/backend/hermes/app/services/repair_public_link_service.py index 5697582..c0d9bfc 100644 --- a/backend/hermes/app/services/repair_public_link_service.py +++ b/backend/hermes/app/services/repair_public_link_service.py @@ -22,14 +22,24 @@ from app.services.repair_notification_service import STATUS_LABELS from app.services.repair_service import repair_label +def normalize_token(token: str) -> str: + return token.strip() + + def public_status_path(token: str) -> str: - return f"/status/{token}" + normalized_token = normalize_token(token) + path = f"/status/{normalized_token}" + base_url = (settings.public_repair_status_base_url or "").strip().rstrip("/") + if not base_url: + return path + return f"{base_url}/{normalized_token}" class RepairPublicLinkService: @staticmethod def hash_token(token: str) -> str: - return hmac.new(settings.secret_key.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest() + normalized_token = normalize_token(token) + return hmac.new(settings.secret_key.encode("utf-8"), normalized_token.encode("utf-8"), hashlib.sha256).hexdigest() @staticmethod def create_token() -> str: @@ -100,14 +110,17 @@ class RepairPublicLinkService: @staticmethod def public_status(db: Session, token: str) -> RepairPublicStatusResponse: - public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(token)) + normalized_token = normalize_token(token) + if not normalized_token: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") + + public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token)) if public_link is None or public_link.repair is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden") - RepairRepository.mark_public_link_used(db, public_link) repair = public_link.repair history = RepairRepository.get_history_public(db, repair.id) - return RepairPublicStatusResponse( + response = RepairPublicStatusResponse( repair_number=repair.repair_number, public_status_label=STATUS_LABELS.get(repair.status, repair.status), device_manufacturer=repair.device_manufacturer, @@ -122,3 +135,5 @@ class RepairPublicLinkService: ], updated_at=repair.updated_at, ) + RepairRepository.mark_public_link_used(db, public_link) + return response diff --git a/docker-compose.yml b/docker-compose.yml index 36aa4e5..65426d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} 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:-} volumes: - ${STORAGE_HOST_PATH:-./storage}:/data/storage diff --git a/funktechnik-schubert-website b/funktechnik-schubert-website index a284bbc..efdc4d5 160000 --- a/funktechnik-schubert-website +++ b/funktechnik-schubert-website @@ -1 +1 @@ -Subproject commit a284bbc1886b26091bea7ea7a5aacee71b4507fe +Subproject commit efdc4d5be10e921f15eaa6a8de7547e0d8893dc4 From c58e212e3a6df82f009bc018984aab6b80317893 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 22:58:47 +0200 Subject: [PATCH 02/15] 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; From 884a20e0430d6db998b747b5918b969d7eb5a57f Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 4 Jul 2026 23:54:20 +0200 Subject: [PATCH 03/15] feat(settings): add SMTP admin configuration --- .env.example | 7 +- ARCHITECTURE.md | 30 +- README-DEV.md | 8 +- ROADMAP.md | 12 +- backend/hermes/.env.example | 4 + backend/hermes/alembic/env.py | 1 + .../0a247aadcfda_create_users.cpython-314.pyc | Bin 2940 -> 2927 bytes .../f2b8d4e6a910_create_system_settings.py | 69 ++++ backend/hermes/app/api/dashboard.py | 43 ++- backend/hermes/app/api/system_settings.py | 67 ++++ backend/hermes/app/db/database.py | 2 + backend/hermes/app/main.py | 2 + backend/hermes/app/models/system_setting.py | 21 ++ backend/hermes/app/rbac/defaults.py | 1 + .../app/repositories/repair_repository.py | 11 + .../system_settings_repository.py | 35 ++ backend/hermes/app/schemas/dashboard.py | 7 + backend/hermes/app/schemas/system_setting.py | 70 ++++ backend/hermes/app/services/audit_service.py | 4 + .../services/repair_notification_service.py | 35 +- .../services/repair_public_link_service.py | 7 +- .../app/services/system_settings_service.py | 316 ++++++++++++++++ docker-compose.yml | 1 + .../api/system-settings/public-links/route.ts | 18 + .../app/api/system-settings/smtp/route.ts | 18 + .../api/system-settings/smtp/test/route.ts | 14 + frontend/athena/app/dashboard/page.tsx | 38 ++ frontend/athena/app/settings/page.tsx | 356 ++++++++++++++++++ frontend/athena/components/Sidebar.tsx | 1 + frontend/athena/proxy.ts | 1 + frontend/athena/types/dashboard.ts | 2 + frontend/athena/types/system-settings.ts | 41 ++ 32 files changed, 1200 insertions(+), 42 deletions(-) create mode 100644 backend/hermes/alembic/versions/f2b8d4e6a910_create_system_settings.py create mode 100644 backend/hermes/app/api/system_settings.py create mode 100644 backend/hermes/app/models/system_setting.py create mode 100644 backend/hermes/app/repositories/system_settings_repository.py create mode 100644 backend/hermes/app/schemas/system_setting.py create mode 100644 backend/hermes/app/services/system_settings_service.py create mode 100644 frontend/athena/app/api/system-settings/public-links/route.ts create mode 100644 frontend/athena/app/api/system-settings/smtp/route.ts create mode 100644 frontend/athena/app/api/system-settings/smtp/test/route.ts create mode 100644 frontend/athena/app/settings/page.tsx create mode 100644 frontend/athena/types/system-settings.ts diff --git a/.env.example b/.env.example index 86fc45b..afd6786 100644 --- a/.env.example +++ b/.env.example @@ -29,11 +29,14 @@ KNOWLEDGE_MAX_UPLOAD_MB=50 # Produktiv als langen Zufallswert setzen. Nicht im Frontend verwenden. OLYMPUS_REPAIR_INTAKE_TOKEN= -# Optionale Website-Basis-URL fuer oeffentliche Reparaturstatuslinks. +# Env-Fallback fuer oeffentliche Reparaturstatuslinks. +# Bevorzugt wird die Admin-Konfiguration unter /settings. # Beispiel: https://test.funktechnik-schubert.de/status PUBLIC_REPAIR_STATUS_BASE_URL= -# Optionaler SMTP-Versand fuer automatische Reparaturstatusmails. +# Env-Fallback fuer SMTP. Bevorzugt wird die Admin-Konfiguration unter /settings. +# Fuer Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv, +# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort. SMTP_HOST= SMTP_PORT=587 SMTP_USERNAME= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 749cee0..ce893db 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -290,12 +290,38 @@ Vorlagen enthalten deutsche Betreffzeilen und Texte mit Platzhaltern fuer `repai 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. +Ab v0.8.4 wird SMTP bevorzugt ueber `system_settings` aus PostgreSQL geladen. Nur wenn die Admin-Konfiguration deaktiviert, unvollstaendig oder nicht vorhanden ist, nutzt Hermes die Env Vars als Fallback. Das SMTP-Passwort wird nie an Athena zurueckgegeben; die API liefert nur `password_is_set`. Verschluesselung at rest ist in der Struktur vorbereitet und fuer v0.8.x als Sicherheitsausbau geplant. + 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. +### System Settings + +`system_settings` speichert zentrale Betriebswerte als Key-Value-Daten: + +- `smtp.host` +- `smtp.port` +- `smtp.username` +- `smtp.password` +- `smtp.from_email` +- `smtp.from_name` +- `smtp.use_tls` +- `smtp.enabled` +- `public.repair_status_base_url` + +Hermes-Endpunkte: + +- `GET /system-settings/smtp` +- `PUT /system-settings/smtp` +- `POST /system-settings/smtp/test` +- `GET /system-settings/public-links` +- `PUT /system-settings/public-links` + +Athena ruft diese Endpunkte ausschliesslich ueber BFF-Routen unter `/api/system-settings/...` auf. Mutierende Requests verwenden den bestehenden Same-Origin-Schutz. Zugriff erfordert `system_settings.manage`, das der Administratorrolle zugewiesen ist. + ### Website Repair Intake Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit. @@ -902,10 +928,10 @@ docker-compose.yml : Server-zu-Server Token fuer `POST /public/repair-intake`. Dieser Wert darf nicht im Frontend oder in Logs erscheinen. `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. +: Env-Fallback fuer von Olympus erzeugte oeffentliche Reparaturstatuslinks, z. B. `https://test.funktechnik-schubert.de/status`. Bevorzugt wird `public.repair_status_base_url` aus der Admin-Konfiguration. Wenn beides leer ist, 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. +: Env-Fallback fuer automatische Reparaturstatusmails. Bevorzugt wird die Admin-Konfiguration unter `/settings`. `SMTP_PASSWORD` darf nicht geloggt, committed oder an Athena zurueckgegeben werden. Fuer Apple Mail/iCloud: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername vollstaendige Mailadresse, Passwort app-spezifisches Passwort. ### Athena diff --git a/README-DEV.md b/README-DEV.md index 275140b..3477ade 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -44,6 +44,8 @@ SMTP_FROM_NAME=Funktechnik Schubert SMTP_USE_TLS=true ``` +`PUBLIC_REPAIR_STATUS_BASE_URL` und die SMTP-Werte sind ab v0.8.4 Fallbacks. Bevorzugt wird die Admin-Konfiguration in Olympus unter `/settings`. Fuer Apple Mail/iCloud gilt: `smtp.mail.me.com`, Port `587`, TLS/STARTTLS aktiv, Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort. + Wenn `SECRET_KEY` Sonderzeichen wie `$` enthaelt, den Wert in der Shell oder Compose-Umgebung korrekt quoten. Secrets gehoeren nicht ins Git. ## Docker Netzwerk @@ -169,6 +171,7 @@ Statuslink-Konzept: - Tokens sind lang, zufaellig und werden nur gehasht gespeichert. - Der Klartexttoken wird nur einmal bei Erstellung zurueckgegeben. - `PUBLIC_REPAIR_STATUS_BASE_URL` kann auf die Website-Route zeigen, z. B. `https://test.funktechnik-schubert.de/status`. +- Ab v0.8.4 kann die Basis-URL im Adminbereich unter `/settings` gepflegt werden; die Env Var bleibt Fallback. - Oeffentliche Statusdaten kommen spaeter ueber `GET /public/repairs/status/{token}`. - Die Antwort enthaelt keine Kundendaten, keine internen Notizen und keine nicht freigegebenen Diagnosen. @@ -178,8 +181,9 @@ Benachrichtigungen: - `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. +- SMTP wird bevorzugt ueber `/settings` konfiguriert. `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME` und `SMTP_USE_TLS` bleiben als Env-Fallback erhalten. +- `SMTP_PASSWORD` wird nicht an Athena zurueckgegeben und gehoert nie ins Git oder in Logs. +- Verschluesselung at rest ist fuer v0.8.x vorbereitet/geplant; bis dahin bleibt das Passwort serverseitig in `system_settings` und wird in Responses/Audits maskiert. Vorbereitete Website-/Portal-Routen fuer spaeter: diff --git a/ROADMAP.md b/ROADMAP.md index dbddbd3..6699f66 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,7 +113,17 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Dashboard-Kennzahlen fuer Statusmails und Reparaturen ohne Kundenmail - SMTP-Ausfall blockiert keine Reparaturprozesse -## v0.8.4 - Kundenportal, geplant +## v0.8.4 - Admin SMTP Settings + +- SMTP-Konfiguration im Olympus-Webinterface unter `/settings` +- Testmail-Versand mit Audit-Log +- `system_settings` als zentrale Tabelle fuer Systemkonfiguration +- Env Vars bleiben als Fallback fuer SMTP und Public Repair Status Base URL +- Statusmail-Versand nutzt bevorzugt Admin-Konfiguration +- Passwort wird nicht an Athena zurueckgegeben +- Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant + +## v0.8.5 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index 805a702..6f7a6d0 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -19,7 +19,11 @@ STORAGE_MAX_UPLOAD_MB=50 KNOWLEDGE_STORAGE_PATH=/data/knowledge KNOWLEDGE_MAX_UPLOAD_MB=50 OLYMPUS_REPAIR_INTAKE_TOKEN= +# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings. PUBLIC_REPAIR_STATUS_BASE_URL= +# Env-Fallback. Bevorzugt wird die Admin-Konfiguration unter /settings. +# Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS aktiv, +# Benutzername = vollstaendige Mailadresse, Passwort = app-spezifisches Passwort. SMTP_HOST= SMTP_PORT=587 SMTP_USERNAME= diff --git a/backend/hermes/alembic/env.py b/backend/hermes/alembic/env.py index fb89de0..1f62d94 100644 --- a/backend/hermes/alembic/env.py +++ b/backend/hermes/alembic/env.py @@ -13,6 +13,7 @@ import app.models.knowledge import app.models.audit import app.models.rbac import app.models.repair +import app.models.system_setting config = context.config diff --git a/backend/hermes/alembic/versions/__pycache__/0a247aadcfda_create_users.cpython-314.pyc b/backend/hermes/alembic/versions/__pycache__/0a247aadcfda_create_users.cpython-314.pyc index 795e2da7863f86962bc8f46585451d106f235fd2..683357331166a9ee87c7de1e524d30aea1ce2730 100644 GIT binary patch delta 47 zcmew(_Fjxjn~#@^0SJ_vHgYvFibd&%7N-^!>lY_ylqRJXm87K>rRF7XUe9=z697Mh B4?zF` delta 60 zcmaDa_D76Mn~#@^0SGS0Y~*TUR8H3qElw>e)=x_ None: + op.create_table( + "system_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("key", sa.String(length=160), nullable=False), + sa.Column("value", sa.Text(), server_default="", nullable=False), + sa.Column("is_secret", sa.Boolean(), server_default="false", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("key"), + ) + op.create_index(op.f("ix_system_settings_key"), "system_settings", ["key"], unique=True) + + op.execute( + """ + INSERT INTO permissions (name, display_name, description, module) + VALUES ( + 'system_settings.manage', + 'Systemeinstellungen verwalten', + 'SMTP und öffentliche Systemlinks verwalten', + 'system' + ) + ON CONFLICT (name) DO NOTHING + """ + ) + op.execute( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles, permissions + WHERE roles.name = 'administrator' + AND permissions.name = 'system_settings.manage' + ON CONFLICT DO NOTHING + """ + ) + + +def downgrade() -> None: + op.execute( + """ + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE name = 'system_settings.manage' + ) + """ + ) + op.execute("DELETE FROM permissions WHERE name = 'system_settings.manage'") + op.drop_index(op.f("ix_system_settings_key"), table_name="system_settings") + op.drop_table("system_settings") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 2f1371f..116fd0f 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -7,11 +7,13 @@ from sqlalchemy.orm import Session from app.core.rbac import get_user_permission_names, require_permission from app.db.database import get_db from app.models.rbac import Role +from app.models.audit import AuditLog from app.models.user import User from app.repositories.customer_repository import CustomerRepository from app.repositories.repair_repository import RepairRepository from app.repositories.user_repository import UserRepository -from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard +from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem +from app.services.system_settings_service import SystemSettingsService logger = logging.getLogger(__name__) @@ -32,6 +34,7 @@ def get_dashboard_summary( users: list[MetricCard] = [] roles: list[MetricCard] = [] repairs: list[MetricCard] = [] + system_status: list[SystemStatusItem] = [] if "customers.read" in permissions: customers = [ @@ -69,6 +72,43 @@ def get_dashboard_summary( MetricCard(label="Offen ohne Kundenmail", value=RepairRepository.count_open_repairs_without_customer_email(db)), ] + if "system_settings.manage" in permissions: + smtp_config = SystemSettingsService.get_smtp_runtime_config(db) + public_links = SystemSettingsService.get_public_links_settings(db) + latest_testmail = db.scalar( + select(AuditLog) + .where(AuditLog.action.in_(["system_settings.smtp.test_sent", "system_settings.smtp.test_failed"])) + .order_by(AuditLog.created_at.desc(), AuditLog.id.desc()) + .limit(1) + ) + latest_failed_status_mail = RepairRepository.latest_failed_status_mail(db) + system_status = [ + SystemStatusItem( + label="SMTP-Konfiguration", + value={ + "database": "Admin-Konfiguration", + "environment": "Umgebung", + "missing": "Nicht konfiguriert", + }[smtp_config.source], + status="ok" if smtp_config.is_configured else "warning", + ), + SystemStatusItem( + label="Public Status Base URL", + value=public_links.repair_status_base_url or "Nicht konfiguriert", + status="ok" if public_links.repair_status_base_url else "warning", + ), + SystemStatusItem( + label="Letzte SMTP-Testmail", + value=latest_testmail.created_at.isoformat() if latest_testmail else "Noch keine Testmail", + status="info" if latest_testmail else "warning", + ), + SystemStatusItem( + label="Letzte fehlgeschlagene Statusmail", + value=latest_failed_status_mail.created_at.isoformat() if latest_failed_status_mail else "Keine Fehler", + status="warning" if latest_failed_status_mail else "ok", + ), + ] + logger.info("dashboard.summary", extra={"actor_user_id": current_user.id}) return DashboardSummary( @@ -77,6 +117,7 @@ def get_dashboard_summary( users=users, roles=roles, repairs=repairs, + system_status=system_status, activities=EmptyWidget( title="Letzte Aktivitäten", message="Noch keine Aktivitäten vorhanden.", diff --git a/backend/hermes/app/api/system_settings.py b/backend/hermes/app/api/system_settings.py new file mode 100644 index 0000000..0c3170f --- /dev/null +++ b/backend/hermes/app/api/system_settings.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.user import User +from app.schemas.system_setting import ( + PublicLinksSettingsResponse, + PublicLinksSettingsUpdate, + SmtpSettingsResponse, + SmtpSettingsUpdate, + SmtpTestRequest, + SmtpTestResponse, +) +from app.services.system_settings_service import SystemSettingsService + +router = APIRouter( + prefix="/system-settings", + tags=["System Settings"], +) + + +@router.get("/smtp", response_model=SmtpSettingsResponse) +def get_smtp_settings( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("system_settings.manage")), +): + return SystemSettingsService.get_smtp_settings(db) + + +@router.put("/smtp", response_model=SmtpSettingsResponse) +def update_smtp_settings( + payload: SmtpSettingsUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("system_settings.manage")), +): + return SystemSettingsService.update_smtp_settings(db, payload, actor=current_user, request=request) + + +@router.post("/smtp/test", response_model=SmtpTestResponse) +def send_smtp_test_mail( + payload: SmtpTestRequest, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("system_settings.manage")), +): + return SystemSettingsService.send_test_mail(db, payload.recipient, actor=current_user, request=request) + + +@router.get("/public-links", response_model=PublicLinksSettingsResponse) +def get_public_links_settings( + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("system_settings.manage")), +): + return SystemSettingsService.get_public_links_settings(db) + + +@router.put("/public-links", response_model=PublicLinksSettingsResponse) +def update_public_links_settings( + payload: PublicLinksSettingsUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("system_settings.manage")), +): + return SystemSettingsService.update_public_links_settings(db, payload, actor=current_user, request=request) diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index caf7949..6a559c7 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -25,6 +25,8 @@ import app.models.customer import app.models.knowledge import app.models.audit import app.models.user +import app.models.repair +import app.models.system_setting def get_db(): diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index fbcc986..d66787b 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -17,6 +17,7 @@ from app.api.knowledge import router as knowledge_router from app.api.permissions import router as permissions_router from app.api.repairs import router as repairs_router from app.api.roles import router as roles_router +from app.api.system_settings import router as system_settings_router from app.api.users import router as users_router from app.db.database import SessionLocal from app.db.health import check_database @@ -42,6 +43,7 @@ app.include_router(customers_router) app.include_router(knowledge_router) app.include_router(repairs_router) app.include_router(dashboard_router) +app.include_router(system_settings_router) logger = logging.getLogger(__name__) diff --git a/backend/hermes/app/models/system_setting.py b/backend/hermes/app/models/system_setting.py new file mode 100644 index 0000000..e3350a3 --- /dev/null +++ b/backend/hermes/app/models/system_setting.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.database import Base + + +class SystemSetting(Base): + __tablename__ = "system_settings" + + id: Mapped[int] = mapped_column(primary_key=True) + key: Mapped[str] = mapped_column(String(160), unique=True, index=True) + value: Mapped[str] = mapped_column(Text, default="", server_default="") + is_secret: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 2cde2c8..96c1699 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -69,6 +69,7 @@ STANDARD_PERMISSIONS = [ ("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"), ("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"), ("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"), + ("system_settings.manage", "Systemeinstellungen verwalten", "SMTP und öffentliche Systemlinks verwalten", "system"), ("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"), ("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"), ("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"), diff --git a/backend/hermes/app/repositories/repair_repository.py b/backend/hermes/app/repositories/repair_repository.py index 1390ee6..fb115e0 100644 --- a/backend/hermes/app/repositories/repair_repository.py +++ b/backend/hermes/app/repositories/repair_repository.py @@ -343,6 +343,17 @@ class RepairRepository: .where(RepairNotificationEvent.status.in_(["failed", "skipped"])) ) or 0 + @staticmethod + def latest_failed_status_mail(db: Session) -> RepairNotificationEvent | None: + return db.scalar( + select(RepairNotificationEvent) + .where(RepairNotificationEvent.event_type == "repair_status_mail") + .where(RepairNotificationEvent.success.is_(False)) + .where(RepairNotificationEvent.status.in_(["failed", "skipped"])) + .order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc()) + .limit(1) + ) + @staticmethod def count_open_repairs_without_customer_email(db: Session) -> int: return db.scalar( diff --git a/backend/hermes/app/repositories/system_settings_repository.py b/backend/hermes/app/repositories/system_settings_repository.py new file mode 100644 index 0000000..fc8adf1 --- /dev/null +++ b/backend/hermes/app/repositories/system_settings_repository.py @@ -0,0 +1,35 @@ +from collections.abc import Iterable + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.system_setting import SystemSetting + + +class SystemSettingsRepository: + @staticmethod + def get(db: Session, key: str) -> SystemSetting | None: + return db.scalar(select(SystemSetting).where(SystemSetting.key == key)) + + @staticmethod + def get_many(db: Session, keys: Iterable[str]) -> dict[str, SystemSetting]: + key_list = list(keys) + if not key_list: + return {} + + settings = db.scalars(select(SystemSetting).where(SystemSetting.key.in_(key_list))).all() + return {setting.key: setting for setting in settings} + + @staticmethod + def upsert(db: Session, *, key: str, value: str, is_secret: bool = False) -> SystemSetting: + setting = SystemSettingsRepository.get(db, key) + if setting is None: + setting = SystemSetting(key=key, value=value, is_secret=is_secret) + db.add(setting) + db.flush() + return setting + + setting.value = value + setting.is_secret = is_secret + db.flush() + return setting diff --git a/backend/hermes/app/schemas/dashboard.py b/backend/hermes/app/schemas/dashboard.py index ae89527..047f18e 100644 --- a/backend/hermes/app/schemas/dashboard.py +++ b/backend/hermes/app/schemas/dashboard.py @@ -13,12 +13,19 @@ class EmptyWidget(BaseModel): message: str +class SystemStatusItem(BaseModel): + label: str + value: str + status: str = "info" + + class DashboardSummary(BaseModel): customers: list[MetricCard] = [] latest_customers: list[CustomerResponse] = [] users: list[MetricCard] = [] roles: list[MetricCard] = [] repairs: list[MetricCard] = [] + system_status: list[SystemStatusItem] = [] activities: EmptyWidget tasks: EmptyWidget tickets: EmptyWidget diff --git a/backend/hermes/app/schemas/system_setting.py b/backend/hermes/app/schemas/system_setting.py new file mode 100644 index 0000000..fa4b266 --- /dev/null +++ b/backend/hermes/app/schemas/system_setting.py @@ -0,0 +1,70 @@ +from typing import Literal + +from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator + + +SettingsSource = Literal["database", "environment", "missing"] + + +def normalize_text(value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +class SmtpSettingsResponse(BaseModel): + host: str = "" + port: int = 587 + username: str = "" + password_is_set: bool = False + from_email: str = "" + from_name: str = "Funktechnik Schubert" + use_tls: bool = True + enabled: bool = False + source: SettingsSource + + +class SmtpSettingsUpdate(BaseModel): + host: str = Field(default="", max_length=255) + port: int = Field(default=587, ge=1, le=65535) + username: str = Field(default="", max_length=255) + password: str | None = Field(default=None, max_length=1000) + from_email: str = Field(default="", max_length=255) + from_name: str = Field(default="Funktechnik Schubert", max_length=255) + use_tls: bool = True + enabled: bool = False + + @field_validator("host", "username", "password", "from_email", "from_name", mode="before") + @classmethod + def normalize_strings(cls, value: object) -> str: + return normalize_text(value) + + @model_validator(mode="after") + def validate_enabled_configuration(self): + if self.enabled and (not self.host or not self.from_email): + raise ValueError("SMTP Host und Absenderadresse sind erforderlich, wenn SMTP aktiviert ist") + return self + + +class SmtpTestRequest(BaseModel): + recipient: EmailStr + + +class SmtpTestResponse(BaseModel): + success: bool + message: str + source: SettingsSource + + +class PublicLinksSettingsResponse(BaseModel): + repair_status_base_url: str = "" + source: SettingsSource + + +class PublicLinksSettingsUpdate(BaseModel): + repair_status_base_url: str = Field(default="", max_length=500) + + @field_validator("repair_status_base_url", mode="before") + @classmethod + def normalize_url(cls, value: object) -> str: + return normalize_text(value).rstrip("/") diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index d37f223..d38a335 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -169,6 +169,10 @@ def action_title(action: str) -> str: "repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt", "repairs.status_mail.sent": "Statusmail versendet", "repairs.status_mail.failed": "Statusmail fehlgeschlagen", + "system_settings.smtp.update": "SMTP-Konfiguration geändert", + "system_settings.smtp.test_sent": "SMTP-Testmail versendet", + "system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen", + "system_settings.public_links.update": "Öffentliche Link-Konfiguration geändert", } 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 1ce0d6e..2e48bd4 100644 --- a/backend/hermes/app/services/repair_notification_service.py +++ b/backend/hermes/app/services/repair_notification_service.py @@ -1,13 +1,10 @@ -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.core.config import settings from app.models.repair import Repair, RepairNotificationEvent from app.models.user import User from app.repositories.repair_repository import RepairRepository @@ -15,6 +12,7 @@ from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotific 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 +from app.services.system_settings_service import SystemSettingsService @dataclass(frozen=True) @@ -98,16 +96,6 @@ 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" @@ -235,8 +223,9 @@ class RepairNotificationService: 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) + smtp_config = SystemSettingsService.get_smtp_runtime_config(db) - if not _smtp_configured(): + if not smtp_config.is_configured: event = RepairRepository.create_notification_event( db, repair_id=repair.id, @@ -253,7 +242,7 @@ class RepairNotificationService: return event try: - RepairNotificationService._send_email(recipient=recipient, subject=subject, text=text, html=html) + SystemSettingsService.send_email(smtp_config, recipient=recipient, subject=subject, text=text, html=html) except Exception: event = RepairRepository.create_notification_event( db, @@ -294,22 +283,6 @@ class RepairNotificationService: ) 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( diff --git a/backend/hermes/app/services/repair_public_link_service.py b/backend/hermes/app/services/repair_public_link_service.py index 3b0e499..f35c231 100644 --- a/backend/hermes/app/services/repair_public_link_service.py +++ b/backend/hermes/app/services/repair_public_link_service.py @@ -19,6 +19,7 @@ from app.schemas.repair import ( ) from app.services.audit_service import write_audit_log from app.services.repair_status_labels import STATUS_LABELS +from app.services.system_settings_service import SystemSettingsService def normalize_token(token: str) -> str: @@ -29,10 +30,10 @@ def repair_label(repair: Repair) -> str: return f"{repair.repair_number} · {repair.customer_name}" -def public_status_path(token: str) -> str: +def public_status_path(db: Session, token: str) -> str: normalized_token = normalize_token(token) path = f"/status/{normalized_token}" - base_url = (settings.public_repair_status_base_url or "").strip().rstrip("/") + base_url = SystemSettingsService.get_public_repair_status_base_url(db) if not base_url: return path return f"{base_url}/{normalized_token}" @@ -109,7 +110,7 @@ class RepairPublicLinkService: last_used_at=public_link.last_used_at, created_at=public_link.created_at, revoked_at=public_link.revoked_at, - public_status_path=public_status_path(token), + public_status_path=public_status_path(db, token), ) @staticmethod diff --git a/backend/hermes/app/services/system_settings_service.py b/backend/hermes/app/services/system_settings_service.py new file mode 100644 index 0000000..e0fa53f --- /dev/null +++ b/backend/hermes/app/services/system_settings_service.py @@ -0,0 +1,316 @@ +import smtplib +from dataclasses import dataclass +from email.message import EmailMessage + +from pydantic import EmailStr +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.config import settings +from app.models.user import User +from app.repositories.system_settings_repository import SystemSettingsRepository +from app.schemas.system_setting import ( + PublicLinksSettingsResponse, + PublicLinksSettingsUpdate, + SmtpSettingsResponse, + SmtpSettingsUpdate, + SmtpTestResponse, + SettingsSource, +) +from app.services.audit_service import write_audit_log + + +SMTP_KEYS = ( + "smtp.host", + "smtp.port", + "smtp.username", + "smtp.password", + "smtp.from_email", + "smtp.from_name", + "smtp.use_tls", + "smtp.enabled", +) + +SECRET_KEYS = {"smtp.password"} + + +@dataclass(frozen=True) +class SmtpRuntimeConfig: + host: str + port: int + username: str + password: str + from_email: str + from_name: str + use_tls: bool + enabled: bool + source: SettingsSource + + @property + def is_configured(self) -> bool: + return self.enabled and bool(self.host and self.from_email) + + @property + def password_is_set(self) -> bool: + return bool(self.password) + + @property + def from_header(self) -> str: + if self.from_name: + return f"{self.from_name} <{self.from_email}>" + return self.from_email + + +def parse_bool(value: str | None, *, default: bool = False) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def parse_int(value: str | None, *, default: int) -> int: + try: + parsed = int(str(value or "").strip()) + except ValueError: + return default + if parsed < 1 or parsed > 65535: + return default + return parsed + + +class SystemSettingsService: + @staticmethod + def get_smtp_settings(db: Session) -> SmtpSettingsResponse: + return SystemSettingsService.smtp_response(SystemSettingsService.get_smtp_runtime_config(db)) + + @staticmethod + def get_smtp_runtime_config(db: Session) -> SmtpRuntimeConfig: + values = SystemSettingsService._get_values(db, SMTP_KEYS) + database_enabled = parse_bool(values.get("smtp.enabled"), default=False) + database_host = values.get("smtp.host", "") + database_from_email = values.get("smtp.from_email", "") + + if database_enabled and database_host and database_from_email: + return SmtpRuntimeConfig( + host=database_host, + port=parse_int(values.get("smtp.port"), default=587), + username=values.get("smtp.username", ""), + password=values.get("smtp.password", ""), + from_email=database_from_email, + from_name=values.get("smtp.from_name", "Funktechnik Schubert") or "Funktechnik Schubert", + use_tls=parse_bool(values.get("smtp.use_tls"), default=True), + enabled=True, + source="database", + ) + + if settings.smtp_host and settings.smtp_from_email: + return SmtpRuntimeConfig( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username or "", + password=settings.smtp_password or "", + from_email=settings.smtp_from_email, + from_name=settings.smtp_from_name, + use_tls=settings.smtp_use_tls, + enabled=True, + source="environment", + ) + + return SmtpRuntimeConfig( + host=database_host or settings.smtp_host or "", + port=parse_int(values.get("smtp.port"), default=settings.smtp_port), + username=values.get("smtp.username", "") or settings.smtp_username or "", + password=values.get("smtp.password", "") or settings.smtp_password or "", + from_email=database_from_email or settings.smtp_from_email or "", + from_name=values.get("smtp.from_name", "") or settings.smtp_from_name, + use_tls=parse_bool(values.get("smtp.use_tls"), default=settings.smtp_use_tls), + enabled=False, + source="missing", + ) + + @staticmethod + def update_smtp_settings( + db: Session, + payload: SmtpSettingsUpdate, + *, + actor: User, + request: Request, + ) -> SmtpSettingsResponse: + current_values = SystemSettingsService._get_values(db, SMTP_KEYS) + password = payload.password if payload.password else current_values.get("smtp.password", "") + + updates = { + "smtp.host": payload.host, + "smtp.port": str(payload.port), + "smtp.username": payload.username, + "smtp.password": password, + "smtp.from_email": payload.from_email, + "smtp.from_name": payload.from_name or "Funktechnik Schubert", + "smtp.use_tls": "true" if payload.use_tls else "false", + "smtp.enabled": "true" if payload.enabled else "false", + } + + for key, value in updates.items(): + SystemSettingsRepository.upsert(db, key=key, value=value, is_secret=key in SECRET_KEYS) + db.commit() + + write_audit_log( + db, + action="system_settings.smtp.update", + entity_type="system_settings", + entity_label="SMTP-Konfiguration", + actor=actor, + request=request, + metadata={ + "enabled": payload.enabled, + "host": payload.host, + "from_email": payload.from_email, + "password_changed": bool(payload.password), + }, + ) + return SystemSettingsService.get_smtp_settings(db) + + @staticmethod + def get_public_links_settings(db: Session) -> PublicLinksSettingsResponse: + value = SystemSettingsService.get_public_repair_status_base_url(db) + source: SettingsSource = "missing" + if value: + db_value = SystemSettingsRepository.get(db, "public.repair_status_base_url") + source = "database" if db_value and db_value.value.strip() else "environment" + return PublicLinksSettingsResponse(repair_status_base_url=value, source=source) + + @staticmethod + def get_public_repair_status_base_url(db: Session) -> str: + setting = SystemSettingsRepository.get(db, "public.repair_status_base_url") + if setting and setting.value.strip(): + return setting.value.strip().rstrip("/") + return (settings.public_repair_status_base_url or "").strip().rstrip("/") + + @staticmethod + def update_public_links_settings( + db: Session, + payload: PublicLinksSettingsUpdate, + *, + actor: User, + request: Request, + ) -> PublicLinksSettingsResponse: + SystemSettingsRepository.upsert( + db, + key="public.repair_status_base_url", + value=payload.repair_status_base_url, + is_secret=False, + ) + db.commit() + write_audit_log( + db, + action="system_settings.public_links.update", + entity_type="system_settings", + entity_label="Öffentliche Links", + actor=actor, + request=request, + metadata={"repair_status_base_url": payload.repair_status_base_url}, + ) + return SystemSettingsService.get_public_links_settings(db) + + @staticmethod + def send_test_mail( + db: Session, + recipient: EmailStr, + *, + actor: User, + request: Request, + ) -> SmtpTestResponse: + smtp_config = SystemSettingsService.get_smtp_runtime_config(db) + if not smtp_config.is_configured: + write_audit_log( + db, + action="system_settings.smtp.test_failed", + entity_type="system_settings", + entity_label="SMTP-Testmail", + actor=actor, + request=request, + metadata={"reason": "smtp_not_configured", "source": smtp_config.source}, + ) + return SmtpTestResponse(success=False, message="SMTP ist nicht vollständig konfiguriert.", source=smtp_config.source) + + try: + SystemSettingsService.send_email( + smtp_config, + recipient=str(recipient), + subject="Olympus CRM SMTP-Test", + text=( + "Hallo,\n\n" + "dies ist eine Testmail aus Olympus CRM. " + "Die SMTP-Konfiguration ist grundsätzlich erreichbar.\n\n" + "Olympus CRM" + ), + html=( + "

Hallo,

" + "

dies ist eine Testmail aus Olympus CRM. " + "Die SMTP-Konfiguration ist grundsätzlich erreichbar.

" + "

Olympus CRM

" + ), + ) + except Exception: + write_audit_log( + db, + action="system_settings.smtp.test_failed", + entity_type="system_settings", + entity_label="SMTP-Testmail", + actor=actor, + request=request, + metadata={"reason": "smtp_send_failed", "source": smtp_config.source}, + ) + return SmtpTestResponse(success=False, message="Testmail konnte nicht versendet werden.", source=smtp_config.source) + + write_audit_log( + db, + action="system_settings.smtp.test_sent", + entity_type="system_settings", + entity_label="SMTP-Testmail", + actor=actor, + request=request, + metadata={"source": smtp_config.source}, + ) + return SmtpTestResponse(success=True, message="Testmail wurde versendet.", source=smtp_config.source) + + @staticmethod + def send_email( + smtp_config: SmtpRuntimeConfig, + *, + recipient: str, + subject: str, + text: str, + html: str, + ) -> None: + message = EmailMessage() + message["Subject"] = subject + message["From"] = smtp_config.from_header + message["To"] = recipient + message.set_content(text) + message.add_alternative(html, subtype="html") + + with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=15) as smtp: + if smtp_config.use_tls: + smtp.starttls() + if smtp_config.username and smtp_config.password: + smtp.login(smtp_config.username, smtp_config.password) + smtp.send_message(message) + + @staticmethod + def smtp_response(smtp_config: SmtpRuntimeConfig) -> SmtpSettingsResponse: + return SmtpSettingsResponse( + host=smtp_config.host, + port=smtp_config.port, + username=smtp_config.username, + password_is_set=smtp_config.password_is_set, + from_email=smtp_config.from_email, + from_name=smtp_config.from_name, + use_tls=smtp_config.use_tls, + enabled=smtp_config.enabled, + source=smtp_config.source, + ) + + @staticmethod + def _get_values(db: Session, keys: tuple[str, ...]) -> dict[str, str]: + settings_by_key = SystemSettingsRepository.get_many(db, keys) + return {key: settings_by_key[key].value.strip() for key in keys if key in settings_by_key} diff --git a/docker-compose.yml b/docker-compose.yml index 8f660ef..5ad8a33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} OLYMPUS_REPAIR_INTAKE_TOKEN: ${OLYMPUS_REPAIR_INTAKE_TOKEN:-} + # Env-Fallbacks: Admin-Konfiguration in PostgreSQL hat Vorrang. PUBLIC_REPAIR_STATUS_BASE_URL: ${PUBLIC_REPAIR_STATUS_BASE_URL:-} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} diff --git a/frontend/athena/app/api/system-settings/public-links/route.ts b/frontend/athena/app/api/system-settings/public-links/route.ts new file mode 100644 index 0000000..a004455 --- /dev/null +++ b/frontend/athena/app/api/system-settings/public-links/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/system-settings/public-links"); +} + +export async function PUT(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/system-settings/public-links"); +} diff --git a/frontend/athena/app/api/system-settings/smtp/route.ts b/frontend/athena/app/api/system-settings/smtp/route.ts new file mode 100644 index 0000000..deed42c --- /dev/null +++ b/frontend/athena/app/api/system-settings/smtp/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, "/system-settings/smtp"); +} + +export async function PUT(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/system-settings/smtp"); +} diff --git a/frontend/athena/app/api/system-settings/smtp/test/route.ts b/frontend/athena/app/api/system-settings/smtp/test/route.ts new file mode 100644 index 0000000..2075228 --- /dev/null +++ b/frontend/athena/app/api/system-settings/smtp/test/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/system-settings/smtp/test"); +} diff --git a/frontend/athena/app/dashboard/page.tsx b/frontend/athena/app/dashboard/page.tsx index ddfb4c0..4b56102 100644 --- a/frontend/athena/app/dashboard/page.tsx +++ b/frontend/athena/app/dashboard/page.tsx @@ -108,6 +108,23 @@ export default function DashboardPage() { )} + {summary.system_status.length > 0 && ( +
+

Systemstatus

+
+ {summary.system_status.map((item) => ( +
+
+

{item.label}

+ +
+

{formatSystemValue(item.value)}

+
+ ))} +
+
+ )} +
= { + database: "Admin-Konfiguration", + environment: "Umgebung", + missing: "Nicht konfiguriert", +}; + +function getErrorMessage(error: unknown, fallback: string) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; + return response?.data?.detail ?? response?.data?.message ?? fallback; + } + return fallback; +} + +function emptySmtpSettings(): SmtpSettings { + return { + host: "", + port: 587, + username: "", + password_is_set: false, + from_email: "", + from_name: "Funktechnik Schubert", + use_tls: true, + enabled: false, + source: "missing", + }; +} + +export default function SettingsPage() { + const { showToast } = useToast(); + const [activeTab, setActiveTab] = useState("smtp"); + const [smtp, setSmtp] = useState(emptySmtpSettings()); + const [newPassword, setNewPassword] = useState(""); + const [testRecipient, setTestRecipient] = useState(""); + const [publicLinks, setPublicLinks] = useState({ + repair_status_base_url: "", + source: "missing", + }); + const [loading, setLoading] = useState(true); + const [savingSmtp, setSavingSmtp] = useState(false); + const [sendingTest, setSendingTest] = useState(false); + const [savingPublicLinks, setSavingPublicLinks] = useState(false); + const [error, setError] = useState(""); + + const loadSettings = useCallback(async () => { + setError(""); + setLoading(true); + try { + const [smtpResponse, publicLinksResponse] = await Promise.all([ + api.get("/system-settings/smtp"), + api.get("/system-settings/public-links"), + ]); + setSmtp(smtpResponse.data); + setPublicLinks(publicLinksResponse.data); + } catch (err) { + setError(getErrorMessage(err, "Einstellungen konnten nicht geladen werden.")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadSettings(); + }); + }, [loadSettings]); + + async function saveSmtp() { + setSavingSmtp(true); + try { + const payload: SmtpSettingsPayload = { + host: smtp.host, + port: smtp.port, + username: smtp.username, + from_email: smtp.from_email, + from_name: smtp.from_name, + use_tls: smtp.use_tls, + enabled: smtp.enabled, + }; + + if (newPassword) { + payload.password = newPassword; + } + + const response = await api.put("/system-settings/smtp", payload); + setSmtp(response.data); + setNewPassword(""); + showToast({ type: "success", title: "SMTP-Konfiguration gespeichert" }); + } catch (err) { + showToast({ + type: "error", + title: "SMTP konnte nicht gespeichert werden", + description: getErrorMessage(err, "Bitte prüfe die Eingaben."), + }); + } finally { + setSavingSmtp(false); + } + } + + async function sendTestMail() { + setSendingTest(true); + try { + const response = await api.post("/system-settings/smtp/test", { + recipient: testRecipient, + }); + showToast({ + type: response.data.success ? "success" : "error", + title: response.data.success ? "Testmail versendet" : "Testmail fehlgeschlagen", + description: response.data.message, + }); + } catch (err) { + showToast({ + type: "error", + title: "Testmail fehlgeschlagen", + description: getErrorMessage(err, "Die Testmail konnte nicht versendet werden."), + }); + } finally { + setSendingTest(false); + } + } + + async function savePublicLinks() { + setSavingPublicLinks(true); + try { + const response = await api.put("/system-settings/public-links", { + repair_status_base_url: publicLinks.repair_status_base_url, + }); + setPublicLinks(response.data); + showToast({ type: "success", title: "Öffentliche Links gespeichert" }); + } catch (err) { + showToast({ + type: "error", + title: "Öffentliche Links konnten nicht gespeichert werden", + description: getErrorMessage(err, "Bitte prüfe die Basis-URL."), + }); + } finally { + setSavingPublicLinks(false); + } + } + + if (loading) { + return
Einstellungen werden geladen...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+
+
+

Einstellungen

+

SMTP-Versand und öffentliche Statuslinks verwalten

+
+
+ setActiveTab("smtp")}> + SMTP + + setActiveTab("public-links")}> + Öffentliche Links + +
+
+ + {activeTab === "smtp" ? ( +
+
+
+
+ +

SMTP

+
+

+ Quelle: {sourceLabels[smtp.source]} + {smtp.password_is_set ? " · Passwort ist gesetzt" : ""} +

+
+ +
+ +
+ + + + + + setSmtp((current) => ({ ...current, host: event.target.value }))} /> + + + + setSmtp((current) => ({ ...current, port: Number(event.target.value) || 587 }))} + /> + + + + setSmtp((current) => ({ ...current, username: event.target.value }))} /> + + + + setNewPassword(event.target.value)} + /> + + + + setSmtp((current) => ({ ...current, from_email: event.target.value }))} + /> + + + + setSmtp((current) => ({ ...current, from_name: event.target.value }))} /> + +
+ +
+ Für Apple Mail/iCloud: smtp.mail.me.com, Port 587, TLS/STARTTLS aktiv, + Benutzername vollständige Mailadresse, Passwort = app-spezifisches Passwort. +
+ +
+
+ +

Testmail senden

+
+
+ setTestRecipient(event.target.value)} + /> + +
+
+
+ ) : ( +
+
+
+
+ +

Öffentliche Links

+
+

Quelle: {sourceLabels[publicLinks.source]}

+
+ +
+ + + setPublicLinks((current) => ({ ...current, repair_status_base_url: event.target.value }))} + /> + + +
+ +

+ Statusmails erzeugen weiterhin sichere Einmal-Token in Hermes. Die Basis-URL bestimmt nur, + auf welche öffentliche Website-Route der Link zeigt. +

+
+
+ )} +
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index c04c193..07d85b7 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -81,6 +81,7 @@ const menu = [ icon: Settings, name: "Einstellungen", href: "/settings", + permission: "system_settings.manage", }, ] satisfies Array<{ icon: typeof LayoutDashboard; diff --git a/frontend/athena/proxy.ts b/frontend/athena/proxy.ts index c44082f..80d0ef8 100644 --- a/frontend/athena/proxy.ts +++ b/frontend/athena/proxy.ts @@ -23,5 +23,6 @@ export const config = { "/roles/:path*", "/customers/:path*", "/repairs/:path*", + "/settings/:path*", ], }; diff --git a/frontend/athena/types/dashboard.ts b/frontend/athena/types/dashboard.ts index bc0f744..c3f4ef1 100644 --- a/frontend/athena/types/dashboard.ts +++ b/frontend/athena/types/dashboard.ts @@ -1,4 +1,5 @@ import type { Customer } from "@/types/customer"; +import type { SystemStatusItem } from "@/types/system-settings"; export interface MetricCard { label: string; @@ -16,6 +17,7 @@ export interface DashboardSummary { users: MetricCard[]; roles: MetricCard[]; repairs: MetricCard[]; + system_status: SystemStatusItem[]; activities: EmptyWidget; tasks: EmptyWidget; tickets: EmptyWidget; diff --git a/frontend/athena/types/system-settings.ts b/frontend/athena/types/system-settings.ts new file mode 100644 index 0000000..c810bad --- /dev/null +++ b/frontend/athena/types/system-settings.ts @@ -0,0 +1,41 @@ +export type SettingsSource = "database" | "environment" | "missing"; + +export interface SmtpSettings { + host: string; + port: number; + username: string; + password_is_set: boolean; + from_email: string; + from_name: string; + use_tls: boolean; + enabled: boolean; + source: SettingsSource; +} + +export interface SmtpSettingsPayload { + host: string; + port: number; + username: string; + password?: string; + from_email: string; + from_name: string; + use_tls: boolean; + enabled: boolean; +} + +export interface SmtpTestResponse { + success: boolean; + message: string; + source: SettingsSource; +} + +export interface PublicLinksSettings { + repair_status_base_url: string; + source: SettingsSource; +} + +export interface SystemStatusItem { + label: string; + value: string; + status: "ok" | "warning" | "info"; +} From 6e7e75f8648ba1b5cb8cc191ca15694cd420a033 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sun, 5 Jul 2026 00:34:57 +0200 Subject: [PATCH 04/15] feat(repairs): add repair documents --- .gitignore | 5 + ARCHITECTURE.md | 60 +++ README-DEV.md | 13 + ROADMAP.md | 13 +- .../a1c5f9e2d430_extend_repair_documents.py | 58 +++ backend/hermes/app/api/dashboard.py | 1 + backend/hermes/app/api/repairs.py | 119 ++++- backend/hermes/app/models/repair.py | 28 +- .../app/repositories/repair_repository.py | 75 ++- backend/hermes/app/schemas/repair.py | 46 ++ backend/hermes/app/services/audit_service.py | 3 + .../app/services/repair_document_service.py | 225 +++++++++ backend/hermes/app/storage/local.py | 1 + .../documents/[documentId]/download/route.ts | 18 + .../[id]/documents/[documentId]/route.ts | 40 ++ .../app/api/repairs/[id]/documents/route.ts | 14 + .../repairs/[id]/documents/upload/route.ts | 21 + frontend/athena/app/repairs/[id]/page.tsx | 5 + .../repairs/RepairDocumentsSection.tsx | 454 ++++++++++++++++++ frontend/athena/types/repair.ts | 29 ++ ...0efae9368ce987-service-manual-bill-fcc.pdf | Bin 5482476 -> 0 bytes 21 files changed, 1222 insertions(+), 6 deletions(-) create mode 100644 backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py create mode 100644 backend/hermes/app/services/repair_document_service.py create mode 100644 frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/documents/route.ts create mode 100644 frontend/athena/app/api/repairs/[id]/documents/upload/route.ts create mode 100644 frontend/athena/components/repairs/RepairDocumentsSection.tsx delete mode 100644 storage/knowledge/documents/1/84dac4e08a654a24a30efae9368ce987-service-manual-bill-fcc.pdf diff --git a/.gitignore b/.gitignore index 4a28a16..b36a9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,11 @@ out/ # =========================== *.log +# =========================== +# Runtime storage +# =========================== +storage/ + # =========================== # Coverage # =========================== diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ce893db..0e0cb43 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -322,6 +322,64 @@ Hermes-Endpunkte: Athena ruft diese Endpunkte ausschliesslich ueber BFF-Routen unter `/api/system-settings/...` auf. Mutierende Requests verwenden den bestehenden Same-Origin-Schutz. Zugriff erfordert `system_settings.manage`, das der Administratorrolle zugewiesen ist. +### Reparaturdokumente + +Ab v0.8.5 verwaltet Olympus Bilder und Dokumente direkt an Reparaturen. Die Fachlogik liegt im Repair-Modul, Dateizugriffe laufen ueber das zentrale Storage Framework. + +Datenmodell `repair_documents`: + +- `repair_id` +- `title` +- `document_type` +- `original_filename` +- `stored_filename` +- `storage_path` +- `mime_type` +- `size_bytes` +- `checksum_sha256` +- `visibility` +- `note` +- `uploaded_by_user_id` +- Zeitstempel + +Dokumenttypen: + +- `device_photo` +- `fault_photo` +- `measurement` +- `estimate` +- `repair_report` +- `shipping` +- `other` + +`visibility` kann `internal` oder `customer` sein. Die Kundensicht ist nur vorbereitet; in v0.8.5 werden Dokumente nicht oeffentlich fuer Kunden ausgeliefert. + +Storage: + +- Namespace: `repairs//documents` +- Erlaubte Uploads: JPG, PNG, WEBP und PDF +- Dateinamen werden normalisiert +- SHA-256 wird gespeichert +- Dateiinhalte werden nicht geloggt +- Storage-Pfade werden nicht in Audit-Metadaten geschrieben +- Browser erhalten Dateien nur ueber Athena-BFF und Hermes-RBAC + +Hermes-Endpunkte: + +- `GET /repairs/{id}/documents` +- `POST /repairs/{id}/documents/upload` +- `GET /repairs/{id}/documents/{document_id}` +- `PUT /repairs/{id}/documents/{document_id}` +- `GET /repairs/{id}/documents/{document_id}/download` +- `DELETE /repairs/{id}/documents/{document_id}` + +RBAC: + +- Liste, Detail und Download erfordern `repairs.read`. +- Upload, Aenderung und Loeschung erfordern `repairs.update`. + +Athena ruft diese Endpunkte ausschliesslich ueber `/api/repairs/[id]/documents...` auf. Mutierende Requests verwenden den Same-Origin-Schutz. + ### Website Repair Intake Hermes stellt `POST /public/repair-intake` fuer eine spaetere serverseitige Website-Anbindung bereit. @@ -741,6 +799,8 @@ Seit v0.7.0 laufen Knowledge-Dateien ueber das zentrale Storage Framework. Knowl Neue Uploads werden im Namespace `knowledge/documents/` gespeichert. Die Datenbank-Metadaten bleiben kompatibel: `knowledge_documents.file_path` enthaelt den Storage-Key fuer neue Dateien oder einen bestehenden Legacy-Pfad fuer alte Dateien. +Reparaturdokumente werden ab v0.8.5 im Namespace `repairs//documents` gespeichert. Zulaessig sind JPG, PNG, WEBP und PDF. PDF-Anzeige bleibt auf Inline-Open/Download begrenzt; ein eigener PDF-Viewer ist ein spaeteres Feature. + Konfiguration: - `STORAGE_PROVIDER`, Default `local` diff --git a/README-DEV.md b/README-DEV.md index 3477ade..165a88c 100644 --- a/README-DEV.md +++ b/README-DEV.md @@ -154,6 +154,10 @@ Athena erreicht Reparaturen ausschliesslich ueber BFF-Routen: - `/api/repairs` - `/api/repairs/[id]` +- `/api/repairs/[id]/documents` +- `/api/repairs/[id]/documents/upload` +- `/api/repairs/[id]/documents/[documentId]` +- `/api/repairs/[id]/documents/[documentId]/download` - `/api/repairs/[id]/status` - `/api/repairs/[id]/history` - `/api/repairs/[id]/public-link` @@ -185,6 +189,15 @@ Benachrichtigungen: - `SMTP_PASSWORD` wird nicht an Athena zurueckgegeben und gehoert nie ins Git oder in Logs. - Verschluesselung at rest ist fuer v0.8.x vorbereitet/geplant; bis dahin bleibt das Passwort serverseitig in `system_settings` und wird in Responses/Audits maskiert. +Reparaturdokumente: + +- Ab v0.8.5 koennen Dokumente und Bilder direkt an Reparaturen gepflegt werden. +- Hermes speichert Uploads ueber `StorageService` unter `repairs//documents`. +- Erlaubte Dateitypen: JPG, PNG, WEBP und PDF. +- Metadaten liegen in `repair_documents`; Dateiinhalte liegen nie im Git oder in `public`. +- `visibility` ist mit `internal` und `customer` vorbereitet. In v0.8.5 gibt es noch keine oeffentliche Kundenanzeige fuer diese Dateien. +- PDF-Dateien koennen inline oder als Download ueber Athena-BFF geoeffnet werden; ein vollstaendiger PDF-Viewer ist ein Folgefeature. + Vorbereitete Website-/Portal-Routen fuer spaeter: - `/status/` diff --git a/ROADMAP.md b/ROADMAP.md index 6699f66..de6753b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,18 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Passwort wird nicht an Athena zurueckgegeben - Encryption at rest fuer Secrets bleibt als v0.8.x-Haertung geplant -## v0.8.5 - Kundenportal, geplant +## v0.8.5 - Reparaturdokumente + +- Dokumente und Bilder direkt an Reparaturen verwalten +- Upload ueber Athena-BFF und Hermes StorageService +- Storage-Pfad `repairs//documents` +- Erlaubte Dateitypen JPG, PNG, WEBP und PDF +- Dokumenttypen fuer Geraetefotos, Fehlerbilder, Messbilder, Kostenvoranschlag, Reparaturbericht, Versandbeleg und Sonstiges +- Sichtbarkeit `internal`/`customer` vorbereitet, aber noch keine oeffentliche Kundenanzeige +- Bildvorschau mit Lightbox, PDF inline/download +- Audit Logs fuer Upload, Aenderung und Loeschung + +## v0.8.6 - Kundenportal, geplant - `/portal/login` fuer spaeteren Kundenlogin - Separates Authentifizierungsmodell fuer Kunden diff --git a/backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py b/backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py new file mode 100644 index 0000000..7091971 --- /dev/null +++ b/backend/hermes/alembic/versions/a1c5f9e2d430_extend_repair_documents.py @@ -0,0 +1,58 @@ +"""extend repair documents + +Revision ID: a1c5f9e2d430 +Revises: f2b8d4e6a910 +Create Date: 2026-07-05 09:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "a1c5f9e2d430" +down_revision: Union[str, Sequence[str], None] = "f2b8d4e6a910" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("repair_documents", sa.Column("original_filename", sa.String(length=255), server_default="", nullable=False)) + op.add_column("repair_documents", sa.Column("stored_filename", sa.String(length=255), server_default="", nullable=False)) + op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), server_default="", nullable=False) + op.add_column("repair_documents", sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False)) + op.add_column("repair_documents", sa.Column("size_bytes", sa.BigInteger(), server_default="0", nullable=False)) + op.add_column("repair_documents", sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False)) + op.add_column("repair_documents", sa.Column("visibility", sa.String(length=40), server_default="internal", nullable=False)) + op.add_column("repair_documents", sa.Column("note", sa.Text(), nullable=True)) + op.add_column("repair_documents", sa.Column("uploaded_by_user_id", sa.Integer(), nullable=True)) + op.add_column("repair_documents", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False)) + op.create_foreign_key( + op.f("fk_repair_documents_uploaded_by_user_id_users"), + "repair_documents", + "users", + ["uploaded_by_user_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index(op.f("ix_repair_documents_checksum_sha256"), "repair_documents", ["checksum_sha256"], unique=False) + op.create_index(op.f("ix_repair_documents_uploaded_by_user_id"), "repair_documents", ["uploaded_by_user_id"], unique=False) + op.create_index(op.f("ix_repair_documents_visibility"), "repair_documents", ["visibility"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_repair_documents_visibility"), table_name="repair_documents") + op.drop_index(op.f("ix_repair_documents_uploaded_by_user_id"), table_name="repair_documents") + op.drop_index(op.f("ix_repair_documents_checksum_sha256"), table_name="repair_documents") + op.drop_constraint(op.f("fk_repair_documents_uploaded_by_user_id_users"), "repair_documents", type_="foreignkey") + op.drop_column("repair_documents", "updated_at") + op.drop_column("repair_documents", "uploaded_by_user_id") + op.drop_column("repair_documents", "note") + op.drop_column("repair_documents", "visibility") + op.drop_column("repair_documents", "checksum_sha256") + op.drop_column("repair_documents", "size_bytes") + op.drop_column("repair_documents", "mime_type") + op.alter_column("repair_documents", "storage_path", existing_type=sa.String(length=500), nullable=True, server_default=None) + op.drop_column("repair_documents", "stored_filename") + op.drop_column("repair_documents", "original_filename") diff --git a/backend/hermes/app/api/dashboard.py b/backend/hermes/app/api/dashboard.py index 116fd0f..9f9545d 100644 --- a/backend/hermes/app/api/dashboard.py +++ b/backend/hermes/app/api/dashboard.py @@ -70,6 +70,7 @@ def get_dashboard_summary( 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)), + MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)), ] if "system_settings.manage" in permissions: diff --git a/backend/hermes/app/api/repairs.py b/backend/hermes/app/api/repairs.py index 8e6d820..ffa8f2a 100644 --- a/backend/hermes/app/api/repairs.py +++ b/backend/hermes/app/api/repairs.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile, status +from fastapi.responses import FileResponse from sqlalchemy.orm import Session from starlette.requests import Request from typing import cast @@ -11,6 +12,10 @@ from app.models.user import User from app.repositories.repair_repository import RepairRepository from app.schemas.repair import ( RepairCreate, + RepairDocumentResponse, + RepairDocumentType, + RepairDocumentUpdate, + RepairDocumentVisibility, RepairIntakePayload, RepairIntakeResponse, RepairNotificationEventResponse, @@ -26,6 +31,7 @@ from app.schemas.repair import ( RepairUpdate, ) from app.services.repair_notification_service import RepairNotificationService +from app.services.repair_document_service import RepairDocumentService from app.services.repair_public_link_service import RepairPublicLinkService from app.services.repair_service import RepairService @@ -130,6 +136,117 @@ def get_repair_history( return RepairRepository.get_history(db, repair_id) +@router.get("/repairs/{repair_id}/documents", response_model=list[RepairDocumentResponse]) +def list_repair_documents( + repair_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + get_repair_or_404(db, repair_id) + return RepairRepository.list_documents(db, repair_id) + + +@router.post("/repairs/{repair_id}/documents/upload", response_model=RepairDocumentResponse, status_code=status.HTTP_201_CREATED) +async def upload_repair_document( + repair_id: int, + request: Request, + file: UploadFile = File(...), + title: str = Form(..., min_length=1, max_length=255), + document_type: RepairDocumentType = Form("other"), + visibility: RepairDocumentVisibility = Form("internal"), + note: str | None = Form(None), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + normalized_title = title.strip() + normalized_note = note.strip() if note else None + if not normalized_title: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Titel ist erforderlich") + return await RepairDocumentService.upload_document( + db, + db_repair, + file=file, + title=normalized_title, + document_type=document_type, + visibility=visibility, + note=normalized_note, + actor=current_user, + request=request, + ) + + +@router.get("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse) +def get_repair_document( + repair_id: int, + document_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + return document + + +@router.put("/repairs/{repair_id}/documents/{document_id}", response_model=RepairDocumentResponse) +def update_repair_document( + repair_id: int, + document_id: int, + payload: RepairDocumentUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.update")), +): + db_repair = get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + return RepairDocumentService.update_document(db, db_repair, document, payload, actor=current_user, request=request) + + +@router.get("/repairs/{repair_id}/documents/{document_id}/download") +def download_repair_document( + repair_id: int, + document_id: int, + disposition: str = Query(default="inline", pattern="^(inline|attachment)$"), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("repairs.read")), +): + get_repair_or_404(db, repair_id) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + file_path = RepairDocumentService.open_document_file(document) + return FileResponse( + file_path, + media_type=document.mime_type or "application/octet-stream", + headers={ + "Content-Disposition": RepairDocumentService.content_disposition( + document, + mode="attachment" if disposition == "attachment" else "inline", + ), + }, + ) + + +@router.delete("/repairs/{repair_id}/documents/{document_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_repair_document( + repair_id: int, + document_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) + document = RepairRepository.get_document(db, repair_id=repair_id, document_id=document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + RepairDocumentService.delete_document(db, db_repair, document, actor=current_user, request=request) + return None + + @router.get("/repairs/{repair_id}/public-link", response_model=RepairPublicLinkResponse) def get_public_link( repair_id: int, diff --git a/backend/hermes/app/models/repair.py b/backend/hermes/app/models/repair.py index a068d2c..53ba9e2 100644 --- a/backend/hermes/app/models/repair.py +++ b/backend/hermes/app/models/repair.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func +from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.database import Base @@ -41,6 +41,7 @@ class Repair(Base): history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin") public_access_tokens: Mapped[list["RepairPublicAccessToken"]] = relationship(back_populates="repair", cascade="all, delete-orphan") notification_events: Mapped[list["RepairNotificationEvent"]] = relationship(back_populates="repair", cascade="all, delete-orphan") + documents: Mapped[list["RepairDocument"]] = relationship(back_populates="repair", cascade="all, delete-orphan") class RepairStatusHistory(Base): @@ -89,10 +90,33 @@ class RepairDocument(Base): id: Mapped[int] = mapped_column(primary_key=True) repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True) file_id: Mapped[int | None] = mapped_column(Integer, nullable=True) - storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True) title: Mapped[str] = mapped_column(String(255)) document_type: Mapped[str] = mapped_column(String(80), index=True) + original_filename: Mapped[str] = mapped_column(String(255), default="", server_default="") + stored_filename: Mapped[str] = mapped_column(String(255), default="", server_default="") + storage_path: Mapped[str] = mapped_column(String(500), default="", server_default="") + mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="") + size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0") + checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True) + visibility: Mapped[str] = mapped_column(String(40), default="internal", server_default="internal", index=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + uploaded_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + repair: Mapped[Repair] = relationship(back_populates="documents") + uploaded_by = relationship("User", lazy="joined") + + @property + def uploaded_by_username(self) -> str: + return self.uploaded_by.username if self.uploaded_by is not None else "" + + @property + def uploaded_by_display_name(self) -> str: + if self.uploaded_by is None: + return "" + display_name = f"{self.uploaded_by.first_name} {self.uploaded_by.last_name}".strip() + return display_name or self.uploaded_by.username class RepairPublicAccessToken(Base): diff --git a/backend/hermes/app/repositories/repair_repository.py b/backend/hermes/app/repositories/repair_repository.py index fb115e0..a32e175 100644 --- a/backend/hermes/app/repositories/repair_repository.py +++ b/backend/hermes/app/repositories/repair_repository.py @@ -3,8 +3,8 @@ from datetime import UTC, datetime from sqlalchemy import Select, func, or_, select from sqlalchemy.orm import Session, selectinload -from app.models.repair import Repair, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory -from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate +from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory +from app.schemas.repair import RepairCreate, RepairDocumentUpdate, RepairStatusUpdate, RepairUpdate class RepairRepository: @@ -362,6 +362,77 @@ class RepairRepository: .where((Repair.customer_email == "") | Repair.customer_email.is_(None)) ) or 0 + @staticmethod + def list_documents(db: Session, repair_id: int) -> list[RepairDocument]: + return list( + db.scalars( + select(RepairDocument) + .where(RepairDocument.repair_id == repair_id) + .order_by(RepairDocument.created_at.desc(), RepairDocument.id.desc()) + ) + ) + + @staticmethod + def get_document(db: Session, *, repair_id: int, document_id: int) -> RepairDocument | None: + return db.scalar( + select(RepairDocument) + .where(RepairDocument.repair_id == repair_id) + .where(RepairDocument.id == document_id) + ) + + @staticmethod + def create_document( + db: Session, + *, + repair_id: int, + title: str, + document_type: str, + original_filename: str, + stored_filename: str, + storage_path: str, + mime_type: str, + size_bytes: int, + checksum_sha256: str, + visibility: str, + note: str | None, + uploaded_by_user_id: int | None, + ) -> RepairDocument: + document = RepairDocument( + repair_id=repair_id, + title=title, + document_type=document_type, + original_filename=original_filename, + stored_filename=stored_filename, + storage_path=storage_path, + mime_type=mime_type, + size_bytes=size_bytes, + checksum_sha256=checksum_sha256, + visibility=visibility, + note=note, + uploaded_by_user_id=uploaded_by_user_id, + ) + db.add(document) + db.commit() + db.refresh(document) + return RepairRepository.get_document(db, repair_id=repair_id, document_id=document.id) or document + + @staticmethod + def update_document(db: Session, document: RepairDocument, payload: RepairDocumentUpdate) -> RepairDocument: + for key, value in payload.model_dump().items(): + setattr(document, key, value) + db.commit() + db.refresh(document) + return RepairRepository.get_document(db, repair_id=document.repair_id, document_id=document.id) or document + + @staticmethod + def delete_document(db: Session, document: RepairDocument) -> None: + db.delete(document) + db.commit() + + @staticmethod + def count_documents(db: Session) -> int: + return db.scalar(select(func.count(RepairDocument.id))) 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 fcb3cfa..cb55b81 100644 --- a/backend/hermes/app/schemas/repair.py +++ b/backend/hermes/app/schemas/repair.py @@ -19,6 +19,16 @@ RepairStatus = Literal[ ] RepairPriority = Literal["low", "normal", "high", "urgent"] RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"] +RepairDocumentType = Literal[ + "device_photo", + "fault_photo", + "measurement", + "estimate", + "repair_report", + "shipping", + "other", +] +RepairDocumentVisibility = Literal["internal", "customer"] def normalize_text(value: object) -> str: @@ -222,3 +232,39 @@ class RepairNotificationEventResponse(BaseModel): class RepairNotificationOverviewResponse(BaseModel): templates: list[RepairNotificationTemplateResponse] events: list[RepairNotificationEventResponse] + + +class RepairDocumentBase(BaseModel): + title: str = Field(min_length=1, max_length=255) + document_type: RepairDocumentType = "other" + visibility: RepairDocumentVisibility = "internal" + note: str | None = None + + @field_validator("title", "note", mode="before") + @classmethod + def normalize_document_strings(cls, value: object) -> str | None: + if value is None: + return None + return normalize_text(value) + + +class RepairDocumentUpdate(RepairDocumentBase): + pass + + +class RepairDocumentResponse(RepairDocumentBase): + id: int + repair_id: int + original_filename: str + stored_filename: str + storage_path: str + mime_type: str + size_bytes: int + checksum_sha256: str + uploaded_by_user_id: int | None + uploaded_by_username: str = "" + uploaded_by_display_name: str = "" + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index d38a335..0ff681a 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -169,6 +169,9 @@ def action_title(action: str) -> str: "repairs.public_link.regenerate": "Reparatur-Statuslink erneut erstellt", "repairs.status_mail.sent": "Statusmail versendet", "repairs.status_mail.failed": "Statusmail fehlgeschlagen", + "repairs.documents.upload": "Reparaturdokument hochgeladen", + "repairs.documents.update": "Reparaturdokument geändert", + "repairs.documents.delete": "Reparaturdokument gelöscht", "system_settings.smtp.update": "SMTP-Konfiguration geändert", "system_settings.smtp.test_sent": "SMTP-Testmail versendet", "system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen", diff --git a/backend/hermes/app/services/repair_document_service.py b/backend/hermes/app/services/repair_document_service.py new file mode 100644 index 0000000..b0e9f2c --- /dev/null +++ b/backend/hermes/app/services/repair_document_service.py @@ -0,0 +1,225 @@ +import mimetypes +from pathlib import Path +from typing import Literal +from urllib.parse import quote + +from fastapi import HTTPException, UploadFile, status +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.models.repair import Repair, RepairDocument +from app.models.user import User +from app.repositories.repair_repository import RepairRepository +from app.schemas.repair import RepairDocumentUpdate +from app.services.audit_service import write_audit_log +from app.storage import get_storage_service +from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError + +ALLOWED_REPAIR_DOCUMENT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"} +ALLOWED_REPAIR_DOCUMENT_MIME_TYPES = { + "image/jpeg", + "image/png", + "image/webp", + "application/pdf", +} + + +DOCUMENT_TYPE_LABELS = { + "device_photo": "Gerätefoto", + "fault_photo": "Fehlerbild", + "measurement": "Messbild", + "estimate": "Kostenvoranschlag", + "repair_report": "Reparaturbericht", + "shipping": "Versandbeleg", + "other": "Dokument", +} + + +def storage_validation_error(exc: StorageValidationError) -> HTTPException: + detail = str(exc) or "Ungültige Datei" + status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE if "groß" in detail else status.HTTP_400_BAD_REQUEST + return HTTPException(status_code=status_code, detail=detail) + + +def _repair_label(repair: Repair) -> str: + return f"{repair.repair_number} · {repair.customer_name}" + + +def _document_label(document: RepairDocument) -> str: + return f"{document.title} · {document.original_filename}" + + +def _audit_document_data(document: RepairDocument) -> dict: + return { + "id": document.id, + "repair_id": document.repair_id, + "title": document.title, + "document_type": document.document_type, + "original_filename": document.original_filename, + "mime_type": document.mime_type, + "size_bytes": document.size_bytes, + "visibility": document.visibility, + "uploaded_by_user_id": document.uploaded_by_user_id, + "created_at": document.created_at, + "updated_at": document.updated_at, + } + + +class RepairDocumentService: + @staticmethod + async def upload_document( + db: Session, + repair: Repair, + *, + file: UploadFile, + title: str, + document_type: str, + visibility: str, + note: str | None, + actor: User, + request: Request, + ) -> RepairDocument: + storage_service = get_storage_service() + max_bytes = storage_service.max_upload_mb * 1024 * 1024 + content = await file.read(max_bytes + 1) + original_filename = file.filename or "" + mime_type = RepairDocumentService._resolve_mime_type(original_filename, file.content_type) + + RepairDocumentService._validate_repair_document_type(original_filename, mime_type) + + try: + metadata = storage_service.save_file( + namespace=f"repairs/{repair.id}/documents", + content=content, + original_filename=original_filename, + mime_type=mime_type, + ) + except StorageValidationError as exc: + raise storage_validation_error(exc) from exc + + try: + document = RepairRepository.create_document( + db, + repair_id=repair.id, + title=title, + document_type=document_type, + original_filename=metadata.original_filename, + stored_filename=metadata.stored_filename, + storage_path=metadata.storage_key, + mime_type=metadata.mime_type, + size_bytes=metadata.size, + checksum_sha256=metadata.checksum_sha256, + visibility=visibility, + note=note, + uploaded_by_user_id=actor.id, + ) + except Exception: + storage_service.delete_file(metadata.storage_key) + raise + + write_audit_log( + db, + action="repairs.documents.upload", + entity_type="repair_documents", + entity_id=document.id, + entity_label=_document_label(document), + actor=actor, + request=request, + metadata={ + "repair_id": repair.id, + "repair_number": repair.repair_number, + "document_type": document.document_type, + "visibility": document.visibility, + "mime_type": document.mime_type, + "size_bytes": document.size_bytes, + }, + ) + return document + + @staticmethod + def update_document( + db: Session, + repair: Repair, + document: RepairDocument, + payload: RepairDocumentUpdate, + *, + actor: User, + request: Request, + ) -> RepairDocument: + before_data = _audit_document_data(document) + updated = RepairRepository.update_document(db, document, payload) + write_audit_log( + db, + action="repairs.documents.update", + entity_type="repair_documents", + entity_id=updated.id, + entity_label=_document_label(updated), + actor=actor, + request=request, + before_data=before_data, + after_data=_audit_document_data(updated), + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + return updated + + @staticmethod + def delete_document( + db: Session, + repair: Repair, + document: RepairDocument, + *, + actor: User, + request: Request, + ) -> None: + before_data = _audit_document_data(document) + storage_path = document.storage_path + label = _document_label(document) + try: + get_storage_service().delete_file(storage_path) + except StorageValidationError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc + + RepairRepository.delete_document(db, document) + write_audit_log( + db, + action="repairs.documents.delete", + entity_type="repair_documents", + entity_id=document.id, + entity_label=label, + actor=actor, + request=request, + before_data=before_data, + metadata={"repair_id": repair.id, "repair_number": repair.repair_number}, + ) + + @staticmethod + def open_document_file(document: RepairDocument) -> Path: + if not document.storage_path: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") + try: + return get_storage_service().open_file(document.storage_path) + except StorageFileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") from exc + except StorageValidationError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc + + @staticmethod + def content_disposition(document: RepairDocument, *, mode: Literal["inline", "attachment"]) -> str: + filename = document.original_filename or document.stored_filename or "reparatur-dokument" + safe_filename = filename.replace('"', "") + encoded_filename = quote(filename) + return f'{mode}; filename="{safe_filename}"; filename*=UTF-8\'\'{encoded_filename}' + + @staticmethod + def _resolve_mime_type(original_filename: str, content_type: str | None) -> str: + if content_type and content_type != "application/octet-stream": + return content_type + return mimetypes.guess_type(original_filename)[0] or "application/octet-stream" + + @staticmethod + def _validate_repair_document_type(original_filename: str, mime_type: str) -> None: + extension = Path(original_filename or "").suffix.lower() + if extension not in ALLOWED_REPAIR_DOCUMENT_EXTENSIONS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt") + if mime_type not in ALLOWED_REPAIR_DOCUMENT_MIME_TYPES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt") diff --git a/backend/hermes/app/storage/local.py b/backend/hermes/app/storage/local.py index 9267a12..aaab81c 100644 --- a/backend/hermes/app/storage/local.py +++ b/backend/hermes/app/storage/local.py @@ -87,6 +87,7 @@ class LocalDiskStorageProvider(StorageProvider): "customers", "projects", "tickets", + "repairs", "imports", "temp", ]: diff --git a/frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts new file mode 100644 index 0000000..b36d87f --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/download/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesStreamRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ + id: string; + documentId: string; + }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id, documentId } = await params; + return proxyHermesStreamRequest( + request, + `/repairs/${id}/documents/${documentId}/download${request.nextUrl.search}`, + ); +} diff --git a/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts new file mode 100644 index 0000000..734fa67 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/[documentId]/route.ts @@ -0,0 +1,40 @@ +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; + documentId: string; + }>; +}; + +async function proxyDocumentRequest(request: NextRequest, { params }: Params) { + const { id, documentId } = await params; + return proxyHermesRequest(request, `/repairs/${id}/documents/${documentId}`); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyDocumentRequest(request, context); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyDocumentRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyDocumentRequest(request, context); +} diff --git a/frontend/athena/app/api/repairs/[id]/documents/route.ts b/frontend/athena/app/api/repairs/[id]/documents/route.ts new file mode 100644 index 0000000..1db71c3 --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +export async function GET(request: NextRequest, { params }: Params) { + const { id } = await params; + return proxyHermesRequest(request, `/repairs/${id}/documents`); +} diff --git a/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts b/frontend/athena/app/api/repairs/[id]/documents/upload/route.ts new file mode 100644 index 0000000..90cd43d --- /dev/null +++ b/frontend/athena/app/api/repairs/[id]/documents/upload/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}/documents/upload`); +} diff --git a/frontend/athena/app/repairs/[id]/page.tsx b/frontend/athena/app/repairs/[id]/page.tsx index ebff51b..9bb4f9a 100644 --- a/frontend/athena/app/repairs/[id]/page.tsx +++ b/frontend/athena/app/repairs/[id]/page.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, CheckCircle2, Copy, Edit, Link2, Mail, Send, ShieldCheck, Wr import DetailSection from "@/components/common/DetailSection"; import { useToast } from "@/components/common/ToastProvider"; +import RepairDocumentsSection from "@/components/repairs/RepairDocumentsSection"; import RepairFormDialog from "@/components/repairs/RepairFormDialog"; import { RepairPriorityBadge, RepairStatusBadge, statusLabels } from "@/components/repairs/RepairStatusBadge"; import RepairStatusDialog from "@/components/repairs/RepairStatusDialog"; @@ -426,6 +427,10 @@ export default function RepairDetailPage({ params }: Params) { })()} + + + +

Reparaturaktionen werden in den Audit Logs erfasst und erscheinen im Activity Feed, wenn `repairs.read` vorhanden ist.

Audit Logs öffnen diff --git a/frontend/athena/components/repairs/RepairDocumentsSection.tsx b/frontend/athena/components/repairs/RepairDocumentsSection.tsx new file mode 100644 index 0000000..f2a6265 --- /dev/null +++ b/frontend/athena/components/repairs/RepairDocumentsSection.tsx @@ -0,0 +1,454 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { + Download, + Eye, + FileText, + ImageIcon, + Plus, + Trash2, + Upload, + X, + ZoomIn, + ZoomOut, +} from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import { useToast } from "@/components/common/ToastProvider"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/api"; +import type { RepairDocument, RepairDocumentType, RepairDocumentVisibility } from "@/types/repair"; + +const documentTypeLabels: Record = { + device_photo: "Gerätefoto", + fault_photo: "Fehlerbild", + measurement: "Messbild", + estimate: "Kostenvoranschlag", + repair_report: "Reparaturbericht", + shipping: "Versandbeleg", + other: "Sonstiges", +}; + +const documentTypes = Object.entries(documentTypeLabels) as Array<[RepairDocumentType, string]>; + +function getErrorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; + return response?.data?.detail ?? response?.data?.message ?? "Aktion konnte nicht abgeschlossen werden"; + } + return "Aktion konnte nicht abgeschlossen werden"; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); +} + +function formatSize(value: number) { + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +function isImage(document: RepairDocument) { + return document.mime_type.startsWith("image/"); +} + +function isPdf(document: RepairDocument) { + return document.mime_type === "application/pdf"; +} + +function downloadUrl(repairId: number, documentId: number, disposition: "inline" | "attachment" = "inline") { + return `/api/repairs/${repairId}/documents/${documentId}/download?disposition=${disposition}`; +} + +type Props = { + repairId: number; + canUpdate: boolean; +}; + +export default function RepairDocumentsSection({ repairId, canUpdate }: Props) { + const { showToast } = useToast(); + const fileInputRef = useRef(null); + const [documents, setDocuments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [uploadOpen, setUploadOpen] = useState(false); + const [title, setTitle] = useState(""); + const [documentType, setDocumentType] = useState("device_photo"); + const [visibility, setVisibility] = useState("internal"); + const [note, setNote] = useState(""); + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [previewDocument, setPreviewDocument] = useState(null); + const [zoom, setZoom] = useState(1); + + const loadDocuments = useCallback(async () => { + setLoading(true); + setError(""); + try { + const response = await api.get(`/repairs/${repairId}/documents`); + setDocuments(response.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [repairId]); + + useEffect(() => { + queueMicrotask(() => { + void loadDocuments(); + }); + }, [loadDocuments]); + + const imageDocuments = useMemo(() => documents.filter(isImage), [documents]); + + useEffect(() => { + if (!previewDocument) return; + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setPreviewDocument(null); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [previewDocument]); + + function resetUploadForm() { + setTitle(""); + setDocumentType("device_photo"); + setVisibility("internal"); + setNote(""); + setSelectedFile(null); + setUploadProgress(0); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + + async function uploadDocument() { + if (!selectedFile) { + showToast({ type: "error", title: "Keine Datei ausgewählt" }); + return; + } + + const formData = new FormData(); + formData.append("file", selectedFile); + formData.append("title", title.trim() || selectedFile.name); + formData.append("document_type", documentType); + formData.append("visibility", visibility); + formData.append("note", note.trim()); + + setUploading(true); + setUploadProgress(0); + try { + await api.post(`/repairs/${repairId}/documents/upload`, formData, { + onUploadProgress: (event) => { + if (event.total) { + setUploadProgress(Math.round((event.loaded / event.total) * 100)); + } + }, + }); + await loadDocuments(); + resetUploadForm(); + setUploadOpen(false); + showToast({ type: "success", title: "Dokument hochgeladen" }); + } catch (err) { + showToast({ + type: "error", + title: "Upload fehlgeschlagen", + description: getErrorMessage(err), + }); + } finally { + setUploading(false); + } + } + + async function deleteDocument() { + if (!deleteTarget) return; + setDeleting(true); + try { + await api.delete(`/repairs/${repairId}/documents/${deleteTarget.id}`); + setDocuments((current) => current.filter((item) => item.id !== deleteTarget.id)); + setDeleteTarget(null); + showToast({ type: "success", title: "Dokument gelöscht" }); + } catch (err) { + showToast({ + type: "error", + title: "Dokument konnte nicht gelöscht werden", + description: getErrorMessage(err), + }); + } finally { + setDeleting(false); + } + } + + function openDocument(document: RepairDocument) { + if (isImage(document)) { + setPreviewDocument(document); + setZoom(1); + return; + } + window.open(downloadUrl(repairId, document.id, "inline"), "_blank", "noopener,noreferrer"); + } + + if (loading) { + return
Dokumente werden geladen...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+
+

+ {documents.length} Datei{documents.length === 1 ? "" : "en"} gespeichert +

+ {canUpdate && ( + + )} +
+ + {documents.length === 0 ? ( +
+

Noch keine Dokumente oder Bilder vorhanden.

+

+ Gerätefotos, Messbilder, PDF-Berichte und Belege werden sicher im Olympus Storage gespeichert. +

+ {canUpdate && ( +
+ +
+ )} +
+ ) : ( +
+ {documents.map((document) => ( +
+ + +
+
+
+ {isImage(document) ? : } +
+

{document.title}

+

{document.original_filename}

+
+
+ {document.note &&

{document.note}

} +
+ +
+ + + + + + +
+ +
+ + + + Download + + {canUpdate && ( + + )} +
+
+
+ ))} +
+ )} + + { + setUploadOpen(open); + if (!open && !uploading) resetUploadForm(); + }}> + + + Dokument hochladen + JPG, PNG, WEBP und PDF werden im Reparatur-Storage abgelegt. + + +
+ + { + const file = event.target.files?.[0] ?? null; + setSelectedFile(file); + if (file && !title) setTitle(file.name); + }} + /> + + + setTitle(event.target.value)} /> + +
+ + + + + + +
+ +