import { getAdminPocketBase } from "./pocketbase-admin"; 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 { const pb = await getAdminPocketBase(); // ── 1. Load PO + supplier ───────────────────────────────────────── let po: PurchaseOrder; try { po = await pb.collection(PO_COLLECTION).getOne(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 => 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 ? `

${operatorMessage .replace(/&/g, "&") .replace(//g, ">") .replace(/\n/g, "
")}

` : ""; 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 { const pb = await getAdminPocketBase(); let po: PurchaseOrder; try { po = await pb.collection(PO_COLLECTION).getOne(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>, po: PurchaseOrder, failure: SendPurchaseOrderEmailFailure ): Promise { 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 { const pb = await getAdminPocketBase(); if (input.outcome === "success") { const sentAt = new Date().toISOString(); const po = await pb.collection(PO_COLLECTION).getOne(input.poId); const payload = { ...(po.email_payload || {}), sentAt, sentMessageId: input.messageId || po.email_payload?.sentMessageId, }; const patch: Record = { 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 = { 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 { 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 { const pb = await getAdminPocketBase(); // 1. PO + order + supplier (all expanded server-side) let po: PurchaseOrder; try { po = await pb.collection(PO_COLLECTION).getOne(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({ 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`; const formData = new FormData(); formData.append( "documents", new Blob([new Uint8Array(pdfBuffer)], { 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 = { 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, }; }