Merge lane/benachrichtigungen in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`: `<NotificationBell />` im Header (Import + 1 Zeile)
|
||||
- `src/app/(app)/account/page.tsx`: `<NotificationPreferencesSection />` (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 `<NotificationBell variant="mobile" />` (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=<id>`)
|
||||
@@ -8,5 +8,8 @@
|
||||
"reports": "Berichte",
|
||||
"documents": "Dokumente",
|
||||
"settings": "Einstellungen",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"audit": "Audit-Protokoll",
|
||||
"email": "E-Mail-Versand",
|
||||
"admin": "Admin-Konsole"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,8 @@
|
||||
"reports": "Reports",
|
||||
"documents": "Documents",
|
||||
"settings": "Settings",
|
||||
"notifications": "Notifications",
|
||||
"audit": "Audit log",
|
||||
"email": "E-mail delivery",
|
||||
"admin": "Admin console"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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("<!doctype html>") && 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);
|
||||
});
|
||||
@@ -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<unknown>, 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);
|
||||
});
|
||||
@@ -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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NotificationPreferencesSection />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-[var(--panel)] px-6 py-2.5 backdrop-blur-md">
|
||||
{/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */}
|
||||
<div className="flex-1" />
|
||||
<NotificationBell />
|
||||
<UiLocaleSwitcher current={identity.uiLocale} />
|
||||
<Link href="/account" className="flex items-center gap-3">
|
||||
<div className="text-right leading-tight">
|
||||
|
||||
@@ -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 <ModulePlaceholder moduleKey="notifications" />;
|
||||
const selectCls = "h-11 rounded-md border border-input bg-card px-3 text-sm";
|
||||
|
||||
export default async function NotificationsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
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 (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead
|
||||
crumb={t("list.crumb")}
|
||||
title={t("list.title")}
|
||||
sub={t("list.sub")}
|
||||
actions={
|
||||
<form action={markAllNotificationsRead}>
|
||||
<Button type="submit" variant="outline" className="min-h-11">
|
||||
<CheckCheck aria-hidden /> {t("list.markAllRead")}
|
||||
</Button>
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
|
||||
<form method="get" className="shadow-card mb-4 flex flex-wrap items-end gap-3 rounded-xl border bg-card p-4">
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("list.filterStatus")}
|
||||
<select name="status" defaultValue={data.filter.status} className={selectCls}>
|
||||
<option value="all">{t("list.statusAll")}</option>
|
||||
<option value="unread">{t("list.statusUnread")}</option>
|
||||
<option value="read">{t("list.statusRead")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("list.filterType")}
|
||||
<select name="type" defaultValue={data.filter.type ?? ""} className={selectCls}>
|
||||
<option value="">{t("list.typeAll")}</option>
|
||||
{EVENT_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`types.${eventKey(type)}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" className="min-h-11">{t("list.apply")}</Button>
|
||||
<Link href="/notifications" className="flex min-h-11 items-center px-2 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
{t("list.reset")}
|
||||
</Link>
|
||||
</form>
|
||||
|
||||
{data.rows.length === 0 ? (
|
||||
<p className="shadow-card rounded-xl border bg-card p-6 text-sm text-muted-foreground">{t("list.empty")}</p>
|
||||
) : (
|
||||
<ul className="shadow-card divide-y rounded-xl border bg-card">
|
||||
{data.rows.map((n) => (
|
||||
<li key={n.id} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
{n.readAt ? (
|
||||
<Pill tone="mut">{t("list.read")}</Pill>
|
||||
) : (
|
||||
<Pill tone="orange">
|
||||
<BellDot className="size-3.5" aria-hidden /> {t("list.unread")}
|
||||
</Pill>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className={n.readAt ? "text-sm font-medium" : "text-sm font-semibold"}>{n.title}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{n.message}</p>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
{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" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{!n.readAt && (
|
||||
<form action={markNotificationRead}>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<Button type="submit" variant="ghost" className="min-h-11">{t("list.markRead")}</Button>
|
||||
</form>
|
||||
)}
|
||||
{n.link && (
|
||||
<form action={openNotification}>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<Button type="submit" variant="outline" className="min-h-11">{t("list.open")}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data.pages > 1 && (
|
||||
<nav className="mt-4 flex items-center justify-between text-[13px]" aria-label={t("list.title")}>
|
||||
{data.page > 1 ? (
|
||||
<Link href={pageHref(data.page - 1)} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">← {t("list.previous")}</Link>
|
||||
) : <span />}
|
||||
<span className="text-muted-foreground">{t("list.page", { page: data.page, pages: data.pages })}</span>
|
||||
{data.page < data.pages ? (
|
||||
<Link href={pageHref(data.page + 1)} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">{t("list.next")} →</Link>
|
||||
) : <span />}
|
||||
</nav>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
|
||||
export default async function AuditLogPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
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<ReturnType<typeof getAuditEntry>> | 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<Record<keyof AuditFilter | "detail", string | number | undefined>>) => {
|
||||
const q = new URLSearchParams();
|
||||
const merged = { ...data.filter, detail: undefined, ...patch } as Record<string, string | number | undefined>;
|
||||
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 (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<Link href="/settings" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden /> {t("audit.back")}
|
||||
</Link>
|
||||
<PageHead crumb={t("audit.crumb")} title={t("audit.title")} sub={t("audit.sub")} />
|
||||
|
||||
<form method="get" className="shadow-card mb-4 grid gap-3 rounded-xl border bg-card p-4 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7">
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.from")}
|
||||
<input type="date" name="from" defaultValue={f.from ?? ""} className={fieldCls} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.to")}
|
||||
<input type="date" name="to" defaultValue={f.to ?? ""} className={fieldCls} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.actor")}
|
||||
<select name="actorId" defaultValue={f.actorId ?? ""} className={fieldCls}>
|
||||
<option value="">{t("audit.actorAll")}</option>
|
||||
{data.actors.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.action")}
|
||||
<select name="action" defaultValue={f.action ?? ""} className={fieldCls}>
|
||||
<option value="">{t("audit.actionAll")}</option>
|
||||
{data.facets.actions.map((a) => <option key={a} value={a}>{actionLabel(a)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.entity")}
|
||||
<select name="entity" defaultValue={f.entity ?? ""} className={fieldCls}>
|
||||
<option value="">{t("audit.entityAll")}</option>
|
||||
{data.facets.entities.map((e) => <option key={e} value={e}>{entityLabel(e)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px] font-semibold text-muted-foreground">
|
||||
{t("audit.entityId")}
|
||||
<input name="entityId" defaultValue={f.entityId ?? ""} className={fieldCls} />
|
||||
</label>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button type="submit" className="min-h-11">{t("audit.apply")}</Button>
|
||||
<Link href="/settings/audit" className="flex min-h-11 items-center px-2 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
{t("audit.reset")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="mb-2 text-[12.5px] text-muted-foreground">{t("audit.total", { count: data.total })}</p>
|
||||
|
||||
{data.rows.length === 0 ? (
|
||||
<p className="shadow-card rounded-xl border bg-card p-6 text-sm text-muted-foreground">{t("audit.empty")}</p>
|
||||
) : (
|
||||
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[720px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[12px] text-muted-foreground">
|
||||
<th className="px-4 py-2.5 font-semibold">{t("audit.time")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("audit.actor")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("audit.action")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("audit.object")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("audit.result")}</th>
|
||||
<th className="px-4 py-2.5"><span className="sr-only">{t("audit.details")}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.rows.map((r) => (
|
||||
<tr key={r.id} className="border-b align-top last:border-0">
|
||||
<td className="px-4 py-2 text-xs whitespace-nowrap text-muted-foreground">
|
||||
{format.dateTime(r.createdAt, { dateStyle: "medium", timeStyle: "medium" })}
|
||||
</td>
|
||||
<td className="px-4 py-2">{r.actorId ? (actorNames.get(r.actorId) ?? r.actorId.slice(0, 8)) : t("audit.system")}</td>
|
||||
<td className="px-4 py-2">{actionLabel(r.action)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="font-medium">{entityLabel(r.entity)}</span>
|
||||
{r.entityId && <span className="ml-1 font-mono text-[11px] text-muted-foreground">#{r.entityId.slice(0, 12)}</span>}
|
||||
{r.scope === "platform" && <span className="ml-1 text-[11px] text-muted-foreground">· {t("audit.platform")}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{r.action === "denied" ? (
|
||||
<Pill tone="risk"><CircleX className="size-3.5" aria-hidden /> {t("audit.resultDenied")}</Pill>
|
||||
) : (
|
||||
<Pill tone="ok"><CircleCheck className="size-3.5" aria-hidden /> {t("audit.resultOk")}</Pill>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<Link href={href({ detail: r.id, page: data.page })} scroll={false} className="inline-flex min-h-11 items-center font-semibold text-[var(--primary)]">
|
||||
{t("audit.details")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.pages > 1 && (
|
||||
<nav className="mt-4 flex items-center justify-between text-[13px]" aria-label={t("audit.title")}>
|
||||
{data.page > 1 ? <Link href={href({ page: data.page - 1 })} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">← {t("audit.previous")}</Link> : <span />}
|
||||
<span className="text-muted-foreground">{t("audit.page", { page: data.page, pages: data.pages })}</span>
|
||||
{data.page < data.pages ? <Link href={href({ page: data.page + 1 })} className="flex min-h-11 items-center font-semibold text-[var(--primary)]">{t("audit.next")} →</Link> : <span />}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<Modal title={t("audit.detailTitle")} sub={`${actionLabel(detail.action)} · ${entityLabel(detail.entity)}`} closeHref={href({ page: data.page })} closeLabel={t("audit.close")}>
|
||||
<div className="max-h-[70vh] overflow-y-auto p-5">
|
||||
<dl className="grid gap-x-6 gap-y-2 text-[13px] sm:grid-cols-2">
|
||||
<div><dt className="text-muted-foreground">{t("audit.time")}</dt><dd>{format.dateTime(detail.createdAt, { dateStyle: "medium", timeStyle: "medium" })}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t("audit.actor")}</dt><dd>{detail.actorName ?? (detail.actorId ?? t("audit.system"))}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t("audit.entity")}</dt><dd>{entityLabel(detail.entity)}{detail.scope === "platform" ? ` · ${t("audit.platform")}` : ""}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t("audit.entityId")}</dt><dd className="font-mono text-[12px] break-all">{detail.entityId ?? "—"}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t("audit.result")}</dt><dd>{detail.action === "denied" ? t("audit.resultDenied") : t("audit.resultOk")}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t("audit.ip")}</dt><dd>{detail.ip ?? t("audit.notCaptured")}</dd></div>
|
||||
<div className="sm:col-span-2"><dt className="text-muted-foreground">{t("audit.userAgent")}</dt><dd className="break-all">{detail.userAgent ?? t("audit.notCaptured")}</dd></div>
|
||||
</dl>
|
||||
|
||||
{detail.diff.length === 0 ? (
|
||||
<p className="mt-5 text-sm text-muted-foreground">{t("audit.noValues")}</p>
|
||||
) : (
|
||||
<div className="mt-5 overflow-x-auto rounded-lg border">
|
||||
<table className="w-full min-w-[560px] text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="px-3 py-2 font-semibold">{t("audit.field")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("audit.before")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("audit.after")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.diff.map((d) => (
|
||||
<tr key={d.key} className={d.changed ? "border-b bg-[var(--ui-primary-soft)] align-top last:border-0" : "border-b align-top last:border-0"}>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{d.key}
|
||||
{d.changed && <span className="ml-1.5 text-[10.5px] font-bold text-[var(--primary)] uppercase">{t("audit.changed")}</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono break-all whitespace-pre-wrap text-muted-foreground">{d.before ?? "—"}</td>
|
||||
<td className="px-3 py-2 font-mono break-all whitespace-pre-wrap">{d.after ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<Link href="/settings" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden /> {t("mailSettings.back")}
|
||||
</Link>
|
||||
<PageHead crumb={t("mailSettings.crumb")} title={t("mailSettings.title")} sub={t("mailSettings.sub")} />
|
||||
|
||||
{sp.saved && (
|
||||
<p role="status" className="mb-4 rounded-lg border border-[var(--ok)] bg-card px-4 py-3 text-sm text-[var(--ok)]">
|
||||
✓ {t("mailSettings.saved")}
|
||||
</p>
|
||||
)}
|
||||
{sp.error && (
|
||||
<p role="alert" className="mb-4 rounded-lg border border-[var(--risk)] bg-card px-4 py-3 text-sm text-[var(--risk)]">
|
||||
⚠ {t("mailSettings.invalid", { detail: sp.error.split(",").map((f) => t.has(`mailSettings.${f}`) ? t(`mailSettings.${f}`) : f).join(", ") })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form action={saveMailSettings} className="grid max-w-4xl gap-5 lg:grid-cols-2">
|
||||
<section className="shadow-card rounded-xl border bg-card p-5">
|
||||
<h2 className="mb-3 font-heading text-sm font-semibold">{t("mailSettings.sender")}</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="mailFromName">{t("mailSettings.fromName")}</Label>
|
||||
<Input id="mailFromName" name="mailFromName" maxLength={100} defaultValue={s.mailFromName ?? ""} placeholder={s.orgName ?? ""} className="mt-1 h-11" />
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.fromNameHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("mailSettings.fromAddress")}</Label>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.fromAddressHint", { address: platformFrom })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="mailReplyTo">{t("mailSettings.replyTo")}</Label>
|
||||
<Input id="mailReplyTo" name="mailReplyTo" type="email" defaultValue={s.mailReplyTo ?? ""} className="mt-1 h-11" />
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.replyToHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shadow-card rounded-xl border bg-card p-5">
|
||||
<h2 className="mb-3 font-heading text-sm font-semibold">{t("mailSettings.recipients")}</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="emergencyRecipients">{t("mailSettings.emergencyRecipients")}</Label>
|
||||
<textarea id="emergencyRecipients" name="emergencyRecipients" defaultValue={s.emergencyRecipients.join("\n")} className={`${areaCls} mt-1`} />
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.emergencyHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="billingRecipients">{t("mailSettings.billingRecipients")}</Label>
|
||||
<textarea id="billingRecipients" name="billingRecipients" defaultValue={s.billingRecipients.join("\n")} className={`${areaCls} mt-1`} />
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("mailSettings.billingHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Button type="submit" className="min-h-11">{t("mailSettings.save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,17 @@ const ENTITY_LABEL: Record<string, string> = {
|
||||
import: "Auftragsimport",
|
||||
report: "Bericht",
|
||||
emergency: "Notdienst",
|
||||
signature: "Unterschrift",
|
||||
import_job: "Auftragsimport",
|
||||
material_usage: "Material",
|
||||
time_entry: "Arbeitszeit",
|
||||
work_session: "Einsatzzeit",
|
||||
photo: "Foto",
|
||||
voice_note: "Sprachnotiz",
|
||||
notification: "Benachrichtigung",
|
||||
notification_settings: "Benachrichtigungseinstellungen",
|
||||
tenant_mail_settings: "E-Mail-Versand",
|
||||
sync_operation: "Synchronisation",
|
||||
document: "Dokument",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { markAllNotificationsRead, openNotification } from "@/server/actions/notifications/inbox";
|
||||
import { bellSummary } from "@/server/services/notifications/inbox";
|
||||
import { isNotificationsModuleEnabled, pageCtx } from "@/server/services/notifications/page-ctx";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Notification bell for the back office header and the mobile header (L4 embeds
|
||||
* `<NotificationBell variant="mobile" />`). Server component, no client JS: the dropdown is a
|
||||
* native <details>. Hidden without `notification:read` or with the module disabled.
|
||||
*/
|
||||
export async function NotificationBell({ variant = "desktop" }: { variant?: "desktop" | "mobile" }) {
|
||||
const ctx = await pageCtx();
|
||||
if (!ctx.permissions.has("notification:read") || !(await isNotificationsModuleEnabled(ctx))) return null;
|
||||
|
||||
const [{ unread, latest }, t, format] = await Promise.all([bellSummary(ctx), getTranslations("notifications"), getFormatter()]);
|
||||
const touch = variant === "mobile" ? "size-12" : "size-11";
|
||||
|
||||
return (
|
||||
<details className="group relative">
|
||||
<summary
|
||||
className={cn(
|
||||
"relative grid cursor-pointer list-none place-items-center rounded-lg text-foreground hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 [&::-webkit-details-marker]:hidden",
|
||||
touch,
|
||||
)}
|
||||
aria-label={`${t("bell.label")} – ${t("bell.unreadCount", { count: unread })}`}
|
||||
>
|
||||
<Bell className="size-5" aria-hidden />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-1 right-1 min-w-[18px] rounded-full bg-[var(--ui-accent)] px-1 text-center text-[10.5px] leading-[18px] font-bold text-[var(--ui-accent-foreground)]"
|
||||
>
|
||||
{unread > 99 ? "99+" : unread}
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
|
||||
<div className="shadow-card absolute right-0 z-40 mt-2 w-[min(24rem,calc(100vw-2rem))] rounded-xl border bg-card">
|
||||
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">{t("bell.label")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("bell.unreadCount", { count: unread })}</p>
|
||||
</div>
|
||||
{unread > 0 && (
|
||||
<form action={markAllNotificationsRead}>
|
||||
<button type="submit" className="min-h-11 rounded-md px-2 text-[12.5px] font-semibold text-[var(--primary)] hover:bg-muted">
|
||||
{t("bell.markAllRead")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latest.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-muted-foreground">{t("bell.empty")}</p>
|
||||
) : (
|
||||
<ul className="max-h-[60vh] divide-y overflow-y-auto">
|
||||
{latest.map((n) => (
|
||||
<li key={n.id}>
|
||||
<form action={openNotification}>
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<button type="submit" className="flex min-h-11 w-full items-start gap-2.5 px-4 py-2.5 text-left hover:bg-muted/60">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("mt-1.5 size-2 shrink-0 rounded-full", n.readAt ? "bg-transparent" : "bg-[var(--ui-accent)]")}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={cn("truncate text-[13px]", n.readAt ? "font-medium" : "font-semibold")}>{n.title}</span>
|
||||
{!n.readAt && <span className="shrink-0 text-[10.5px] font-bold text-[var(--primary)] uppercase">{t("list.unread")}</span>}
|
||||
</span>
|
||||
<span className="line-clamp-2 block text-[12px] text-muted-foreground">{n.message}</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
{format.dateTime(n.createdAt, { dateStyle: "short", timeStyle: "short" })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="border-t px-4 py-2">
|
||||
<Link href="/notifications" className="flex min-h-11 items-center justify-center text-[13px] font-semibold text-[var(--primary)]">
|
||||
{t("bell.showAll")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { saveNotificationPreferences, type PreferencesState } from "@/server/actions/notifications/preferences";
|
||||
|
||||
export type PreferenceItem = { type: string; label: string; email: boolean; mandatory: boolean };
|
||||
|
||||
/** E-mail opt-out per notification type (in-app notifications are always on). */
|
||||
export function NotificationPreferencesForm({
|
||||
items,
|
||||
labels,
|
||||
}: {
|
||||
items: PreferenceItem[];
|
||||
labels: { email: string; mandatory: string; save: string; saved: string; error: string };
|
||||
}) {
|
||||
const [state, action, pending] = useActionState(saveNotificationPreferences, { status: "idle" } as PreferencesState);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-3">
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{items.map((item) => {
|
||||
const id = `pref-${item.type}`;
|
||||
return (
|
||||
<li key={item.type} className="flex min-h-11 items-center justify-between gap-3 px-3 py-1.5">
|
||||
<label htmlFor={id} className="flex-1 text-[13px]">
|
||||
{item.label}
|
||||
{item.mandatory && <span className="block text-[11.5px] text-muted-foreground">{item.mandatory ? labels.mandatory : null}</span>}
|
||||
</label>
|
||||
<span className="flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
{labels.email}
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
name="emailOn"
|
||||
value={item.type}
|
||||
defaultChecked={item.email}
|
||||
disabled={item.mandatory}
|
||||
className="size-5 accent-[var(--primary)]"
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" disabled={pending} className="min-h-11">
|
||||
{labels.save}
|
||||
</Button>
|
||||
<p role="status" className="text-[12.5px]">
|
||||
{state.status === "saved" && <span className="text-[var(--ok)]">✓ {labels.saved}</span>}
|
||||
{state.status === "error" && <span className="text-[var(--risk)]">⚠ {labels.error}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getPreferences } from "@/server/services/notifications/preferences";
|
||||
import { isNotificationsModuleEnabled, pageCtx } from "@/server/services/notifications/page-ctx";
|
||||
import { eventKey } from "@/server/services/notifications/texts";
|
||||
import { NotificationPreferencesForm } from "./preferences-form";
|
||||
|
||||
/** "Benachrichtigungen" section of /account (self-contained: loads its own data). */
|
||||
export async function NotificationPreferencesSection() {
|
||||
const ctx = await pageCtx();
|
||||
if (!ctx.permissions.has("notification:read") || !(await isNotificationsModuleEnabled(ctx))) return null;
|
||||
const [prefs, t, tc] = await Promise.all([getPreferences(ctx), getTranslations("notifications"), getTranslations("common")]);
|
||||
|
||||
return (
|
||||
<section className="shadow-card mt-5 rounded-xl border bg-card p-5" aria-labelledby="notification-preferences">
|
||||
<p id="notification-preferences" className="mb-1 font-heading text-sm font-semibold">
|
||||
{t("preferences.title")}
|
||||
</p>
|
||||
<p className="mb-3 text-[12.5px] text-muted-foreground">{t("preferences.sub")}</p>
|
||||
<NotificationPreferencesForm
|
||||
items={prefs.map((p) => ({ type: p.type, label: t(`types.${eventKey(p.type)}`), email: p.email, mandatory: p.mandatory }))}
|
||||
labels={{
|
||||
email: t("preferences.email"),
|
||||
mandatory: t("preferences.mandatory"),
|
||||
save: t("preferences.save"),
|
||||
saved: t("preferences.saved"),
|
||||
error: tc("readOnly"),
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
Bell,
|
||||
History,
|
||||
Mail,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ModuleKey } from "@/lib/modules";
|
||||
@@ -49,7 +52,10 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
{ href: "/teams", label: "teams", icon: UsersRound, module: "teams", permissions: ["team:read"], section: "main" },
|
||||
{ href: "/reports", label: "reports", icon: FileText, module: "reports", permissions: ["report:read"], section: "main" },
|
||||
{ href: "/documents", label: "documents", icon: FolderOpen, module: "documents", permissions: ["document:read"], section: "main" },
|
||||
{ href: "/notifications", label: "notifications", icon: Bell, module: "notifications", permissions: ["notification:read"], section: "main" },
|
||||
{ href: "/settings", label: "settings", icon: Settings, permissions: ["tenant:manage"], section: "admin" },
|
||||
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
|
||||
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
|
||||
];
|
||||
|
||||
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
||||
import { markAllRead, markRead } from "@/server/services/notifications/inbox";
|
||||
|
||||
const guard = moduleGuard("notifications");
|
||||
|
||||
/** Mark one own notification as read. */
|
||||
export async function markNotificationRead(formData: FormData) {
|
||||
const g = await guard("notification:read");
|
||||
await markRead(ctxFromGuard(g), formData.get("id"));
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
/** Mark as read and open the linked entity (relative links only). */
|
||||
export async function openNotification(formData: FormData) {
|
||||
const g = await guard("notification:read");
|
||||
let target = "/notifications";
|
||||
try {
|
||||
const { link } = await markRead(ctxFromGuard(g), formData.get("id"));
|
||||
if (link) target = link;
|
||||
} catch (err) {
|
||||
if (!(err instanceof ServiceError)) throw err;
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
redirect(target);
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead() {
|
||||
const g = await guard("notification:read");
|
||||
await markAllRead(ctxFromGuard(g));
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ZodError } from "zod";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ctxFromGuard } from "@/server/services/context";
|
||||
import { updateMailSettings } from "@/server/services/notifications/mail-settings";
|
||||
|
||||
const guard = moduleGuard("notifications");
|
||||
|
||||
/** Tenant mail settings (§33.2) — tenant:manage. Result is reported via query string. */
|
||||
export async function saveMailSettings(formData: FormData) {
|
||||
const g = await guard("tenant:manage");
|
||||
let target = "/settings/email?saved=1";
|
||||
try {
|
||||
await updateMailSettings(ctxFromGuard(g), {
|
||||
mailFromName: String(formData.get("mailFromName") ?? ""),
|
||||
mailReplyTo: String(formData.get("mailReplyTo") ?? ""),
|
||||
emergencyRecipients: String(formData.get("emergencyRecipients") ?? ""),
|
||||
billingRecipients: String(formData.get("billingRecipients") ?? ""),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof ZodError)) throw err;
|
||||
const fields = [...new Set(err.issues.map((i) => String(i.path[0] ?? "")))].filter(Boolean).join(",");
|
||||
target = `/settings/email?error=${encodeURIComponent(fields || "input")}`;
|
||||
}
|
||||
revalidatePath("/settings/email");
|
||||
redirect(target);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ctxFromGuard } from "@/server/services/context";
|
||||
import { setPreferences } from "@/server/services/notifications/preferences";
|
||||
|
||||
const guard = moduleGuard("notifications");
|
||||
|
||||
export type PreferencesState = { status: "idle" | "saved" | "error" };
|
||||
|
||||
/** Save the current user's e-mail opt-outs (checked boxes = e-mail on). */
|
||||
export async function saveNotificationPreferences(_prev: PreferencesState, formData: FormData): Promise<PreferencesState> {
|
||||
const g = await guard("notification:read");
|
||||
try {
|
||||
await setPreferences(ctxFromGuard(g), { emailOn: formData.getAll("emailOn").map(String) });
|
||||
} catch (err) {
|
||||
console.error("[notifications] saving preferences failed:", (err as Error).message);
|
||||
return { status: "error" };
|
||||
}
|
||||
revalidatePath("/account");
|
||||
return { status: "saved" };
|
||||
}
|
||||
@@ -40,8 +40,33 @@ export type TemplateVars = {
|
||||
eventType: string;
|
||||
};
|
||||
test: { name: string; when: string };
|
||||
// ---- Craftvia domain notifications (lane L6). `name` may be "" for configured external recipients.
|
||||
craftvia_team_assigned: {
|
||||
name: string; number: string; title: string; customer: string;
|
||||
site?: string; plannedStart?: string; team?: string; actionUrl: string; footer?: CraftviaFooter;
|
||||
};
|
||||
craftvia_report_review: {
|
||||
name: string; number: string; title: string; customer: string;
|
||||
submittedBy?: string; reportType?: "daily" | "completion"; actionUrl: string; footer?: CraftviaFooter;
|
||||
};
|
||||
craftvia_billing_release: {
|
||||
name: string; number: string; title: string; customer: string; actionUrl: string; footer?: CraftviaFooter;
|
||||
};
|
||||
craftvia_emergency: {
|
||||
name: string; phase: "created" | "completed"; number: string; technician: string; customer: string;
|
||||
start: string; end?: string; actionUrl: string; footer?: CraftviaFooter;
|
||||
};
|
||||
craftvia_document_failed: {
|
||||
name: string; fileName: string; error?: string; actionUrl: string; footer?: CraftviaFooter;
|
||||
};
|
||||
craftvia_notification: {
|
||||
name: string; subject: string; body: string; actionUrl?: string; footer?: CraftviaFooter;
|
||||
};
|
||||
};
|
||||
|
||||
/** Why the recipient gets a Craftvia notification — controls the footer line. */
|
||||
export type CraftviaFooter = "user" | "mandatory" | "configured";
|
||||
|
||||
export type TemplateKey = keyof TemplateVars;
|
||||
|
||||
export const TEMPLATE_KEYS = [
|
||||
@@ -55,6 +80,16 @@ export const TEMPLATE_KEYS = [
|
||||
"test",
|
||||
] as const satisfies readonly TemplateKey[];
|
||||
|
||||
/** Craftvia domain templates (lane L6) — kept separate so SEC1 fixtures stay unchanged. */
|
||||
export const CRAFTVIA_TEMPLATE_KEYS = [
|
||||
"craftvia_team_assigned",
|
||||
"craftvia_report_review",
|
||||
"craftvia_billing_release",
|
||||
"craftvia_emergency",
|
||||
"craftvia_document_failed",
|
||||
"craftvia_notification",
|
||||
] as const satisfies readonly TemplateKey[];
|
||||
|
||||
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
|
||||
const FOOTER_NOTE: Record<Locale, string> = {
|
||||
de: "Sie erhalten diese Benachrichtigung aufgrund Ihrer Rolle in Ihrem Betrieb. Die Einstellungen dazu finden Sie in Ihrem Profil.",
|
||||
@@ -63,6 +98,169 @@ const FOOTER_NOTE: Record<Locale, string> = {
|
||||
|
||||
type Builder<K extends TemplateKey> = (vars: TemplateVars[K]) => EmailContent;
|
||||
|
||||
// ---- Craftvia domain notifications (lane L6) ----
|
||||
type CraftviaKey = (typeof CRAFTVIA_TEMPLATE_KEYS)[number];
|
||||
|
||||
const CRAFTVIA_FOOTER: Record<Locale, Record<CraftviaFooter, string>> = {
|
||||
de: {
|
||||
user: FOOTER_NOTE.de,
|
||||
mandatory: "Pflichtbenachrichtigung zum Notdienst – sie lässt sich nicht abbestellen.",
|
||||
configured: "Sie erhalten diese Nachricht, weil Ihre Adresse im Betrieb als fester Empfänger hinterlegt ist.",
|
||||
},
|
||||
en: {
|
||||
user: FOOTER_NOTE.en,
|
||||
mandatory: "Mandatory emergency call-out notification – it cannot be turned off.",
|
||||
configured: "You are receiving this message because your address is set up as a fixed recipient in your company.",
|
||||
},
|
||||
};
|
||||
|
||||
const greetDe = (name: string) => (name ? `Hallo ${name},` : "Guten Tag,");
|
||||
const greetEn = (name: string) => (name ? `Hello ${name},` : "Hello,");
|
||||
|
||||
const craftviaDe: { [K in CraftviaKey]: Builder<K> } = {
|
||||
craftvia_team_assigned: (v) => ({
|
||||
subject: `${BRAND.name}: Neuer Auftrag ${v.number}`,
|
||||
heading: "Neuer Auftrag für Ihr Team",
|
||||
paragraphs: [
|
||||
greetDe(v.name),
|
||||
`der Auftrag ${v.number} „${v.title}" wurde ${v.team ? `dem Team ${v.team}` : "Ihnen"} zugewiesen.`,
|
||||
`Kunde: ${v.customer}`,
|
||||
...(v.site ? [`Objekt: ${v.site}`] : []),
|
||||
...(v.plannedStart ? [`Geplanter Beginn: ${v.plannedStart}`] : []),
|
||||
],
|
||||
action: { label: "Auftrag öffnen", url: v.actionUrl },
|
||||
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
|
||||
}),
|
||||
craftvia_report_review: (v) => ({
|
||||
subject: `${BRAND.name}: Bericht zur Prüfung – ${v.number}`,
|
||||
heading: "Bericht zur Prüfung",
|
||||
paragraphs: [
|
||||
greetDe(v.name),
|
||||
`${v.submittedBy ?? "Das Team"} hat ${v.reportType === "daily" ? "einen Tagesbericht" : v.reportType === "completion" ? "einen Abschlussbericht" : "einen Bericht"} zu ${v.number} „${v.title}" eingereicht.`,
|
||||
`Kunde: ${v.customer}`,
|
||||
"Bitte prüfen und freigeben oder eine Korrektur anfordern.",
|
||||
],
|
||||
action: { label: "Bericht prüfen", url: v.actionUrl },
|
||||
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
|
||||
}),
|
||||
craftvia_billing_release: (v) => ({
|
||||
subject: `${BRAND.name}: Auftrag ${v.number} zur Abrechnung`,
|
||||
heading: "Auftrag bereit zur Abrechnung",
|
||||
paragraphs: [
|
||||
greetDe(v.name),
|
||||
`der Auftrag ${v.number} „${v.title}" ist geprüft und zur Abrechnung freigegeben.`,
|
||||
`Kunde: ${v.customer}`,
|
||||
],
|
||||
action: { label: "Auftrag öffnen", url: v.actionUrl },
|
||||
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
|
||||
}),
|
||||
craftvia_emergency: (v) => ({
|
||||
subject: `${BRAND.name}: Notdiensteinsatz ${v.number} ${v.phase === "completed" ? "abgeschlossen" : "erstellt"}`,
|
||||
heading: v.phase === "completed" ? "Neuer Notdiensteinsatz abgeschlossen" : "Neuer Notdiensteinsatz erstellt",
|
||||
paragraphs: [
|
||||
greetDe(v.name),
|
||||
`Monteur: ${v.technician}`,
|
||||
`Kunde: ${v.customer}`,
|
||||
`Einsatzbeginn: ${v.start}`,
|
||||
...(v.end ? [`Einsatzende: ${v.end}`] : []),
|
||||
`Status: ${v.phase === "completed" ? "Zur Prüfung und Abrechnung" : "In Arbeit"}`,
|
||||
],
|
||||
action: { label: "Einsatz öffnen", url: v.actionUrl },
|
||||
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "mandatory"],
|
||||
}),
|
||||
craftvia_document_failed: (v) => ({
|
||||
subject: `${BRAND.name}: Fehler bei der Dokumentverarbeitung`,
|
||||
heading: "Dokument konnte nicht verarbeitet werden",
|
||||
paragraphs: [
|
||||
greetDe(v.name),
|
||||
`die Datei „${v.fileName}" konnte nicht automatisch ausgelesen werden.`,
|
||||
...(v.error ? [`Grund: ${v.error}`] : []),
|
||||
"Die Datei bleibt gespeichert. Den Auftrag bitte manuell erfassen oder die Datei erneut hochladen.",
|
||||
],
|
||||
action: { label: "Import öffnen", url: v.actionUrl },
|
||||
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
|
||||
}),
|
||||
craftvia_notification: (v) => ({
|
||||
subject: `${BRAND.name}: ${v.subject}`,
|
||||
heading: v.subject,
|
||||
paragraphs: [greetDe(v.name), v.body],
|
||||
action: v.actionUrl ? { label: `In ${BRAND.name} öffnen`, url: v.actionUrl } : undefined,
|
||||
footerNote: CRAFTVIA_FOOTER.de[v.footer ?? "user"],
|
||||
}),
|
||||
};
|
||||
|
||||
const craftviaEn: { [K in CraftviaKey]: Builder<K> } = {
|
||||
craftvia_team_assigned: (v) => ({
|
||||
subject: `${BRAND.name}: New work order ${v.number}`,
|
||||
heading: "New work order for your team",
|
||||
paragraphs: [
|
||||
greetEn(v.name),
|
||||
`work order ${v.number} "${v.title}" was assigned to ${v.team ? `team ${v.team}` : "you"}.`,
|
||||
`Customer: ${v.customer}`,
|
||||
...(v.site ? [`Site: ${v.site}`] : []),
|
||||
...(v.plannedStart ? [`Planned start: ${v.plannedStart}`] : []),
|
||||
],
|
||||
action: { label: "Open work order", url: v.actionUrl },
|
||||
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
|
||||
}),
|
||||
craftvia_report_review: (v) => ({
|
||||
subject: `${BRAND.name}: Report for review – ${v.number}`,
|
||||
heading: "Report for review",
|
||||
paragraphs: [
|
||||
greetEn(v.name),
|
||||
`${v.submittedBy ?? "The team"} submitted ${v.reportType === "daily" ? "a daily report" : v.reportType === "completion" ? "a completion report" : "a report"} for ${v.number} "${v.title}".`,
|
||||
`Customer: ${v.customer}`,
|
||||
"Please review and approve it or request a correction.",
|
||||
],
|
||||
action: { label: "Review report", url: v.actionUrl },
|
||||
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
|
||||
}),
|
||||
craftvia_billing_release: (v) => ({
|
||||
subject: `${BRAND.name}: Work order ${v.number} ready for billing`,
|
||||
heading: "Work order ready for billing",
|
||||
paragraphs: [
|
||||
greetEn(v.name),
|
||||
`work order ${v.number} "${v.title}" has been reviewed and released for billing.`,
|
||||
`Customer: ${v.customer}`,
|
||||
],
|
||||
action: { label: "Open work order", url: v.actionUrl },
|
||||
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
|
||||
}),
|
||||
craftvia_emergency: (v) => ({
|
||||
subject: `${BRAND.name}: Emergency call-out ${v.number} ${v.phase === "completed" ? "completed" : "created"}`,
|
||||
heading: v.phase === "completed" ? "New emergency call-out completed" : "New emergency call-out created",
|
||||
paragraphs: [
|
||||
greetEn(v.name),
|
||||
`Technician: ${v.technician}`,
|
||||
`Customer: ${v.customer}`,
|
||||
`Start: ${v.start}`,
|
||||
...(v.end ? [`End: ${v.end}`] : []),
|
||||
`Status: ${v.phase === "completed" ? "Ready for review and billing" : "In progress"}`,
|
||||
],
|
||||
action: { label: "Open call-out", url: v.actionUrl },
|
||||
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "mandatory"],
|
||||
}),
|
||||
craftvia_document_failed: (v) => ({
|
||||
subject: `${BRAND.name}: Document processing failed`,
|
||||
heading: "Document could not be processed",
|
||||
paragraphs: [
|
||||
greetEn(v.name),
|
||||
`the file "${v.fileName}" could not be read automatically.`,
|
||||
...(v.error ? [`Reason: ${v.error}`] : []),
|
||||
"The file stays stored. Please enter the work order manually or upload the file again.",
|
||||
],
|
||||
action: { label: "Open import", url: v.actionUrl },
|
||||
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
|
||||
}),
|
||||
craftvia_notification: (v) => ({
|
||||
subject: `${BRAND.name}: ${v.subject}`,
|
||||
heading: v.subject,
|
||||
paragraphs: [greetEn(v.name), v.body],
|
||||
action: v.actionUrl ? { label: `Open in ${BRAND.name}`, url: v.actionUrl } : undefined,
|
||||
footerNote: CRAFTVIA_FOOTER.en[v.footer ?? "user"],
|
||||
}),
|
||||
};
|
||||
|
||||
const de: { [K in TemplateKey]: Builder<K> } = {
|
||||
invitation: (v) => ({
|
||||
subject: `Ihr Zugang zu ${BRAND.name}`,
|
||||
@@ -139,6 +337,7 @@ const de: { [K in TemplateKey]: Builder<K> } = {
|
||||
"Erreicht sie Sie, sind SMTP-Konfiguration und Versandweg in Ordnung.",
|
||||
],
|
||||
}),
|
||||
...craftviaDe,
|
||||
};
|
||||
|
||||
const en: { [K in TemplateKey]: Builder<K> } = {
|
||||
@@ -217,6 +416,7 @@ const en: { [K in TemplateKey]: Builder<K> } = {
|
||||
"If it reaches you, SMTP configuration and delivery path are working.",
|
||||
],
|
||||
}),
|
||||
...craftviaEn,
|
||||
};
|
||||
|
||||
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Read-only audit log viewer (spec §26). Requires `audit:read`; always tenant-bound via ctx.db
|
||||
* (the tenant client filters AuditLog by tenantId). Audit rows are never modified here.
|
||||
*/
|
||||
|
||||
export const AUDIT_PAGE_SIZE = 50;
|
||||
|
||||
const dateStr = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
const optStr = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.transform((v) => v || undefined)
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
export const auditFilterSchema = z.object({
|
||||
from: dateStr,
|
||||
to: dateStr,
|
||||
actorId: optStr(64),
|
||||
action: optStr(40),
|
||||
entity: optStr(60),
|
||||
entityId: optStr(128),
|
||||
page: z.coerce.number().int().min(1).max(100_000).catch(1),
|
||||
});
|
||||
export type AuditFilter = z.infer<typeof auditFilterSchema>;
|
||||
|
||||
export function auditWhere(f: AuditFilter): Prisma.AuditLogWhereInput {
|
||||
const createdAt: Prisma.DateTimeFilter = {};
|
||||
if (f.from) createdAt.gte = new Date(`${f.from}T00:00:00.000Z`);
|
||||
if (f.to) createdAt.lt = new Date(new Date(`${f.to}T00:00:00.000Z`).getTime() + 86_400_000);
|
||||
return {
|
||||
...(f.from || f.to ? { createdAt } : {}),
|
||||
...(f.actorId ? { actorId: f.actorId } : {}),
|
||||
...(f.action ? { action: f.action } : {}),
|
||||
...(f.entity ? { entity: f.entity } : {}),
|
||||
...(f.entityId ? { entityId: { contains: f.entityId } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function queryAuditLog(ctx: ServiceCtx, input: unknown) {
|
||||
assertCan(ctx, "audit:read");
|
||||
const filter = auditFilterSchema.parse(input ?? {});
|
||||
const where = auditWhere(filter);
|
||||
const [rows, total, actions, entities, users] = await Promise.all([
|
||||
ctx.db.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (filter.page - 1) * AUDIT_PAGE_SIZE,
|
||||
take: AUDIT_PAGE_SIZE,
|
||||
select: { id: true, createdAt: true, actorId: true, action: true, entity: true, entityId: true, scope: true },
|
||||
}),
|
||||
ctx.db.auditLog.count({ where }),
|
||||
ctx.db.auditLog.findMany({ distinct: ["action"], select: { action: true }, orderBy: { action: "asc" } }),
|
||||
ctx.db.auditLog.findMany({ distinct: ["entity"], select: { entity: true }, orderBy: { entity: "asc" } }),
|
||||
ctx.db.user.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
page: filter.page,
|
||||
pages: Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE)),
|
||||
filter,
|
||||
facets: { actions: actions.map((a) => a.action), entities: entities.map((e) => e.entity) },
|
||||
actors: users,
|
||||
};
|
||||
}
|
||||
|
||||
export type DiffRow = { key: string; before: string | null; after: string | null; changed: boolean };
|
||||
|
||||
function show(v: unknown): string | null {
|
||||
if (v === undefined) return null;
|
||||
if (v === null) return "null";
|
||||
return typeof v === "string" ? v : JSON.stringify(v);
|
||||
}
|
||||
|
||||
/** Field-wise before/after comparison (top-level keys; nested values rendered as JSON). */
|
||||
export function diffAudit(before: unknown, after: unknown): DiffRow[] {
|
||||
const b = before && typeof before === "object" && !Array.isArray(before) ? (before as Record<string, unknown>) : null;
|
||||
const a = after && typeof after === "object" && !Array.isArray(after) ? (after as Record<string, unknown>) : null;
|
||||
if (!b && !a) {
|
||||
if (before === undefined && after === undefined) return [];
|
||||
if (before == null && after == null) return [];
|
||||
return [{ key: "value", before: show(before ?? undefined), after: show(after ?? undefined), changed: show(before) !== show(after) }];
|
||||
}
|
||||
const keys = [...new Set([...Object.keys(b ?? {}), ...Object.keys(a ?? {})])].sort();
|
||||
return keys.map((key) => {
|
||||
const bv = show(b?.[key]);
|
||||
const av = show(a?.[key]);
|
||||
return { key, before: bv, after: av, changed: bv !== av };
|
||||
});
|
||||
}
|
||||
|
||||
/** Extract request metadata if a writer stored it (writeAuditLog does not capture it yet). */
|
||||
function requestMeta(...sources: unknown[]): { ip: string | null; userAgent: string | null } {
|
||||
for (const s of sources) {
|
||||
if (s && typeof s === "object") {
|
||||
const o = s as Record<string, unknown>;
|
||||
const ip = typeof o.ip === "string" ? o.ip : null;
|
||||
const userAgent = typeof o.userAgent === "string" ? o.userAgent : null;
|
||||
if (ip || userAgent) return { ip, userAgent };
|
||||
}
|
||||
}
|
||||
return { ip: null, userAgent: null };
|
||||
}
|
||||
|
||||
export async function getAuditEntry(ctx: ServiceCtx, id: unknown) {
|
||||
assertCan(ctx, "audit:read");
|
||||
const entryId = z.string().min(1).max(64).parse(id);
|
||||
const row = await ctx.db.auditLog.findFirst({ where: { id: entryId } });
|
||||
if (!row) throw new ServiceError("not_found", "audit entry not found");
|
||||
const actor = row.actorId ? await ctx.db.user.findFirst({ where: { id: row.actorId }, select: { name: true } }) : null;
|
||||
return {
|
||||
...row,
|
||||
actorName: actor?.name ?? null,
|
||||
diff: diffAudit(row.before ?? undefined, row.after ?? undefined),
|
||||
...requestMeta(row.after, row.before),
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,224 @@
|
||||
import type { DomainEvent } from "@/lib/events";
|
||||
import { absoluteUrl } from "@/server/mail/config";
|
||||
import { enqueueMail, type EnqueueInput } from "@/server/mail/service";
|
||||
import { formatWhen, normalizeLocale, type CraftviaFooter, type Locale, type TemplateKey } from "@/server/mail/templates";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { resolveRecipients, type EventFacts, type Target } from "./recipients";
|
||||
import { eventText, fallbackText } from "./texts";
|
||||
|
||||
/**
|
||||
* Placeholder — replaced by lane "notifications" (recipient rules, in-app Notification rows,
|
||||
* e-mail via the mail queue). Keeps emitEvent() callable for all other lanes meanwhile.
|
||||
* Domain event → in-app notifications + e-mails (ARCHITEKTUR §4.1). Called only via emitEvent().
|
||||
*
|
||||
* - Never throws: every failure is logged, business data is never rolled back.
|
||||
* - In-app rows via ctx.db (tenant guard). An identical unread notification (same user, type,
|
||||
* entity) is refreshed instead of duplicated.
|
||||
* - E-mail via the mail queue with dedupeKey `event:entity:user` (+ optional `data.occurrenceId`
|
||||
* for repeatable events such as daily reports), so the same event twice sends one mail.
|
||||
* - NotificationPreference.email=false opts out per event type — except mandatory events
|
||||
* (emergency call-outs).
|
||||
*/
|
||||
export async function handleEvent(_ctx: ServiceCtx, _event: DomainEvent): Promise<void> {
|
||||
// intentionally empty
|
||||
export async function handleEvent(ctx: ServiceCtx, event: DomainEvent): Promise<void> {
|
||||
try {
|
||||
await dispatch(ctx, event);
|
||||
} catch (err) {
|
||||
console.error(`[notifications] ${event.type} for ${event.entityType}:${event.entityId} failed:`, (err as Error)?.message ?? err);
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatch(ctx: ServiceCtx, event: DomainEvent): Promise<void> {
|
||||
const settings = await ctx.db.tenantSettings.findFirst({
|
||||
select: { locale: true, emergencyRecipients: true, billingRecipients: true },
|
||||
});
|
||||
const plan = await resolveRecipients(ctx, event, settings);
|
||||
if (!plan) return;
|
||||
|
||||
const occurrence = typeof event.data?.occurrenceId === "string" || typeof event.data?.occurrenceId === "number"
|
||||
? `:${event.data.occurrenceId}`
|
||||
: "";
|
||||
const optedOut = await optedOutUserIds(ctx, event, plan.users);
|
||||
|
||||
for (const user of plan.users) {
|
||||
try {
|
||||
const link = linkFor(event, plan.facts, user.permissions);
|
||||
const text = eventText(user.locale, event.type, textVars(user.locale, plan.facts));
|
||||
const notificationId = await upsertInApp(ctx, event, user.userId, text, link);
|
||||
|
||||
const wantsMail = plan.mailUserIds.has(user.userId) && (plan.mandatory || !optedOut.has(user.userId));
|
||||
if (!wantsMail) continue;
|
||||
const result = await enqueueMail(
|
||||
buildMail(ctx, event, plan.facts, {
|
||||
to: user.email,
|
||||
name: user.name,
|
||||
locale: user.locale,
|
||||
link,
|
||||
footer: plan.mandatory ? "mandatory" : "user",
|
||||
dedupeKey: `${event.type}:${event.entityId}:${user.userId}${occurrence}`,
|
||||
text,
|
||||
}),
|
||||
);
|
||||
if (result.status === "queued" || result.status === "sent") {
|
||||
await ctx.db.notification.update({ where: { id: notificationId }, data: { emailedAt: new Date() } });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[notifications] ${event.type} → user ${user.userId} failed:`, (err as Error)?.message ?? err);
|
||||
}
|
||||
}
|
||||
|
||||
const tenantLocale = normalizeLocale(settings?.locale);
|
||||
for (const email of plan.externalEmails) {
|
||||
try {
|
||||
const link = linkFor(event, plan.facts, new Set(["work_order:read_all", "report:approve"]));
|
||||
await enqueueMail(
|
||||
buildMail(ctx, event, plan.facts, {
|
||||
to: email,
|
||||
name: "",
|
||||
locale: tenantLocale,
|
||||
link,
|
||||
footer: plan.mandatory ? "mandatory" : "configured",
|
||||
dedupeKey: `${event.type}:${event.entityId}:ext:${email}${occurrence}`,
|
||||
text: eventText(tenantLocale, event.type, textVars(tenantLocale, plan.facts)),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[notifications] ${event.type} → configured recipient failed:`, (err as Error)?.message ?? err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function optedOutUserIds(ctx: ServiceCtx, event: DomainEvent, users: Target[]): Promise<Set<string>> {
|
||||
if (!users.length) return new Set();
|
||||
const prefs = await ctx.db.notificationPreference.findMany({
|
||||
where: { eventType: event.type, email: false, userId: { in: users.map((u) => u.userId) } },
|
||||
select: { userId: true },
|
||||
});
|
||||
return new Set(prefs.map((p) => p.userId));
|
||||
}
|
||||
|
||||
async function upsertInApp(
|
||||
ctx: ServiceCtx,
|
||||
event: DomainEvent,
|
||||
userId: string,
|
||||
text: { title: string; message: string },
|
||||
link: string | null,
|
||||
): Promise<string> {
|
||||
const existing = await ctx.db.notification.findFirst({
|
||||
where: { userId, type: event.type, entityType: event.entityType, entityId: event.entityId, readAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await ctx.db.notification.update({
|
||||
where: { id: existing.id },
|
||||
data: { title: text.title, message: text.message, link, createdAt: new Date() },
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
const row = await ctx.db.notification.create({
|
||||
data: {
|
||||
tenantId: ctx.tenantId,
|
||||
userId,
|
||||
type: event.type,
|
||||
title: text.title,
|
||||
message: text.message,
|
||||
entityType: event.entityType,
|
||||
entityId: event.entityId,
|
||||
link,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
function textVars(locale: Locale, f: EventFacts): Record<string, string | undefined> {
|
||||
return {
|
||||
number: f.number,
|
||||
title: f.title,
|
||||
customer: f.customer,
|
||||
actor: f.actorName ?? fallbackText(locale, "system"),
|
||||
fileName: f.fileName ?? fallbackText(locale, "document"),
|
||||
reason: f.syncErrorCode,
|
||||
};
|
||||
}
|
||||
|
||||
/** Relative in-app link, chosen by what the recipient can open (back office vs. mobile). */
|
||||
export function linkFor(event: DomainEvent, f: EventFacts, permissions: ReadonlySet<string>): string | null {
|
||||
const backoffice = permissions.has("work_order:read_all");
|
||||
switch (event.entityType) {
|
||||
case "work_order":
|
||||
return backoffice ? `/work-orders/${event.entityId}` : `/m/orders/${event.entityId}`;
|
||||
case "report":
|
||||
if (backoffice) return `/reports/${event.entityId}`;
|
||||
return f.workOrderId ? `/m/orders/${f.workOrderId}/report` : null;
|
||||
case "import_job":
|
||||
return `/imports/${event.entityId}`;
|
||||
case "sync_operation":
|
||||
return backoffice ? "/work-orders/conflicts" : "/m/sync";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type MailTarget = {
|
||||
to: string;
|
||||
name: string;
|
||||
locale: Locale;
|
||||
link: string | null;
|
||||
footer: CraftviaFooter;
|
||||
dedupeKey: string;
|
||||
text: { title: string; message: string };
|
||||
};
|
||||
|
||||
function buildMail(ctx: ServiceCtx, event: DomainEvent, f: EventFacts, t: MailTarget): EnqueueInput<TemplateKey> {
|
||||
const unknown = fallbackText(t.locale, "unknown");
|
||||
const actionUrl = absoluteUrl(t.link ?? "/notifications");
|
||||
const base = { to: t.to, tenantId: ctx.tenantId, locale: t.locale, dedupeKey: t.dedupeKey };
|
||||
const order = { number: f.number ?? unknown, title: f.title ?? unknown, customer: f.customer ?? unknown };
|
||||
|
||||
switch (event.type) {
|
||||
case "work_order.assigned":
|
||||
return {
|
||||
...base,
|
||||
template: "craftvia_team_assigned",
|
||||
vars: {
|
||||
name: t.name, ...order, site: f.site, team: f.team, actionUrl, footer: t.footer,
|
||||
plannedStart: f.plannedStart ? formatWhen(f.plannedStart, t.locale) : undefined,
|
||||
},
|
||||
};
|
||||
case "report.submitted":
|
||||
return {
|
||||
...base,
|
||||
template: "craftvia_report_review",
|
||||
vars: { name: t.name, ...order, submittedBy: f.actorName, reportType: f.reportType, actionUrl, footer: t.footer },
|
||||
};
|
||||
case "work_order.released_for_billing":
|
||||
return { ...base, template: "craftvia_billing_release", vars: { name: t.name, ...order, actionUrl, footer: t.footer } };
|
||||
case "emergency.created":
|
||||
case "emergency.completed":
|
||||
return {
|
||||
...base,
|
||||
template: "craftvia_emergency",
|
||||
vars: {
|
||||
name: t.name,
|
||||
phase: event.type === "emergency.completed" ? "completed" : "created",
|
||||
number: order.number,
|
||||
technician: f.actorName ?? unknown,
|
||||
customer: order.customer,
|
||||
start: f.emergencyStart ? formatWhen(f.emergencyStart, t.locale) : unknown,
|
||||
end: f.emergencyEnd ? formatWhen(f.emergencyEnd, t.locale) : undefined,
|
||||
actionUrl,
|
||||
footer: t.footer,
|
||||
},
|
||||
};
|
||||
case "import.failed":
|
||||
return {
|
||||
...base,
|
||||
template: "craftvia_document_failed",
|
||||
vars: { name: t.name, fileName: f.fileName ?? unknown, error: f.errorMessage, actionUrl, footer: t.footer },
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...base,
|
||||
template: "craftvia_notification",
|
||||
vars: { name: t.name, subject: t.text.title, body: t.text.message, actionUrl, footer: t.footer },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { z } from "zod";
|
||||
import { EVENT_TYPES } from "@/lib/events";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Personal notification inbox. A user only ever sees/changes their OWN notifications
|
||||
* (userId filter) inside their tenant (ctx.db). Foreign ids → not_found (no existence leak).
|
||||
*/
|
||||
|
||||
export const PAGE_SIZE = 25;
|
||||
|
||||
export const listFilterSchema = z.object({
|
||||
status: z.enum(["all", "unread", "read"]).catch("all"),
|
||||
type: z.enum(EVENT_TYPES).optional().catch(undefined),
|
||||
page: z.coerce.number().int().min(1).max(10_000).catch(1),
|
||||
});
|
||||
export type ListFilter = z.infer<typeof listFilterSchema>;
|
||||
|
||||
export async function listNotifications(ctx: ServiceCtx, input: unknown) {
|
||||
assertCan(ctx, "notification:read");
|
||||
const filter = listFilterSchema.parse(input ?? {});
|
||||
const where = {
|
||||
userId: ctx.userId,
|
||||
...(filter.status === "unread" ? { readAt: null } : filter.status === "read" ? { readAt: { not: null } } : {}),
|
||||
...(filter.type ? { type: filter.type } : {}),
|
||||
};
|
||||
const [rows, total] = await Promise.all([
|
||||
ctx.db.notification.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (filter.page - 1) * PAGE_SIZE,
|
||||
take: PAGE_SIZE,
|
||||
select: { id: true, type: true, title: true, message: true, link: true, readAt: true, createdAt: true, entityType: true, entityId: true },
|
||||
}),
|
||||
ctx.db.notification.count({ where }),
|
||||
]);
|
||||
return { rows, total, page: filter.page, pages: Math.max(1, Math.ceil(total / PAGE_SIZE)), filter };
|
||||
}
|
||||
|
||||
/** Unread counter + latest 10 for the bell. */
|
||||
export async function bellSummary(ctx: ServiceCtx) {
|
||||
assertCan(ctx, "notification:read");
|
||||
const [unread, latest] = await Promise.all([
|
||||
ctx.db.notification.count({ where: { userId: ctx.userId, readAt: null } }),
|
||||
ctx.db.notification.findMany({
|
||||
where: { userId: ctx.userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
select: { id: true, type: true, title: true, message: true, link: true, readAt: true, createdAt: true },
|
||||
}),
|
||||
]);
|
||||
return { unread, latest };
|
||||
}
|
||||
|
||||
const idSchema = z.string().min(1).max(64);
|
||||
|
||||
/** Mark one own notification as read; returns its link (safe relative path or null). */
|
||||
export async function markRead(ctx: ServiceCtx, id: unknown): Promise<{ link: string | null }> {
|
||||
assertCan(ctx, "notification:read");
|
||||
const notificationId = idSchema.parse(id);
|
||||
const row = await ctx.db.notification.findFirst({
|
||||
where: { id: notificationId, userId: ctx.userId },
|
||||
select: { id: true, readAt: true, link: true },
|
||||
});
|
||||
if (!row) throw new ServiceError("not_found", "notification not found");
|
||||
if (!row.readAt) {
|
||||
const readAt = new Date();
|
||||
await ctx.db.notification.update({ where: { id: row.id }, data: { readAt } });
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.userId,
|
||||
action: "update",
|
||||
entity: "notification",
|
||||
entityId: row.id,
|
||||
before: { readAt: null },
|
||||
after: { readAt: readAt.toISOString() },
|
||||
});
|
||||
}
|
||||
return { link: safeLink(row.link) };
|
||||
}
|
||||
|
||||
export async function markAllRead(ctx: ServiceCtx): Promise<{ count: number }> {
|
||||
assertCan(ctx, "notification:read");
|
||||
const res = await ctx.db.notification.updateMany({ where: { userId: ctx.userId, readAt: null }, data: { readAt: new Date() } });
|
||||
if (res.count > 0) {
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.userId,
|
||||
action: "update",
|
||||
entity: "notification",
|
||||
before: { unread: res.count },
|
||||
after: { unread: 0 },
|
||||
});
|
||||
}
|
||||
return { count: res.count };
|
||||
}
|
||||
|
||||
/** Only same-origin relative paths are followed (no open redirect). */
|
||||
export function safeLink(link: string | null | undefined): string | null {
|
||||
if (!link || !link.startsWith("/") || link.startsWith("//") || link.includes("\\")) return null;
|
||||
return link;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { assertCan, type ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Tenant mail settings (spec §33.2). The sender ADDRESS stays the platform domain (SPF/DKIM);
|
||||
* per tenant only the display name and reply-to address are configurable, plus fixed recipient
|
||||
* lists for emergency call-outs and billing.
|
||||
*/
|
||||
|
||||
export const MAX_RECIPIENTS = 20;
|
||||
|
||||
/** Split a textarea/comma list into trimmed, lower-cased, unique entries. */
|
||||
export function splitAddressList(raw: unknown): string[] {
|
||||
if (Array.isArray(raw)) return [...new Set(raw.map((x) => String(x).trim().toLowerCase()).filter(Boolean))];
|
||||
if (typeof raw !== "string") return [];
|
||||
return [...new Set(raw.split(/[\s,;]+/).map((x) => x.trim().toLowerCase()).filter(Boolean))];
|
||||
}
|
||||
|
||||
const email = z.string().trim().toLowerCase().email().max(254);
|
||||
|
||||
export const mailSettingsSchema = z.object({
|
||||
// No CR/LF or angle brackets/quotes: the name ends up in a mail header (header injection).
|
||||
mailFromName: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(100)
|
||||
.regex(/^[^\r\n<>"]*$/, "invalid characters")
|
||||
.transform((v) => v || null),
|
||||
mailReplyTo: z
|
||||
.union([z.literal(""), email])
|
||||
.transform((v) => v || null),
|
||||
emergencyRecipients: z.preprocess(splitAddressList, z.array(email).max(MAX_RECIPIENTS)),
|
||||
billingRecipients: z.preprocess(splitAddressList, z.array(email).max(MAX_RECIPIENTS)),
|
||||
});
|
||||
|
||||
export type MailSettings = {
|
||||
mailFromName: string | null;
|
||||
mailReplyTo: string | null;
|
||||
emergencyRecipients: string[];
|
||||
billingRecipients: string[];
|
||||
};
|
||||
|
||||
const SELECT = { mailFromName: true, mailReplyTo: true, emergencyRecipients: true, billingRecipients: true, orgName: true } as const;
|
||||
|
||||
export async function getMailSettings(ctx: ServiceCtx): Promise<MailSettings & { orgName: string | null }> {
|
||||
assertCan(ctx, "tenant:manage");
|
||||
const s = await ctx.db.tenantSettings.findFirst({ select: SELECT });
|
||||
return {
|
||||
mailFromName: s?.mailFromName ?? null,
|
||||
mailReplyTo: s?.mailReplyTo ?? null,
|
||||
emergencyRecipients: s?.emergencyRecipients ?? [],
|
||||
billingRecipients: s?.billingRecipients ?? [],
|
||||
orgName: s?.orgName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateMailSettings(ctx: ServiceCtx, input: unknown): Promise<MailSettings> {
|
||||
assertCan(ctx, "tenant:manage");
|
||||
const data = mailSettingsSchema.parse(input);
|
||||
const before = await ctx.db.tenantSettings.findFirst({ select: SELECT });
|
||||
|
||||
if (before) {
|
||||
await ctx.db.tenantSettings.update({ where: { tenantId: ctx.tenantId }, data });
|
||||
} else {
|
||||
const tenant = await ctx.db.tenant.findUnique({ where: { id: ctx.tenantId }, select: { name: true } });
|
||||
await ctx.db.tenantSettings.create({ data: { tenantId: ctx.tenantId, orgName: tenant?.name ?? "", ...data } });
|
||||
}
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.userId,
|
||||
action: "update",
|
||||
entity: "tenant_mail_settings",
|
||||
before: before
|
||||
? {
|
||||
mailFromName: before.mailFromName,
|
||||
mailReplyTo: before.mailReplyTo,
|
||||
emergencyRecipients: before.emergencyRecipients,
|
||||
billingRecipients: before.billingRecipients,
|
||||
}
|
||||
: undefined,
|
||||
after: data,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sender override for tenant mails — ready for the mail core once it accepts per-mail
|
||||
* `fromName`/`replyTo` (reported as foundation requirement; deliver.ts currently uses the
|
||||
* global MAIL_FROM_NAME/MAIL_REPLY_TO only).
|
||||
*/
|
||||
export async function tenantMailSender(ctx: ServiceCtx): Promise<{ fromName: string | null; replyTo: string | null }> {
|
||||
const s = await ctx.db.tenantSettings.findFirst({ select: { mailFromName: true, mailReplyTo: true, orgName: true } });
|
||||
return { fromName: s?.mailFromName || s?.orgName || null, replyTo: s?.mailReplyTo ?? null };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Read-path context for server components (pages, bell). Permissions come from the session
|
||||
* (JWT copy), which is accepted for READ paths (AGENTS.md); mutations go through moduleGuard,
|
||||
* which re-checks permissions against the database.
|
||||
*/
|
||||
export async function pageCtx(): Promise<ServiceCtx> {
|
||||
const session = await requireSession();
|
||||
return {
|
||||
db: dbForTenant(session.user.tenantId),
|
||||
tenantId: session.user.tenantId,
|
||||
userId: session.user.id,
|
||||
permissions: new Set(session.user.permissions ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
export async function isNotificationsModuleEnabled(ctx: ServiceCtx): Promise<boolean> {
|
||||
const row = await ctx.db.tenantModule.findUnique({
|
||||
where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: "notifications" } },
|
||||
select: { enabled: true },
|
||||
});
|
||||
return !row || row.enabled;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod";
|
||||
import { EVENT_TYPES, type EventType } from "@/lib/events";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { assertCan, type ServiceCtx } from "@/server/services/context";
|
||||
import { MANDATORY_EMAIL_EVENTS } from "./recipients";
|
||||
|
||||
/**
|
||||
* Per-user e-mail opt-out per event type (NotificationPreference, default opt-in).
|
||||
* Mandatory events (emergency call-outs) are always on and cannot be stored as opted out.
|
||||
*/
|
||||
|
||||
export type PreferenceRow = { type: EventType; email: boolean; mandatory: boolean };
|
||||
|
||||
export async function getPreferences(ctx: ServiceCtx): Promise<PreferenceRow[]> {
|
||||
assertCan(ctx, "notification:read");
|
||||
const rows = await ctx.db.notificationPreference.findMany({
|
||||
where: { userId: ctx.userId },
|
||||
select: { eventType: true, email: true },
|
||||
});
|
||||
const map = new Map(rows.map((r) => [r.eventType, r.email]));
|
||||
return EVENT_TYPES.map((type) => {
|
||||
const mandatory = MANDATORY_EMAIL_EVENTS.has(type);
|
||||
return { type, mandatory, email: mandatory ? true : (map.get(type) ?? true) };
|
||||
});
|
||||
}
|
||||
|
||||
export const setPreferencesSchema = z.object({
|
||||
/** event types that should still send e-mail; everything else (non-mandatory) is opted out */
|
||||
emailOn: z.array(z.enum(EVENT_TYPES)).max(EVENT_TYPES.length),
|
||||
});
|
||||
|
||||
export async function setPreferences(ctx: ServiceCtx, input: unknown): Promise<PreferenceRow[]> {
|
||||
assertCan(ctx, "notification:read");
|
||||
const { emailOn } = setPreferencesSchema.parse(input);
|
||||
const on = new Set<EventType>(emailOn);
|
||||
const before = await getPreferences(ctx);
|
||||
|
||||
for (const type of EVENT_TYPES) {
|
||||
const email = MANDATORY_EMAIL_EVENTS.has(type) ? true : on.has(type);
|
||||
await ctx.db.notificationPreference.upsert({
|
||||
where: { userId_eventType: { userId: ctx.userId, eventType: type } },
|
||||
update: { email },
|
||||
create: { tenantId: ctx.tenantId, userId: ctx.userId, eventType: type, email },
|
||||
});
|
||||
}
|
||||
|
||||
const after = await getPreferences(ctx);
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.userId,
|
||||
action: "update",
|
||||
entity: "notification_settings",
|
||||
entityId: ctx.userId,
|
||||
before: Object.fromEntries(before.map((p) => [p.type, p.email])),
|
||||
after: Object.fromEntries(after.map((p) => [p.type, p.email])),
|
||||
});
|
||||
return after;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { DomainEvent, EventType } from "@/lib/events";
|
||||
import { normalizeLocale, type Locale } from "@/server/mail/templates";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Recipient rules per domain event (spec §19.4, §20, §33.1; ARCHITEKTUR §4.1).
|
||||
*
|
||||
* Tenant separation: every lookup runs through `ctx.db` (tenant guard + RLS). User ids coming
|
||||
* from entity rows are re-resolved via `ctx.db.user` with status ACTIVE, so ids of other tenants
|
||||
* (or deactivated users) can never become recipients.
|
||||
*/
|
||||
|
||||
/** Events whose e-mail cannot be turned off by the user (spec §19.4). */
|
||||
export const MANDATORY_EMAIL_EVENTS: ReadonlySet<EventType> = new Set(["emergency.created", "emergency.completed"]);
|
||||
|
||||
/**
|
||||
* System-result events: the affected user is informed even if the event was emitted in their own
|
||||
* context (a worker/sync run on their behalf) — the "actor excluded" rule does not apply to them.
|
||||
*/
|
||||
const SYSTEM_RESULT_EVENTS: ReadonlySet<EventType> = new Set(["import.ready_for_review", "import.failed", "sync.failed"]);
|
||||
|
||||
export type Target = {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
locale: Locale;
|
||||
permissions: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
/** Facts about the entity, used for texts, templates and links. */
|
||||
export type EventFacts = {
|
||||
workOrderId?: string;
|
||||
number?: string;
|
||||
title?: string;
|
||||
customer?: string;
|
||||
site?: string;
|
||||
team?: string;
|
||||
plannedStart?: Date | null;
|
||||
isEmergency?: boolean;
|
||||
reportId?: string;
|
||||
reportType?: "daily" | "completion";
|
||||
importJobId?: string;
|
||||
fileName?: string;
|
||||
errorMessage?: string;
|
||||
syncErrorCode?: string;
|
||||
actorName?: string;
|
||||
emergencyStart?: Date | null;
|
||||
emergencyEnd?: Date | null;
|
||||
};
|
||||
|
||||
export type RecipientPlan = {
|
||||
/** Users receiving an in-app notification (actor already removed where applicable). */
|
||||
users: Target[];
|
||||
/** Users who additionally get an e-mail (subject to preferences unless mandatory). */
|
||||
mailUserIds: ReadonlySet<string>;
|
||||
/** Configured external addresses (tenant mail settings) — e-mail only. */
|
||||
externalEmails: string[];
|
||||
mandatory: boolean;
|
||||
facts: EventFacts;
|
||||
};
|
||||
|
||||
const USER_SELECT = {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
identity: { select: { uiLocale: true } },
|
||||
userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } },
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
function hasPermissionWhere(key: string): Prisma.UserWhereInput {
|
||||
return { userRoles: { some: { role: { rolePermissions: { some: { permission: { key } } } } } } };
|
||||
}
|
||||
|
||||
/** Load active users of the current tenant matching `where` (always via the tenant client). */
|
||||
export async function loadTargets(ctx: ServiceCtx, where: Prisma.UserWhereInput, fallbackLocale: string | null | undefined): Promise<Target[]> {
|
||||
const rows = await ctx.db.user.findMany({ where: { AND: [where, { status: "ACTIVE" }] }, select: USER_SELECT });
|
||||
return rows.map((u) => ({
|
||||
userId: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
locale: normalizeLocale(u.identity?.uiLocale ?? fallbackLocale),
|
||||
permissions: new Set(u.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key))),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Back office responsible for reviews: users with work_order:read_all AND report:approve. */
|
||||
export function backofficeWhere(): Prisma.UserWhereInput {
|
||||
return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("report:approve")] };
|
||||
}
|
||||
|
||||
export function billingWhere(): Prisma.UserWhereInput {
|
||||
return hasPermissionWhere("work_order:release_billing");
|
||||
}
|
||||
|
||||
type LoadedWorkOrder = {
|
||||
id: string;
|
||||
number: string;
|
||||
title: string;
|
||||
isEmergency: boolean;
|
||||
plannedStart: Date | null;
|
||||
createdAt: Date;
|
||||
teamLeadUserId: string | null;
|
||||
customer: { companyName: string | null; firstName: string | null; lastName: string | null } | null;
|
||||
site: { name: string | null } | null;
|
||||
team: { id: string; name: string; leaderUserId: string | null } | null;
|
||||
assignees: { userId: string }[];
|
||||
};
|
||||
|
||||
async function loadWorkOrder(ctx: ServiceCtx, id: string): Promise<LoadedWorkOrder | null> {
|
||||
return ctx.db.workOrder.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
title: true,
|
||||
isEmergency: true,
|
||||
plannedStart: true,
|
||||
createdAt: true,
|
||||
teamLeadUserId: true,
|
||||
customer: { select: { companyName: true, firstName: true, lastName: true } },
|
||||
site: { select: { name: true } },
|
||||
team: { select: { id: true, name: true, leaderUserId: true } },
|
||||
assignees: { select: { userId: true } },
|
||||
},
|
||||
}) as Promise<LoadedWorkOrder | null>;
|
||||
}
|
||||
|
||||
export function customerLabel(c: LoadedWorkOrder["customer"]): string | undefined {
|
||||
if (!c) return undefined;
|
||||
return c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || undefined;
|
||||
}
|
||||
|
||||
/** Active team members + team leader + order team lead + individual assignees. */
|
||||
async function participantIds(ctx: ServiceCtx, wo: LoadedWorkOrder): Promise<string[]> {
|
||||
const ids = new Set<string>();
|
||||
if (wo.team) {
|
||||
const now = new Date();
|
||||
const members = await ctx.db.teamMember.findMany({
|
||||
where: { teamId: wo.team.id, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] },
|
||||
select: { userId: true },
|
||||
});
|
||||
members.forEach((m) => ids.add(m.userId));
|
||||
if (wo.team.leaderUserId) ids.add(wo.team.leaderUserId);
|
||||
}
|
||||
if (wo.teamLeadUserId) ids.add(wo.teamLeadUserId);
|
||||
wo.assignees.forEach((a) => ids.add(a.userId));
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function teamLeadIds(wo: LoadedWorkOrder): string[] {
|
||||
return [...new Set([wo.teamLeadUserId, wo.team?.leaderUserId].filter((x): x is string => !!x))];
|
||||
}
|
||||
|
||||
function workOrderFacts(wo: LoadedWorkOrder): EventFacts {
|
||||
return {
|
||||
workOrderId: wo.id,
|
||||
number: wo.number,
|
||||
title: wo.title,
|
||||
customer: customerLabel(wo.customer),
|
||||
site: wo.site?.name ?? undefined,
|
||||
team: wo.team?.name,
|
||||
plannedStart: wo.plannedStart,
|
||||
isEmergency: wo.isEmergency,
|
||||
};
|
||||
}
|
||||
|
||||
function dateFromData(v: unknown): Date | null {
|
||||
if (typeof v !== "string" && typeof v !== "number") return null;
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
type Rule = {
|
||||
users: Prisma.UserWhereInput[];
|
||||
/** ids from entity rows — re-resolved through ctx.db.user */
|
||||
userIds: string[];
|
||||
/** false = in-app only for the users (e-mail goes to `external`); default true */
|
||||
mailToUsers?: boolean;
|
||||
external?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve recipients for an event. Returns null when the entity is not visible in this tenant
|
||||
* (unknown id, other tenant, soft-deleted) — then nothing is sent.
|
||||
*/
|
||||
export async function resolveRecipients(
|
||||
ctx: ServiceCtx,
|
||||
event: DomainEvent,
|
||||
settings: { locale?: string | null; emergencyRecipients?: string[]; billingRecipients?: string[] } | null,
|
||||
): Promise<RecipientPlan | null> {
|
||||
const facts: EventFacts = {};
|
||||
let rule: Rule;
|
||||
|
||||
switch (event.type) {
|
||||
case "work_order.assigned":
|
||||
case "work_order.changed":
|
||||
case "work_order.cancelled": {
|
||||
const wo = await loadWorkOrder(ctx, event.entityId);
|
||||
if (!wo) return null;
|
||||
Object.assign(facts, workOrderFacts(wo));
|
||||
rule = { users: [], userIds: await participantIds(ctx, wo) };
|
||||
break;
|
||||
}
|
||||
case "work_order.started":
|
||||
case "work_order.daily_report_created":
|
||||
case "work_order.technically_completed":
|
||||
case "work_order.signature_missing":
|
||||
case "work_order.missing_required": {
|
||||
const wo = await loadWorkOrder(ctx, event.entityId);
|
||||
if (!wo) return null;
|
||||
Object.assign(facts, workOrderFacts(wo));
|
||||
rule = { users: [backofficeWhere()], userIds: [] };
|
||||
break;
|
||||
}
|
||||
case "work_order.released_for_billing": {
|
||||
const wo = await loadWorkOrder(ctx, event.entityId);
|
||||
if (!wo) return null;
|
||||
Object.assign(facts, workOrderFacts(wo));
|
||||
const configured = settings?.billingRecipients ?? [];
|
||||
// In-app for billing staff; e-mail to the configured billing recipients, or — if none are
|
||||
// configured — to the billing staff themselves.
|
||||
rule = {
|
||||
users: [billingWhere()],
|
||||
userIds: [],
|
||||
mailToUsers: configured.length === 0,
|
||||
external: configured,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "emergency.created":
|
||||
case "emergency.completed": {
|
||||
const wo = await loadWorkOrder(ctx, event.entityId);
|
||||
if (!wo) return null;
|
||||
Object.assign(facts, workOrderFacts(wo));
|
||||
facts.emergencyStart = dateFromData(event.data?.startedAt) ?? wo.plannedStart ?? wo.createdAt;
|
||||
facts.emergencyEnd = event.type === "emergency.completed" ? (dateFromData(event.data?.endedAt) ?? new Date()) : null;
|
||||
rule = { users: [backofficeWhere()], userIds: [], external: settings?.emergencyRecipients ?? [] };
|
||||
break;
|
||||
}
|
||||
case "report.submitted":
|
||||
case "report.approved":
|
||||
case "report.rejected": {
|
||||
const report = await ctx.db.report.findFirst({
|
||||
where: { id: event.entityId },
|
||||
select: { id: true, type: true, workOrderId: true, createdById: true },
|
||||
});
|
||||
if (!report) return null;
|
||||
const wo = await loadWorkOrder(ctx, report.workOrderId);
|
||||
if (!wo) return null;
|
||||
Object.assign(facts, workOrderFacts(wo), { reportId: report.id, reportType: report.type });
|
||||
if (event.type === "report.submitted") {
|
||||
// data.approvalStage (set by the reports lane): "team" → team leads only,
|
||||
// "backoffice" → back office only, unset → both.
|
||||
const stage = event.data?.approvalStage;
|
||||
const leadsWhere: Prisma.UserWhereInput = {
|
||||
AND: [hasPermissionWhere("report:approve_team"), { id: { in: teamLeadIds(wo) } }],
|
||||
};
|
||||
if (stage === "team") rule = { users: [leadsWhere], userIds: [] };
|
||||
else if (stage === "backoffice") rule = { users: [backofficeWhere()], userIds: [] };
|
||||
else rule = { users: [backofficeWhere(), leadsWhere], userIds: [] };
|
||||
} else {
|
||||
const ids = await participantIds(ctx, wo);
|
||||
if (report.createdById) ids.push(report.createdById);
|
||||
rule = { users: [], userIds: ids };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "import.ready_for_review":
|
||||
case "import.failed": {
|
||||
const job = await ctx.db.importJob.findFirst({
|
||||
where: { id: event.entityId },
|
||||
select: { id: true, importedById: true, errorMessage: true, documentId: true },
|
||||
});
|
||||
if (!job) return null;
|
||||
const doc = await ctx.db.document.findFirst({ where: { id: job.documentId }, select: { fileName: true } });
|
||||
Object.assign(facts, { importJobId: job.id, fileName: doc?.fileName, errorMessage: job.errorMessage ?? undefined });
|
||||
rule = { users: [], userIds: job.importedById ? [job.importedById] : [] };
|
||||
break;
|
||||
}
|
||||
case "sync.failed": {
|
||||
const op = await ctx.db.syncOperation.findFirst({
|
||||
where: { id: event.entityId },
|
||||
select: { id: true, userId: true, errorCode: true, entityType: true, entityId: true },
|
||||
});
|
||||
if (!op) return null;
|
||||
facts.syncErrorCode = op.errorCode ?? (typeof event.data?.reason === "string" ? event.data.reason : undefined);
|
||||
if (op.entityType === "work_order" && op.entityId) {
|
||||
const wo = await loadWorkOrder(ctx, op.entityId);
|
||||
if (wo) Object.assign(facts, workOrderFacts(wo));
|
||||
}
|
||||
rule = { users: [backofficeWhere()], userIds: [op.userId] };
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const or: Prisma.UserWhereInput[] = [...rule.users];
|
||||
if (rule.userIds.length) or.push({ id: { in: [...new Set(rule.userIds)] } });
|
||||
let users = or.length ? await loadTargets(ctx, { OR: or }, settings?.locale) : [];
|
||||
|
||||
const keepActor = SYSTEM_RESULT_EVENTS.has(event.type);
|
||||
if (!keepActor) users = users.filter((u) => u.userId !== ctx.userId);
|
||||
|
||||
const mailUserIds = new Set(rule.mailToUsers === false ? [] : users.map((u) => u.userId));
|
||||
|
||||
// Configured external addresses: skip those already covered by a user mail (no double mail).
|
||||
const userMails = new Set(users.filter((u) => mailUserIds.has(u.userId)).map((u) => u.email.toLowerCase()));
|
||||
const externalEmails = [...new Set((rule.external ?? []).map((e) => e.trim().toLowerCase()).filter(Boolean))].filter(
|
||||
(e) => !userMails.has(e),
|
||||
);
|
||||
|
||||
// Actor display name (for "{actor} hat …" texts) — tenant-bound lookup.
|
||||
const actor = await ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } });
|
||||
facts.actorName = typeof event.data?.technician === "string" ? event.data.technician : actor?.name;
|
||||
|
||||
return { users, mailUserIds, externalEmails, mandatory: MANDATORY_EMAIL_EVENTS.has(event.type), facts };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { EventType } from "@/lib/events";
|
||||
import type { Locale } from "@/server/mail/templates";
|
||||
// Single source for notification texts: the UI message catalog (namespace "notifications").
|
||||
// Imported statically so the same texts work in requests, workers and test scripts
|
||||
// (next-intl's request scope is not available outside a request).
|
||||
import de from "../../../../messages/de/notifications.json";
|
||||
import en from "../../../../messages/en/notifications.json";
|
||||
|
||||
const CATALOG = { de, en } as const;
|
||||
|
||||
export type EventKey = keyof typeof de.events;
|
||||
|
||||
/** "work_order.assigned" → "work_order_assigned" (next-intl keys must not contain dots). */
|
||||
export function eventKey(type: EventType): EventKey {
|
||||
return type.replace(".", "_") as EventKey;
|
||||
}
|
||||
|
||||
function fill(template: string, vars: Record<string, string | undefined>, unknown: string): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_m, key: string) => {
|
||||
const value = vars[key];
|
||||
return value != null && value !== "" ? value : unknown;
|
||||
});
|
||||
}
|
||||
|
||||
/** In-app title/message for an event in the recipient's language. */
|
||||
export function eventText(
|
||||
locale: Locale,
|
||||
type: EventType,
|
||||
vars: Record<string, string | undefined>,
|
||||
): { title: string; message: string } {
|
||||
const cat = CATALOG[locale];
|
||||
const entry = cat.events[eventKey(type)];
|
||||
return {
|
||||
title: fill(entry.title, vars, cat.fallback.unknown),
|
||||
message: fill(entry.message, vars, cat.fallback.unknown),
|
||||
};
|
||||
}
|
||||
|
||||
export function typeLabel(locale: Locale, type: EventType): string {
|
||||
return CATALOG[locale].types[eventKey(type)];
|
||||
}
|
||||
|
||||
export function fallbackText(locale: Locale, key: keyof typeof de.fallback): string {
|
||||
return CATALOG[locale].fallback[key];
|
||||
}
|
||||
Reference in New Issue
Block a user