diff --git a/docs/craftvia/lanes/benachrichtigungen.md b/docs/craftvia/lanes/benachrichtigungen.md new file mode 100644 index 0000000..62dd2f6 --- /dev/null +++ b/docs/craftvia/lanes/benachrichtigungen.md @@ -0,0 +1,108 @@ +# Lane L6 – Benachrichtigungen & Audit + +Branch `lane/benachrichtigungen` (Basis `bf44567`, `feature/craftvia-mvp`). Spec §19.4, §20, §26, §33, ARCHITEKTUR §4.1. + +## Umfang / erfüllte Spec-Punkte + +| Punkt | Umsetzung | +|---|---| +| §20.1 Ereignisse, ARCHITEKTUR §4.1 | `handleEvent` (Signatur unverändert) löst alle 17 `EVENT_TYPES` auf: In-App-`Notification` über `ctx.db` + E-Mail über `enqueueMail` | +| §20.2 Kanäle | In-App (Glocke, `/notifications`) + E-Mail (Mail-Queue, MailLog) | +| §19.4 Notdienst | Pflichtmail an Backoffice + feste Notdienst-Empfänger, Format „Neuer Notdiensteinsatz abgeschlossen / Monteur / Kunde / Einsatzbeginn / Einsatzende / Status" | +| §33.1 E-Mail-Ereignisse | Templates `craftvia_team_assigned`, `craftvia_report_review`, `craftvia_billing_release`, `craftvia_emergency`, `craftvia_document_failed`, `craftvia_notification` (de/en, Link in die App über `APP_BASE_URL`, Craftvia-Fußzeile mit Grund des Empfangs) | +| §33.2 Konfiguration | `/settings/email` (tenant:manage): Absendername, Antwortadresse, Empfänger Notdienst, Empfänger Abrechnung (je max. 20). Absenderadresse bleibt Plattform-Domain (SPF/DKIM) – nur Hinweis. Vorlagen je Mandant: nicht im MVP | +| §26 Audit | `/settings/audit` (audit:read): Filter Zeitraum/Benutzer/Aktion/Objektart/Objekt-ID, Pagination, Detail-Popup mit before/after-Diff, Ergebnis (erfolgreich/abgelehnt), IP/User-Agent (derzeit „nicht erfasst", s. Fundament-Bedarf) | +| Nutzer-Einstellungen | `/account` Abschnitt „Benachrichtigungen": E-Mail-Opt-out je Typ, Notdienst als Pflicht (nicht abwählbar) | + +### Empfängerregeln (`src/server/services/notifications/recipients.ts`) + +| Event | In-App | E-Mail | +|---|---|---| +| `work_order.assigned` / `changed` / `cancelled` | aktive Teammitglieder (validFrom/validTo) + Teamleiter des Teams + `teamLeadUserId` + Einzel-Assignees | dieselben | +| `work_order.started` / `daily_report_created` / `technically_completed` / `signature_missing` / `missing_required` | Backoffice = Nutzer mit `work_order:read_all` **und** `report:approve` | dieselben | +| `report.submitted` | `data.approvalStage="team"` → Teamleiter des Auftrags mit `report:approve_team`; `"backoffice"` → Backoffice; ohne Angabe → beide | dieselben | +| `report.approved` / `rejected` | Ersteller (`createdById`) + Team des Auftrags | dieselben | +| `work_order.released_for_billing` | Nutzer mit `work_order:release_billing` | feste Abrechnungsempfänger; sind keine hinterlegt, die Nutzer selbst | +| `emergency.created` / `completed` | Backoffice | Backoffice + feste Notdienst-Empfänger, **Pflicht** (Opt-out wirkungslos) | +| `import.ready_for_review` / `import.failed` | importierender Nutzer (`importedById`) | derselbe | +| `sync.failed` | betroffener Nutzer (`SyncOperation.userId`) + Backoffice | dieselben | + +Allgemein: Akteur (`ctx.userId`) ausgenommen – außer bei System-Ergebnis-Events (`import.*`, `sync.failed`), deren Betroffener sonst nie informiert würde. Alle IDs werden über `ctx.db.user` (Status ACTIVE) neu aufgelöst → nie Nutzer anderer Mandanten. Feste Adressen, die zugleich einem Nutzer-Empfänger gehören, erhalten keine Doppelmail. Sprache je Empfänger: `Identity.uiLocale` → Mandanten-Locale → `de`. Link je Empfänger: Backoffice (`work_order:read_all`) → `/work-orders/…`, `/reports/…`; sonst mobile Routen `/m/orders/…`, `/m/sync`. + +### Verträge für andere Lanes (über `DomainEvent.data`) + +- `occurrenceId` (string/number): für wiederholbare Events (z. B. Tagesbericht je Tag, mehrere `changed`) – wird an den Mail-`dedupeKey` gehängt. Ohne `occurrenceId` gilt `event:entity:user` (gleiches Event zweimal → eine Mail). +- `approvalStage` (`"team"` | `"backoffice"`) bei `report.submitted` (L5). +- `startedAt` / `endedAt` (ISO) und optional `technician` bei `emergency.*` (L8); Fallback: geplanter Beginn bzw. Erstellzeit, Ende = Eventzeit, Monteur = Akteur. +- `reason` bei `sync.failed` (Fallback, falls `SyncOperation.errorCode` leer). +- In-App-Dedupe: existiert eine **ungelesene** Benachrichtigung gleichen Typs zur selben Entität, wird sie aufgefrischt statt verdoppelt. + +## Dateien + +Neu: +- `src/server/services/notifications/{recipients,texts,inbox,preferences,mail-settings,page-ctx}.ts`, `handle-event.ts` (Platzhalter ersetzt) +- `src/server/services/audit/viewer.ts` (Audit-Viewer-Service; neuer Pfad ohne Owner, fachlich Teil dieser Lane) +- `src/server/actions/notifications/{inbox,preferences,mail-settings}.ts` (je `moduleGuard("notifications")` + `await guard(...)`) +- `src/components/notifications/{bell,preferences-section,preferences-form}.tsx` +- `src/app/(app)/notifications/page.tsx` (Platzhalter ersetzt), `src/app/(app)/settings/email/{layout,page}.tsx`, `src/app/(app)/settings/audit/page.tsx` +- `messages/{de,en}/notifications.json` +- `prisma/migrations/20260914120000_benachrichtigungen_tenant_mail_settings/migration.sql` +- `scripts/test-benachrichtigungen-events.ts`, `scripts/test-benachrichtigungen-inbox-audit.ts` + +Erlaubte Fremd-Eingriffe: +- `src/server/mail/templates.ts`: nur neue Template-Keys + `CRAFTVIA_TEMPLATE_KEYS` + Craftvia-Fußzeilen (SEC1-`TEMPLATE_KEYS` unverändert, damit `test-mail.ts` gleich bleibt) +- `src/app/(app)/layout.tsx`: `` im Header (Import + 1 Zeile) +- `src/app/(app)/account/page.tsx`: `` (Import + neuer Abschnitt) +- `src/lib/nav.ts`: Einträge `/notifications`, `/settings/email`, `/settings/audit` (+ Icons) und passende Labels in `messages/{de,en}/nav.json` +- `src/components/audit-trail.tsx`: Entity-Labels der Craftvia-Entitäten +- `prisma/schema.prisma`: 4 Felder an `TenantSettings` + +## Migration (begründet) + +`TenantSettings` hatte keine Mailfelder (nur Fundament-`smtp Json`, das für SMTP-Zugangsdaten gedacht ist). Neue Spalten `mail_from_name`, `mail_reply_to`, `emergency_recipients TEXT[]`, `billing_recipients TEXT[]`. Keine neue Tabelle → kein `enable_tenant_rls`, keine TENANT_MODELS-Änderung (tenant_settings ist bereits mandantengebunden). + +## Tests + +| Skript | Prüfungen | Ergebnis | +|---|---|---| +| `test-benachrichtigungen-events.ts` | 63: Empfänger je Eventgruppe (assigned inkl. Einzel-Assignee, cancelled, started, report.submitted team/ohne Stufe, report.approved, released_for_billing, emergency.completed, import.failed, sync.failed), Akteur ausgenommen, Mandantentrennung (fremder Kontext, fremde Entität, fremde User-ID als Ersteller), Dedupe, occurrenceId, Opt-out, Pflichtmail Notdienst trotz Opt-out, feste Empfänger ohne Doppelmail, handleEvent/emitEvent werfen nie, Templates de/en + §19.4-Format | grün | +| `test-benachrichtigungen-inbox-audit.ts` | 46: Posteingang nur eigene + Filter, Monteur/Mandant B → `not_found` bei fremder Benachrichtigung, ohne `notification:read` → `forbidden`, markAllRead mandantengetrennt, Audit bei markRead, Open-Redirect-Schutz, Präferenz-Defaults, Mailkonfiguration nur `tenant:manage` + Validierung (Header-Injection, ungültige/zu viele Adressen) + Mandantentrennung + Audit, Audit-Viewer nur mit `audit:read`, nur eigener Mandant, Filter, Detail-Diff | grün | + +**Gate (`npm run gate`): grün** – prisma generate, tsc, lint, build inkl. Modul-Guard-Check (16 Action-Dateien), 24/24 Testskripte. + +Hinweis Umgebung: Mit eigener Lane-Datenbank muss `RLS_DATABASE_URL` auf dieselbe DB zeigen +(`postgresql://craftvia_app:craftvia_app_local@localhost:5432/craftvia_benachrichtigungen?schema=public`), +sonst schlägt `test-rls-enforcement.ts` fehl: Owner-Client und `craftvia_app`-Client landen dann in verschiedenen Datenbanken. Das hat nichts mit dem Code dieser Lane zu tun; in der kopierten `.env` ist die Zeile auskommentiert. + +**Smoke (Dev-Server :3106, Server-Rendering per HTTP, Seed-Mandant „demo")**, 13 Prüfungen grün: +`/notifications` inkl. Filter Status/Typ, `/settings/email`, `/settings/audit` inkl. Detail-Popup, `/account` (Abschnitt Benachrichtigungen), Glocke und Navigation im Header/Sidebar (Admin); +Backoffice sieht das Audit-Protokoll, wird von `/settings/email` umgeleitet; Monteur wird von `/settings/audit` umgeleitet und sieht keine Admin-Navigation. +Sitzungen wurden lokal ohne Passworteingabe erzeugt (`finalizeIdentityLogin` + `AUTH_SECRET`); die Smoke-Daten wurden danach entfernt. Visuelle Prüfung (Responsive 1024/768/375 px) steht noch aus. + +## Stubs / Abhängigkeiten + +- Keine Stubs nötig: Empfängerauflösung liest direkt die Domänentabellen (WorkOrder, Team, TeamMember, WorkOrderAssignee, Report, ImportJob, Document, SyncOperation). +- **L4 (Mobile-Header):** Glocke einbinden mit `import { NotificationBell } from "@/components/notifications/bell";` und `` (48-px-Touchziel). Server-Komponente, blendet sich ohne `notification:read`/bei deaktiviertem Modul selbst aus. +- **L2/L4/L5/L8:** Links zeigen auf `/work-orders/[id]`, `/work-orders/conflicts`, `/reports/[id]`, `/m/orders/[id]`, `/m/orders/[id]/report`, `/m/sync`, `/imports/[id]` (Routen laut ARCHITEKTUR §5). +- Settings-Übersicht (`/settings/page.tsx`, Fundament) verlinkt die neuen Seiten noch nicht; erreichbar über die Sidebar. + +## Fundament-Bedarf (nicht selbst geändert) + +1. **IP/User-Agent im Audit-Log (Spec §26):** `writeAuditLog` erfasst weder IP noch User-Agent, `AuditLog` hat keine Spalten dafür. Vorschlag: Spalten `ip`, `user_agent` + Ermittlung aus `headers()` im action-guard. Der Viewer zeigt die Werte bereits an, sobald `after.ip`/`after.userAgent` bzw. künftige Spalten gefüllt sind (derzeit „nicht erfasst"). +2. **Absendername/Reply-To je Mandant im Mail-Kern:** `deliverMail` nutzt nur globale `MAIL_FROM_NAME`/`MAIL_REPLY_TO`. Benötigt: optionale `fromName`/`replyTo` in `EnqueueInput`/`MailJob` und deren Verwendung in `deliver.ts`. Die gespeicherten Werte liefert `tenantMailSender(ctx)` (`services/notifications/mail-settings.ts`); bis dahin werden sie gespeichert, aber beim Versand noch nicht angewendet. +3. `src/server/mail/notifications.ts#notifyUser` (SEC1) bleibt bestehen; Craftvia-Fachmodule nutzen ausschließlich `emitEvent`. AGENTS.md-Andockpunkt „Benachrichtigungen" sollte auf `emitEvent` zeigen. + +## Bekannte Lücken + +- Mandanteneigene E-Mail-Vorlagen (§33.2 „E-Mail-Vorlagen") nicht umgesetzt. +- Glocke aktualisiert sich bei Navigation/Aktion (Server-Rendering), kein Live-Push/Polling. +- Kein Aufräumen alter Benachrichtigungen (Löschfrist) – Kandidat für einen Job. +- In-App-Opt-out gibt es bewusst nicht (nur E-Mail). + +## Screens / Routen + +- `/notifications` – Liste mit Filter Status/Typ, „Öffnen" (markiert gelesen + springt zur Entität), „Als gelesen markieren", „Alle als gelesen markieren" +- Glocke im Backoffice-Header – Zähler ungelesen, Dropdown letzte 10, „Alle gelesen", „Alle anzeigen" +- `/account` – Abschnitt „Benachrichtigungen" +- `/settings/email` – Mandanten-Mailkonfiguration +- `/settings/audit` – Audit-Protokoll mit Filter und Detail-Popup (`?detail=`) diff --git a/messages/de/nav.json b/messages/de/nav.json index a3ecd70..c9996f9 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -8,5 +8,8 @@ "reports": "Berichte", "documents": "Dokumente", "settings": "Einstellungen", + "notifications": "Benachrichtigungen", + "audit": "Audit-Protokoll", + "email": "E-Mail-Versand", "admin": "Admin-Konsole" } diff --git a/messages/de/notifications.json b/messages/de/notifications.json new file mode 100644 index 0000000..4262b25 --- /dev/null +++ b/messages/de/notifications.json @@ -0,0 +1,183 @@ +{ + "bell": { + "label": "Benachrichtigungen", + "unreadCount": "{count, plural, =0 {Keine ungelesenen} one {# ungelesen} other {# ungelesen}}", + "empty": "Keine Benachrichtigungen.", + "markAllRead": "Alle gelesen", + "showAll": "Alle anzeigen" + }, + "list": { + "crumb": "Übersicht", + "title": "Benachrichtigungen", + "sub": "Zuweisungen, Berichte, Notdienst und Fehler – neueste zuerst.", + "filterStatus": "Status", + "filterType": "Art", + "statusAll": "Alle", + "statusUnread": "Ungelesen", + "statusRead": "Gelesen", + "typeAll": "Alle Arten", + "apply": "Filtern", + "reset": "Zurücksetzen", + "empty": "Keine Benachrichtigungen für diese Auswahl.", + "unread": "Neu", + "read": "Gelesen", + "open": "Öffnen", + "markRead": "Als gelesen markieren", + "markAllRead": "Alle als gelesen markieren", + "previous": "Zurück", + "next": "Weiter", + "page": "Seite {page} von {pages}" + }, + "types": { + "work_order_assigned": "Teamzuweisung", + "work_order_changed": "Auftrag geändert", + "work_order_cancelled": "Auftrag storniert", + "work_order_started": "Auftrag gestartet", + "work_order_daily_report_created": "Tagesbericht erstellt", + "work_order_technically_completed": "Technisch abgeschlossen", + "work_order_signature_missing": "Unterschrift fehlt", + "work_order_missing_required": "Pflichtangaben fehlen", + "work_order_released_for_billing": "Zur Abrechnung freigegeben", + "report_submitted": "Bericht zur Prüfung", + "report_approved": "Bericht freigegeben", + "report_rejected": "Bericht zurückgewiesen", + "emergency_created": "Notdiensteinsatz erstellt", + "emergency_completed": "Notdiensteinsatz abgeschlossen", + "import_ready_for_review": "Import zur Prüfung", + "import_failed": "Fehler bei Dokumentverarbeitung", + "sync_failed": "Synchronisationsfehler" + }, + "events": { + "work_order_assigned": { "title": "Neuer Auftrag {number}", "message": "{title} – {customer} wurde Ihrem Team zugewiesen." }, + "work_order_changed": { "title": "Auftrag {number} geändert", "message": "{title} – {customer}: Angaben wurden aktualisiert." }, + "work_order_cancelled": { "title": "Auftrag {number} storniert", "message": "{title} – {customer} wurde storniert." }, + "work_order_started": { "title": "Auftrag {number} gestartet", "message": "{actor} hat den Einsatz bei {customer} begonnen." }, + "work_order_daily_report_created": { "title": "Tagesbericht zu {number}", "message": "{actor} hat einen Tagesbericht für {customer} erstellt." }, + "work_order_technically_completed": { "title": "Auftrag {number} technisch abgeschlossen", "message": "{title} – {customer}: Einsatz vor Ort beendet." }, + "work_order_signature_missing": { "title": "Unterschrift fehlt: {number}", "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." }, + "work_order_missing_required": { "title": "Pflichtangaben fehlen: {number}", "message": "{title} – {customer}: Dokumentation unvollständig." }, + "work_order_released_for_billing": { "title": "Auftrag {number} zur Abrechnung", "message": "{title} – {customer} ist bereit zur Abrechnung." }, + "report_submitted": { "title": "Bericht zur Prüfung: {number}", "message": "{actor} hat einen Bericht zu {title} eingereicht." }, + "report_approved": { "title": "Bericht freigegeben: {number}", "message": "Der Bericht zu {title} wurde freigegeben." }, + "report_rejected": { "title": "Bericht zurückgewiesen: {number}", "message": "Der Bericht zu {title} braucht eine Korrektur." }, + "emergency_created": { "title": "Notdiensteinsatz {number} erstellt", "message": "{actor} hat einen Notdiensteinsatz bei {customer} angelegt." }, + "emergency_completed": { "title": "Notdiensteinsatz {number} abgeschlossen", "message": "{customer}: zur Prüfung und Abrechnung." }, + "import_ready_for_review": { "title": "Import zur Prüfung bereit", "message": "Die Daten aus {fileName} liegen zur Prüfung vor." }, + "import_failed": { "title": "Fehler bei Dokumentverarbeitung", "message": "{fileName} konnte nicht verarbeitet werden." }, + "sync_failed": { "title": "Synchronisation fehlgeschlagen", "message": "Eine Änderung konnte nicht übernommen werden ({reason})." } + }, + "fallback": { + "unknown": "unbekannt", + "system": "System", + "document": "Dokument" + }, + "preferences": { + "title": "Benachrichtigungen", + "sub": "In der App erhalten Sie alle Benachrichtigungen. Hier legen Sie fest, welche zusätzlich per E-Mail kommen.", + "email": "E-Mail", + "mandatory": "Pflicht – nicht abbestellbar", + "save": "Speichern", + "saved": "Einstellungen gespeichert." + }, + "mailSettings": { + "crumb": "Einstellungen", + "title": "E-Mail-Versand", + "sub": "Absender, Antwortadresse und feste Empfänger für Notdienst und Abrechnung.", + "back": "Zurück zu Einstellungen", + "sender": "Absender", + "fromName": "Absendername", + "fromNameHint": "Erscheint als Name im Posteingang. Leer = Firmenname.", + "fromAddress": "Absenderadresse", + "fromAddressHint": "Die Adresse bleibt {address}, damit Mails zugestellt werden (SPF/DKIM).", + "replyTo": "Antwortadresse", + "replyToHint": "Antworten auf Benachrichtigungen gehen an diese Adresse.", + "recipients": "Feste Empfänger", + "emergencyRecipients": "Empfänger Notdienst", + "emergencyHint": "Eine Adresse je Zeile. Erhalten jeden neuen und abgeschlossenen Notdiensteinsatz.", + "billingRecipients": "Empfänger Abrechnung", + "billingHint": "Eine Adresse je Zeile. Erhalten Aufträge, die zur Abrechnung freigegeben sind. Leer = Backoffice.", + "save": "Speichern", + "saved": "Einstellungen gespeichert.", + "invalid": "Bitte Eingaben prüfen: {detail}" + }, + "audit": { + "crumb": "Einstellungen", + "title": "Audit-Protokoll", + "sub": "Wer hat wann was geändert – nur lesbar.", + "back": "Zurück zu Einstellungen", + "from": "Von", + "to": "Bis", + "actor": "Benutzer", + "actorAll": "Alle Benutzer", + "action": "Aktion", + "actionAll": "Alle Aktionen", + "entity": "Objektart", + "entityAll": "Alle Objektarten", + "entityId": "Objekt-ID", + "apply": "Filtern", + "reset": "Zurücksetzen", + "time": "Zeitpunkt", + "object": "Objekt", + "result": "Ergebnis", + "resultOk": "Erfolgreich", + "resultDenied": "Abgelehnt", + "details": "Details", + "empty": "Keine Einträge für diese Auswahl.", + "total": "{count, plural, one {# Eintrag} other {# Einträge}}", + "previous": "Zurück", + "next": "Weiter", + "page": "Seite {page} von {pages}", + "system": "System", + "platform": "Plattform", + "detailTitle": "Eintrag im Audit-Protokoll", + "close": "Schließen", + "field": "Feld", + "before": "Vorher", + "after": "Nachher", + "changed": "geändert", + "noValues": "Keine Werte protokolliert.", + "ip": "IP-Adresse", + "userAgent": "User-Agent", + "notCaptured": "nicht erfasst", + "actions": { + "create": "Erstellt", + "update": "Geändert", + "delete": "Gelöscht", + "login": "Anmeldung", + "logout": "Abmeldung", + "denied": "Abgelehnt", + "export": "Export", + "import": "Import", + "provision": "Eingerichtet", + "approve": "Freigegeben", + "reject": "Zurückgewiesen" + }, + "entities": { + "user": "Benutzer", + "role": "Rolle", + "tenant": "Mandant", + "tenant_settings": "Einstellungen", + "tenant_mail_settings": "E-Mail-Versand", + "tenant_module": "Modul", + "module": "Modul", + "session": "Sitzung", + "account_inactive": "Inaktives Konto", + "work_order": "Auftrag", + "customer": "Kunde", + "site": "Objekt", + "team": "Team", + "report": "Bericht", + "signature": "Unterschrift", + "document": "Dokument", + "import_job": "Auftragsimport", + "material_usage": "Material", + "time_entry": "Arbeitszeit", + "work_session": "Einsatzzeit", + "photo": "Foto", + "voice_note": "Sprachnotiz", + "notification": "Benachrichtigung", + "notification_settings": "Benachrichtigungseinstellungen", + "sync_operation": "Synchronisation" + } + } +} diff --git a/messages/en/nav.json b/messages/en/nav.json index 8a208a6..ebd2d6e 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -8,5 +8,8 @@ "reports": "Reports", "documents": "Documents", "settings": "Settings", + "notifications": "Notifications", + "audit": "Audit log", + "email": "E-mail delivery", "admin": "Admin console" } diff --git a/messages/en/notifications.json b/messages/en/notifications.json new file mode 100644 index 0000000..2bd8e15 --- /dev/null +++ b/messages/en/notifications.json @@ -0,0 +1,183 @@ +{ + "bell": { + "label": "Notifications", + "unreadCount": "{count, plural, =0 {No unread} one {# unread} other {# unread}}", + "empty": "No notifications.", + "markAllRead": "Mark all read", + "showAll": "Show all" + }, + "list": { + "crumb": "Overview", + "title": "Notifications", + "sub": "Assignments, reports, emergency call-outs and errors – newest first.", + "filterStatus": "Status", + "filterType": "Type", + "statusAll": "All", + "statusUnread": "Unread", + "statusRead": "Read", + "typeAll": "All types", + "apply": "Filter", + "reset": "Reset", + "empty": "No notifications for this selection.", + "unread": "New", + "read": "Read", + "open": "Open", + "markRead": "Mark as read", + "markAllRead": "Mark all as read", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" + }, + "types": { + "work_order_assigned": "Team assignment", + "work_order_changed": "Work order changed", + "work_order_cancelled": "Work order cancelled", + "work_order_started": "Work order started", + "work_order_daily_report_created": "Daily report created", + "work_order_technically_completed": "Technically completed", + "work_order_signature_missing": "Signature missing", + "work_order_missing_required": "Required information missing", + "work_order_released_for_billing": "Released for billing", + "report_submitted": "Report for review", + "report_approved": "Report approved", + "report_rejected": "Report rejected", + "emergency_created": "Emergency call-out created", + "emergency_completed": "Emergency call-out completed", + "import_ready_for_review": "Import ready for review", + "import_failed": "Document processing failed", + "sync_failed": "Sync failed" + }, + "events": { + "work_order_assigned": { "title": "New work order {number}", "message": "{title} – {customer} was assigned to your team." }, + "work_order_changed": { "title": "Work order {number} changed", "message": "{title} – {customer}: details were updated." }, + "work_order_cancelled": { "title": "Work order {number} cancelled", "message": "{title} – {customer} was cancelled." }, + "work_order_started": { "title": "Work order {number} started", "message": "{actor} started work at {customer}." }, + "work_order_daily_report_created": { "title": "Daily report for {number}", "message": "{actor} created a daily report for {customer}." }, + "work_order_technically_completed": { "title": "Work order {number} technically completed", "message": "{title} – {customer}: on-site work finished." }, + "work_order_signature_missing": { "title": "Signature missing: {number}", "message": "{title} – {customer}: no customer signature." }, + "work_order_missing_required": { "title": "Required information missing: {number}", "message": "{title} – {customer}: documentation incomplete." }, + "work_order_released_for_billing": { "title": "Work order {number} ready for billing", "message": "{title} – {customer} is ready for billing." }, + "report_submitted": { "title": "Report for review: {number}", "message": "{actor} submitted a report for {title}." }, + "report_approved": { "title": "Report approved: {number}", "message": "The report for {title} was approved." }, + "report_rejected": { "title": "Report rejected: {number}", "message": "The report for {title} needs a correction." }, + "emergency_created": { "title": "Emergency call-out {number} created", "message": "{actor} created an emergency call-out at {customer}." }, + "emergency_completed": { "title": "Emergency call-out {number} completed", "message": "{customer}: ready for review and billing." }, + "import_ready_for_review": { "title": "Import ready for review", "message": "The data from {fileName} is ready for review." }, + "import_failed": { "title": "Document processing failed", "message": "{fileName} could not be processed." }, + "sync_failed": { "title": "Sync failed", "message": "A change could not be applied ({reason})." } + }, + "fallback": { + "unknown": "unknown", + "system": "System", + "document": "document" + }, + "preferences": { + "title": "Notifications", + "sub": "You receive all notifications in the app. Choose which ones are also sent by e-mail.", + "email": "E-mail", + "mandatory": "Mandatory – cannot be turned off", + "save": "Save", + "saved": "Settings saved." + }, + "mailSettings": { + "crumb": "Settings", + "title": "E-mail delivery", + "sub": "Sender, reply-to address and fixed recipients for emergency call-outs and billing.", + "back": "Back to settings", + "sender": "Sender", + "fromName": "Sender name", + "fromNameHint": "Shown as the name in the inbox. Empty = company name.", + "fromAddress": "Sender address", + "fromAddressHint": "The address stays {address} so that mail is delivered (SPF/DKIM).", + "replyTo": "Reply-to address", + "replyToHint": "Replies to notifications go to this address.", + "recipients": "Fixed recipients", + "emergencyRecipients": "Emergency recipients", + "emergencyHint": "One address per line. Receive every new and completed emergency call-out.", + "billingRecipients": "Billing recipients", + "billingHint": "One address per line. Receive work orders released for billing. Empty = back office.", + "save": "Save", + "saved": "Settings saved.", + "invalid": "Please check your input: {detail}" + }, + "audit": { + "crumb": "Settings", + "title": "Audit log", + "sub": "Who changed what and when – read only.", + "back": "Back to settings", + "from": "From", + "to": "To", + "actor": "User", + "actorAll": "All users", + "action": "Action", + "actionAll": "All actions", + "entity": "Object type", + "entityAll": "All object types", + "entityId": "Object ID", + "apply": "Filter", + "reset": "Reset", + "time": "Time", + "object": "Object", + "result": "Result", + "resultOk": "Succeeded", + "resultDenied": "Denied", + "details": "Details", + "empty": "No entries for this selection.", + "total": "{count, plural, one {# entry} other {# entries}}", + "previous": "Previous", + "next": "Next", + "page": "Page {page} of {pages}", + "system": "System", + "platform": "Platform", + "detailTitle": "Audit log entry", + "close": "Close", + "field": "Field", + "before": "Before", + "after": "After", + "changed": "changed", + "noValues": "No values recorded.", + "ip": "IP address", + "userAgent": "User agent", + "notCaptured": "not recorded", + "actions": { + "create": "Created", + "update": "Updated", + "delete": "Deleted", + "login": "Sign-in", + "logout": "Sign-out", + "denied": "Denied", + "export": "Export", + "import": "Import", + "provision": "Provisioned", + "approve": "Approved", + "reject": "Rejected" + }, + "entities": { + "user": "User", + "role": "Role", + "tenant": "Tenant", + "tenant_settings": "Settings", + "tenant_mail_settings": "E-mail delivery", + "tenant_module": "Module", + "module": "Module", + "session": "Session", + "account_inactive": "Inactive account", + "work_order": "Work order", + "customer": "Customer", + "site": "Site", + "team": "Team", + "report": "Report", + "signature": "Signature", + "document": "Document", + "import_job": "Work order import", + "material_usage": "Material", + "time_entry": "Working time", + "work_session": "Work session", + "photo": "Photo", + "voice_note": "Voice note", + "notification": "Notification", + "notification_settings": "Notification settings", + "sync_operation": "Sync" + } + } +} diff --git a/prisma/migrations/20260914120000_benachrichtigungen_tenant_mail_settings/migration.sql b/prisma/migrations/20260914120000_benachrichtigungen_tenant_mail_settings/migration.sql new file mode 100644 index 0000000..85db4af --- /dev/null +++ b/prisma/migrations/20260914120000_benachrichtigungen_tenant_mail_settings/migration.sql @@ -0,0 +1,9 @@ +-- L6 Benachrichtigungen: Mandanten-Mailkonfiguration (Spec §33.2). +-- Nur neue Spalten an tenant_settings (Tabelle ist bereits mandantengebunden/RLS), keine neue Tabelle. +-- Absenderadresse bleibt Plattform-Domain (SPF/DKIM); je Mandant nur Anzeigename und Reply-To. + +-- AlterTable +ALTER TABLE "tenant_settings" ADD COLUMN "billing_recipients" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "emergency_recipients" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "mail_from_name" TEXT, +ADD COLUMN "mail_reply_to" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a65baf..640dcbe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -70,6 +70,11 @@ model TenantSettings { timezone String @default("Europe/Berlin") securityPolicy Json @default("{}") @map("security_policy") // pw/mfa/session smtp Json @default("{}") + // Craftvia §33.2 — tenant mail settings (sender address stays the platform domain for SPF/DKIM). + mailFromName String? @map("mail_from_name") + mailReplyTo String? @map("mail_reply_to") + emergencyRecipients String[] @default([]) @map("emergency_recipients") + billingRecipients String[] @default([]) @map("billing_recipients") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/scripts/test-benachrichtigungen-events.ts b/scripts/test-benachrichtigungen-events.ts new file mode 100644 index 0000000..a614f00 --- /dev/null +++ b/scripts/test-benachrichtigungen-events.ts @@ -0,0 +1,290 @@ +// L6 Benachrichtigungen — Empfängerauflösung je Event, Akteur ausgenommen, Mandantentrennung, +// Dedupe, Opt-out, Pflichtmail Notdienst, feste Empfänger, handleEvent wirft nie, Templates. +// +// Lauf: npx tsx scripts/test-benachrichtigungen-events.ts (lokale DB aus .env, SMTP optional) + +import "dotenv/config"; +import { prisma, dbForTenant } from "../src/server/db"; +import { provisionTenant } from "../src/server/provision"; +import { ROLE_DEFS, type RoleKey } from "../src/server/rbac"; +import type { ServiceCtx } from "../src/server/services/context"; +import { EVENT_TYPES, type DomainEvent } from "../src/lib/events"; +import { handleEvent } from "../src/server/services/notifications/handle-event"; +import { emitEvent } from "../src/server/events"; +import { setPreferences } from "../src/server/services/notifications/preferences"; +import { CRAFTVIA_TEMPLATE_KEYS, renderTemplate } from "../src/server/mail/templates"; +import { closeQueues } from "../src/server/mail/queue"; +import { closeMailProvider } from "../src/server/mail/provider-smtp"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +const SLUG_A = "zz-l6-events-a"; +const SLUG_B = "zz-l6-events-b"; +const MAIL_DOMAIN = "zz-l6-events.test"; + +async function cleanup() { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (ids.length) { + const where = { tenantId: { in: ids } }; + await prisma.notification.deleteMany({ where }); + await prisma.notificationPreference.deleteMany({ where }); + await prisma.mailLog.deleteMany({ where }); + await prisma.syncOperation.deleteMany({ where }); + await prisma.importJob.deleteMany({ where }); + await prisma.document.deleteMany({ where }); + await prisma.report.deleteMany({ where }); + await prisma.workOrderAssignee.deleteMany({ where }); + await prisma.workOrder.deleteMany({ where }); + await prisma.teamMember.deleteMany({ where }); + await prisma.team.deleteMany({ where }); + await prisma.customer.deleteMany({ where }); + await prisma.auditLog.deleteMany({ where }); + await prisma.tenantModule.deleteMany({ where }); + await prisma.tenantSettings.deleteMany({ where }); + await prisma.user.deleteMany({ where }); + await prisma.role.deleteMany({ where }); + await prisma.numberSequence.deleteMany({ where }); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.mailLog.deleteMany({ where: { to: { endsWith: `@${MAIL_DOMAIN}` } } }); + await prisma.identity.deleteMany({ where: { email: { endsWith: `@${MAIL_DOMAIN}` }, memberships: { none: {} } } }); +} + +async function createUser(tenantId: string, local: string, name: string, role: RoleKey) { + const email = `${local}@${MAIL_DOMAIN}`; + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + const roleRow = await prisma.role.findUniqueOrThrow({ where: { tenantId_key: { tenantId, key: role } } }); + return prisma.user.create({ + data: { tenantId, identityId: identity.id, email, name, status: "ACTIVE", userRoles: { create: [{ roleId: roleRow.id }] } }, + }); +} + +const ctxFor = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({ + db: dbForTenant(tenantId), + tenantId, + userId, + permissions: new Set(ROLE_DEFS[role].permissions), +}); + +const notifs = (userId: string, type: string, entityId: string) => + prisma.notification.count({ where: { userId, type, entityId } }); +const mails = (dedupeKey: string) => prisma.mailLog.count({ where: { dedupeKey } }); + +async function main() { + await cleanup(); + + // ---------- Fixtures ---------- + const tA = await provisionTenant(prisma, { name: "L6 Events A", slug: SLUG_A, admin: { email: `admin-a@${MAIL_DOMAIN}`, name: "Admin A", password: "Zz-Test-1234!" } }); + const tB = await provisionTenant(prisma, { name: "L6 Events B", slug: SLUG_B, admin: { email: `admin-b@${MAIL_DOMAIN}`, name: "Admin B", password: "Zz-Test-1234!" } }); + const adminA = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tA.id, email: `admin-a@${MAIL_DOMAIN}` } } }); + + const backoffice = await createUser(tA.id, "backoffice-a", "Bea Backoffice", "backoffice"); + const lead = await createUser(tA.id, "lead-a", "Tim Teamleiter", "team-lead"); + const tech1 = await createUser(tA.id, "tech1-a", "Max Monteur", "technician"); + const tech2 = await createUser(tA.id, "tech2-a", "Ole Ohneteam", "technician"); + const backofficeB = await createUser(tB.id, "backoffice-b", "Bernd Fremd", "backoffice"); + + const customerA = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Lutz Meier GmbH" } }); + const teamA = await prisma.team.create({ data: { tenantId: tA.id, name: "Team Nord", leaderUserId: lead.id } }); + await prisma.teamMember.create({ data: { tenantId: tA.id, teamId: teamA.id, userId: tech1.id, validFrom: new Date(Date.now() - 86_400_000) } }); + const wo = await prisma.workOrder.create({ + data: { tenantId: tA.id, number: "A-ZZ-1", title: "Heizung warten", customerId: customerA.id, assignedTeamId: teamA.id, status: "assigned" }, + }); + const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Fremdkunde" } }); + const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-ZZ-1", title: "Fremdauftrag", customerId: customerB.id } }); + + const boCtx = ctxFor(tA.id, backoffice.id, "backoffice"); + const leadCtx = ctxFor(tA.id, lead.id, "team-lead"); + const tech1Ctx = ctxFor(tA.id, tech1.id, "technician"); + const ev = (type: DomainEvent["type"], entityId = wo.id, data?: DomainEvent["data"]): DomainEvent => ({ + type, entityType: type.startsWith("report.") ? "report" : "work_order", entityId, data, + }); + + // ---------- 1) work_order.assigned → Team (Mitglieder + Leiter), Akteur ausgenommen ---------- + await handleEvent(boCtx, ev("work_order.assigned")); + ok((await notifs(tech1.id, "work_order.assigned", wo.id)) === 1, "(1) assigned: Teammitglied erhält In-App-Benachrichtigung"); + ok((await notifs(lead.id, "work_order.assigned", wo.id)) === 1, "(1) assigned: Teamleiter erhält In-App-Benachrichtigung"); + ok((await notifs(tech2.id, "work_order.assigned", wo.id)) === 0, "(1) assigned: Monteur ohne Zuweisung erhält nichts"); + ok((await notifs(backoffice.id, "work_order.assigned", wo.id)) === 0, "(1) assigned: Akteur (Backoffice) ausgenommen"); + ok((await mails(`work_order.assigned:${wo.id}:${tech1.id}`)) === 1, "(1) assigned: E-Mail an Teammitglied eingestellt"); + const assignedMail = await prisma.mailLog.findFirst({ where: { dedupeKey: `work_order.assigned:${wo.id}:${lead.id}` } }); + ok(assignedMail?.template === "craftvia_team_assigned" && assignedMail.tenantId === tA.id, "(1) assigned: Template craftvia_team_assigned, Mandant A"); + const n1 = await prisma.notification.findFirst({ where: { userId: tech1.id, type: "work_order.assigned" } }); + ok(n1?.link === `/m/orders/${wo.id}` && !!n1?.title.includes("A-ZZ-1"), "(1) assigned: Titel mit Auftragsnummer, Link in die mobile Ansicht"); + + // Einzel-Assignee außerhalb des Teams + await prisma.workOrderAssignee.create({ data: { tenantId: tA.id, workOrderId: wo.id, userId: tech2.id } }); + await handleEvent(boCtx, ev("work_order.assigned")); + ok((await notifs(tech2.id, "work_order.assigned", wo.id)) === 1, "(1b) assigned: Einzel-Assignee erhält Benachrichtigung"); + await prisma.workOrderAssignee.deleteMany({ where: { workOrderId: wo.id } }); + + // ---------- 2) Dedupe: gleiches Event zweimal → eine Mail, keine doppelte In-App-Zeile ---------- + ok((await mails(`work_order.assigned:${wo.id}:${tech1.id}`)) === 1, "(2) Dedupe: zweites assigned-Event → weiterhin genau eine Mail"); + ok((await notifs(tech1.id, "work_order.assigned", wo.id)) === 1, "(2) Dedupe: ungelesene Benachrichtigung aufgefrischt statt verdoppelt"); + + // ---------- 3) Akteur ausgenommen (Teamleiter storniert) ---------- + await handleEvent(leadCtx, ev("work_order.cancelled")); + ok((await notifs(lead.id, "work_order.cancelled", wo.id)) === 0, "(3) cancelled: auslösender Teamleiter erhält nichts"); + ok((await notifs(tech1.id, "work_order.cancelled", wo.id)) === 1, "(3) cancelled: Teammitglied erhält Benachrichtigung"); + + // ---------- 4) started → Backoffice (work_order:read_all + report:approve) ---------- + await handleEvent(tech1Ctx, ev("work_order.started")); + ok((await notifs(backoffice.id, "work_order.started", wo.id)) === 1, "(4) started: Backoffice erhält Benachrichtigung"); + ok((await notifs(adminA.id, "work_order.started", wo.id)) === 1, "(4) started: Mandantenadmin (hat read_all + approve) erhält Benachrichtigung"); + ok((await notifs(tech1.id, "work_order.started", wo.id)) === 0, "(4) started: Akteur (Monteur) ausgenommen"); + ok((await notifs(lead.id, "work_order.started", wo.id)) === 0, "(4) started: Teamleiter ist kein Backoffice"); + ok((await notifs(backofficeB.id, "work_order.started", wo.id)) === 0, "(4) started: Backoffice des Mandanten B erhält nichts"); + const started = await prisma.notification.findFirst({ where: { userId: backoffice.id, type: "work_order.started" } }); + ok(started?.link === `/work-orders/${wo.id}` && !!started?.message.includes("Max Monteur"), "(4) started: Backoffice-Link + Akteurname im Text"); + + // ---------- 5) Mandantentrennung ---------- + const beforeCount = await prisma.notification.count({ where: { tenantId: { in: [tA.id, tB.id] } } }); + await handleEvent(ctxFor(tB.id, backofficeB.id, "backoffice"), ev("work_order.assigned", wo.id)); + ok((await prisma.notification.count({ where: { tenantId: { in: [tA.id, tB.id] } } })) === beforeCount, "(5) Kontext Mandant B + Auftrag von A → keinerlei Benachrichtigung"); + await handleEvent(boCtx, ev("work_order.assigned", woB.id)); + ok((await prisma.notification.count({ where: { tenantId: { in: [tA.id, tB.id] } } })) === beforeCount, "(5) Kontext Mandant A + Auftrag von B → keinerlei Benachrichtigung"); + + const report = await prisma.report.create({ + data: { tenantId: tA.id, workOrderId: wo.id, type: "completion", reportDate: new Date(), lineageId: "zz-l6-lineage", content: {}, status: "approved", createdById: backofficeB.id }, + }); + await handleEvent(boCtx, ev("report.approved", report.id)); + ok((await prisma.notification.count({ where: { userId: backofficeB.id } })) === 0, "(5) report.approved: fremde User-ID als Ersteller wird nie Empfänger"); + ok((await notifs(tech1.id, "report.approved", report.id)) === 1, "(5) report.approved: Team des Auftrags erhält Benachrichtigung"); + ok((await prisma.notification.count({ where: { tenantId: tB.id } })) === 0, "(5) Mandant B hat insgesamt keine Benachrichtigungen erhalten"); + ok((await prisma.mailLog.count({ where: { tenantId: tA.id, to: backofficeB.email } })) === 0, "(5) keine Mail an Nutzer des Mandanten B"); + + // ---------- 6) report.submitted: Stufe team → nur Teamleiter; ohne Stufe → Backoffice + Teamleiter ---------- + await prisma.report.update({ where: { id: report.id }, data: { status: "submitted" } }); + await handleEvent(tech1Ctx, ev("report.submitted", report.id, { approvalStage: "team" })); + ok((await notifs(lead.id, "report.submitted", report.id)) === 1, "(6) submitted/team: Teamleiter erhält Prüfauftrag"); + ok((await notifs(backoffice.id, "report.submitted", report.id)) === 0, "(6) submitted/team: Backoffice nicht beteiligt"); + await handleEvent(leadCtx, ev("report.submitted", report.id)); + ok((await notifs(backoffice.id, "report.submitted", report.id)) === 1, "(6) submitted ohne Stufe: Backoffice erhält Prüfauftrag"); + const reviewMail = await prisma.mailLog.findFirst({ where: { dedupeKey: `report.submitted:${report.id}:${backoffice.id}` } }); + ok(reviewMail?.template === "craftvia_report_review", "(6) submitted: Template craftvia_report_review"); + + // ---------- 7) Opt-out je Typ ---------- + const allButChanged = EVENT_TYPES.filter((t) => t !== "work_order.changed"); + await setPreferences(tech1Ctx, { emailOn: allButChanged }); + await handleEvent(boCtx, ev("work_order.changed")); + ok((await notifs(tech1.id, "work_order.changed", wo.id)) === 1, "(7) Opt-out: In-App-Benachrichtigung bleibt"); + ok((await mails(`work_order.changed:${wo.id}:${tech1.id}`)) === 0, "(7) Opt-out: keine E-Mail an abbestellenden Monteur"); + ok((await mails(`work_order.changed:${wo.id}:${lead.id}`)) === 1, "(7) Opt-out: Teamleiter ohne Opt-out erhält E-Mail"); + + // occurrenceId erlaubt wiederholbare Events + await handleEvent(tech1Ctx, ev("work_order.daily_report_created", wo.id, { occurrenceId: "2026-09-14" })); + await handleEvent(tech1Ctx, ev("work_order.daily_report_created", wo.id, { occurrenceId: "2026-09-15" })); + ok((await prisma.mailLog.count({ where: { dedupeKey: { startsWith: `work_order.daily_report_created:${wo.id}:${backoffice.id}` } } })) === 2, + "(7b) occurrenceId: zwei Tagesberichte → zwei Mails"); + + // ---------- 8) Pflichtmail Notdienst trotz Opt-out + feste Notdienst-Empfänger ---------- + await prisma.notificationPreference.upsert({ + where: { userId_eventType: { userId: backoffice.id, eventType: "emergency.completed" } }, + update: { email: false }, + create: { tenantId: tA.id, userId: backoffice.id, eventType: "emergency.completed", email: false }, + }); + await prisma.tenantSettings.update({ + where: { tenantId: tA.id }, + data: { emergencyRecipients: [`notdienst@${MAIL_DOMAIN}`, backoffice.email], billingRecipients: [`abrechnung@${MAIL_DOMAIN}`] }, + }); + await prisma.workOrder.update({ where: { id: wo.id }, data: { isEmergency: true } }); + await handleEvent(tech1Ctx, ev("emergency.completed", wo.id, { startedAt: "2026-07-28T21:42:00.000Z", endedAt: "2026-07-28T23:18:00.000Z" })); + const emergencyMail = await prisma.mailLog.findFirst({ where: { dedupeKey: `emergency.completed:${wo.id}:${backoffice.id}` } }); + ok(!!emergencyMail, "(8) Notdienst: Pflichtmail an Backoffice trotz gespeichertem Opt-out"); + ok(emergencyMail?.template === "craftvia_emergency", "(8) Notdienst: Template craftvia_emergency"); + ok((await mails(`emergency.completed:${wo.id}:ext:notdienst@${MAIL_DOMAIN}`)) === 1, "(8) Notdienst: fester Notdienst-Empfänger erhält Mail"); + ok((await mails(`emergency.completed:${wo.id}:ext:${backoffice.email}`)) === 0, "(8) Notdienst: keine Doppelmail, wenn fester Empfänger zugleich Nutzer ist"); + ok((await notifs(tech1.id, "emergency.completed", wo.id)) === 0, "(8) Notdienst: auslösender Monteur ausgenommen"); + await setPreferences(ctxFor(tA.id, backoffice.id, "backoffice"), { emailOn: [] }); + const emPref = await prisma.notificationPreference.findUnique({ where: { userId_eventType: { userId: backoffice.id, eventType: "emergency.created" } } }); + ok(emPref?.email === true, "(8) Notdienst: Pflichttyp lässt sich über setPreferences nicht abbestellen"); + + // ---------- 9) Abrechnung → feste Abrechnungsempfänger (Mail), Backoffice In-App ---------- + await handleEvent(leadCtx, ev("work_order.released_for_billing")); + ok((await mails(`work_order.released_for_billing:${wo.id}:ext:abrechnung@${MAIL_DOMAIN}`)) === 1, "(9) Abrechnung: fester Abrechnungsempfänger erhält Mail"); + ok((await notifs(backoffice.id, "work_order.released_for_billing", wo.id)) === 1, "(9) Abrechnung: Backoffice erhält In-App-Benachrichtigung"); + ok((await mails(`work_order.released_for_billing:${wo.id}:${backoffice.id}`)) === 0, "(9) Abrechnung: bei festen Empfängern keine zusätzliche Nutzer-Mail"); + + // ---------- 10) import.* → importierender Nutzer (auch wenn er Akteur ist); sync.failed ---------- + await setPreferences(boCtx, { emailOn: [...EVENT_TYPES] }); // undo the opt-out-all from step 8 + const doc = await prisma.document.create({ + data: { + tenantId: tA.id, category: "order_confirmation", fileName: "auftrag.pdf", storageKey: `${tA.id}/zz-l6.pdf`, + mimeType: "application/pdf", fileSize: 10, checksum: "zz", lineageId: "zz-l6-doc-lineage", + }, + }); + { + const job = await prisma.importJob.create({ data: { tenantId: tA.id, documentId: doc.id, status: "failed", errorMessage: "unlesbar", importedById: backoffice.id } }); + await handleEvent(boCtx, { type: "import.failed", entityType: "import_job", entityId: job.id }); + ok((await notifs(backoffice.id, "import.failed", job.id)) === 1, "(10) import.failed: importierender Nutzer informiert (System-Ergebnis)"); + ok((await prisma.mailLog.findFirst({ where: { dedupeKey: `import.failed:${job.id}:${backoffice.id}` } }))?.template === "craftvia_document_failed", + "(10) import.failed: Template craftvia_document_failed"); + ok((await notifs(adminA.id, "import.failed", job.id)) === 0, "(10) import.failed: nur der importierende Nutzer"); + } + const op = await prisma.syncOperation.create({ + data: { tenantId: tA.id, userId: tech1.id, clientOpId: "zz-l6-op", opType: "work_order.transition", entityType: "work_order", entityId: wo.id, payload: {}, status: "conflict", errorCode: "version_conflict", clientCreatedAt: new Date() }, + }); + await handleEvent(tech1Ctx, { type: "sync.failed", entityType: "sync_operation", entityId: op.id }); + ok((await notifs(tech1.id, "sync.failed", op.id)) === 1, "(10) sync.failed: betroffener Nutzer informiert"); + ok((await notifs(backoffice.id, "sync.failed", op.id)) === 1, "(10) sync.failed: Backoffice informiert"); + ok((await prisma.notification.findFirst({ where: { userId: tech1.id, type: "sync.failed" } }))?.link === "/m/sync", "(10) sync.failed: Monteur-Link /m/sync"); + + // ---------- 11) handleEvent / emitEvent werfen nie ---------- + const brokenDb = new Proxy({}, { get: () => { throw new Error("db down"); } }) as ServiceCtx["db"]; + const origError = console.error; + console.error = () => {}; + let threw = false; + try { + await handleEvent({ ...boCtx, db: brokenDb }, ev("work_order.assigned")); + await emitEvent({ ...boCtx, db: brokenDb }, ev("work_order.started")); + await handleEvent(boCtx, ev("work_order.changed", "does-not-exist")); + } catch { + threw = true; + } finally { + console.error = origError; + } + ok(!threw, "(11) handleEvent/emitEvent werfen bei DB-Fehler oder unbekannter Entität nicht"); + + // ---------- 12) Craftvia-Templates de/en ---------- + const sample = { + craftvia_team_assigned: { name: "Max", number: "A-1", title: "Heizung", customer: "Meier", actionUrl: "https://x.test/a" }, + craftvia_report_review: { name: "", number: "A-1", title: "Heizung", customer: "Meier", reportType: "completion", actionUrl: "https://x.test/r" }, + craftvia_billing_release: { name: "", number: "A-1", title: "Heizung", customer: "Meier", actionUrl: "https://x.test/b", footer: "configured" }, + craftvia_emergency: { name: "Bea", phase: "completed", number: "N-1", technician: "Martin Solarczek", customer: "Lutz Meier", start: "28.07.2026, 23:42", end: "29.07.2026, 01:18", actionUrl: "https://x.test/e" }, + craftvia_document_failed: { name: "Bea", fileName: "a.pdf", error: "unlesbar", actionUrl: "https://x.test/i" }, + craftvia_notification: { name: "Bea", subject: "Hinweis", body: "Text", actionUrl: "https://x.test/n" }, + } as const; + for (const key of CRAFTVIA_TEMPLATE_KEYS) { + for (const locale of ["de", "en"] as const) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const r = renderTemplate(key, locale, sample[key] as any); + ok(r.subject.includes("Craftvia") && r.html.includes("") && r.text.length > 0 && !r.text.includes("<"), `(12) Template ${key}/${locale} rendert`); + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const em = renderTemplate("craftvia_emergency", "de", sample.craftvia_emergency as any); + ok(em.text.includes("Neuer Notdiensteinsatz abgeschlossen") && em.text.includes("Monteur: Martin Solarczek") && em.text.includes("Einsatzende: 29.07.2026, 01:18") && em.text.includes("Status: Zur Prüfung und Abrechnung"), + "(12) Notdienst-Mail im Format Spec §19.4"); + ok(em.text.includes("nicht abbestellen"), "(12) Notdienst-Mail trägt Pflicht-Hinweis"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ok(renderTemplate("craftvia_report_review", "de", sample.craftvia_report_review as any).text.includes("Guten Tag,"), "(12) Anrede ohne Namen für feste Empfänger"); +} + +main() + .catch((err) => { + console.error(err); + failures++; + }) + .finally(async () => { + await cleanup().catch((e) => console.error("cleanup:", e)); + await closeQueues().catch(() => {}); + await closeMailProvider().catch(() => {}); + await prisma.$disconnect(); + console.log(failures ? `\n${failures} Fehler.` : "\nAlle Prüfungen bestanden."); + process.exit(failures ? 1 : 0); + }); diff --git a/scripts/test-benachrichtigungen-inbox-audit.ts b/scripts/test-benachrichtigungen-inbox-audit.ts new file mode 100644 index 0000000..17e3837 --- /dev/null +++ b/scripts/test-benachrichtigungen-inbox-audit.ts @@ -0,0 +1,198 @@ +// L6 Benachrichtigungen & Audit — Posteingang (nur eigene, Mandantentrennung, Rollen), +// Einstellungen je Nutzer, Mandanten-Mailkonfiguration (tenant:manage, Validierung), +// Audit-Viewer (nur mit audit:read, mandantengebunden, Filter, Diff). +// +// Lauf: npx tsx scripts/test-benachrichtigungen-inbox-audit.ts + +import "dotenv/config"; +import { ZodError } from "zod"; +import { prisma, dbForTenant } from "../src/server/db"; +import { provisionTenant } from "../src/server/provision"; +import { ROLE_DEFS, type RoleKey } from "../src/server/rbac"; +import { ServiceError, type ServiceCtx } from "../src/server/services/context"; +import { bellSummary, listNotifications, markAllRead, markRead, safeLink } from "../src/server/services/notifications/inbox"; +import { getPreferences } from "../src/server/services/notifications/preferences"; +import { getMailSettings, splitAddressList, updateMailSettings } from "../src/server/services/notifications/mail-settings"; +import { diffAudit, getAuditEntry, queryAuditLog } from "../src/server/services/audit/viewer"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +async function expectCode(fn: () => Promise, code: ServiceError["code"] | "zod", msg: string) { + try { + await fn(); + ok(false, `${msg} — kein Fehler`); + } catch (err) { + const actual = err instanceof ServiceError ? err.code : err instanceof ZodError ? "zod" : (err as Error).message; + ok(actual === code, `${msg}${actual === code ? "" : ` — erhalten: ${actual}`}`); + } +} + +const SLUG_A = "zz-l6-inbox-a"; +const SLUG_B = "zz-l6-inbox-b"; +const MAIL_DOMAIN = "zz-l6-inbox.test"; + +async function cleanup() { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (ids.length) { + const where = { tenantId: { in: ids } }; + await prisma.notification.deleteMany({ where }); + await prisma.notificationPreference.deleteMany({ where }); + await prisma.mailLog.deleteMany({ where }); + await prisma.auditLog.deleteMany({ where }); + await prisma.tenantModule.deleteMany({ where }); + await prisma.tenantSettings.deleteMany({ where }); + await prisma.user.deleteMany({ where }); + await prisma.role.deleteMany({ where }); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: `@${MAIL_DOMAIN}` }, memberships: { none: {} } } }); +} + +async function createUser(tenantId: string, local: string, name: string, role: RoleKey) { + const email = `${local}@${MAIL_DOMAIN}`; + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + const roleRow = await prisma.role.findUniqueOrThrow({ where: { tenantId_key: { tenantId, key: role } } }); + return prisma.user.create({ + data: { tenantId, identityId: identity.id, email, name, status: "ACTIVE", userRoles: { create: [{ roleId: roleRow.id }] } }, + }); +} + +const ctxFor = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({ + db: dbForTenant(tenantId), + tenantId, + userId, + permissions: new Set(ROLE_DEFS[role].permissions), +}); + +async function main() { + await cleanup(); + + const tA = await provisionTenant(prisma, { name: "L6 Inbox A", slug: SLUG_A, admin: { email: `admin-a@${MAIL_DOMAIN}`, name: "Admin A", password: "Zz-Test-1234!" } }); + const tB = await provisionTenant(prisma, { name: "L6 Inbox B", slug: SLUG_B, admin: { email: `admin-b@${MAIL_DOMAIN}`, name: "Admin B", password: "Zz-Test-1234!" } }); + const adminA = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tA.id, email: `admin-a@${MAIL_DOMAIN}` } } }); + const adminB = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: tB.id, email: `admin-b@${MAIL_DOMAIN}` } } }); + const backoffice = await createUser(tA.id, "backoffice-a", "Bea Backoffice", "backoffice"); + const tech1 = await createUser(tA.id, "tech1-a", "Max Monteur", "technician"); + const tech2 = await createUser(tA.id, "tech2-a", "Ole Ohneteam", "technician"); + + const adminACtx = ctxFor(tA.id, adminA.id, "tenant-admin"); + const adminBCtx = ctxFor(tB.id, adminB.id, "tenant-admin"); + const boCtx = ctxFor(tA.id, backoffice.id, "backoffice"); + const tech1Ctx = ctxFor(tA.id, tech1.id, "technician"); + const tech2Ctx = ctxFor(tA.id, tech2.id, "technician"); + + const mk = (tenantId: string, userId: string, type: string, title: string, readAt: Date | null = null) => + prisma.notification.create({ data: { tenantId, userId, type, title, message: "Text", entityType: "work_order", entityId: "wo-zz", link: "/m/orders/wo-zz", readAt } }); + const n1 = await mk(tA.id, tech1.id, "work_order.assigned", "Neuer Auftrag A-1"); + await mk(tA.id, tech1.id, "work_order.changed", "Auftrag A-1 geändert"); + await mk(tA.id, tech1.id, "work_order.cancelled", "Auftrag A-2 storniert", new Date()); + const nB = await mk(tB.id, adminB.id, "work_order.assigned", "Fremd B-1"); + + // ---------- 1) Posteingang: nur eigene Benachrichtigungen ---------- + const list1 = await listNotifications(tech1Ctx, {}); + ok(list1.total === 3, "(1) Monteur sieht seine 3 Benachrichtigungen"); + ok((await listNotifications(tech1Ctx, { status: "unread" })).total === 2, "(1) Filter ungelesen"); + ok((await listNotifications(tech1Ctx, { status: "read" })).total === 1, "(1) Filter gelesen"); + ok((await listNotifications(tech1Ctx, { type: "work_order.changed" })).total === 1, "(1) Filter Typ"); + ok((await listNotifications(tech1Ctx, { type: "no.such_type", status: "bogus" })).total === 3, "(1) ungültige Filterwerte fallen auf Standard zurück"); + ok((await listNotifications(tech2Ctx, {})).total === 0, "(1) anderer Monteur sieht keine fremden Benachrichtigungen"); + ok((await listNotifications(adminACtx, {})).total === 0, "(1) auch der Mandantenadmin sieht keine fremden Benachrichtigungen"); + const bell = await bellSummary(tech1Ctx); + ok(bell.unread === 2 && bell.latest.length === 3, "(1) Glocke: Zähler ungelesen + letzte Einträge"); + + // ---------- 2) Rollen/Scope: fremde Benachrichtigung → not_found ---------- + await expectCode(() => markRead(tech2Ctx, n1.id), "not_found", "(2) Monteur ohne Zuordnung kann fremde Benachrichtigung nicht als gelesen markieren"); + await expectCode(() => markRead(adminBCtx, n1.id), "not_found", "(2) Mandant B kann Benachrichtigung von A nicht ändern"); + await expectCode(() => markRead(tech1Ctx, nB.id), "not_found", "(2) Mandant A kann Benachrichtigung von B nicht ändern"); + ok((await prisma.notification.findUniqueOrThrow({ where: { id: n1.id } })).readAt === null, "(2) fremder Zugriff hat nichts verändert"); + await expectCode(() => listNotifications({ ...tech1Ctx, permissions: new Set() }, {}), "forbidden", "(2) ohne notification:read → forbidden"); + + // ---------- 3) Mandantentrennung markAllRead ---------- + await markAllRead(adminBCtx); + ok((await prisma.notification.count({ where: { tenantId: tA.id, readAt: null } })) === 2, "(3) markAllRead in B lässt A unberührt"); + ok((await prisma.notification.findUniqueOrThrow({ where: { id: nB.id } })).readAt !== null, "(3) markAllRead in B markiert eigene"); + + // ---------- 4) markRead eigene + Audit + sicherer Link ---------- + const res = await markRead(tech1Ctx, n1.id); + ok(res.link === "/m/orders/wo-zz", "(4) markRead liefert relativen Link"); + ok((await prisma.notification.findUniqueOrThrow({ where: { id: n1.id } })).readAt !== null, "(4) markRead setzt readAt"); + ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "notification", entityId: n1.id, actorId: tech1.id } })) === 1, "(4) markRead schreibt Audit-Eintrag"); + ok(safeLink("https://evil.test") === null && safeLink("//evil.test") === null && safeLink("/\\evil") === null && safeLink("/work-orders/1") === "/work-orders/1", + "(4) safeLink lässt nur relative Pfade zu (kein Open Redirect)"); + + // ---------- 5) Einstellungen je Nutzer ---------- + const prefs = await getPreferences(tech1Ctx); + ok(prefs.every((p) => p.email) && prefs.filter((p) => p.mandatory).map((p) => p.type).sort().join() === "emergency.completed,emergency.created", + "(5) Standard: alles an, Notdienst als Pflicht markiert"); + + // ---------- 6) Mandanten-Mailkonfiguration ---------- + await expectCode(() => updateMailSettings(tech1Ctx, { mailFromName: "X", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "forbidden", + "(6) Monteur darf Mailkonfiguration nicht ändern"); + await expectCode(() => updateMailSettings(boCtx, { mailFromName: "X", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "forbidden", + "(6) Backoffice ohne tenant:manage darf Mailkonfiguration nicht ändern"); + await expectCode(() => getMailSettings(tech1Ctx), "forbidden", "(6) Monteur darf Mailkonfiguration nicht lesen"); + ok(splitAddressList("A@x.de, b@x.de;\nA@x.de c@x.de").join() === "a@x.de,b@x.de,c@x.de", "(6) Adressliste: trennt, normalisiert, dedupliziert"); + const saved = await updateMailSettings(adminACtx, { + mailFromName: "Musterbau Büro", + mailReplyTo: "Buero@Musterbau.test", + emergencyRecipients: "notdienst@musterbau.test\nchef@musterbau.test", + billingRecipients: "abrechnung@musterbau.test", + }); + ok(saved.mailReplyTo === "buero@musterbau.test" && saved.emergencyRecipients.length === 2, "(6) Admin speichert Mailkonfiguration"); + const rowA = await prisma.tenantSettings.findUniqueOrThrow({ where: { tenantId: tA.id } }); + ok(rowA.mailFromName === "Musterbau Büro" && rowA.billingRecipients.join() === "abrechnung@musterbau.test", "(6) Werte in tenant_settings persistiert"); + ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "tenant_mail_settings", actorId: adminA.id } })) === 1, "(6) Audit-Eintrag mit before/after"); + const sB = await getMailSettings(adminBCtx); + ok(sB.emergencyRecipients.length === 0 && sB.mailFromName === null, "(6) Mandant B sieht die Mailkonfiguration von A nicht"); + await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "Evil\r\nBcc: x@y.z", mailReplyTo: "", emergencyRecipients: "", billingRecipients: "" }), "zod", + "(6) Absendername mit Zeilenumbruch abgelehnt (Header-Injection)"); + await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "", mailReplyTo: "", emergencyRecipients: "kein-mail", billingRecipients: "" }), "zod", + "(6) ungültige Empfängeradresse abgelehnt"); + await expectCode(() => updateMailSettings(adminACtx, { mailFromName: "", mailReplyTo: "", emergencyRecipients: Array.from({ length: 21 }, (_, i) => `n${i}@x.de`).join("\n"), billingRecipients: "" }), "zod", + "(6) mehr als 20 Empfänger abgelehnt"); + + // ---------- 7) Audit-Viewer ---------- + await prisma.auditLog.create({ data: { tenantId: tB.id, actorId: adminB.id, action: "update", entity: "zz_marker", entityId: "b-secret", before: { a: 1 }, after: { a: 2 } } }); + const markerA = await prisma.auditLog.create({ + data: { tenantId: tA.id, actorId: backoffice.id, action: "update", entity: "work_order", entityId: "wo-zz-audit", before: { status: "planned", title: "Alt" }, after: { status: "assigned", title: "Alt", team: "Nord" } }, + }); + await expectCode(() => queryAuditLog(tech1Ctx, {}), "forbidden", "(7) Monteur ohne audit:read → forbidden"); + await expectCode(() => queryAuditLog(ctxFor(tA.id, tech2.id, "team-lead"), {}), "forbidden", "(7) Teamleiter ohne audit:read → forbidden"); + await expectCode(() => getAuditEntry(tech1Ctx, markerA.id), "forbidden", "(7) Detail ohne audit:read → forbidden"); + const all = await queryAuditLog(boCtx, {}); + ok(all.total > 0, "(7) Backoffice mit audit:read sieht Einträge"); + const allIds = (await queryAuditLog(boCtx, { page: 1 })).rows.map((r) => r.id); + const foreign = await prisma.auditLog.count({ where: { id: { in: allIds }, NOT: { tenantId: tA.id } } }); + ok(foreign === 0, "(7) Audit-Viewer liefert ausschließlich Einträge des eigenen Mandanten"); + ok((await queryAuditLog(boCtx, { entity: "zz_marker" })).total === 0, "(7) Eintrag von Mandant B ist in A nicht auffindbar"); + const bEntry = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: tB.id, entity: "zz_marker" } }); + await expectCode(() => getAuditEntry(boCtx, bEntry.id), "not_found", "(7) Detail eines B-Eintrags aus A → not_found"); + ok((await queryAuditLog(boCtx, { entity: "work_order", entityId: "wo-zz" })).total === 1, "(7) Filter Objektart + Objekt-ID"); + ok((await queryAuditLog(boCtx, { actorId: backoffice.id, action: "update" })).rows.every((r) => r.actorId === backoffice.id && r.action === "update"), "(7) Filter Benutzer + Aktion"); + const today = new Date().toISOString().slice(0, 10); + ok((await queryAuditLog(boCtx, { from: today, to: today, entity: "work_order" })).total === 1, "(7) Filter Zeitraum (heute) findet Eintrag"); + ok((await queryAuditLog(boCtx, { from: "2000-01-01", to: "2000-01-02" })).total === 0, "(7) Filter Zeitraum (Vergangenheit) leer"); + ok(all.facets.entities.includes("work_order") && !all.facets.entities.includes("zz_marker"), "(7) Objektart-Auswahl nur aus eigenem Mandanten"); + const detail = await getAuditEntry(boCtx, markerA.id); + const byKey = Object.fromEntries(detail.diff.map((d) => [d.key, d])); + ok(byKey.status?.changed === true && byKey.title?.changed === false && byKey.team?.before === null && byKey.team?.after === "Nord", "(7) Detail: before/after-Diff korrekt"); + ok(detail.actorName === "Bea Backoffice" && detail.ip === null && detail.userAgent === null, "(7) Detail: Akteurname, IP/User-Agent nicht erfasst"); + ok(diffAudit(undefined, undefined).length === 0 && diffAudit(null, { x: [1] })[0]?.after === "[1]", "(7) diffAudit: leere und verschachtelte Werte"); +} + +main() + .catch((err) => { + console.error(err); + failures++; + }) + .finally(async () => { + await cleanup().catch((e) => console.error("cleanup:", e)); + await prisma.$disconnect(); + console.log(failures ? `\n${failures} Fehler.` : "\nAlle Prüfungen bestanden."); + process.exit(failures ? 1 : 0); + }); diff --git a/src/app/(app)/account/page.tsx b/src/app/(app)/account/page.tsx index 77f1aea..0333a75 100644 --- a/src/app/(app)/account/page.tsx +++ b/src/app/(app)/account/page.tsx @@ -12,6 +12,7 @@ import { TenantRecoveryRegenForm } from "@/components/tenant-recovery-regen-form import { PasskeyManager } from "@/components/passkey-manager"; import { ChangePasswordSelfForm } from "@/components/auth-recovery-forms"; import { describePasswordPolicy, resolvePasswordPolicy } from "@/lib/password-policy"; +import { NotificationPreferencesSection } from "@/components/notifications/preferences-section"; /** * Persönliches Konto des Mandanten-Nutzers (Paket C): optionale MFA selbst @@ -117,6 +118,8 @@ export default async function AccountPage() { Eine Änderung ist derzeit nur über die Plattform-Administration möglich.

+ + ); diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 9b5527e..1d7808c 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -13,6 +13,7 @@ import { TenantBrand } from "@/components/brand/tenant-brand"; import { TenantSwitcher } from "@/components/tenant-switcher"; import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; +import { NotificationBell } from "@/components/notifications/bell"; export default async function AppLayout({ children, @@ -135,6 +136,7 @@ export default async function AppLayout({
{/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */}
+
diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx index dceecbf..498ae23 100644 --- a/src/app/(app)/notifications/page.tsx +++ b/src/app/(app)/notifications/page.tsx @@ -1,5 +1,134 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { BellDot, CheckCheck } from "lucide-react"; +import { getFormatter, getTranslations } from "next-intl/server"; +import { EVENT_TYPES } from "@/lib/events"; +import { PageHead, Pill } from "@/components/mockup-ui"; +import { Button } from "@/components/ui/button"; +import { markAllNotificationsRead, markNotificationRead, openNotification } from "@/server/actions/notifications/inbox"; +import { listNotifications } from "@/server/services/notifications/inbox"; +import { pageCtx } from "@/server/services/notifications/page-ctx"; +import { eventKey } from "@/server/services/notifications/texts"; -export default function Page() { - return ; +const selectCls = "h-11 rounded-md border border-input bg-card px-3 text-sm"; + +export default async function NotificationsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const ctx = await pageCtx(); + if (!ctx.permissions.has("notification:read")) redirect("/dashboard"); + const sp = await searchParams; + const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v); + const data = await listNotifications(ctx, { status: one(sp.status), type: one(sp.type), page: one(sp.page) }); + const [t, format] = await Promise.all([getTranslations("notifications"), getFormatter()]); + + const pageHref = (page: number) => { + const q = new URLSearchParams(); + if (data.filter.status !== "all") q.set("status", data.filter.status); + if (data.filter.type) q.set("type", data.filter.type); + q.set("page", String(page)); + return `/notifications?${q.toString()}`; + }; + + return ( +
+ + + + } + /> + +
+ + + + + {t("list.reset")} + +
+ + {data.rows.length === 0 ? ( +

{t("list.empty")}

+ ) : ( +
    + {data.rows.map((n) => ( +
  • +
    + {n.readAt ? ( + {t("list.read")} + ) : ( + + {t("list.unread")} + + )} +
    +

    {n.title}

    +

    {n.message}

    +

    + {EVENT_TYPES.includes(n.type as (typeof EVENT_TYPES)[number]) + ? t(`types.${eventKey(n.type as (typeof EVENT_TYPES)[number])}`) + : n.type} + {" · "} + {format.dateTime(n.createdAt, { dateStyle: "medium", timeStyle: "short" })} +

    +
    +
    +
    + {!n.readAt && ( +
    + + +
    + )} + {n.link && ( +
    + + +
    + )} +
    +
  • + ))} +
+ )} + + {data.pages > 1 && ( + + )} +
+ ); } diff --git a/src/app/(app)/settings/audit/page.tsx b/src/app/(app)/settings/audit/page.tsx new file mode 100644 index 0000000..e0ebfc5 --- /dev/null +++ b/src/app/(app)/settings/audit/page.tsx @@ -0,0 +1,201 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { ArrowLeft, CircleCheck, CircleX } from "lucide-react"; +import { getFormatter, getTranslations } from "next-intl/server"; +import { Modal } from "@/components/modal"; +import { PageHead, Pill } from "@/components/mockup-ui"; +import { Button } from "@/components/ui/button"; +import { getAuditEntry, queryAuditLog, type AuditFilter } from "@/server/services/audit/viewer"; +import { ServiceError } from "@/server/services/context"; +import { pageCtx } from "@/server/services/notifications/page-ctx"; + +const fieldCls = "h-11 rounded-md border border-input bg-card px-3 text-sm"; + +type SP = Record; + +export default async function AuditLogPage({ searchParams }: { searchParams: Promise }) { + const ctx = await pageCtx(); + if (!ctx.permissions.has("audit:read")) redirect("/dashboard"); + const sp = await searchParams; + const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v); + + const data = await queryAuditLog(ctx, { + from: one(sp.from), to: one(sp.to), actorId: one(sp.actorId), action: one(sp.action), + entity: one(sp.entity), entityId: one(sp.entityId), page: one(sp.page), + }); + const detailId = one(sp.detail); + let detail: Awaited> | null = null; + if (detailId) { + try { + detail = await getAuditEntry(ctx, detailId); + } catch (err) { + if (!(err instanceof ServiceError)) throw err; + } + } + + const [t, format] = await Promise.all([getTranslations("notifications"), getFormatter()]); + const actorNames = new Map(data.actors.map((a) => [a.id, a.name])); + const actionLabel = (a: string) => (t.has(`audit.actions.${a}`) ? t(`audit.actions.${a}`) : a); + const entityLabel = (e: string) => (t.has(`audit.entities.${e}`) ? t(`audit.entities.${e}`) : e); + + const href = (patch: Partial>) => { + const q = new URLSearchParams(); + const merged = { ...data.filter, detail: undefined, ...patch } as Record; + for (const [k, v] of Object.entries(merged)) if (v !== undefined && v !== "" && !(k === "page" && v === 1)) q.set(k, String(v)); + const s = q.toString(); + return `/settings/audit${s ? `?${s}` : ""}`; + }; + const f = data.filter; + + return ( +
+ + {t("audit.back")} + + + +
+ + + + + + +
+ + + {t("audit.reset")} + +
+
+ +

{t("audit.total", { count: data.total })}

+ + {data.rows.length === 0 ? ( +

{t("audit.empty")}

+ ) : ( +
+ + + + + + + + + + + + + {data.rows.map((r) => ( + + + + + + + + + ))} + +
{t("audit.time")}{t("audit.actor")}{t("audit.action")}{t("audit.object")}{t("audit.result")}{t("audit.details")}
+ {format.dateTime(r.createdAt, { dateStyle: "medium", timeStyle: "medium" })} + {r.actorId ? (actorNames.get(r.actorId) ?? r.actorId.slice(0, 8)) : t("audit.system")}{actionLabel(r.action)} + {entityLabel(r.entity)} + {r.entityId && #{r.entityId.slice(0, 12)}} + {r.scope === "platform" && · {t("audit.platform")}} + + {r.action === "denied" ? ( + {t("audit.resultDenied")} + ) : ( + {t("audit.resultOk")} + )} + + + {t("audit.details")} + +
+
+ )} + + {data.pages > 1 && ( + + )} + + {detail && ( + +
+
+
{t("audit.time")}
{format.dateTime(detail.createdAt, { dateStyle: "medium", timeStyle: "medium" })}
+
{t("audit.actor")}
{detail.actorName ?? (detail.actorId ?? t("audit.system"))}
+
{t("audit.entity")}
{entityLabel(detail.entity)}{detail.scope === "platform" ? ` · ${t("audit.platform")}` : ""}
+
{t("audit.entityId")}
{detail.entityId ?? "—"}
+
{t("audit.result")}
{detail.action === "denied" ? t("audit.resultDenied") : t("audit.resultOk")}
+
{t("audit.ip")}
{detail.ip ?? t("audit.notCaptured")}
+
{t("audit.userAgent")}
{detail.userAgent ?? t("audit.notCaptured")}
+
+ + {detail.diff.length === 0 ? ( +

{t("audit.noValues")}

+ ) : ( +
+ + + + + + + + + + {detail.diff.map((d) => ( + + + + + + ))} + +
{t("audit.field")}{t("audit.before")}{t("audit.after")}
+ {d.key} + {d.changed && {t("audit.changed")}} + {d.before ?? "—"}{d.after ?? "—"}
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/app/(app)/settings/email/layout.tsx b/src/app/(app)/settings/email/layout.tsx new file mode 100644 index 0000000..dcc4d43 --- /dev/null +++ b/src/app/(app)/settings/email/layout.tsx @@ -0,0 +1,7 @@ +import { requireModule } from "@/server/modules"; + +/** Tenant mail settings belong to the "notifications" module (actions use its moduleGuard). */ +export default async function MailSettingsLayout({ children }: Readonly<{ children: React.ReactNode }>) { + await requireModule("notifications"); + return <>{children}; +} diff --git a/src/app/(app)/settings/email/page.tsx b/src/app/(app)/settings/email/page.tsx new file mode 100644 index 0000000..afad8b3 --- /dev/null +++ b/src/app/(app)/settings/email/page.tsx @@ -0,0 +1,83 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { PageHead } from "@/components/mockup-ui"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { getMailConfig } from "@/server/mail/config"; +import { saveMailSettings } from "@/server/actions/notifications/mail-settings"; +import { getMailSettings } from "@/server/services/notifications/mail-settings"; +import { pageCtx } from "@/server/services/notifications/page-ctx"; + +const areaCls = "min-h-28 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm"; + +export default async function MailSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) { + const ctx = await pageCtx(); + if (!ctx.permissions.has("tenant:manage")) redirect("/dashboard"); + const [sp, s, t] = await Promise.all([searchParams, getMailSettings(ctx), getTranslations("notifications")]); + const platformFrom = getMailConfig().config?.from ?? "—"; + + return ( +
+ + {t("mailSettings.back")} + + + + {sp.saved && ( +

+ ✓ {t("mailSettings.saved")} +

+ )} + {sp.error && ( +

+ ⚠ {t("mailSettings.invalid", { detail: sp.error.split(",").map((f) => t.has(`mailSettings.${f}`) ? t(`mailSettings.${f}`) : f).join(", ") })} +

+ )} + +
+
+

{t("mailSettings.sender")}

+
+
+ + +

{t("mailSettings.fromNameHint")}

+
+
+ +

{t("mailSettings.fromAddressHint", { address: platformFrom })}

+
+
+ + +

{t("mailSettings.replyToHint")}

+
+
+
+ +
+

{t("mailSettings.recipients")}

+
+
+ +