WFDASH-WEBAPP/src/lib/server/purchase-orders-service.ts
Dave 7555196166 feat(audit): implement audit trail for delivery notes and orders
- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field.
- Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability.
- Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications.
- Enhanced the `package.json` with a new migration script for setting up the audit log functionality.

This feature improves accountability and transparency in the management of delivery notes and orders.
2026-09-30 01:48:44 +02:00

824 lines
No EOL
30 KiB
TypeScript

import { getAdminPocketBase } from "./pocketbase-admin";
import { compressBuffer } from "@/lib/server/attachment-compressor";
import { buildDefaultPoEmailBody } from "./po-email-template";
import { fetchLetterhead, loadLetterheadLogoDataUri } from "@/lib/letterhead";
import { renderPurchaseOrderPdf } from "@/lib/pdf/render";
import type {
Order,
OrderItem,
PurchaseOrder,
PurchaseOrderDocumentEntry,
Supplier,
} from "@/lib/types";
/**
* Server-side Purchase Order pipeline used by the API route and the n8n
* workflow that does the actual SMTP send.
*
* Mirrors the pattern established by `quotes-service.ts` — pre-send
* validation + status flip happens here synchronously; the SMTP send
* itself runs in n8n, fired either by:
* - the operator clicking Send PO (this route), or
* - the scheduled cron (n8n polls `purchase_orders` where status="draft"
* AND created < now-15min, fires the same flow).
*
* Status lifecycle: draft → sending → sent
* └→ mail_failed (retryable)
* └→ cancelled (terminal)
*/
const PO_COLLECTION = "purchase_orders";
const SUPPLIERS_COLLECTION = "suppliers";
const ORDER_ITEMS_COLLECTION = "order_items";
/**
* Statuses the operator is allowed to send from.
*
* `sent` is intentionally included: it lets the operator resend a PO
* after a successful send (wrong address, didn't reach the supplier,
* supplier asked for it again). The route and modal label the action
* "Resend" for both `sent` and `mail_failed` so the operator doesn't
* have to wonder which state the PO is in.
*
* `cancelled` and `sending` are NOT sendable:
* - `cancelled` is terminal — the operator actively decided to
* kill this PO. Letting them send from there would silently undo
* that decision. If they change their mind they have to flip
* status manually.
* - `sending` means an n8n run is in flight; double-clicking Send
* while one is in flight would race the operator's first send
* to completion (and potentially send the same email twice).
* The UI shows a "Sending…" disabled button in that state.
*/
const SENDABLE_STATUSES = new Set(["draft", "mail_failed", "sent"]);
/** Pretty loose email check — SMTP will reject truly malformed addresses. */
function looksLikeEmail(s: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
}
export interface SendPurchaseOrderEmailPayload {
/** Optional override for the primary To: address. Defaults to supplier.email. */
to?: string;
cc: string;
bcc: string;
subject: string;
message: string;
}
export interface SendPurchaseOrderEmailFailure {
code:
| "no_recipient"
| "invalid_recipient"
| "no_pdf"
| "no_line_items"
| "invalid_status"
| "purchase_order_not_found"
| "supplier_not_found"
| "supplier_no_email";
message: string;
}
export interface SendPurchaseOrderEmailResult {
purchaseOrder: PurchaseOrder;
/** Null on success; populated when pre-send validation rejected the send. */
failure: SendPurchaseOrderEmailFailure | null;
/**
* True when the operator typed an email that didn't match the
* supplier's stored email and we updated supplier.email to match.
* Front-end uses this to surface a "Updated the supplier email to
* match" confirmation in the toast.
*/
supplierEmailUpdated?: boolean;
}
/**
* Pre-send validation + status flip for a Purchase Order outbound email.
*
* The flow mirrors `sendQuoteEmail` exactly:
* 1. Load the PO + supplier (separately, getOne doesn't expand).
* 2. Validate status (must be draft or mail_failed).
* 3. Validate supplier email is real (not empty, looks like an email).
* 4. Validate operator's CC/BCC entries.
* 5. Validate PO has at least one line item linked — without lines the
* email body would be empty and the supplier would have no idea
* what to deliver.
* 6. Flip status to `sending`, persist composition, clear previous
* error. The actual SMTP send runs in n8n.
*
* IMPORTANT: we do NOT generate the RFC 5322 Message-ID here. n8n
* generates it at SMTP time and writes it back on success/failure via
* `recordPurchaseOrderSendOutcome`. This keeps the Message-ID
* guarantee (unique per message) in the sender's hands.
*/
export async function sendPurchaseOrderEmail(
poId: string,
payload: SendPurchaseOrderEmailPayload
): Promise<SendPurchaseOrderEmailResult> {
const pb = await getAdminPocketBase();
// ── 1. Load PO + supplier ─────────────────────────────────────────
let po: PurchaseOrder;
try {
po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(poId);
} catch {
return {
purchaseOrder: null as unknown as PurchaseOrder,
failure: {
code: "purchase_order_not_found",
message: "Purchase Order does not exist.",
},
};
}
let supplier: Supplier | null = null;
if (po.supplier) {
try {
supplier = (await pb
.collection(SUPPLIERS_COLLECTION)
.getOne(po.supplier)) as Supplier;
} catch {
return {
purchaseOrder: po,
failure: {
code: "supplier_not_found",
message: "Supplier record not found for this PO.",
},
};
}
}
// ── 2. Validate status ────────────────────────────────────────────
if (!SENDABLE_STATUSES.has(po.status)) {
return markPreSendFailure(pb, po, {
code: "invalid_status",
message: `Cannot send from status "${po.status}". Allowed: ${[...SENDABLE_STATUSES].join(", ")}.`,
});
}
// ── 3. Validate supplier email ────────────────────────────────────
if (!supplier) {
return markPreSendFailure(pb, po, {
code: "supplier_not_found",
message: "This PO has no supplier attached.",
});
}
const primary = (payload.to || supplier.email || "").trim();
if (!looksLikeEmail(primary)) {
return markPreSendFailure(pb, po, {
code: "supplier_no_email",
message:
"No valid recipient email on this PO (supplier.email is empty and no override was provided).",
});
}
// ── 4. Validate CC / BCC entries ──────────────────────────────────
for (const list of [payload.cc, payload.bcc]) {
for (const raw of (list || "").split(",")) {
const v = raw.trim();
if (v && !looksLikeEmail(v)) {
return markPreSendFailure(pb, po, {
code: "invalid_recipient",
message: `"${v}" doesn't look like an email address.`,
});
}
}
}
// ── 5. Validate PO has at least one linked line item ──────────────
const linkedItems = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getList(1, 1, {
filter: `purchase_order = "${poId}"`,
});
if (linkedItems.totalItems === 0) {
return markPreSendFailure(pb, po, {
code: "no_line_items",
message:
"This PO has no line items attached. Re-link items on the order detail page before sending.",
});
}
// ── 5b. Validate the PO has a generated PDF attached. ─────────────
// The route fires the n8n webhook which downloads the PO PDF
// from PB by filename. If neither `documents` nor
// `documents_history` resolves to a usable filename, the
// download 404s and the workflow surfaces to the operator as
// "Webhook returned HTTP 500". Reject the send here instead
// — clearer error, no wasted webhook round-trip.
//
// The fallback chain matches `send-email/route.ts`'s
// `latestFilename` computation so the service and the route
// agree on what counts as "the latest PDF". Mirrors the
// service-layer → route-layer duplication pattern already
// used in stock-purchases + dispatch.
//
// `documents_history` can contain legacy `[null]` entries
// (PO was created before the array was filled in); filter
// those out before calling `.find()` / accessing `.file` —
// otherwise the whole send throws on a malformed row.
const documentsField = po.documents;
const documentsList: string[] = Array.isArray(documentsField)
? documentsField
: typeof documentsField === "string" && documentsField
? [documentsField]
: [];
const documentsHistory = (po.documents_history ?? []).filter(
(h): h is NonNullable<typeof h> => h != null,
);
const latestFilename =
documentsList[documentsList.length - 1] ??
(documentsHistory.find((h) => h.status === "current") ??
documentsHistory[documentsHistory.length - 1])?.file ??
"";
if (!latestFilename) {
return markPreSendFailure(pb, po, {
code: "no_pdf",
message:
"This PO has no generated PDF attached. Open the order detail page and regenerate the document before sending.",
});
}
// ── 5b. Generic body default ───────────────────────────────
// When the operator leaves the message blank, substitute a
// professional supplier-personalised template. SMTP relays
// commonly strip attachments from multipart/alternative bodies
// that have no body section (RFC 2046 violation), so an empty
// body is also the direct cause of "no PDF in inbox" reports.
//
// IMPORTANT: the default body deliberately omits ANY
// end-customer context (company name, customer rep, customer's
// PO reference). If the supplier knows who the order is for,
// they'll start going direct — that's the entire margin model.
// Operators who need to add specifics can still type their
// own message in the modal; we only fill the blank.
//
// We pass a minimal context: PO number, supplier name, and
// the line count (useful for the supplier's receiving check).
// ── 6. Persist composition + flip status to `sending`.
// We DO flip status synchronously so the operator's button hides
// immediately (no "click 50 times" bug) AND so the cron pass can
// distinguish "manually sent + awaiting n8n" from "still draft".
// The webhook pickup is still immediate (fired by the route right
// after this returns); if it misses, the cron pass picks up any
// `status="sending"` PO older than 15min and re-fires.
//
// `email_payload.to` stores the operator's override. Empty
// string = "fall back to supplier.email" (the actual send-time
// resolution happens in n8n, which reads `email_payload.to` or
// falls through to `expand.supplier.email`).
const lineCount = linkedItems.totalItems;
const defaultBody = buildDefaultPoEmailBody({
poNumber: po.po_number || po.id,
supplierName: supplier.suppliername || "",
lineCount,
});
const operatorMessage = (payload.message || "").trim();
// When the operator types their own message, that's what the supplier
// sees — both HTML and plain-text forms are produced by wrapping the
// operator's text in a minimal paragraph so it renders cleanly either
// way. When the operator leaves it blank, fall through to the default
// builder's HTML/plain pair.
const operatorHtml = operatorMessage
? `<p style="margin:0 0 16px 0;font-size:14px;color:#111111;line-height:1.55;">${operatorMessage
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>")}</p>`
: "";
const body = operatorMessage
? { text: operatorMessage, html: operatorHtml }
: defaultBody;
const payload_json = {
to: (payload.to || "").trim(),
subject: payload.subject || `Purchase Order ${po.po_number || po.id}`,
body: body.html,
bodyText: body.text,
cc: payload.cc || "",
bcc: payload.bcc || "",
sentMessageId: po.email_payload?.sentMessageId,
sentAt: po.email_payload?.sentAt,
supplierReplyMessageId: po.email_payload?.supplierReplyMessageId,
supplierReplyPreview: po.email_payload?.supplierReplyPreview,
};
const updated = (await pb.collection(PO_COLLECTION).update(poId, {
status: "sending",
email_error: "",
email_payload: payload_json,
})) as PurchaseOrder;
// ── 7. Supplier email auto-update.
// If the operator typed a NEW email address that doesn't match
// supplier.email, persist it to the supplier record so the next
// PO to this supplier prefills correctly. Only writes when:
// - the override is non-empty (empty = "use supplier default")
// - the override is a valid email
// - the override differs from the supplier's current email
// We never blank out a supplier.email — empty overrides are
// honoured at send-time but never overwrite the source of truth.
let supplierEmailUpdated = false;
const override = (payload.to || "").trim();
if (
override &&
looksLikeEmail(override) &&
override !== (supplier.email || "").trim()
) {
try {
await pb.collection(SUPPLIERS_COLLECTION).update(supplier.id, {
email: override,
});
supplierEmailUpdated = true;
} catch (supErr) {
// Non-fatal — the send itself succeeds. The supplier.email update
// is a convenience for future POs; if it fails (PB permission,
// concurrent edit), we just log and move on.
console.warn(
`[po-send-email] failed to persist supplier.email update for ${supplier.id}:`,
supErr
);
}
}
return { purchaseOrder: updated, failure: null, supplierEmailUpdated };
}
/**
* Retry an outbound send after a `mail_failed`. Same as
* `sendPurchaseOrderEmail` but reuses the previously persisted
* composition (`email_payload`) without re-reading the modal.
*/
export async function retryPurchaseOrderEmail(
poId: string
): Promise<SendPurchaseOrderEmailResult> {
const pb = await getAdminPocketBase();
let po: PurchaseOrder;
try {
po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(poId);
} catch {
return {
purchaseOrder: null as unknown as PurchaseOrder,
failure: {
code: "purchase_order_not_found",
message: "Purchase Order does not exist.",
},
};
}
if (po.status !== "mail_failed") {
return markPreSendFailure(pb, po, {
code: "invalid_status",
message: `Cannot retry from status "${po.status}". Only mail_failed POs can be retried.`,
});
}
// Retry is fire-and-forget: clear the previous error, persist a
// "retry requested" hint into the payload, return the PO. n8n's
// webhook handler does the actual state machine (status flip +
// SMTP send + outcome write-back).
const updated = (await pb.collection(PO_COLLECTION).update(poId, {
email_error: "",
})) as PurchaseOrder;
return { purchaseOrder: updated, failure: null };
}
/**
* Synchronously flip a PO to `mail_failed` + write the failure reason.
* Used when pre-send validation rejects the send — we never reach n8n
* on this path, so the failure must be recorded here.
*/
async function markPreSendFailure(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
po: PurchaseOrder,
failure: SendPurchaseOrderEmailFailure
): Promise<SendPurchaseOrderEmailResult> {
const updated = (await pb.collection(PO_COLLECTION).update(po.id, {
status: "mail_failed",
email_error: `[pre-send] ${failure.message}`,
})) as PurchaseOrder;
return { purchaseOrder: updated, failure };
}
/**
* Records the *outcome* of an SMTP send attempt. Called by the n8n
* `Purchase Order Outbound Send` workflow (or any other worker that's
* been handed the send).
*
* `outcome = "success"` flips status to `sent` + stamps delivery fields.
* It deliberately does NOT touch linked `order_items.item_status` — the
* operator marks items ready-to-collect manually from the order page /
* stock-orders page after the supplier confirms, which then drops them
* off the buyer-tasks PO Sent tab. Auto-flipping on send would hide the
* item before the supplier has even confirmed.
*
* `outcome = "failure"` flips to `mail_failed` + stamps error.
*
* This is intentionally separate from `sendPurchaseOrderEmail` so the
* n8n path doesn't have to replay validation — by the time n8n runs,
* pre-send checks already passed.
*/
export interface RecordPoSendOutcomeInput {
poId: string;
outcome: "success" | "failure";
/** SMTP-issued Message-ID. Stored on success AND failure. */
messageId?: string;
/** Failure reason (SMTP response text). Required when outcome = "failure". */
errorMessage?: string;
}
export async function recordPurchaseOrderSendOutcome(
input: RecordPoSendOutcomeInput
): Promise<PurchaseOrder> {
const pb = await getAdminPocketBase();
if (input.outcome === "success") {
const sentAt = new Date().toISOString();
const po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(input.poId);
const payload = {
...(po.email_payload || {}),
sentAt,
sentMessageId: input.messageId || po.email_payload?.sentMessageId,
};
const patch: Record<string, unknown> = {
status: "sent",
email_error: "",
email_payload: payload,
};
if (input.messageId) patch.email_message_id = input.messageId;
const updated = (await pb.collection(PO_COLLECTION).update(
input.poId,
patch
)) as PurchaseOrder;
// Read the linked order_items to keep the back-link on the PO in
// sync. `order_items.purchase_order` is the canonical direction;
// `purchase_orders.order_item` is the mirror that surfaces in the
// PB admin UI and lets any future PO-side render read the items
// without an extra query.
//
// We deliberately do NOT touch `item_status` here. Previously this
// function auto-flipped linked items from `po_generated` to
// `collection_pool` on send, which dropped them off buyer-tasks
// immediately — before the supplier had even confirmed. The
// operator now marks items ready-to-collect from the order page /
// stock-orders page after the supplier commits, and only that
// explicit flip removes them from the buyer-tasks PO Sent tab.
//
// The filter intentionally doesn't restrict by `item_status` —
// we want every item linked to this PO regardless of where it is
// in the lifecycle, so the back-link array stays accurate.
const linked = await pb.collection(ORDER_ITEMS_COLLECTION).getFullList({
filter: `purchase_order = "${input.poId}"`,
fields: "id",
});
await pb.collection(PO_COLLECTION).update(input.poId, {
order_item: linked.map((it) => it.id),
});
return updated;
}
// outcome = "failure"
const reason = (input.errorMessage || "Unknown SMTP failure").slice(0, 990);
const patch: Record<string, unknown> = {
status: "mail_failed",
email_error: `[smtp] ${reason}`,
};
if (input.messageId) patch.email_message_id = input.messageId;
return (await pb.collection(PO_COLLECTION).update(input.poId, patch)) as PurchaseOrder;
}
/**
* Scan for POs in `status="draft"` older than `olderThanMs`. Used by
* the n8n cron workflow (`WFDASH — Purchase Order Send (Cron).js`)
* which fires every 15 minutes and dispatches each candidate.
*
* The cron doesn't read from PocketBase directly — it asks the
* dashboard to do the scan, so the only credentials it needs are
* AGENT_API_KEY (Bearer) + DASHBOARD_URL. This sidesteps the
* `N8N_BLOCK_ENV_ACCESS_IN_NODE=true` problem in our docker-compose
* (Code nodes can't read $env.* at runtime).
*
* Returns the list (possibly empty) plus a `now` timestamp the
* caller can echo back in logs.
*/
export interface StalePurchaseOrder {
id: string;
poNumber: string;
supplierName: string;
supplierEmail: string;
ageMinutes: number;
}
export interface FindStalePurchaseOrdersResult {
items: StalePurchaseOrder[];
scanned: number;
now: string;
olderThanMs: number;
}
export async function findStalePurchaseOrders(
olderThanMs: number = 15 * 60 * 1000,
limit: number = 50,
): Promise<FindStalePurchaseOrdersResult> {
const pb = await getAdminPocketBase();
const now = new Date();
const cutoff = new Date(now.getTime() - olderThanMs).toISOString();
const list = await pb.collection(PO_COLLECTION).getList<{
id: string;
po_number: string;
supplier: string;
expand?: { supplier?: { suppliername?: string; email?: string } };
created: string;
}>(1, limit, {
filter: `status="draft" && created<"${cutoff}"`,
sort: "created",
fields: "id,po_number,supplier,created",
expand: "supplier",
});
const items: StalePurchaseOrder[] = list.items.map((p) => {
const ageMs = now.getTime() - new Date(p.created).getTime();
return {
id: p.id,
poNumber: p.po_number || "",
supplierName: p.expand?.supplier?.suppliername || "",
supplierEmail: p.expand?.supplier?.email || "",
ageMinutes: Math.round(ageMs / 60000),
};
});
return {
items,
scanned: list.totalItems,
now: now.toISOString(),
olderThanMs,
};
}
/* ============================================================================
* PDF generation
* ========================================================================= */
/** Validation error from generatePurchaseOrderPdf — caller maps to HTTP 422. */
export class PurchaseOrderPdfValidationError extends Error {
constructor(
message: string,
public readonly code:
| "missing_relations"
| "no_items"
| "no_letterhead"
| "purchase_order_not_found",
public readonly details?: {
poId?: string;
orderId?: string;
supplierId?: string;
},
) {
super(message);
this.name = "PurchaseOrderPdfValidationError";
}
}
export interface GeneratePurchaseOrderPdfInput {
/** Free-text reason written to `documents_history.reason`. v1 ignores it. */
notes?: string;
/** Operator id/name for the audit trail. Optional. */
generatedBy?: string;
}
export interface GeneratePurchaseOrderPdfResult {
pdfId: string;
version: number;
filename: string;
downloadUrl: string;
total: number;
generatedAt: string;
}
/**
* Server-side render of a Purchase Order PDF and append it to
* `purchase_orders.documents` (file) + `documents_history` (json).
*
* Shared by the explicit `/api/v1/purchase-orders/[id]/pdf` route
* (operator clicks "Regenerate PDF") and the buyer-tasks auto-PDF
* path (`generatePoForGroup` calls this right after creating the
* draft PO record so the operator's "Generate PO" click produces a
* file ready to attach on Send).
*
* What it does NOT do: flip the PO to `sent`. The PDF lifecycle is
* decoupled from the email lifecycle — this used to flip to "sent"
* as a legacy shortcut, but caused the send-inbox/outbox state to
* drift when operators regenerated a PDF without intending to send.
* The PO stays in `draft` until the operator explicitly hits Send
* (or the cron fires).
*
* Status transitions:
* - v1 generation from a brand-new PO: status left alone (already
* `draft` per the process page).
* - v≥2 (regenerate): if the PO was previously `sent` or
* `mail_failed`, flip status back to `draft` and clear
* `email_error`. This is what the operator wants when they
* regenerate after a bad send — they want to re-send. The
* previous send's `email_message_id` + `email_payload.sentAt`
* are preserved as the audit trail.
* - `cancelled` is intentionally NOT touched — an explicit cancel
* is an operator decision ("this PO is dead"); resurrecting it
* on regenerate would silently undo that. The operator must
* flip status manually if they want to re-send after a cancel.
*
* Throws `PurchaseOrderPdfValidationError` for user-actionable
* preconditions (missing relations, no items, missing letterhead)
* so the caller can map them to HTTP 422. Other errors propagate.
*/
export async function generatePurchaseOrderPdf(
poId: string,
input: GeneratePurchaseOrderPdfInput = {},
): Promise<GeneratePurchaseOrderPdfResult> {
const pb = await getAdminPocketBase();
// 1. PO + order + supplier (all expanded server-side)
let po: PurchaseOrder;
try {
po = await pb.collection(PO_COLLECTION).getOne<PurchaseOrder>(poId, {
expand: "order,supplier",
});
} catch {
throw new PurchaseOrderPdfValidationError(
"Purchase order does not exist.",
"purchase_order_not_found",
{ poId },
);
}
const order = po.expand?.order as Order | undefined;
const supplier = po.expand?.supplier as Supplier | undefined;
if (!order || !supplier) {
throw new PurchaseOrderPdfValidationError(
"Purchase order is missing the order or supplier relation. Cannot render PDF.",
"missing_relations",
{
poId: po.id,
orderId: po.order || undefined,
supplierId: po.supplier || undefined,
},
);
}
// 2. Items linked to this PO
const items = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<OrderItem>({
filter: `purchase_order = "${poId}"`,
sort: "created",
});
if (items.length === 0) {
throw new PurchaseOrderPdfValidationError(
"This PO has no line items attached. Re-create the PO from the process page " +
"before generating a PDF.",
"no_items",
{ poId: po.id },
);
}
// 3. Letterhead — always CNL
let letterhead;
try {
letterhead = await fetchLetterhead("CNL");
} catch (err) {
throw new PurchaseOrderPdfValidationError(
err instanceof Error
? err.message
: "CNL letterhead not found in letterhead_settings.",
"no_letterhead",
{ poId: po.id, orderId: order.id },
);
}
// 4. Logo
const logoDataUri = await loadLetterheadLogoDataUri("CNL");
// 5. Compute totals (locked snapshot for storage). Uses the
// supplier-facing `order_qty` so shorted lines don't accidentally
// inflate the total — mirrors the per-line Qty column in the PDF.
const subtotal =
Math.round(
items.reduce(
(s, it) =>
s +
(Number(it.order_qty ?? it.quantity) || 0) *
(Number(it.cost_price) || 0),
0,
) * 100,
) / 100;
// 6. Next version number
const history: PurchaseOrderDocumentEntry[] = po.documents_history ?? [];
const nextVersion =
history.length > 0 ? Math.max(...history.map((h) => h.version)) + 1 : 1;
// 7. Render PDF
const generatedAtIso = new Date().toISOString();
const pdfBuffer = await renderPurchaseOrderPdf({
purchaseOrder: po,
items,
order,
supplier,
letterhead,
logoDataUri,
generatedAtIso,
totalAmount: subtotal,
});
// 8. Append PDF to purchase_orders.documents (multipart)
const safePo = String(po.po_number || poId).replace(/[^\w.-]/g, "_");
const filename = `PO-CNL-${safePo}-v${nextVersion}.pdf`;
// Compress the generated PDF before upload so the new revision's
// PB token is stable from day one. PO PDFs are typically
// already-optimised (they're rendered with the letterhead PDF
// embedded), but `gs /ebook` still reliably shaves 10-30% off
// and we want the protective layer here the same as everywhere
// else. See `attachment-compressor.ts` for the rationale.
const { buffer: compressedPdfBuffer } = await compressBuffer(
pdfBuffer,
filename,
"application/pdf",
);
const formData = new FormData();
formData.append(
"documents",
new Blob([new Uint8Array(compressedPdfBuffer)], { type: "application/pdf" }),
filename,
);
const updated = await pb.collection(PO_COLLECTION).update<{
documents: string[] | string;
}>(poId, formData);
const docsRaw = updated?.documents ?? [];
const docs = Array.isArray(docsRaw) ? docsRaw : [docsRaw];
const newToken = docs[docs.length - 1] ?? "";
// 9. Append history entry — mark previous "current" as "superseded"
const newHistoryBase: PurchaseOrderDocumentEntry[] = history.map((h) =>
h.status === "current" ? { ...h, status: "superseded" } : h,
);
const newHistoryEntry: PurchaseOrderDocumentEntry = {
version: nextVersion,
file: filename,
token: newToken,
generatedAt: generatedAtIso,
generatedBy: input.generatedBy || "",
reason: (input.notes || "").slice(0, 500),
status: "current",
};
await pb.collection(PO_COLLECTION).update(poId, {
documents_history: [...newHistoryBase, newHistoryEntry],
});
// 10. Persist total_amount. On v≥2 (regenerate) we ALSO reset the
// status to `draft` and clear `email_error` so the operator can
// re-send the new version. v1 generation from a brand-new PO
// leaves status alone (it's already `draft` per the process page).
//
// Why reset on regenerate but not on first generate? After the
// first send (status="sent" or "mail_failed"), the operator
// regenerates specifically because they want to send a new
// version — and the existing "Send" button only renders for
// `draft` / `mail_failed` (`sent` POs had no way to resend
// before this change). Resetting to `draft` makes the Send button
// reappear AND clears the stale `email_error` from the prior
// failure so it doesn't show in the toast on the new send.
//
// We deliberately preserve `email_message_id` and
// `email_payload.sentAt` — those are the audit trail of the
// previous send (what went out, when, with which Message-ID).
// Wiping them would lose the record of "v1 was sent to
// supplier@example.com on 2026-08-01, then regenerated as v2".
//
// `cancelled` POs are an explicit operator decision ("this PO
// is dead, don't send it") — a regenerate should NOT silently
// resurrect them. The operator must flip status back to
// `draft` manually if they want to re-send after a cancel.
const finalStatus = po.status;
const resetFields: Record<string, unknown> = {
total_amount: subtotal,
};
if (nextVersion >= 2 && finalStatus !== "cancelled") {
resetFields.status = "draft";
resetFields.email_error = "";
}
await pb.collection(PO_COLLECTION).update(poId, resetFields);
return {
pdfId: poId,
version: nextVersion,
filename,
downloadUrl: `/api/v1/files/purchase_orders/${poId}/${filename}`,
total: subtotal,
generatedAt: generatedAtIso,
};
}