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:
@@ -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