From e0106b8f8efd16e810f317db05d2a785b9465c02 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:48:45 +0200 Subject: [PATCH 1/3] =?UTF-8?q?L11=20Kundenversand:=20Mail-Anh=C3=A4nge=20?= =?UTF-8?q?per=20Dokument-Referenz=20und=20Template=20craftvia=5Freport=5F?= =?UTF-8?q?customer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MailJob/EnqueueInput: attachments als { documentId } (keine Bytes in Redis, nur mit tenantId) - deliverMail: Anhänge mandantengebunden aus MailLog.tenantId laden, SHA-256 prüfen, Größenlimit MAIL_MAX_ATTACHMENT_BYTES (Default 10 MB); Fehler -> failed ohne Versand - SMTP-Provider reicht Anhänge an nodemailer durch; optionaler Provider für Tests - Template craftvia_report_customer (de/en) ohne App-Link, eigene CUSTOMER_TEMPLATE_KEYS Co-Authored-By: Claude Opus 5 --- src/server/mail/deliver.ts | 88 ++++++++++++++++++++++++++++++-- src/server/mail/job.ts | 8 +++ src/server/mail/provider-smtp.ts | 3 ++ src/server/mail/provider.ts | 4 ++ src/server/mail/service.ts | 12 ++++- src/server/mail/templates.ts | 48 +++++++++++++++++ 6 files changed, 157 insertions(+), 6 deletions(-) diff --git a/src/server/mail/deliver.ts b/src/server/mail/deliver.ts index 77241e7..46e5d3a 100644 --- a/src/server/mail/deliver.ts +++ b/src/server/mail/deliver.ts @@ -1,9 +1,10 @@ +import { createHash } from "node:crypto"; import { prisma } from "@/server/db"; import { getMailConfig, mailFrom, type MailConfig } from "./config"; import { getMailProvider } from "./provider-smtp"; -import { TransientMailError } from "./provider"; +import { TransientMailError, type MailProvider, type OutgoingAttachment } from "./provider"; import { renderTemplate } from "./templates"; -import type { MailJob } from "./job"; +import type { MailAttachmentRef, MailJob } from "./job"; /** * 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. * 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 provider = getMailProvider(); + const provider = options.provider ?? getMailProvider(); if (!config || !provider) { // Kein stiller Fehlversand: die Zeile bleibt `pending`, der Grund steht dran. await prisma.mailLog.update({ @@ -45,6 +68,8 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }> const sender = await tenantSender(job.mailLogId, config); 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({ from: sender.from, to: job.to, @@ -54,6 +79,7 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }> text: rendered.text, // Auto-Antworten und Abwesenheitsnotizen unterdrücken (RFC 3834). headers: { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All" }, + ...(attachments ? { attachments } : {}), }); 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 { + 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). */ export async function markMailFailed(mailLogId: string, error: string): Promise { await prisma.mailLog.update({ diff --git a/src/server/mail/job.ts b/src/server/mail/job.ts index 0d59bb3..c6ac539 100644 --- a/src/server/mail/job.ts +++ b/src/server/mail/job.ts @@ -18,9 +18,17 @@ export type MailJob = { to: string; locale: Locale; 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]; +/** L11 — Verweis auf ein gespeichertes Dokument des Mandanten der Mail. */ +export type MailAttachmentRef = { documentId: string }; + export const MAIL_QUEUE = "mail"; export const MAIL_DLQ = "mail-dead-letter"; /** diff --git a/src/server/mail/provider-smtp.ts b/src/server/mail/provider-smtp.ts index 64862a1..e6180b1 100644 --- a/src/server/mail/provider-smtp.ts +++ b/src/server/mail/provider-smtp.ts @@ -66,6 +66,9 @@ export class SmtpMailProvider implements MailProvider { html: msg.html, text: msg.text, headers: msg.headers, + ...(msg.attachments?.length + ? { attachments: msg.attachments.map((a) => ({ filename: a.filename, content: a.content, contentType: a.contentType })) } + : {}), }); return { messageId: info.messageId }; } catch (err) { diff --git a/src/server/mail/provider.ts b/src/server/mail/provider.ts index d8a0478..cf0be00 100644 --- a/src/server/mail/provider.ts +++ b/src/server/mail/provider.ts @@ -14,8 +14,12 @@ export type OutgoingMail = { html: string; text: string; headers?: Record; + /** 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 interface MailProvider { diff --git a/src/server/mail/service.ts b/src/server/mail/service.ts index 676264b..16ea827 100644 --- a/src/server/mail/service.ts +++ b/src/server/mail/service.ts @@ -3,7 +3,7 @@ import { prisma } from "@/server/db"; import { deliverMail, MailNotConfiguredError } from "./deliver"; import { getMailQueue, isQueueEnabled, isQueueReady } from "./queue"; 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(...)`. @@ -34,6 +34,11 @@ export type EnqueueInput = { * erzeugt eine eigene Mail), für Benachrichtigungen gesetzt. */ 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 = @@ -48,6 +53,9 @@ export async function enqueueMail( ): Promise { const locale: Locale = normalizeLocale(input.locale); 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; try { @@ -72,7 +80,7 @@ export async function enqueueMail( 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 // einem Redis-Ausfall fällt der Versand auf den Inline-Pfad zurück, statt die diff --git a/src/server/mail/templates.ts b/src/server/mail/templates.ts index 1a962ad..28f7383 100644 --- a/src/server/mail/templates.ts +++ b/src/server/mail/templates.ts @@ -62,6 +62,10 @@ export type TemplateVars = { craftvia_notification: { 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. */ @@ -90,6 +94,9 @@ export const CRAFTVIA_TEMPLATE_KEYS = [ "craftvia_notification", ] 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. */ const FOOTER_NOTE: Record = { 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 } = { }), }; +// ---- 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 } = { + 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 } = { + 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 } = { invitation: (v) => ({ subject: `Ihr Zugang zu ${BRAND.name}`, @@ -338,6 +384,7 @@ const de: { [K in TemplateKey]: Builder } = { ], }), ...craftviaDe, + ...customerDe, }; const en: { [K in TemplateKey]: Builder } = { @@ -417,6 +464,7 @@ const en: { [K in TemplateKey]: Builder } = { ], }), ...craftviaEn, + ...customerEn, }; const CATALOG: Record }> = { de, en }; From 29c80a0e7359b1af3b63f8b2bb3f4414976d62d1 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:48:57 +0200 Subject: [PATCH 2/3] L11 Kundenversand: Bericht-PDF an Kunden senden (Service, Action, API, Berichtsseite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendReportToCustomer: report:approve, Scope, nur approved mit PDF, Empfänger Ansprechpartner -> Kunde, Dedupe je Adresse+Version, Audit export - Server Action + POST /api/v1/reports/{id}/send inkl. OpenAPI-Eintrag - /reports/[id]: Abschnitt „An Kunden senden" mit bisherigen Versänden, Texte de/en Co-Authored-By: Claude Opus 5 --- messages/de/reports.json | 31 ++++ messages/en/reports.json | 31 ++++ src/app/(app)/reports/[id]/page.tsx | 15 ++ src/app/api/v1/reports/[id]/send/route.ts | 15 ++ .../reports/send-to-customer-state.ts | 9 ++ src/components/reports/send-to-customer.tsx | 125 ++++++++++++++++ src/lib/api/openapi.ts | 27 ++++ .../actions/reports/send-to-customer.ts | 25 ++++ .../services/reports/send-to-customer.ts | 135 ++++++++++++++++++ 9 files changed, 413 insertions(+) create mode 100644 src/app/api/v1/reports/[id]/send/route.ts create mode 100644 src/components/reports/send-to-customer-state.ts create mode 100644 src/components/reports/send-to-customer.tsx create mode 100644 src/server/actions/reports/send-to-customer.ts create mode 100644 src/server/services/reports/send-to-customer.ts diff --git a/messages/de/reports.json b/messages/de/reports.json index c97594c..1a7da9f 100644 --- a/messages/de/reports.json +++ b/messages/de/reports.json @@ -226,6 +226,37 @@ "alreadySigned": "Unterschrift liegt vor.", "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": { "page": "Seite {page} von {pages}", "reportId": "Bericht-ID", diff --git a/messages/en/reports.json b/messages/en/reports.json index 4dbc881..fef17bf 100644 --- a/messages/en/reports.json +++ b/messages/en/reports.json @@ -226,6 +226,37 @@ "alreadySigned": "Signature captured.", "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": { "page": "Page {page} of {pages}", "reportId": "Report ID", diff --git a/src/app/(app)/reports/[id]/page.tsx b/src/app/(app)/reports/[id]/page.tsx index 53268ce..afc4488 100644 --- a/src/app/(app)/reports/[id]/page.tsx +++ b/src/app/(app)/reports/[id]/page.tsx @@ -14,6 +14,8 @@ import { ServiceError } from "@/server/services/context"; import { tenantTimeZone } from "@/server/services/reports/build-content"; import { getReportDetail } from "@/server/services/reports/queries"; 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). */ 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 dt = (d: Date | null) => (d ? format.dateTime(d, { dateStyle: "medium", timeStyle: "short", timeZone }) : null); 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]> = [ [t("field.workOrder"), `${workOrder.number}`], @@ -87,6 +97,11 @@ export default async function ReportDetailPage({ params, searchParams }: { param
+ {customerMail && ( +
+ +
+ )}
diff --git a/src/app/api/v1/reports/[id]/send/route.ts b/src/app/api/v1/reports/[id]/send/route.ts new file mode 100644 index 0000000..73a36d0 --- /dev/null +++ b/src/app/api/v1/reports/[id]/send/route.ts @@ -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 }); +}); diff --git a/src/components/reports/send-to-customer-state.ts b/src/components/reports/send-to-customer-state.ts new file mode 100644 index 0000000..271def1 --- /dev/null +++ b/src/components/reports/send-to-customer-state.ts @@ -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" }; diff --git a/src/components/reports/send-to-customer.tsx b/src/components/reports/send-to-customer.tsx new file mode 100644 index 0000000..9223bd8 --- /dev/null +++ b/src/components/reports/send-to-customer.tsx @@ -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 = ( +

+ {bad ? : hint ? : } + {t(`result.${state.result}`, { to: state.to })} +

+ ); + } 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 = ( +

+ + {key ? t(key) : tr(`errors.${state.code}`)} +

+ ); + } + + return ( +
+

+ {t("title")} +

+

{t("sub")}

+ +
+ +
+ + +

+ {defaultTo ? t("toHint") : t("noDefault")} +

+
+
+ +