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 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-27 03:39:49 +05:30
parent a8e3b5d69b
commit d82d18bac2

View file

@ -5,6 +5,7 @@ import type { PurchaseOrder, User } from "@prisma/client";
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
const resend = isDev ? null : new Resend(process.env.RESEND_API_KEY); 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 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 = export type NotificationEvent =
| "PO_SUBMITTED" | "PO_SUBMITTED"
@ -25,10 +26,42 @@ interface NotifyParams {
note?: string; 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) { export async function notify({ event, po, recipients, note }: NotifyParams) {
const subject = buildSubject(event, po.poNumber); const subject = buildSubject(event, po.poNumber);
if (!subject) return; 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( await Promise.allSettled(
recipients.map(async (recipient) => { recipients.map(async (recipient) => {
const body = buildInAppBody(event, po, recipient); const body = buildInAppBody(event, po, recipient);
@ -37,7 +70,7 @@ export async function notify({ event, po, recipients, note }: NotifyParams) {
let status = "sent"; let status = "sent";
if (isDev) { if (isDev) {
console.log( 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 { } else {
try { try {
@ -45,7 +78,7 @@ export async function notify({ event, po, recipients, note }: NotifyParams) {
from: FROM, from: FROM,
to: recipient.email, to: recipient.email,
subject, subject,
html: buildHtml(event, po, recipient, note), html: buildHtml(event, richPo, recipient, note),
}); });
if (error) status = "failed"; if (error) status = "failed";
} catch { } catch {
@ -118,21 +151,17 @@ function buildInAppLink(
const isSubmitter = recipient.id === po.submitterId; const isSubmitter = recipient.id === po.submitterId;
switch (event) { switch (event) {
// Manager needs to act on the approval queue
case "PO_SUBMITTED": case "PO_SUBMITTED":
case "VENDOR_ID_PROVIDED": case "VENDOR_ID_PROVIDED":
return isManager ? `/approvals/${po.id}` : `/po/${po.id}`; return isManager ? `/approvals/${po.id}` : `/po/${po.id}`;
// Accounts needs to process payment; everyone else sees the PO
case "PO_APPROVED": case "PO_APPROVED":
case "PO_APPROVED_WITH_NOTE": case "PO_APPROVED_WITH_NOTE":
return isAccounts ? `/payments` : `/po/${po.id}`; return isAccounts ? `/payments` : `/po/${po.id}`;
// Submitter needs to open the edit form
case "EDITS_REQUESTED": case "EDITS_REQUESTED":
return isSubmitter ? `/po/${po.id}/edit` : `/po/${po.id}`; return isSubmitter ? `/po/${po.id}/edit` : `/po/${po.id}`;
// Submitter needs to confirm receipt
case "PAYMENT_SENT": case "PAYMENT_SENT":
return isSubmitter ? `/po/${po.id}/receipt` : `/po/${po.id}`; return isSubmitter ? `/po/${po.id}/receipt` : `/po/${po.id}`;
@ -141,10 +170,41 @@ 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 ───────────────────────────────────────────────────────────── // ── Email subject ─────────────────────────────────────────────────────────────
function buildSubject(event: NotificationEvent, poNumber: string): string | null { function buildSubject(event: NotificationEvent, poNumber: string): string | null {
const base = `PO ${poNumber}`; const base = `Purchase Order ${poNumber}`;
const map: Record<NotificationEvent, string> = { const map: Record<NotificationEvent, string> = {
PO_SUBMITTED: `${base} submitted for review`, PO_SUBMITTED: `${base} submitted for review`,
PO_APPROVED: `${base} has been approved`, PO_APPROVED: `${base} has been approved`,
@ -160,34 +220,7 @@ function buildSubject(event: NotificationEvent, poNumber: string): string | null
return map[event] ?? null; return map[event] ?? null;
} }
// ── Email HTML ──────────────────────────────────────────────────────────────── // ── Email body text ───────────────────────────────────────────────────────────
function buildHtml(
event: NotificationEvent,
po: PurchaseOrder & { submitter: User },
recipient: User,
note?: string
): string {
return `
<!DOCTYPE html>
<html>
<body style="font-family:Inter,sans-serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#111827;">
<div style="border-bottom:2px solid #2563eb;padding-bottom:12px;margin-bottom:24px;">
<span style="font-size:20px;font-weight:700;color:#1d4ed8;">PPMS</span>
</div>
<p style="margin:0 0 16px;">Hi ${recipient.name},</p>
<p style="margin:0 0 24px;">${buildEmailBody(event, po, note)}</p>
<div style="background:#f3f4f6;border-radius:8px;padding:16px;font-size:13px;color:#374151;">
<strong>PO Number:</strong> ${po.poNumber}<br/>
<strong>Title:</strong> ${po.title}<br/>
<strong>Submitted by:</strong> ${po.submitter.name}
</div>
<p style="margin-top:32px;font-size:12px;color:#9ca3af;">
You received this message because you are a stakeholder on this purchase order.
</p>
</body>
</html>`;
}
function buildEmailBody( function buildEmailBody(
event: NotificationEvent, event: NotificationEvent,
@ -222,3 +255,131 @@ function buildEmailBody(
return `There has been an update on purchase order ${po.poNumber}.`; 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", `<span style="font-family:monospace;">${po.poNumber}</span>`],
["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", `<strong>${fmt(totalAmount)}</strong>`],
]
.filter(Boolean)
.map(
(row) =>
`<tr>
<td style="padding:4px 12px 4px 0;font-size:13px;color:#6b7280;white-space:nowrap;vertical-align:top;">${row![0]}</td>
<td style="padding:4px 0;font-size:13px;color:#111827;">${row![1]}</td>
</tr>`
)
.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 `<tr style="border-bottom:1px solid #f3f4f6;">
<td style="padding:7px 8px 7px 0;font-size:13px;color:#111827;">${li.name}</td>
<td style="padding:7px 4px;font-size:13px;color:#374151;text-align:right;white-space:nowrap;">${qty} ${li.unit}</td>
<td style="padding:7px 4px;font-size:13px;color:#374151;text-align:right;white-space:nowrap;">${fmt(price)}</td>
<td style="padding:7px 0 7px 4px;font-size:13px;color:#374151;text-align:right;white-space:nowrap;">${Math.round(gst * 100)}%</td>
<td style="padding:7px 0 7px 4px;font-size:13px;color:#111827;font-weight:600;text-align:right;white-space:nowrap;">${fmt(lineTotal)}</td>
</tr>`;
})
.join("");
const lineItemsSection = po.lineItems.length > 0
? `<div style="margin-top:24px;">
<p style="margin:0 0 8px;font-size:13px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:0.05em;">Line Items</p>
<table style="width:100%;border-collapse:collapse;border-top:1px solid #e5e7eb;">
<thead>
<tr style="background:#f9fafb;">
<th style="padding:6px 8px 6px 0;font-size:12px;font-weight:600;color:#6b7280;text-align:left;">Item</th>
<th style="padding:6px 4px;font-size:12px;font-weight:600;color:#6b7280;text-align:right;">Qty</th>
<th style="padding:6px 4px;font-size:12px;font-weight:600;color:#6b7280;text-align:right;">Unit Price</th>
<th style="padding:6px 4px;font-size:12px;font-weight:600;color:#6b7280;text-align:right;">GST</th>
<th style="padding:6px 0 6px 4px;font-size:12px;font-weight:600;color:#6b7280;text-align:right;">Total</th>
</tr>
</thead>
<tbody>${lineItemRows}</tbody>
<tfoot>
<tr style="border-top:2px solid #e5e7eb;">
<td colspan="4" style="padding:8px 4px 0 0;font-size:13px;font-weight:700;color:#111827;text-align:right;">Grand Total</td>
<td style="padding:8px 0 0 4px;font-size:14px;font-weight:700;color:#1d4ed8;text-align:right;">${fmt(totalAmount)}</td>
</tr>
</tfoot>
</table>
</div>`
: "";
return `<!DOCTYPE html>
<html>
<head><meta name="viewport" content="width=device-width,initial-scale=1"/></head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:Inter,-apple-system,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 16px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;">
<!-- Header -->
<tr><td style="background:#1d4ed8;padding:20px 32px;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">PPMS</span>
<span style="font-size:13px;color:#93c5fd;margin-left:10px;">Pelagia Marine</span>
</td></tr>
<!-- Body -->
<tr><td style="padding:32px;">
<p style="margin:0 0 20px;font-size:15px;color:#111827;">Hi ${recipient.name},</p>
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">${bodyText}</p>
<!-- Action button -->
<table cellpadding="0" cellspacing="0" style="margin-bottom:32px;">
<tr><td style="background:#2563eb;border-radius:8px;">
<a href="${actionUrl}" style="display:inline-block;padding:12px 24px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${actionLabel} </a>
</td></tr>
</table>
<!-- PO summary -->
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px 20px;margin-bottom:4px;">
<p style="margin:0 0 10px;font-size:12px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.06em;">Purchase Order Details</p>
<table cellpadding="0" cellspacing="0" style="width:100%;">
${summaryRows}
</table>
</div>
${lineItemsSection}
</td></tr>
<!-- Footer -->
<tr><td style="background:#f8fafc;border-top:1px solid #e5e7eb;padding:16px 32px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;">
You received this because you are a stakeholder on this purchase order.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}