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:
@@ -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} />
|
||||
|
||||
@@ -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") },
|
||||
}),
|
||||
},
|
||||
"/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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user