Merge lane/kundenversand in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
# Lane L11 – Kundenversand (`lane/kundenversand`)
|
||||||
|
|
||||||
|
Stand: 2026-09-14 · Basis `bc58738` (`feature/craftvia-mvp`, L1–L9 + L10b integriert) · Spec §17.3 („optional per E-Mail versendet"), §36.2 (Soll)
|
||||||
|
|
||||||
|
## 1. Umfang / erfüllte Punkte
|
||||||
|
|
||||||
|
| Punkt | Umsetzung |
|
||||||
|
|---|---|
|
||||||
|
| **Mail-Fundament: Anhänge per Referenz** | `MailJob.attachments?: MailAttachmentRef[]` (`{ documentId }`) – in Redis landen nur IDs, nie Bytes. `EnqueueInput.attachments` nur mit `tenantId` (sonst Fehler vor dem MailLog-Insert). `OutgoingMail.attachments` → nodemailer. `MailLog` unverändert (keine Spalte). |
|
||||||
|
| **Zustellung (deliver.ts)** | `loadMailAttachments(mailLogId, refs)` vor `provider.send`: Mandant **ausschließlich aus der MailLog-Zeile**, Document mit `tenantId = MailLog.tenantId`, `deletedAt: null`, Storage-Key mit Mandanten-Präfix, Bytes über `readStoredBytes` (Lazy-Import), SHA-256 = `checksum`, Summe ≤ `MAIL_MAX_ATTACHMENT_BYTES` (Default 10 MB), max. 10 Anhänge. Fehler → `MailAttachmentError` → MailLog `failed` mit klarer Meldung, **keine** Mail (auch nicht ohne Anhang). Speicher nicht erreichbar → `TransientMailError` (Retry). `deliverMail(job, { provider? })` für Tests (additiv). |
|
||||||
|
| **Template** | `craftvia_report_customer` (de/en), eigene Liste `CUSTOMER_TEMPLATE_KEYS` (SEC1-/L6-Listen und deren Tests unverändert). Vars `{ customerName, tenantName, reportTitle, reportDate, message? }`. Kein App-Link, Hinweis „Der Arbeitsnachweis ist als PDF angehängt.", Fußzeile „…von <Betrieb> über Craftvia versendet". Betreff einzeilig (CR/LF entfernt), Freitext HTML-escaped (email-brand). Absendername/Reply-To des Mandanten über bestehendes `tenantSender`. |
|
||||||
|
| **Service** | `services/reports/send-to-customer.ts#sendReportToCustomer(ctx, { reportId, to?, message? }, deps?)`: `report:approve`, `requireVisibleReport`, Status `approved` sonst `blocked report_not_approved`, PDF-Dokument vorhanden (nicht gelöscht) sonst `blocked pdf_missing`, Empfänger `to` (Zod-E-Mail, getrimmt, klein) oder Ansprechpartner des Auftrags (nicht gelöscht) → Kunde, sonst `invalid recipient_missing` (`details.field = "to"`). `dedupeKey report-customer:<reportId>:<to>:<version>` → `duplicate`. Audit `export`/`report` mit `{ op: "send_to_customer", to, version, mailLogId, delivery, withMessage }`. Sprache = Mandanten-Locale (Default de). Zusätzlich `defaultReportRecipient`, `listCustomerMailings`. |
|
||||||
|
| **UI** | `/reports/[id]`: neuer Abschnitt „An Kunden senden" (nur `approved` + PDF + `report:approve`): Empfänger vorbelegt (Hinweis, wenn keiner hinterlegt), optionale Nachricht, Button (44 px); Rückmeldung versendet / wird versendet / bereits an … gesendet / fehlgeschlagen (Text + Icon); Liste „Bisherige Versände" aus `MailLog` (Adresse, Zeitpunkt, Version, Status mit Icon + Text). |
|
||||||
|
| **Server Action** | `actions/reports/send-to-customer.ts#sendReportToCustomerAction` (`moduleGuard("reports")`, `await guard("report:approve")`), State-Typ in `components/reports/send-to-customer-state.ts`. |
|
||||||
|
| **API** | `POST /api/v1/reports/{id}/send` (`requireApiContext("reports", "report:approve")`, `withApi` → Same-Origin), Body optional `{ to?, message? }`; 202 `queued`, 200 `sent`/`duplicate`/`failed`; Fehler 404/422 im Einheitsformat. OpenAPI-Eintrag `sendReportToCustomer`. |
|
||||||
|
|
||||||
|
## 2. Dateien
|
||||||
|
|
||||||
|
- **Neu:** `src/server/services/reports/send-to-customer.ts`, `src/server/actions/reports/send-to-customer.ts`, `src/components/reports/send-to-customer.tsx`, `src/components/reports/send-to-customer-state.ts`, `src/app/api/v1/reports/[id]/send/route.ts`, `scripts/test-report-customer-mail.ts`, dieser Bericht.
|
||||||
|
- **Geändert (additiv):** `src/server/mail/{job,service,provider,provider-smtp,deliver,templates}.ts`, `src/app/(app)/reports/[id]/page.tsx` (Imports + neuer Abschnitt), `src/lib/api/openapi.ts` (ein Pfad), `messages/{de,en}/reports.json` (`customerMail`).
|
||||||
|
- **Fremd-Eingriffe:** keine. **Migrationen:** keine (MailLog/Document reichen). **Neue Abhängigkeiten:** keine.
|
||||||
|
|
||||||
|
## 3. Tests
|
||||||
|
|
||||||
|
| Skript | Prüfungen | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| `test-report-customer-mail.ts` | 41 | Monteur/Teamleiter → forbidden, Mandant B (Senden, Versandliste) → not_found; nicht freigegeben / ohne PDF / PDF gelöscht → blocked mit reason; ohne Empfänger → invalid `recipient_missing`; Header-Injection-Adresse → invalid; Default-Empfänger Kontakt → Kunde bei gelöschtem Kontakt; MailLog pending + Template + Mandant + dedupeKey; Nutzlast nur Dokument-Referenz; Audit export; `enqueueMail` mit Anhang ohne tenantId → Fehler ohne MailLog; Dedupe (Groß-/Kleinschreibung) und erneuter Versand an andere Adresse; Zustellung mit Fake-Provider (Bytes = gespeichertes PDF, SHA-256 = checksum, `.pdf`/`application/pdf`, Betreff, Text ohne App-Link, Reply-To/Absendername, HTML-Escaping, MailLog sent); **Mandantentrennung beim Zustellen** (Dokument von B, Plattform-MailLog, unbekannte ID) → failed, nichts gesendet; Prüfsummen-Manipulation, soft-gelöschtes Dokument, Größenlimit → failed; Template de/en; SMTP-Durchstich gegen Mailhog (Anhang im Rohtext, MailLog sent; Skip ohne Mailhog) |
|
||||||
|
| `test-mail.ts`, `test-audit-mail-context.ts` | unverändert | grün |
|
||||||
|
|
||||||
|
**Gate (`npm run gate`) grün:** prisma generate, tsc, lint (0 Fehler, 3 vorbestehende Warnungen in fremden Dateien: `scripts/test-betrieb-api.ts`, `src/app/(app)/layout.tsx`, `src/server/services/field/mime.ts`), build inkl. Modul-Guard-Check (32 Action-Dateien), **53/53 Testskripte**. Lane-DB `craftvia_kundenversand`, `RLS_DATABASE_URL` auf dieselbe DB.
|
||||||
|
|
||||||
|
**HTTP-Smoke** (Dev-Server :3112 ohne `REDIS_URL` → Inline-Versand an Mailhog, Session-Cookies ohne Passworteingabe nach `scripts/smoke-auth.ts`, Fixtures im Mandanten `demo`, danach entfernt): **13/13 grün** – Backoffice sieht den Abschnitt mit vorbelegtem Empfänger bei freigegebenem Bericht, nicht bei eingereichtem; Monteur 404; API anonym 401, fremder Origin 403, Monteur 403, nicht freigegeben 422 `blocked`, ungültige Adresse 422, Backoffice 200 `sent`, zweiter Aufruf 200 `duplicate`; Seite zeigt den Versand mit Status; Mailhog enthält die Mail mit PDF-Anhang.
|
||||||
|
|
||||||
|
## 4. Stubs / Abhängigkeiten
|
||||||
|
|
||||||
|
Keine Stubs. Genutzt: L5 `requireVisibleReport`/`contentOf`/PDF-Dokument, L1 `readStoredBytes`, `requireApiContext`/`withApi`, SEC1/L6 Mail-Kern inkl. `tenantSender`.
|
||||||
|
|
||||||
|
## 5. Bekannte Lücken / offene Punkte
|
||||||
|
|
||||||
|
1. **Worker-Retries bei permanentem Anhangsfehler:** `worker.ts` (nicht Lane-Ownership) wiederholt jeden Nicht-`MailNotConfiguredError` bis zu 5-mal. `MailAttachmentError` ist permanent – das MailLog steht sofort auf `failed`, die Retries laufen aber ins Leere und enden in der Dead-Letter-Queue. Vorschlag (Einzeiler im Worker): `MailAttachmentError` wie `MailNotConfiguredError` in `UnrecoverableError` umwandeln.
|
||||||
|
2. **Ergebnis `failed` bei fehlender SMTP-Konfiguration:** Das MailLog bleibt `pending` (SEC1-Verhalten), die UI meldet „fehlgeschlagen"; ein erneuter Versand an dieselbe Adresse ist wegen Dedupe dann `duplicate`. Erneutes Zustellen ausstehender MailLogs ist Betriebsthema (kein Resend-Knopf im MVP).
|
||||||
|
3. Versandliste zeigt nur die aktuelle Berichtsversion (neue Version = neuer Bericht = neue Liste); keine Anzeige von Fehlertexten (bewusst, keine internen Details).
|
||||||
|
4. Keine Mehrfach-Empfänger/CC; Sprache folgt der Mandanten-Locale, nicht dem Kunden.
|
||||||
|
5. Umgebung: Das Kopieren der Haupt-`.env` wurde vom Berechtigungssystem blockiert; die Lane-`.env` wurde aus `.env.example` (lokale Docker-Defaults, eigene `AUTH_SECRET`/`PASSWORD_PEPPER`) mit DB `craftvia_kundenversand` und `RLS_DATABASE_URL` auf dieselbe DB erzeugt.
|
||||||
|
6. Visuelle Browser-Prüfung nicht durchgeführt (nur Server-Rendering/HTTP).
|
||||||
|
|
||||||
|
## 6. Screens / Routen
|
||||||
|
|
||||||
|
| Route | Änderung |
|
||||||
|
|---|---|
|
||||||
|
| `/reports/[id]` | Abschnitt „An Kunden senden" + „Bisherige Versände" |
|
||||||
|
| `POST /api/v1/reports/{id}/send` | neu |
|
||||||
@@ -226,6 +226,37 @@
|
|||||||
"alreadySigned": "Unterschrift liegt vor.",
|
"alreadySigned": "Unterschrift liegt vor.",
|
||||||
"noReport": "Zuerst den Abschlussbericht erstellen."
|
"noReport": "Zuerst den Abschlussbericht erstellen."
|
||||||
},
|
},
|
||||||
|
"customerMail": {
|
||||||
|
"title": "An Kunden senden",
|
||||||
|
"sub": "Das freigegebene PDF geht als Anhang an den Kunden. Antworten gehen an die Antwortadresse Ihres Betriebs.",
|
||||||
|
"to": "Empfänger",
|
||||||
|
"toHint": "Vorbelegt mit dem Ansprechpartner des Auftrags bzw. dem Kunden.",
|
||||||
|
"noDefault": "Beim Auftrag ist keine E-Mail-Adresse hinterlegt. Bitte Empfänger eingeben.",
|
||||||
|
"message": "Nachricht (optional)",
|
||||||
|
"messagePlaceholder": "Kurze Nachricht an den Kunden",
|
||||||
|
"submit": "Bericht senden",
|
||||||
|
"sending": "Wird gesendet …",
|
||||||
|
"history": "Bisherige Versände",
|
||||||
|
"historyEmpty": "Noch nicht an den Kunden gesendet.",
|
||||||
|
"result": {
|
||||||
|
"sent": "Bericht an {to} versendet.",
|
||||||
|
"queued": "Bericht an {to} wird versendet.",
|
||||||
|
"duplicate": "Diese Version wurde bereits an {to} gesendet.",
|
||||||
|
"failed": "Versand an {to} fehlgeschlagen. Bitte später erneut versuchen."
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"sent": "Versendet",
|
||||||
|
"pending": "In Zustellung",
|
||||||
|
"failed": "Fehlgeschlagen",
|
||||||
|
"other": "Nicht zugestellt"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"recipient_missing": "Bitte eine E-Mail-Adresse angeben.",
|
||||||
|
"invalid_to": "Bitte eine gültige E-Mail-Adresse angeben.",
|
||||||
|
"report_not_approved": "Nur freigegebene Berichte können versendet werden.",
|
||||||
|
"pdf_missing": "Das PDF ist noch nicht erzeugt."
|
||||||
|
}
|
||||||
|
},
|
||||||
"pdf": {
|
"pdf": {
|
||||||
"page": "Seite {page} von {pages}",
|
"page": "Seite {page} von {pages}",
|
||||||
"reportId": "Bericht-ID",
|
"reportId": "Bericht-ID",
|
||||||
|
|||||||
@@ -226,6 +226,37 @@
|
|||||||
"alreadySigned": "Signature captured.",
|
"alreadySigned": "Signature captured.",
|
||||||
"noReport": "Create the completion report first."
|
"noReport": "Create the completion report first."
|
||||||
},
|
},
|
||||||
|
"customerMail": {
|
||||||
|
"title": "Send to customer",
|
||||||
|
"sub": "The approved PDF is sent to the customer as an attachment. Replies go to your company's reply address.",
|
||||||
|
"to": "Recipient",
|
||||||
|
"toHint": "Prefilled with the work order contact or the customer.",
|
||||||
|
"noDefault": "No e-mail address is stored for this work order. Please enter a recipient.",
|
||||||
|
"message": "Message (optional)",
|
||||||
|
"messagePlaceholder": "Short message to the customer",
|
||||||
|
"submit": "Send report",
|
||||||
|
"sending": "Sending …",
|
||||||
|
"history": "Previous sends",
|
||||||
|
"historyEmpty": "Not sent to the customer yet.",
|
||||||
|
"result": {
|
||||||
|
"sent": "Report sent to {to}.",
|
||||||
|
"queued": "Report to {to} is being sent.",
|
||||||
|
"duplicate": "This version has already been sent to {to}.",
|
||||||
|
"failed": "Sending to {to} failed. Please try again later."
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"sent": "Sent",
|
||||||
|
"pending": "Being delivered",
|
||||||
|
"failed": "Failed",
|
||||||
|
"other": "Not delivered"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"recipient_missing": "Please enter an e-mail address.",
|
||||||
|
"invalid_to": "Please enter a valid e-mail address.",
|
||||||
|
"report_not_approved": "Only approved reports can be sent.",
|
||||||
|
"pdf_missing": "The PDF has not been generated yet."
|
||||||
|
}
|
||||||
|
},
|
||||||
"pdf": {
|
"pdf": {
|
||||||
"page": "Page {page} of {pages}",
|
"page": "Page {page} of {pages}",
|
||||||
"reportId": "Report ID",
|
"reportId": "Report ID",
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
// L11 Kundenversand — Bericht-PDF per E-Mail an Kunden (Spec §17.3, §36.2).
|
||||||
|
//
|
||||||
|
// Deckt ab: Rechte/Scope (Monteur/Teamleiter → forbidden, Mandant B → not_found), Status-/PDF-Sperren,
|
||||||
|
// Empfängerermittlung, MailLog pending + Nutzlast nur mit Dokument-Referenz, Zustellung mit Fake-Provider
|
||||||
|
// (Bytes = gespeichertes PDF, Prüfsumme, Dateiname), Mandantentrennung beim Zustellen, Prüfsummen-
|
||||||
|
// Manipulation, gelöschtes Dokument, Größenlimit, Dedupe, Versandliste, Template-Escaping, optional
|
||||||
|
// echter SMTP-Durchstich gegen Mailhog.
|
||||||
|
//
|
||||||
|
// Lauf: npx tsx scripts/test-report-customer-mail.ts
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
// Inline mode without SMTP for the service part: the MailLog must stay `pending` (no queue, no real mail).
|
||||||
|
const SAVED_SMTP_HOST = process.env.SMTP_HOST;
|
||||||
|
delete process.env.REDIS_URL;
|
||||||
|
delete process.env.SMTP_HOST;
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const ok = (cond: boolean, msg: string) => {
|
||||||
|
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SLUG_A = "zz-kundenversand-a";
|
||||||
|
const SLUG_B = "zz-kundenversand-b";
|
||||||
|
const MAIL_DOMAIN = "zz-kundenversand.test";
|
||||||
|
const EMAIL = (s: string) => `${s}@${MAIL_DOMAIN}`;
|
||||||
|
const PDF_BYTES = Buffer.from("%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n", "latin1");
|
||||||
|
const sha = (b: Buffer) => createHash("sha256").update(b).digest("hex");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { prisma, dbForTenant } = await import("../src/server/db");
|
||||||
|
const { ROLE_DEFS } = await import("../src/server/rbac");
|
||||||
|
const { ServiceError } = await import("../src/server/services/context");
|
||||||
|
const { buildReportContent } = await import("../src/server/services/reports/build-content");
|
||||||
|
const { storeFile } = await import("../src/server/services/documents/store");
|
||||||
|
const { readStoredBytes } = await import("../src/server/services/documents/read");
|
||||||
|
const svc = await import("../src/server/services/reports/send-to-customer");
|
||||||
|
const { enqueueMail } = await import("../src/server/mail/service");
|
||||||
|
const { deliverMail } = await import("../src/server/mail/deliver");
|
||||||
|
const { resetMailConfigCache, getMailConfig } = await import("../src/server/mail/config");
|
||||||
|
const { renderTemplate, CUSTOMER_TEMPLATE_KEYS } = await import("../src/server/mail/templates");
|
||||||
|
const { closeMailProvider } = await import("../src/server/mail/provider-smtp");
|
||||||
|
type ServiceCtx = import("../src/server/services/context").ServiceCtx;
|
||||||
|
type RoleKey = import("../src/server/rbac").RoleKey;
|
||||||
|
type MailJob = import("../src/server/mail/job").MailJob;
|
||||||
|
type OutgoingMail = import("../src/server/mail/provider").OutgoingMail;
|
||||||
|
|
||||||
|
const expectCode = async (fn: () => Promise<unknown>, code: string, msg: string, reason?: string) => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
ok(false, `${msg} — kein Fehler`);
|
||||||
|
} catch (err) {
|
||||||
|
const got = err instanceof ServiceError ? err.code : (err as Error).name === "ZodError" ? "invalid" : (err as Error).message;
|
||||||
|
const gotReason = err instanceof ServiceError ? (err.details as { reason?: string } | undefined)?.reason : undefined;
|
||||||
|
ok(got === code && (!reason || gotReason === reason), `${msg} (${got}${gotReason ? `/${gotReason}` : ""})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
await prisma.mailLog.deleteMany({ where: { to: { endsWith: `@${MAIL_DOMAIN}` } } });
|
||||||
|
if (ids.length) {
|
||||||
|
const w = { where: { tenantId: { in: ids } } };
|
||||||
|
await prisma.mailLog.deleteMany(w);
|
||||||
|
await prisma.report.deleteMany(w);
|
||||||
|
await prisma.workOrderStatusChange.deleteMany(w);
|
||||||
|
await prisma.workOrderAssignee.deleteMany(w);
|
||||||
|
await prisma.document.deleteMany(w);
|
||||||
|
await prisma.workOrder.deleteMany(w);
|
||||||
|
await prisma.teamMember.deleteMany(w);
|
||||||
|
await prisma.team.deleteMany(w);
|
||||||
|
await prisma.contact.deleteMany(w);
|
||||||
|
await prisma.customer.deleteMany(w);
|
||||||
|
await prisma.numberSequence.deleteMany(w);
|
||||||
|
await prisma.auditLog.deleteMany(w);
|
||||||
|
await prisma.tenantSettings.deleteMany(w);
|
||||||
|
await prisma.user.deleteMany(w);
|
||||||
|
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
await prisma.identity.deleteMany({ where: { email: { endsWith: `@${MAIL_DOMAIN}` } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mkUser = async (tenantId: string, key: string, name: string) => {
|
||||||
|
const identity = await prisma.identity.create({ data: { email: EMAIL(`${key}-${tenantId.slice(-6)}`), passwordHash: "x" } });
|
||||||
|
return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } });
|
||||||
|
};
|
||||||
|
const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({
|
||||||
|
db: dbForTenant(tenantId),
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
permissions: new Set(ROLE_DEFS[role].permissions),
|
||||||
|
});
|
||||||
|
|
||||||
|
class FakeProvider {
|
||||||
|
sent: OutgoingMail[] = [];
|
||||||
|
async send(msg: OutgoingMail) {
|
||||||
|
this.sent.push(msg);
|
||||||
|
return { messageId: `<fake-${this.sent.length}@${MAIL_DOMAIN}>` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
// ---------- fixtures ----------
|
||||||
|
const tA = await prisma.tenant.create({ data: { name: "Kundenversand A", slug: SLUG_A } });
|
||||||
|
const tB = await prisma.tenant.create({ data: { name: "Kundenversand B", slug: SLUG_B } });
|
||||||
|
await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Musterbau A GmbH", mailFromName: "Musterbau A", mailReplyTo: EMAIL("buero") } });
|
||||||
|
await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Betrieb B" } });
|
||||||
|
|
||||||
|
const office = await mkUser(tA.id, "office", "Bernd Büro");
|
||||||
|
const tech = await mkUser(tA.id, "tech", "Max Monteur");
|
||||||
|
const lead = await mkUser(tA.id, "lead", "Tina Teamleiter");
|
||||||
|
const officeB = await mkUser(tB.id, "officeb", "Zoe Büro B");
|
||||||
|
const officeCtx = ctxOf(tA.id, office.id, "backoffice");
|
||||||
|
const techCtx = ctxOf(tA.id, tech.id, "technician");
|
||||||
|
const leadCtx = ctxOf(tA.id, lead.id, "team-lead");
|
||||||
|
const officeBCtx = ctxOf(tB.id, officeB.id, "backoffice");
|
||||||
|
|
||||||
|
const team = await prisma.team.create({ data: { tenantId: tA.id, name: "Team Kunde", leaderUserId: lead.id } });
|
||||||
|
await prisma.teamMember.create({ data: { tenantId: tA.id, teamId: team.id, userId: tech.id, validFrom: new Date("2026-01-01") } });
|
||||||
|
const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kunde <Hausverwaltung> GmbH", customerNumber: "K-09100", email: EMAIL("kunde") } });
|
||||||
|
const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Frau Kontakt", email: EMAIL("Kontakt") } });
|
||||||
|
const customerNoMail = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Ohne Mail KG", customerNumber: "K-09101" } });
|
||||||
|
|
||||||
|
const mkOrder = (number: string, customerId: string, contactId: string | null) =>
|
||||||
|
prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tA.id, number, customerId, contactId, title: "Heizung warten", status: "in_review", assignedTeamId: team.id,
|
||||||
|
assignees: { create: [{ tenantId: tA.id, userId: tech.id }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wo = await mkOrder("A-09100", customer.id, contact.id);
|
||||||
|
const woNoMail = await mkOrder("A-09101", customerNoMail.id, null);
|
||||||
|
|
||||||
|
const storePdf = (ctx: ServiceCtx, workOrderId: string | null, name: string) =>
|
||||||
|
storeFile(ctx, {
|
||||||
|
bytes: PDF_BYTES, fileName: name, declaredMime: "application/pdf", category: "completion_report", visibility: "customer_report",
|
||||||
|
...(workOrderId ? { links: { workOrderId } } : {}),
|
||||||
|
});
|
||||||
|
const mkReport = async (workOrderId: string, number: string, status: "approved" | "submitted", pdfDocumentId: string | null) => {
|
||||||
|
const content = await buildReportContent(officeCtx, { workOrderId, type: "completion", reportDate: "2026-09-10", reportNumber: number, version: 1, technicianUserId: tech.id });
|
||||||
|
return prisma.report.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tA.id, workOrderId, type: "completion", reportDate: new Date("2026-09-10T00:00:00Z"), version: 1, lineageId: `zz-${number}`,
|
||||||
|
status, content: content as unknown as Prisma.InputJsonValue, pdfDocumentId, pdfChecksum: null, approvedAt: status === "approved" ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pdfDoc = await storePdf(officeCtx, wo.id, "B-09100-v1.pdf");
|
||||||
|
const stored = await readStoredBytes(pdfDoc.storageKey);
|
||||||
|
ok(Boolean(stored && stored.equals(PDF_BYTES)), "Fixture: PDF im Objektspeicher abgelegt und lesbar");
|
||||||
|
const rApproved = await mkReport(wo.id, "B-09100", "approved", pdfDoc.id);
|
||||||
|
const rSubmitted = await mkReport(wo.id, "B-09101", "submitted", null);
|
||||||
|
const rNoPdf = await mkReport(wo.id, "B-09102", "approved", null);
|
||||||
|
const pdfNoMail = await storePdf(officeCtx, woNoMail.id, "B-09103-v1.pdf");
|
||||||
|
const rNoMail = await mkReport(woNoMail.id, "B-09103", "approved", pdfNoMail.id);
|
||||||
|
|
||||||
|
// ---------- rights / scope ----------
|
||||||
|
console.log("\n— Rechte, Scope, Mandantentrennung —");
|
||||||
|
await expectCode(() => svc.sendReportToCustomer(techCtx, { reportId: rApproved.id }), "forbidden", "Monteur → forbidden");
|
||||||
|
await expectCode(() => svc.sendReportToCustomer(leadCtx, { reportId: rApproved.id }), "forbidden", "Teamleiter (ohne report:approve) → forbidden");
|
||||||
|
await expectCode(() => svc.sendReportToCustomer(officeBCtx, { reportId: rApproved.id, to: EMAIL("b") }), "not_found", "Mandant B: Bericht von A senden → not_found");
|
||||||
|
await expectCode(() => svc.listCustomerMailings(officeBCtx, rApproved.id), "not_found", "Mandant B: Versandliste von A → not_found");
|
||||||
|
|
||||||
|
// ---------- blockers ----------
|
||||||
|
console.log("\n— Sperren & Empfänger —");
|
||||||
|
await expectCode(() => svc.sendReportToCustomer(officeCtx, { reportId: rSubmitted.id, to: EMAIL("x") }), "blocked", "Nicht freigegeben → blocked", "report_not_approved");
|
||||||
|
await expectCode(() => svc.sendReportToCustomer(officeCtx, { reportId: rNoPdf.id, to: EMAIL("x") }), "blocked", "Ohne PDF → blocked", "pdf_missing");
|
||||||
|
await expectCode(() => svc.sendReportToCustomer(officeCtx, { reportId: rNoMail.id }), "invalid", "Ohne Empfänger (kein Kontakt, Kunde ohne Mail) → invalid", "recipient_missing");
|
||||||
|
await expectCode(() => svc.sendReportToCustomer(officeCtx, { reportId: rApproved.id, to: "kein-mail\r\nBcc: x@y.z" }), "invalid", "Ungültige/Header-Injection-Adresse → invalid");
|
||||||
|
ok((await svc.defaultReportRecipient(officeCtx, { workOrderId: wo.id })) === EMAIL("kontakt"), "Default-Empfänger = Ansprechpartner des Auftrags (normalisiert)");
|
||||||
|
await prisma.contact.update({ where: { id: contact.id }, data: { deletedAt: new Date() } });
|
||||||
|
ok((await svc.defaultReportRecipient(officeCtx, { workOrderId: wo.id })) === EMAIL("kunde"), "Gelöschter Ansprechpartner → Kunde als Empfänger");
|
||||||
|
await prisma.contact.update({ where: { id: contact.id }, data: { deletedAt: null } });
|
||||||
|
|
||||||
|
// ---------- send (inline, SMTP disabled → pending) ----------
|
||||||
|
console.log("\n— Einstellen —");
|
||||||
|
const captured: Array<Parameters<typeof enqueueMail>[0]> = [];
|
||||||
|
const deps = { enqueue: async (input: Parameters<typeof enqueueMail>[0]) => (captured.push(input), enqueueMail(input)) };
|
||||||
|
const first = await svc.sendReportToCustomer(officeCtx, { reportId: rApproved.id, message: "Vielen Dank für den Auftrag.\n\nMit freundlichen Grüßen" }, deps);
|
||||||
|
ok(first.to === EMAIL("kontakt") && first.version === 1 && Boolean(first.mailLogId), `Versand angestoßen an Ansprechpartner (${first.status})`);
|
||||||
|
const row = first.mailLogId ? await prisma.mailLog.findUnique({ where: { id: first.mailLogId } }) : null;
|
||||||
|
ok(row?.status === "pending" && row.template === "craftvia_report_customer" && row.tenantId === tA.id && row.scope === "tenant", "MailLog pending, Template craftvia_report_customer, Mandant A");
|
||||||
|
ok(row?.dedupeKey === `report-customer:${rApproved.id}:${EMAIL("kontakt")}:1`, "dedupeKey = report-customer:<id>:<to>:<version>");
|
||||||
|
const payload = JSON.stringify(captured[0]);
|
||||||
|
ok(
|
||||||
|
JSON.stringify(captured[0]?.attachments) === JSON.stringify([{ documentId: pdfDoc.id }]) && !payload.includes("%PDF") && payload.length < 2000,
|
||||||
|
"Nutzlast enthält nur die Dokument-Referenz, keine Bytes",
|
||||||
|
);
|
||||||
|
const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "report", entityId: rApproved.id, action: "export" } });
|
||||||
|
const after = audit?.after as { op?: string; to?: string; version?: number } | null;
|
||||||
|
ok(after?.op === "send_to_customer" && after.to === EMAIL("kontakt") && after.version === 1, "Audit export mit op/to/version");
|
||||||
|
|
||||||
|
let enqueueThrew = false;
|
||||||
|
try {
|
||||||
|
await enqueueMail({ tenantId: null, template: "craftvia_report_customer", to: EMAIL("p"), vars: { customerName: "x", tenantName: "x", reportTitle: "x", reportDate: "x" }, attachments: [{ documentId: pdfDoc.id }] });
|
||||||
|
} catch {
|
||||||
|
enqueueThrew = true;
|
||||||
|
}
|
||||||
|
ok(enqueueThrew && (await prisma.mailLog.count({ where: { to: EMAIL("p") } })) === 0, "enqueueMail: Anhänge ohne tenantId → Fehler, kein MailLog");
|
||||||
|
|
||||||
|
// ---------- dedupe ----------
|
||||||
|
console.log("\n— Dedupe & Versandliste —");
|
||||||
|
const dup = await svc.sendReportToCustomer(officeCtx, { reportId: rApproved.id, to: EMAIL("KONTAKT") }, deps);
|
||||||
|
ok(dup.status === "duplicate", `Gleiche Adresse (andere Schreibweise) + Version → duplicate (${dup.status})`);
|
||||||
|
const second = await svc.sendReportToCustomer(officeCtx, { reportId: rApproved.id, to: EMAIL("zweit") }, deps);
|
||||||
|
ok(second.status !== "duplicate" && Boolean(second.mailLogId), "Andere Adresse → erneuter Versand erlaubt");
|
||||||
|
const mailings = await svc.listCustomerMailings(officeCtx, rApproved.id);
|
||||||
|
ok(mailings.length === 2 && mailings.every((m) => m.version === 1) && mailings.some((m) => m.to === EMAIL("zweit")), "Versandliste zeigt beide Versände mit Version");
|
||||||
|
|
||||||
|
// ---------- delivery with fake provider ----------
|
||||||
|
console.log("\n— Zustellung —");
|
||||||
|
process.env.SMTP_HOST = SAVED_SMTP_HOST || "localhost";
|
||||||
|
resetMailConfigCache();
|
||||||
|
ok(Boolean(getMailConfig().config), "SMTP-Konfiguration für Zustelltests aktiv");
|
||||||
|
const jobOf = (mailLogId: string, input: Parameters<typeof enqueueMail>[0]): MailJob =>
|
||||||
|
({ mailLogId, template: input.template, to: input.to.trim().toLowerCase(), locale: input.locale === "en" ? "en" : "de", vars: input.vars, attachments: input.attachments }) as MailJob;
|
||||||
|
|
||||||
|
const fake = new FakeProvider();
|
||||||
|
await deliverMail(jobOf(first.mailLogId!, captured[0]), { provider: fake });
|
||||||
|
const msg = fake.sent[0];
|
||||||
|
const att = msg?.attachments?.[0];
|
||||||
|
ok(fake.sent.length === 1 && msg.attachments?.length === 1, "Fake-Provider erhält genau eine Mail mit einem Anhang");
|
||||||
|
ok(Boolean(att && att.content.equals(PDF_BYTES)), "Anhang-Bytes = gespeichertes PDF");
|
||||||
|
ok(Boolean(att && sha(att.content) === pdfDoc.checksum), "SHA-256 des Anhangs = Document.checksum");
|
||||||
|
ok(Boolean(att && att.filename.endsWith(".pdf") && att.contentType === "application/pdf"), `Dateiname .pdf, Typ application/pdf (${att?.filename})`);
|
||||||
|
ok(msg.subject.includes("B-09100") && msg.subject.includes("Musterbau A GmbH") && !/[\r\n]/.test(msg.subject), `Betreff mit Berichtsnummer und Betrieb (${msg.subject})`);
|
||||||
|
ok(msg.text.includes("als PDF angehängt") && msg.text.includes("Vielen Dank für den Auftrag.") && !msg.text.includes("/reports/"), "Text: Hinweis Anhang, Nachricht, kein App-Link");
|
||||||
|
ok(msg.replyTo === EMAIL("buero") && msg.from.startsWith("Musterbau A"), "Antwortadresse/Absendername des Mandanten");
|
||||||
|
ok(msg.html.includes("Kunde <Hausverwaltung> GmbH") && !msg.html.includes("<Hausverwaltung>"), "Kundenname im HTML escaped");
|
||||||
|
const sentRow = await prisma.mailLog.findUnique({ where: { id: first.mailLogId! } });
|
||||||
|
ok(sentRow?.status === "sent" && Boolean(sentRow.providerMessageId), "MailLog → sent");
|
||||||
|
|
||||||
|
const deliverExpectFail = async (job: MailJob, msgText: string, errorPart: string) => {
|
||||||
|
const p = new FakeProvider();
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
await deliverMail(job, { provider: p });
|
||||||
|
} catch {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
const r = await prisma.mailLog.findUnique({ where: { id: job.mailLogId } });
|
||||||
|
ok(threw && p.sent.length === 0 && r?.status === "failed" && Boolean(r.error?.includes(errorPart)), `${msgText} (${r?.status}: ${r?.error})`);
|
||||||
|
};
|
||||||
|
const newLog = async (tenantId: string | null) =>
|
||||||
|
(await prisma.mailLog.create({ data: { tenantId, scope: tenantId ? "tenant" : "platform", to: EMAIL("iso"), template: "craftvia_report_customer" } })).id;
|
||||||
|
const vars = captured[0].vars;
|
||||||
|
const mkJob = (mailLogId: string, documentId: string): MailJob =>
|
||||||
|
({ mailLogId, template: "craftvia_report_customer", to: EMAIL("iso"), locale: "de", vars, attachments: [{ documentId }] }) as MailJob;
|
||||||
|
|
||||||
|
console.log("\n— Mandantentrennung & Integrität beim Zustellen —");
|
||||||
|
const docB = await storePdf(officeBCtx, null, "fremd.pdf");
|
||||||
|
await deliverExpectFail(mkJob(await newLog(tA.id), docB.id), "Anhang-Referenz auf Dokument von Mandant B → failed, nichts gesendet", "nicht verfügbar");
|
||||||
|
await deliverExpectFail(mkJob(await newLog(null), pdfDoc.id), "Plattform-MailLog mit Anhang → failed, nichts gesendet", "Mandanten-Mails");
|
||||||
|
await deliverExpectFail(mkJob(await newLog(tA.id), "does-not-exist"), "Unbekannte Dokument-ID → failed", "nicht verfügbar");
|
||||||
|
|
||||||
|
const tampered = await storePdf(officeCtx, wo.id, "manipuliert.pdf");
|
||||||
|
await prisma.document.update({ where: { id: tampered.id }, data: { checksum: "0".repeat(64) } });
|
||||||
|
await deliverExpectFail(mkJob(await newLog(tA.id), tampered.id), "Prüfsummen-Manipulation → failed, nichts gesendet", "Prüfsumme");
|
||||||
|
|
||||||
|
const deleted = await storePdf(officeCtx, wo.id, "geloescht.pdf");
|
||||||
|
await prisma.document.update({ where: { id: deleted.id }, data: { deletedAt: new Date() } });
|
||||||
|
await deliverExpectFail(mkJob(await newLog(tA.id), deleted.id), "Soft-gelöschtes Dokument → failed", "nicht verfügbar");
|
||||||
|
|
||||||
|
process.env.MAIL_MAX_ATTACHMENT_BYTES = "10";
|
||||||
|
await deliverExpectFail(mkJob(await newLog(tA.id), pdfDoc.id), "Anhang über MAIL_MAX_ATTACHMENT_BYTES → failed", "Größe");
|
||||||
|
delete process.env.MAIL_MAX_ATTACHMENT_BYTES;
|
||||||
|
|
||||||
|
// service-level: PDF soft-deleted after approval
|
||||||
|
await prisma.document.update({ where: { id: pdfNoMail.id }, data: { deletedAt: new Date() } });
|
||||||
|
await expectCode(() => svc.sendReportToCustomer(officeCtx, { reportId: rNoMail.id, to: EMAIL("x") }), "blocked", "PDF-Dokument gelöscht → blocked", "pdf_missing");
|
||||||
|
|
||||||
|
// ---------- templates ----------
|
||||||
|
console.log("\n— Template —");
|
||||||
|
for (const key of CUSTOMER_TEMPLATE_KEYS) {
|
||||||
|
for (const locale of ["de", "en"] as const) {
|
||||||
|
const r = renderTemplate(key, locale, { customerName: "K", tenantName: "Betrieb\r\nBcc: x", reportTitle: "Abschlussbericht B-1", reportDate: "10.09.2026", message: "<b>Hallo</b>" });
|
||||||
|
ok(
|
||||||
|
r.html.includes("<!doctype html>") && r.html.includes("<b>Hallo</b>") && !r.html.includes("<b>Hallo</b>") && !/[\r\n]/.test(r.subject) && r.text.includes("<b>Hallo</b>"),
|
||||||
|
`${key}/${locale}: rendert, Nachricht escaped, Betreff einzeilig`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- real SMTP (Mailhog) ----------
|
||||||
|
console.log("\n— SMTP-Durchstich (Mailhog, optional) —");
|
||||||
|
const mailhog = await fetch("http://localhost:8025/api/v2/messages?limit=1").then((r) => r.ok).catch(() => false);
|
||||||
|
if (!mailhog || !SAVED_SMTP_HOST) {
|
||||||
|
console.log("↷ übersprungen: Mailhog/SMTP nicht erreichbar");
|
||||||
|
} else {
|
||||||
|
const smtpTo = EMAIL(`smtp-${Date.now()}`);
|
||||||
|
const smtpLog = (await prisma.mailLog.create({ data: { tenantId: tA.id, to: smtpTo, template: "craftvia_report_customer" } })).id;
|
||||||
|
await deliverMail({ ...mkJob(smtpLog, pdfDoc.id), to: smtpTo } as MailJob);
|
||||||
|
const found = (await fetch(`http://localhost:8025/api/v2/search?kind=to&query=${encodeURIComponent(smtpTo)}`).then((r) => r.json())) as {
|
||||||
|
items?: Array<{ Raw?: { Data?: string } }>;
|
||||||
|
};
|
||||||
|
const raw = found.items?.[0]?.Raw?.Data ?? "";
|
||||||
|
ok(/Content-Disposition: attachment/i.test(raw) && raw.includes("B-09100-v1.pdf") && raw.includes(PDF_BYTES.toString("base64").slice(0, 20)), "Mailhog: Mail mit PDF-Anhang zugestellt");
|
||||||
|
ok((await prisma.mailLog.findUnique({ where: { id: smtpLog } }))?.status === "sent", "MailLog nach SMTP-Versand → sent");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await cleanup().catch((e) => console.error("cleanup failed", e));
|
||||||
|
await closeMailProvider().catch(() => {});
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
failures++;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ Alle Kundenversand-Tests grün");
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
});
|
||||||
@@ -14,6 +14,8 @@ import { ServiceError } from "@/server/services/context";
|
|||||||
import { tenantTimeZone } from "@/server/services/reports/build-content";
|
import { tenantTimeZone } from "@/server/services/reports/build-content";
|
||||||
import { getReportDetail } from "@/server/services/reports/queries";
|
import { getReportDetail } from "@/server/services/reports/queries";
|
||||||
import { readCtx } from "@/server/services/reports/read-ctx";
|
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||||
|
import { SendToCustomer } from "@/components/reports/send-to-customer";
|
||||||
|
import { defaultReportRecipient, listCustomerMailings } from "@/server/services/reports/send-to-customer";
|
||||||
|
|
||||||
/** /reports/[id] — structured view, PDF, versions, approve/reject/new version (reject as popup ?reject=1). */
|
/** /reports/[id] — structured view, PDF, versions, approve/reject/new version (reject as popup ?reject=1). */
|
||||||
export default async function ReportDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ reject?: string }> }) {
|
export default async function ReportDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ reject?: string }> }) {
|
||||||
@@ -29,6 +31,14 @@ export default async function ReportDetailPage({ params, searchParams }: { param
|
|||||||
const { report, content, versions, workOrder, permissions } = detail;
|
const { report, content, versions, workOrder, permissions } = detail;
|
||||||
const dt = (d: Date | null) => (d ? format.dateTime(d, { dateStyle: "medium", timeStyle: "short", timeZone }) : null);
|
const dt = (d: Date | null) => (d ? format.dateTime(d, { dateStyle: "medium", timeStyle: "short", timeZone }) : null);
|
||||||
const base = `/reports/${id}`;
|
const base = `/reports/${id}`;
|
||||||
|
// L11: customer mail section — approved report with PDF, backoffice right (the action re-checks against the DB)
|
||||||
|
const customerMail =
|
||||||
|
report.status === "approved" && report.pdfDocumentId && ctx.permissions.has("report:approve")
|
||||||
|
? await Promise.all([defaultReportRecipient(ctx, report), listCustomerMailings(ctx, id)]).then(([defaultTo, rows]) => ({
|
||||||
|
defaultTo,
|
||||||
|
mailings: rows.map((m) => ({ id: m.id, to: m.to, status: m.status, version: m.version, when: dt(m.sentAt ?? m.createdAt) ?? "" })),
|
||||||
|
}))
|
||||||
|
: null;
|
||||||
|
|
||||||
const meta: Array<[string, string | null]> = [
|
const meta: Array<[string, string | null]> = [
|
||||||
[t("field.workOrder"), `${workOrder.number}`],
|
[t("field.workOrder"), `${workOrder.number}`],
|
||||||
@@ -87,6 +97,11 @@ export default async function ReportDetailPage({ params, searchParams }: { param
|
|||||||
<div className="mb-4 max-w-3xl">
|
<div className="mb-4 max-w-3xl">
|
||||||
<LotseReportPanel reportId={id} />
|
<LotseReportPanel reportId={id} />
|
||||||
</div>
|
</div>
|
||||||
|
{customerMail && (
|
||||||
|
<div className="mb-4 max-w-3xl">
|
||||||
|
<SendToCustomer reportId={id} defaultTo={customerMail.defaultTo} mailings={customerMail.mailings} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||||
<ReportView content={content} reportId={id} timeZone={timeZone} />
|
<ReportView content={content} reportId={id} timeZone={timeZone} />
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { requireApiContext } from "@/server/api/context";
|
||||||
|
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||||
|
import { sendReportToCustomer } from "@/server/services/reports/send-to-customer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/reports/:id/send — e-mail the approved report PDF to the customer (L11).
|
||||||
|
* Body (optional): { to?: string, message?: string }. 202 queued, 200 sent/duplicate/failed.
|
||||||
|
*/
|
||||||
|
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||||
|
const ctx = await requireApiContext("reports", "report:approve");
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await readJsonObject(req, { allowEmpty: true });
|
||||||
|
const res = await sendReportToCustomer(ctx, { reportId: id, to: body.to, message: body.message });
|
||||||
|
return json(res, { status: res.status === "queued" ? 202 : 200 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReportActionErrorCode } from "@/lib/reports/action-state";
|
||||||
|
|
||||||
|
/** Result of the "send report to customer" action (client-safe; kept out of the "use server" file). */
|
||||||
|
export type SendToCustomerState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "ok"; result: "sent" | "queued" | "duplicate" | "failed"; to: string; at: number }
|
||||||
|
| { status: "error"; code: ReportActionErrorCode; field?: string; reason?: string; at: number };
|
||||||
|
|
||||||
|
export const SEND_TO_CUSTOMER_IDLE: SendToCustomerState = { status: "idle" };
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CheckCircle2, Clock, Info, Mail, XCircle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { sendReportToCustomerAction } from "@/server/actions/reports/send-to-customer";
|
||||||
|
import { SEND_TO_CUSTOMER_IDLE } from "./send-to-customer-state";
|
||||||
|
|
||||||
|
export type CustomerMailingRow = { id: string; to: string; status: string; version: number | null; when: string };
|
||||||
|
|
||||||
|
const KNOWN_REASONS = new Set(["recipient_missing", "report_not_approved", "pdf_missing"]);
|
||||||
|
|
||||||
|
/** /reports/[id] section „An Kunden senden" (approved + report:approve). The service enforces rights and status. */
|
||||||
|
export function SendToCustomer({ reportId, defaultTo, mailings }: { reportId: string; defaultTo: string | null; mailings: CustomerMailingRow[] }) {
|
||||||
|
const t = useTranslations("reports.customerMail");
|
||||||
|
const tr = useTranslations("reports");
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, action, pending] = useActionState(sendReportToCustomerAction, SEND_TO_CUSTOMER_IDLE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status === "ok") router.refresh();
|
||||||
|
}, [state, router]);
|
||||||
|
|
||||||
|
let feedback: React.ReactNode = null;
|
||||||
|
if (state.status === "ok") {
|
||||||
|
const bad = state.result === "failed";
|
||||||
|
const hint = state.result === "duplicate";
|
||||||
|
feedback = (
|
||||||
|
<p role={bad ? "alert" : "status"} className={`flex items-center gap-2 text-[13px] font-semibold ${bad ? "text-[var(--risk)]" : hint ? "text-muted-foreground" : "text-[var(--ok)]"}`}>
|
||||||
|
{bad ? <XCircle className="size-4 shrink-0" aria-hidden /> : hint ? <Info className="size-4 shrink-0" aria-hidden /> : <CheckCircle2 className="size-4 shrink-0" aria-hidden />}
|
||||||
|
{t(`result.${state.result}`, { to: state.to })}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
} else if (state.status === "error") {
|
||||||
|
const key =
|
||||||
|
state.reason && KNOWN_REASONS.has(state.reason)
|
||||||
|
? `errors.${state.reason}`
|
||||||
|
: state.field === "to"
|
||||||
|
? "errors.invalid_to"
|
||||||
|
: null;
|
||||||
|
feedback = (
|
||||||
|
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-[13px] font-semibold text-[var(--risk)]">
|
||||||
|
<XCircle className="size-4 shrink-0" aria-hidden />
|
||||||
|
{key ? t(key) : tr(`errors.${state.code}`)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="shadow-card rounded-xl border bg-card p-4" aria-labelledby="customer-mail-title">
|
||||||
|
<h2 id="customer-mail-title" className="font-heading text-[15px] font-semibold">
|
||||||
|
{t("title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("sub")}</p>
|
||||||
|
|
||||||
|
<form action={action} className="mt-3 space-y-3">
|
||||||
|
<input type="hidden" name="reportId" value={reportId} />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="customer-mail-to">{t("to")} *</Label>
|
||||||
|
<Input
|
||||||
|
id="customer-mail-to"
|
||||||
|
name="to"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
maxLength={254}
|
||||||
|
autoComplete="email"
|
||||||
|
defaultValue={defaultTo ?? ""}
|
||||||
|
className="mt-1 h-11"
|
||||||
|
aria-invalid={state.status === "error" && state.field === "to"}
|
||||||
|
aria-describedby="customer-mail-to-hint"
|
||||||
|
/>
|
||||||
|
<p id="customer-mail-to-hint" className="mt-1 text-[12px] text-muted-foreground">
|
||||||
|
{defaultTo ? t("toHint") : t("noDefault")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="customer-mail-message">{t("message")}</Label>
|
||||||
|
<Textarea id="customer-mail-message" name="message" maxLength={2000} rows={3} className="mt-1 min-h-20" placeholder={t("messagePlaceholder")} />
|
||||||
|
</div>
|
||||||
|
{feedback}
|
||||||
|
<Button type="submit" disabled={pending} className="h-11 px-4">
|
||||||
|
<Mail aria-hidden />
|
||||||
|
{pending ? t("sending") : t("submit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3 className="mt-4 text-[13px] font-semibold">{t("history")}</h3>
|
||||||
|
{mailings.length === 0 ? (
|
||||||
|
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("historyEmpty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-2 space-y-1.5">
|
||||||
|
{mailings.map((m) => (
|
||||||
|
<li key={m.id} className="flex flex-wrap items-center justify-between gap-2 text-[13px]">
|
||||||
|
<span className="min-w-0 break-all">
|
||||||
|
{m.to}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" · "}
|
||||||
|
{m.when}
|
||||||
|
{m.version ? ` · v${m.version}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<DeliveryStatus status={m.status} label={t(`delivery.${m.status === "sent" || m.status === "pending" || m.status === "failed" ? m.status : "other"}`)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeliveryStatus({ status, label }: { status: string; label: string }) {
|
||||||
|
const Icon = status === "sent" ? CheckCircle2 : status === "pending" ? Clock : XCircle;
|
||||||
|
const tone = status === "sent" ? "text-[var(--ok)]" : status === "pending" ? "text-muted-foreground" : "text-[var(--risk)]";
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[12.5px] font-semibold ${tone}`}>
|
||||||
|
<Icon className="size-3.5" aria-hidden />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1015,6 +1015,33 @@ const paths: Record<string, Record<string, Schema>> = {
|
|||||||
responses: { "200": binaryResponse("Datei"), ...errors("not_found") },
|
responses: { "200": binaryResponse("Datei"), ...errors("not_found") },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
"/reports/{id}/send": {
|
||||||
|
post: op({
|
||||||
|
tag: "Berichte",
|
||||||
|
operationId: "sendReportToCustomer",
|
||||||
|
summary: "Freigegebenes Berichts-PDF per E-Mail an den Kunden senden",
|
||||||
|
description:
|
||||||
|
"Nur Status `approved` mit PDF (sonst 422 `blocked`, details.reason `report_not_approved` | `pdf_missing`). Empfänger: `to` oder Ansprechpartner des Auftrags → Kunde; keiner → 422 `invalid` (details.reason `recipient_missing`). Gleiche Adresse + Version → `duplicate` (keine zweite Mail). Das PDF wird als Dokument-Referenz eingestellt und beim Zustellen mandantengebunden geladen und per SHA-256 geprüft. Audit `export`.",
|
||||||
|
module: "reports",
|
||||||
|
permissions: ["report:approve"],
|
||||||
|
parameters: [idParam("des Berichts")],
|
||||||
|
requestBody: jsonBody(obj({ to: str({ format: "email", maxLength: 254 }), message: str({ maxLength: 2000 }) }), false),
|
||||||
|
responses: {
|
||||||
|
"200": jsonResponse(
|
||||||
|
"Versendet, bereits versendet oder Zustellung fehlgeschlagen",
|
||||||
|
obj(
|
||||||
|
{ status: str({ enum: ["sent", "duplicate", "failed"] }), reportId: str(), to: str({ format: "email" }), version: int(), mailLogId: str() },
|
||||||
|
["status", "reportId", "to", "version"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"202": jsonResponse(
|
||||||
|
"In die Mail-Queue eingestellt",
|
||||||
|
obj({ status: str({ const: "queued" }), reportId: str(), to: str({ format: "email" }), version: int(), mailLogId: str() }, ["status", "reportId", "to", "version"]),
|
||||||
|
),
|
||||||
|
...errors("not_found", "unprocessable"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
"/sync": {
|
"/sync": {
|
||||||
post: op({
|
post: op({
|
||||||
tag: "Einsatz",
|
tag: "Einsatz",
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import type { SendToCustomerState } from "@/components/reports/send-to-customer-state";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
||||||
|
import { sendReportToCustomer } from "@/server/services/reports/send-to-customer";
|
||||||
|
import { errorState, str } from "./_state";
|
||||||
|
|
||||||
|
const guard = moduleGuard("reports");
|
||||||
|
|
||||||
|
/** Backoffice: send the approved report PDF to the customer by e-mail (form fields: reportId, to, message). */
|
||||||
|
export async function sendReportToCustomerAction(_prev: SendToCustomerState, fd: FormData): Promise<SendToCustomerState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("report:approve"));
|
||||||
|
const res = await sendReportToCustomer(ctx, { reportId: str(fd, "reportId") ?? "", to: str(fd, "to"), message: str(fd, "message") });
|
||||||
|
revalidatePath(`/reports/${res.reportId}`);
|
||||||
|
return { status: "ok", result: res.status, to: res.to, at: Date.now() };
|
||||||
|
} catch (err) {
|
||||||
|
const state = errorState(err);
|
||||||
|
if (state.status !== "error") return { status: "error", code: "generic", at: Date.now() };
|
||||||
|
const reason = err instanceof ServiceError ? (err.details as { reason?: string } | undefined)?.reason : undefined;
|
||||||
|
return { status: "error", code: state.code, field: state.field, reason, at: state.at };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { prisma } from "@/server/db";
|
import { prisma } from "@/server/db";
|
||||||
import { getMailConfig, mailFrom, type MailConfig } from "./config";
|
import { getMailConfig, mailFrom, type MailConfig } from "./config";
|
||||||
import { getMailProvider } from "./provider-smtp";
|
import { getMailProvider } from "./provider-smtp";
|
||||||
import { TransientMailError } from "./provider";
|
import { TransientMailError, type MailProvider, type OutgoingAttachment } from "./provider";
|
||||||
import { renderTemplate } from "./templates";
|
import { renderTemplate } from "./templates";
|
||||||
import type { MailJob } from "./job";
|
import type { MailAttachmentRef, MailJob } from "./job";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEC1 — die eigentliche Zustellung.
|
* SEC1 — die eigentliche Zustellung.
|
||||||
@@ -25,13 +26,35 @@ export class MailNotConfiguredError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** L11 — Anhang nicht zustellbar (fehlt, fremder Mandant, Prüfsumme, Größe). Permanent, kein Retry-Grund. */
|
||||||
|
export class MailAttachmentError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "MailAttachmentError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L11 — Obergrenze aller Anhänge einer Mail (Summe), `MAIL_MAX_ATTACHMENT_BYTES`, Default 10 MB. */
|
||||||
|
export const DEFAULT_MAIL_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||||
|
const MAX_ATTACHMENTS_PER_MAIL = 10;
|
||||||
|
|
||||||
|
export function mailMaxAttachmentBytes(): number {
|
||||||
|
const n = Number(process.env.MAIL_MAX_ATTACHMENT_BYTES);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_MAIL_MAX_ATTACHMENT_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeliverOptions = {
|
||||||
|
/** Nur für Tests/Sonderfälle: anderer Provider als der konfigurierte SMTP-Singleton. */
|
||||||
|
provider?: MailProvider;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rendert und versendet einen Job und schreibt das Ergebnis ins MailLog.
|
* Rendert und versendet einen Job und schreibt das Ergebnis ins MailLog.
|
||||||
* Wirft bei temporären Fehlern `TransientMailError` (→ Retry durch den Worker).
|
* Wirft bei temporären Fehlern `TransientMailError` (→ Retry durch den Worker).
|
||||||
*/
|
*/
|
||||||
export async function deliverMail(job: MailJob): Promise<{ messageId: string }> {
|
export async function deliverMail(job: MailJob, options: DeliverOptions = {}): Promise<{ messageId: string }> {
|
||||||
const { config, reason } = getMailConfig();
|
const { config, reason } = getMailConfig();
|
||||||
const provider = getMailProvider();
|
const provider = options.provider ?? getMailProvider();
|
||||||
if (!config || !provider) {
|
if (!config || !provider) {
|
||||||
// Kein stiller Fehlversand: die Zeile bleibt `pending`, der Grund steht dran.
|
// Kein stiller Fehlversand: die Zeile bleibt `pending`, der Grund steht dran.
|
||||||
await prisma.mailLog.update({
|
await prisma.mailLog.update({
|
||||||
@@ -45,6 +68,8 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }>
|
|||||||
const sender = await tenantSender(job.mailLogId, config);
|
const sender = await tenantSender(job.mailLogId, config);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// L11: Anhänge vor dem Versand auflösen – schlägt das fehl, geht KEINE Mail (auch ohne Anhang) raus.
|
||||||
|
const attachments = job.attachments?.length ? await loadMailAttachments(job.mailLogId, job.attachments) : undefined;
|
||||||
const { messageId } = await provider.send({
|
const { messageId } = await provider.send({
|
||||||
from: sender.from,
|
from: sender.from,
|
||||||
to: job.to,
|
to: job.to,
|
||||||
@@ -54,6 +79,7 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }>
|
|||||||
text: rendered.text,
|
text: rendered.text,
|
||||||
// Auto-Antworten und Abwesenheitsnotizen unterdrücken (RFC 3834).
|
// Auto-Antworten und Abwesenheitsnotizen unterdrücken (RFC 3834).
|
||||||
headers: { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All" },
|
headers: { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All" },
|
||||||
|
...(attachments ? { attachments } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.mailLog.update({
|
await prisma.mailLog.update({
|
||||||
@@ -83,6 +109,60 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L11 — Dokument-Referenzen eines Jobs in Anhänge auflösen.
|
||||||
|
*
|
||||||
|
* Sicherheitsgrenzen (der Job kommt aus Redis und gilt als nicht vertrauenswürdig):
|
||||||
|
* - Mandant ausschließlich aus der MailLog-Zeile (nicht aus dem Job); Plattform-Mails → keine Anhänge.
|
||||||
|
* - Dokument nur mit `tenantId = MailLog.tenantId`, nicht soft-gelöscht, Storage-Key mit Mandanten-Präfix.
|
||||||
|
* - SHA-256 der gelesenen Bytes muss der gespeicherten Prüfsumme entsprechen.
|
||||||
|
* - Summe ≤ `MAIL_MAX_ATTACHMENT_BYTES`, höchstens 10 Anhänge.
|
||||||
|
* Owner-Client bewusst: der Worker hat keinen Request-/Mandantenkontext (wie tenantSender).
|
||||||
|
*/
|
||||||
|
export async function loadMailAttachments(mailLogId: string, refs: MailAttachmentRef[]): Promise<OutgoingAttachment[]> {
|
||||||
|
const log = await prisma.mailLog.findUnique({ where: { id: mailLogId }, select: { tenantId: true } });
|
||||||
|
const tenantId = log?.tenantId;
|
||||||
|
if (!tenantId) throw new MailAttachmentError("Anhänge sind nur für Mandanten-Mails zulässig.");
|
||||||
|
if (refs.length > MAX_ATTACHMENTS_PER_MAIL) throw new MailAttachmentError("Zu viele Anhänge.");
|
||||||
|
|
||||||
|
const max = mailMaxAttachmentBytes();
|
||||||
|
const { readStoredBytes } = await import("@/server/services/documents/read");
|
||||||
|
let total = 0;
|
||||||
|
const out: OutgoingAttachment[] = [];
|
||||||
|
for (const ref of refs) {
|
||||||
|
const documentId = typeof ref?.documentId === "string" ? ref.documentId : "";
|
||||||
|
const doc = documentId
|
||||||
|
? await prisma.document.findFirst({
|
||||||
|
where: { id: documentId, tenantId, deletedAt: null },
|
||||||
|
select: { storageKey: true, fileName: true, mimeType: true, checksum: true, fileSize: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (!doc || !doc.storageKey.startsWith(`${tenantId}/`)) throw new MailAttachmentError("Anhang nicht verfügbar.");
|
||||||
|
if (total + doc.fileSize > max) throw new MailAttachmentError("Anhänge überschreiten die zulässige Größe.");
|
||||||
|
|
||||||
|
let bytes: Buffer | null;
|
||||||
|
try {
|
||||||
|
bytes = await readStoredBytes(doc.storageKey);
|
||||||
|
} catch (err) {
|
||||||
|
// Speicher kurz nicht erreichbar → erneuter Versuch sinnvoll.
|
||||||
|
throw new TransientMailError("Anhang konnte nicht gelesen werden.", { cause: err });
|
||||||
|
}
|
||||||
|
if (!bytes) throw new MailAttachmentError("Anhang nicht verfügbar.");
|
||||||
|
total += bytes.byteLength;
|
||||||
|
if (total > max) throw new MailAttachmentError("Anhänge überschreiten die zulässige Größe.");
|
||||||
|
if (createHash("sha256").update(bytes).digest("hex") !== doc.checksum) {
|
||||||
|
throw new MailAttachmentError("Prüfsumme des Anhangs stimmt nicht.");
|
||||||
|
}
|
||||||
|
out.push({ filename: safeAttachmentName(doc.fileName), content: bytes, contentType: doc.mimeType });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeAttachmentName(name: string): string {
|
||||||
|
const cleaned = name.replace(/[\r\n"\\/<>]/g, "_").trim().slice(0, 150);
|
||||||
|
return cleaned || "anhang";
|
||||||
|
}
|
||||||
|
|
||||||
/** Endgültiges Scheitern nach Ausschöpfung aller Versuche (Dead-Letter). */
|
/** Endgültiges Scheitern nach Ausschöpfung aller Versuche (Dead-Letter). */
|
||||||
export async function markMailFailed(mailLogId: string, error: string): Promise<void> {
|
export async function markMailFailed(mailLogId: string, error: string): Promise<void> {
|
||||||
await prisma.mailLog.update({
|
await prisma.mailLog.update({
|
||||||
|
|||||||
@@ -18,9 +18,17 @@ export type MailJob = {
|
|||||||
to: string;
|
to: string;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
vars: TemplateVars[K];
|
vars: TemplateVars[K];
|
||||||
|
/**
|
||||||
|
* L11 — Anhänge NUR als Referenz auf ein `Document` (nie Bytes in Redis). Aufgelöst wird beim
|
||||||
|
* Zustellen in deliver.ts, strikt im Mandanten der MailLog-Zeile und mit Prüfsummen-Vergleich.
|
||||||
|
*/
|
||||||
|
attachments?: MailAttachmentRef[];
|
||||||
};
|
};
|
||||||
}[TemplateKey];
|
}[TemplateKey];
|
||||||
|
|
||||||
|
/** L11 — Verweis auf ein gespeichertes Dokument des Mandanten der Mail. */
|
||||||
|
export type MailAttachmentRef = { documentId: string };
|
||||||
|
|
||||||
export const MAIL_QUEUE = "mail";
|
export const MAIL_QUEUE = "mail";
|
||||||
export const MAIL_DLQ = "mail-dead-letter";
|
export const MAIL_DLQ = "mail-dead-letter";
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export class SmtpMailProvider implements MailProvider {
|
|||||||
html: msg.html,
|
html: msg.html,
|
||||||
text: msg.text,
|
text: msg.text,
|
||||||
headers: msg.headers,
|
headers: msg.headers,
|
||||||
|
...(msg.attachments?.length
|
||||||
|
? { attachments: msg.attachments.map((a) => ({ filename: a.filename, content: a.content, contentType: a.contentType })) }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
return { messageId: info.messageId };
|
return { messageId: info.messageId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ export type OutgoingMail = {
|
|||||||
html: string;
|
html: string;
|
||||||
text: string;
|
text: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
/** L11 — bereits aufgelöste, geprüfte Anhänge (Bytes nur im Prozess, nie in der Queue). */
|
||||||
|
attachments?: OutgoingAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OutgoingAttachment = { filename: string; content: Buffer; contentType: string };
|
||||||
|
|
||||||
export type SendResult = { messageId: string };
|
export type SendResult = { messageId: string };
|
||||||
|
|
||||||
export interface MailProvider {
|
export interface MailProvider {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { prisma } from "@/server/db";
|
|||||||
import { deliverMail, MailNotConfiguredError } from "./deliver";
|
import { deliverMail, MailNotConfiguredError } from "./deliver";
|
||||||
import { getMailQueue, isQueueEnabled, isQueueReady } from "./queue";
|
import { getMailQueue, isQueueEnabled, isQueueReady } from "./queue";
|
||||||
import { normalizeLocale, type Locale, type TemplateKey, type TemplateVars } from "./templates";
|
import { normalizeLocale, type Locale, type TemplateKey, type TemplateVars } from "./templates";
|
||||||
import type { MailJob } from "./job";
|
import type { MailAttachmentRef, MailJob } from "./job";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEC1 — Einstiegspunkt für alle Mails: `enqueueMail(...)`.
|
* SEC1 — Einstiegspunkt für alle Mails: `enqueueMail(...)`.
|
||||||
@@ -34,6 +34,11 @@ export type EnqueueInput<K extends TemplateKey = TemplateKey> = {
|
|||||||
* erzeugt eine eigene Mail), für Benachrichtigungen gesetzt.
|
* erzeugt eine eigene Mail), für Benachrichtigungen gesetzt.
|
||||||
*/
|
*/
|
||||||
dedupeKey?: string;
|
dedupeKey?: string;
|
||||||
|
/**
|
||||||
|
* L11 — Dokument-Referenzen als Anhang (nur mit `tenantId`; Plattform-Mails haben keine Anhänge).
|
||||||
|
* Es landen nur IDs in Redis; Bytes werden erst beim Zustellen geladen und geprüft.
|
||||||
|
*/
|
||||||
|
attachments?: MailAttachmentRef[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EnqueueResult =
|
export type EnqueueResult =
|
||||||
@@ -48,6 +53,9 @@ export async function enqueueMail<K extends TemplateKey>(
|
|||||||
): Promise<EnqueueResult> {
|
): Promise<EnqueueResult> {
|
||||||
const locale: Locale = normalizeLocale(input.locale);
|
const locale: Locale = normalizeLocale(input.locale);
|
||||||
const to = input.to.trim().toLowerCase();
|
const to = input.to.trim().toLowerCase();
|
||||||
|
const attachments = input.attachments?.length ? input.attachments.map((a) => ({ documentId: a.documentId })) : undefined;
|
||||||
|
// Programmierfehler, kein Zustellproblem: vor dem MailLog-Insert abweisen.
|
||||||
|
if (attachments && !input.tenantId) throw new Error("Mail-Anhänge sind nur für Mandanten-Mails zulässig.");
|
||||||
|
|
||||||
let mailLogId: string;
|
let mailLogId: string;
|
||||||
try {
|
try {
|
||||||
@@ -72,7 +80,7 @@ export async function enqueueMail<K extends TemplateKey>(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = { mailLogId, template: input.template, to, locale, vars: input.vars } as MailJob;
|
const job = { mailLogId, template: input.template, to, locale, vars: input.vars, ...(attachments ? { attachments } : {}) } as MailJob;
|
||||||
|
|
||||||
// Queue nur nutzen, wenn Redis konfiguriert UND gerade erreichbar ist. Bei
|
// Queue nur nutzen, wenn Redis konfiguriert UND gerade erreichbar ist. Bei
|
||||||
// einem Redis-Ausfall fällt der Versand auf den Inline-Pfad zurück, statt die
|
// einem Redis-Ausfall fällt der Versand auf den Inline-Pfad zurück, statt die
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export type TemplateVars = {
|
|||||||
craftvia_notification: {
|
craftvia_notification: {
|
||||||
name: string; subject: string; body: string; actionUrl?: string; footer?: CraftviaFooter;
|
name: string; subject: string; body: string; actionUrl?: string; footer?: CraftviaFooter;
|
||||||
};
|
};
|
||||||
|
// ---- Craftvia customer mail (lane L11). No app link: the customer has no account.
|
||||||
|
craftvia_report_customer: {
|
||||||
|
customerName: string; tenantName: string; reportTitle: string; reportDate: string; message?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Why the recipient gets a Craftvia notification — controls the footer line. */
|
/** Why the recipient gets a Craftvia notification — controls the footer line. */
|
||||||
@@ -90,6 +94,9 @@ export const CRAFTVIA_TEMPLATE_KEYS = [
|
|||||||
"craftvia_notification",
|
"craftvia_notification",
|
||||||
] as const satisfies readonly TemplateKey[];
|
] as const satisfies readonly TemplateKey[];
|
||||||
|
|
||||||
|
/** Customer-facing Craftvia mails (lane L11) — separate list, recipients are external customers. */
|
||||||
|
export const CUSTOMER_TEMPLATE_KEYS = ["craftvia_report_customer"] as const satisfies readonly TemplateKey[];
|
||||||
|
|
||||||
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
|
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
|
||||||
const FOOTER_NOTE: Record<Locale, string> = {
|
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.",
|
de: "Sie erhalten diese Benachrichtigung aufgrund Ihrer Rolle in Ihrem Betrieb. Die Einstellungen dazu finden Sie in Ihrem Profil.",
|
||||||
@@ -261,6 +268,45 @@ const craftviaEn: { [K in CraftviaKey]: Builder<K> } = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Customer mails (lane L11) ----
|
||||||
|
type CustomerKey = (typeof CUSTOMER_TEMPLATE_KEYS)[number];
|
||||||
|
|
||||||
|
/** Subject lines must never carry CR/LF (header injection); free text is HTML-escaped by email-brand. */
|
||||||
|
const oneLine = (s: string) => s.replace(/[\r\n]+/g, " ").trim();
|
||||||
|
const messageParagraphs = (m?: string) =>
|
||||||
|
(m ?? "")
|
||||||
|
.split(/\r?\n\s*\r?\n|\r?\n/)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const customerDe: { [K in CustomerKey]: Builder<K> } = {
|
||||||
|
craftvia_report_customer: (v) => ({
|
||||||
|
subject: oneLine(`${v.reportTitle} – ${v.tenantName}`),
|
||||||
|
heading: oneLine(v.reportTitle),
|
||||||
|
paragraphs: [
|
||||||
|
"Guten Tag,",
|
||||||
|
`anbei erhalten Sie von ${v.tenantName} den ${v.reportTitle} vom ${v.reportDate} für ${v.customerName}.`,
|
||||||
|
...messageParagraphs(v.message),
|
||||||
|
"Der Arbeitsnachweis ist als PDF angehängt.",
|
||||||
|
],
|
||||||
|
footerNote: `Diese Nachricht wurde von ${v.tenantName} über ${BRAND.name} versendet.`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const customerEn: { [K in CustomerKey]: Builder<K> } = {
|
||||||
|
craftvia_report_customer: (v) => ({
|
||||||
|
subject: oneLine(`${v.reportTitle} – ${v.tenantName}`),
|
||||||
|
heading: oneLine(v.reportTitle),
|
||||||
|
paragraphs: [
|
||||||
|
"Hello,",
|
||||||
|
`please find attached the ${v.reportTitle} dated ${v.reportDate} from ${v.tenantName} for ${v.customerName}.`,
|
||||||
|
...messageParagraphs(v.message),
|
||||||
|
"The work record is attached as a PDF.",
|
||||||
|
],
|
||||||
|
footerNote: `This message was sent by ${v.tenantName} via ${BRAND.name}.`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
const de: { [K in TemplateKey]: Builder<K> } = {
|
const de: { [K in TemplateKey]: Builder<K> } = {
|
||||||
invitation: (v) => ({
|
invitation: (v) => ({
|
||||||
subject: `Ihr Zugang zu ${BRAND.name}`,
|
subject: `Ihr Zugang zu ${BRAND.name}`,
|
||||||
@@ -338,6 +384,7 @@ const de: { [K in TemplateKey]: Builder<K> } = {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
...craftviaDe,
|
...craftviaDe,
|
||||||
|
...customerDe,
|
||||||
};
|
};
|
||||||
|
|
||||||
const en: { [K in TemplateKey]: Builder<K> } = {
|
const en: { [K in TemplateKey]: Builder<K> } = {
|
||||||
@@ -417,6 +464,7 @@ const en: { [K in TemplateKey]: Builder<K> } = {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
...craftviaEn,
|
...craftviaEn,
|
||||||
|
...customerEn,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };
|
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Report } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { enqueueMail, type EnqueueInput, type EnqueueResult } from "@/server/mail/service";
|
||||||
|
import { normalizeLocale, type Locale } from "@/server/mail/templates";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { contentOf, requireVisibleReport } from "./common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L11 Kundenversand (Spec §17.3 „optional per E-Mail versendet", §36.2):
|
||||||
|
* the approved, immutable report PDF goes to the customer as e-mail attachment.
|
||||||
|
*
|
||||||
|
* - Right `report:approve` (customer communication is a backoffice task), report in scope.
|
||||||
|
* - Only `approved` reports with a PDF; the mail job carries a Document REFERENCE only.
|
||||||
|
* - Recipient: explicit `to` or the work order contact's e-mail, else the customer's e-mail.
|
||||||
|
* - Idempotent per (report version, recipient) via the MailLog dedupeKey → `duplicate`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CUSTOMER_MAIL_TEMPLATE = "craftvia_report_customer" as const;
|
||||||
|
|
||||||
|
const blankToUndefined = (v: unknown) => (typeof v === "string" && v.trim() === "" ? undefined : v);
|
||||||
|
|
||||||
|
export const sendReportToCustomerSchema = z.object({
|
||||||
|
reportId: z.string().min(1).max(64),
|
||||||
|
to: z.preprocess((v) => {
|
||||||
|
const b = blankToUndefined(v);
|
||||||
|
return typeof b === "string" ? b.trim().toLowerCase() : b;
|
||||||
|
}, z.email().max(254).optional()),
|
||||||
|
message: z.preprocess(blankToUndefined, z.string().trim().max(2000).optional()),
|
||||||
|
});
|
||||||
|
export type SendReportToCustomerInput = z.input<typeof sendReportToCustomerSchema>;
|
||||||
|
|
||||||
|
export type SendReportToCustomerResult = {
|
||||||
|
status: "sent" | "queued" | "duplicate" | "failed";
|
||||||
|
reportId: string;
|
||||||
|
to: string;
|
||||||
|
version: number;
|
||||||
|
mailLogId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SendReportDeps = {
|
||||||
|
enqueue: (input: EnqueueInput<typeof CUSTOMER_MAIL_TEMPLATE>) => Promise<EnqueueResult>;
|
||||||
|
};
|
||||||
|
export const defaultSendReportDeps: SendReportDeps = { enqueue: (input) => enqueueMail(input) };
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<Locale, Record<Report["type"], string>> = {
|
||||||
|
de: { daily: "Tagesbericht", completion: "Abschlussbericht" },
|
||||||
|
en: { daily: "daily report", completion: "completion report" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmail = (v: string | null | undefined): v is string => Boolean(v && z.email().max(254).safeParse(v.trim()).success);
|
||||||
|
|
||||||
|
export const customerMailDedupePrefix = (reportId: string) => `report-customer:${reportId}:`;
|
||||||
|
|
||||||
|
/** Default recipient: contact of the work order (not deleted) → customer. `null` when neither has a valid address. */
|
||||||
|
export async function defaultReportRecipient(ctx: ServiceCtx, report: Pick<Report, "workOrderId">): Promise<string | null> {
|
||||||
|
const wo = await ctx.db.workOrder.findFirst({
|
||||||
|
where: { id: report.workOrderId },
|
||||||
|
select: { contact: { select: { email: true, deletedAt: true } }, customer: { select: { email: true } } },
|
||||||
|
});
|
||||||
|
const contactEmail = wo?.contact && !wo.contact.deletedAt ? wo.contact.email : null;
|
||||||
|
if (isEmail(contactEmail)) return contactEmail.trim().toLowerCase();
|
||||||
|
if (isEmail(wo?.customer?.email)) return wo.customer.email.trim().toLowerCase();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendReportToCustomer(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
raw: SendReportToCustomerInput,
|
||||||
|
deps: SendReportDeps = defaultSendReportDeps,
|
||||||
|
): Promise<SendReportToCustomerResult> {
|
||||||
|
const input = sendReportToCustomerSchema.parse(raw);
|
||||||
|
assertCan(ctx, "report:approve");
|
||||||
|
const report = await requireVisibleReport(ctx, input.reportId);
|
||||||
|
|
||||||
|
if (report.status !== "approved") throw new ServiceError("blocked", "report_not_approved", { reason: "report_not_approved" });
|
||||||
|
const pdf = report.pdfDocumentId
|
||||||
|
? await ctx.db.document.findFirst({ where: { id: report.pdfDocumentId, deletedAt: null }, select: { id: true } })
|
||||||
|
: null;
|
||||||
|
if (!pdf) throw new ServiceError("blocked", "pdf_missing", { reason: "pdf_missing" });
|
||||||
|
|
||||||
|
const to = input.to ?? (await defaultReportRecipient(ctx, report));
|
||||||
|
if (!to) throw new ServiceError("invalid", "recipient_missing", { field: "to", reason: "recipient_missing" });
|
||||||
|
|
||||||
|
const content = contentOf(report);
|
||||||
|
const settings = await ctx.db.tenantSettings.findFirst({ select: { locale: true } });
|
||||||
|
const locale = normalizeLocale(settings?.locale);
|
||||||
|
const reportTitle =
|
||||||
|
locale === "en"
|
||||||
|
? `${TYPE_LABEL.en[report.type]} ${content.reportNumber} for work order ${content.workOrder.number}`
|
||||||
|
: `${TYPE_LABEL.de[report.type]} ${content.reportNumber} zum Auftrag ${content.workOrder.number}`;
|
||||||
|
// reportDate is a DATE column (UTC midnight) → format in UTC like the report page does
|
||||||
|
const reportDate = new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { dateStyle: "medium", timeZone: "UTC" }).format(report.reportDate);
|
||||||
|
|
||||||
|
const result = await deps.enqueue({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
template: CUSTOMER_MAIL_TEMPLATE,
|
||||||
|
to,
|
||||||
|
locale,
|
||||||
|
vars: { customerName: content.customer.name, tenantName: content.tenant.name, reportTitle, reportDate, ...(input.message ? { message: input.message } : {}) },
|
||||||
|
attachments: [{ documentId: pdf.id }],
|
||||||
|
dedupeKey: `${customerMailDedupePrefix(report.id)}${to}:${report.version}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === "duplicate") return { status: "duplicate", reportId: report.id, to, version: report.version };
|
||||||
|
|
||||||
|
const status: SendReportToCustomerResult["status"] = result.status === "sent" || result.status === "queued" ? result.status : "failed";
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "export",
|
||||||
|
entity: "report",
|
||||||
|
entityId: report.id,
|
||||||
|
after: { op: "send_to_customer", to, version: report.version, mailLogId: result.mailLogId, delivery: status, withMessage: Boolean(input.message) },
|
||||||
|
});
|
||||||
|
return { status, reportId: report.id, to, version: report.version, mailLogId: result.mailLogId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomerMailing = { id: string; to: string; status: string; version: number | null; createdAt: Date; sentAt: Date | null };
|
||||||
|
|
||||||
|
/** Previous customer mailings of this report version (MailLog, template craftvia_report_customer). */
|
||||||
|
export async function listCustomerMailings(ctx: ServiceCtx, reportId: string): Promise<CustomerMailing[]> {
|
||||||
|
const report = await requireVisibleReport(ctx, reportId);
|
||||||
|
const prefix = customerMailDedupePrefix(report.id);
|
||||||
|
const rows = await ctx.db.mailLog.findMany({
|
||||||
|
where: { tenantId: ctx.tenantId, template: CUSTOMER_MAIL_TEMPLATE, dedupeKey: { startsWith: prefix } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
select: { id: true, to: true, status: true, dedupeKey: true, createdAt: true, sentAt: true },
|
||||||
|
});
|
||||||
|
return rows.map((r) => {
|
||||||
|
const v = Number((r.dedupeKey ?? "").split(":").pop());
|
||||||
|
return { id: r.id, to: r.to, status: r.status, version: Number.isInteger(v) ? v : null, createdAt: r.createdAt, sentAt: r.sentAt };
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user