L11 Kundenversand: Bericht-PDF an Kunden senden (Service, Action, API, Berichtsseite)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:48:57 +02:00
co-authored by Claude Opus 5
parent e0106b8f8e
commit 29c80a0e73
9 changed files with 413 additions and 0 deletions
+31
View File
@@ -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",
+31
View File
@@ -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",
+15
View File
@@ -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
<div className="mb-4 max-w-3xl">
<LotseReportPanel reportId={id} />
</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]">
<ReportView content={content} reportId={id} timeZone={timeZone} />
+15
View File
@@ -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" };
+125
View File
@@ -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>
);
}
+27
View File
@@ -1015,6 +1015,33 @@ const paths: Record<string, Record<string, Schema>> = {
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": {
post: op({
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 };
}
}
@@ -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 };
});
}