From d82d18bac21619be6da556b97ece2aad15a7ff39 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 27 May 2026 03:39:49 +0530 Subject: [PATCH] feat(email): rich PO details, action buttons, Purchase Order in subject Subject line now reads 'Purchase Order XXXX' instead of 'PO XXXX'. Each email includes a recipient-specific deep-link action button, a PO summary card (number, title, submitter, vendor, vessel, cost centre, total), and a line items table with qty/unit price/GST/line total. notify() fetches enriched data internally so no call sites change. Co-Authored-By: Claude Sonnet 4.6 --- App/lib/notifier.ts | 251 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 206 insertions(+), 45 deletions(-) diff --git a/App/lib/notifier.ts b/App/lib/notifier.ts index d336d7a..f475142 100644 --- a/App/lib/notifier.ts +++ b/App/lib/notifier.ts @@ -5,6 +5,7 @@ import type { PurchaseOrder, User } from "@prisma/client"; const isDev = process.env.NODE_ENV === "development"; const resend = isDev ? null : new Resend(process.env.RESEND_API_KEY); const FROM = `${process.env.EMAIL_FROM_NAME ?? "PPMS"} <${process.env.EMAIL_FROM ?? "noreply@ppms.pelagiamarine.com"}>`; +const APP_URL = (process.env.NEXTAUTH_URL ?? "https://portal.pelagiamarine.com").replace(/\/$/, ""); export type NotificationEvent = | "PO_SUBMITTED" @@ -25,10 +26,42 @@ interface NotifyParams { note?: string; } +type EnrichedPo = PurchaseOrder & { + submitter: User; + vendor: { name: string } | null; + vessel: { name: string } | null; + account: { name: string; code: string } | null; + lineItems: { + name: string; + quantity: import("@prisma/client").Prisma.Decimal; + unit: string; + unitPrice: import("@prisma/client").Prisma.Decimal; + gstRate: import("@prisma/client").Prisma.Decimal | null; + }[]; +}; + +function fmt(n: number): string { + return new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 2 }).format(n); +} + export async function notify({ event, po, recipients, note }: NotifyParams) { const subject = buildSubject(event, po.poNumber); if (!subject) return; + // Fetch full PO once so all emails in this batch get line items / vendor / vessel + const enriched = await db.purchaseOrder.findUnique({ + where: { id: po.id }, + include: { + submitter: true, + vendor: { select: { name: true } }, + vessel: { select: { name: true } }, + account: { select: { name: true, code: true } }, + lineItems: { orderBy: { sortOrder: "asc" }, select: { name: true, quantity: true, unit: true, unitPrice: true, gstRate: true } }, + }, + }) as EnrichedPo | null; + + const richPo: EnrichedPo = enriched ?? { ...po, vendor: null, vessel: null, account: null, lineItems: [] }; + await Promise.allSettled( recipients.map(async (recipient) => { const body = buildInAppBody(event, po, recipient); @@ -37,7 +70,7 @@ export async function notify({ event, po, recipients, note }: NotifyParams) { let status = "sent"; if (isDev) { console.log( - `\nšŸ“§ [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildEmailBody(event, po, note)}\n` + `\nšŸ“§ [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildEmailBody(event, po, note)}\n Link: ${APP_URL}${link}\n` ); } else { try { @@ -45,7 +78,7 @@ export async function notify({ event, po, recipients, note }: NotifyParams) { from: FROM, to: recipient.email, subject, - html: buildHtml(event, po, recipient, note), + html: buildHtml(event, richPo, recipient, note), }); if (error) status = "failed"; } catch { @@ -118,21 +151,17 @@ function buildInAppLink( const isSubmitter = recipient.id === po.submitterId; switch (event) { - // Manager needs to act on the approval queue case "PO_SUBMITTED": case "VENDOR_ID_PROVIDED": return isManager ? `/approvals/${po.id}` : `/po/${po.id}`; - // Accounts needs to process payment; everyone else sees the PO case "PO_APPROVED": case "PO_APPROVED_WITH_NOTE": return isAccounts ? `/payments` : `/po/${po.id}`; - // Submitter needs to open the edit form case "EDITS_REQUESTED": return isSubmitter ? `/po/${po.id}/edit` : `/po/${po.id}`; - // Submitter needs to confirm receipt case "PAYMENT_SENT": return isSubmitter ? `/po/${po.id}/receipt` : `/po/${po.id}`; @@ -141,53 +170,57 @@ function buildInAppLink( } } +// ── Email action label (button text) ────────────────────────────────────────── + +function buildActionLabel(event: NotificationEvent, recipient: User, po: PurchaseOrder): string { + const isManager = recipient.role === "MANAGER" || recipient.role === "SUPERUSER"; + const isAccounts = recipient.role === "ACCOUNTS"; + const isSubmitter = recipient.id === po.submitterId; + + switch (event) { + case "PO_SUBMITTED": + case "VENDOR_ID_PROVIDED": + return isManager ? "Review Purchase Order" : "View Purchase Order"; + case "PO_APPROVED": + case "PO_APPROVED_WITH_NOTE": + return isAccounts ? "Process Payment" : "View Purchase Order"; + case "PO_REJECTED": + return "View Purchase Order"; + case "EDITS_REQUESTED": + return isSubmitter ? "Edit Purchase Order" : "View Purchase Order"; + case "VENDOR_ID_REQUESTED": + return "Provide Vendor ID"; + case "PAYMENT_PROCESSING": + return "View Purchase Order"; + case "PAYMENT_SENT": + return isSubmitter ? "Confirm Receipt" : "View Purchase Order"; + case "RECEIPT_CONFIRMED": + return "View Purchase Order"; + default: + return "View Purchase Order"; + } +} + // ── Email subject ───────────────────────────────────────────────────────────── function buildSubject(event: NotificationEvent, poNumber: string): string | null { - const base = `PO ${poNumber}`; + const base = `Purchase Order ${poNumber}`; const map: Record = { - PO_SUBMITTED: `${base} submitted for review`, - PO_APPROVED: `${base} has been approved`, - PO_APPROVED_WITH_NOTE: `${base} has been approved`, - PO_REJECTED: `${base} has been rejected`, - EDITS_REQUESTED: `Edits requested on ${base}`, - VENDOR_ID_REQUESTED: `Vendor ID needed for ${base}`, - VENDOR_ID_PROVIDED: `Vendor ID provided for ${base}`, - PAYMENT_PROCESSING: `Payment initiated for ${base}`, - PAYMENT_SENT: `Payment confirmed for ${base}`, - RECEIPT_CONFIRMED: `Receipt confirmed — ${base} closed`, + PO_SUBMITTED: `${base} submitted for review`, + PO_APPROVED: `${base} has been approved`, + PO_APPROVED_WITH_NOTE: `${base} has been approved`, + PO_REJECTED: `${base} has been rejected`, + EDITS_REQUESTED: `Edits requested on ${base}`, + VENDOR_ID_REQUESTED: `Vendor ID needed for ${base}`, + VENDOR_ID_PROVIDED: `Vendor ID provided for ${base}`, + PAYMENT_PROCESSING: `Payment initiated for ${base}`, + PAYMENT_SENT: `Payment confirmed for ${base}`, + RECEIPT_CONFIRMED: `Receipt confirmed — ${base} closed`, }; return map[event] ?? null; } -// ── Email HTML ──────────────────────────────────────────────────────────────── - -function buildHtml( - event: NotificationEvent, - po: PurchaseOrder & { submitter: User }, - recipient: User, - note?: string -): string { - return ` - - - -
- PPMS -
-

Hi ${recipient.name},

-

${buildEmailBody(event, po, note)}

-
- PO Number: ${po.poNumber}
- Title: ${po.title}
- Submitted by: ${po.submitter.name} -
-

- You received this message because you are a stakeholder on this purchase order. -

- -`; -} +// ── Email body text ─────────────────────────────────────────────────────────── function buildEmailBody( event: NotificationEvent, @@ -222,3 +255,131 @@ function buildEmailBody( return `There has been an update on purchase order ${po.poNumber}.`; } } + +// ── Email HTML ──────────────────────────────────────────────────────────────── + +function buildHtml( + event: NotificationEvent, + po: EnrichedPo, + recipient: User, + note?: string +): string { + const actionPath = buildInAppLink(event, po, recipient); + const actionUrl = `${APP_URL}${actionPath}`; + const actionLabel = buildActionLabel(event, recipient, po); + const bodyText = buildEmailBody(event, po, note); + + const totalAmount = Number(po.totalAmount); + + // ── PO summary card ──────────────────────────────────────────────────────── + const summaryRows = [ + ["Purchase Order", `${po.poNumber}`], + ["Title", po.title], + ["Submitted by", po.submitter.name], + po.vendor ? ["Vendor", po.vendor.name] : null, + po.vessel ? ["Vessel", po.vessel.name] : null, + po.account ? ["Cost Centre", `${po.account.name} (${po.account.code})`] : null, + ["Total Amount", `${fmt(totalAmount)}`], + ] + .filter(Boolean) + .map( + (row) => + ` + ${row![0]} + ${row![1]} + ` + ) + .join(""); + + // ── Line items table ─────────────────────────────────────────────────────── + const lineItemRows = po.lineItems + .map((li) => { + const qty = Number(li.quantity); + const price = Number(li.unitPrice); + const gst = Number(li.gstRate ?? 0.18); + const lineTotal = qty * price * (1 + gst); + return ` + ${li.name} + ${qty} ${li.unit} + ${fmt(price)} + ${Math.round(gst * 100)}% + ${fmt(lineTotal)} + `; + }) + .join(""); + + const lineItemsSection = po.lineItems.length > 0 + ? `
+

Line Items

+ + + + + + + + + + + ${lineItemRows} + + + + + + +
ItemQtyUnit PriceGSTTotal
Grand Total${fmt(totalAmount)}
+
` + : ""; + + return ` + + + + + +
+ + + + + + + + + + + +
+ PPMS + Pelagia Marine +
+

Hi ${recipient.name},

+

${bodyText}

+ + + + +
+ ${actionLabel} → +
+ + +
+

Purchase Order Details

+ + ${summaryRows} +
+
+ + ${lineItemsSection} + +
+

+ You received this because you are a stakeholder on this purchase order. +

+
+
+ +`; +}