PO_SUBMITTED: managers only, submitter no longer copied PAYMENT_SENT: submitter only (it is a receipt prompt, not a manager action) PARTIAL_RECEIPT_CONFIRMED: managers now notified via new event type RECEIPT_CONFIRMED: unchanged (managers + accounts) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
import { Resend } from "resend";
|
|
import { db } from "@/lib/db";
|
|
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"
|
|
| "PO_APPROVED"
|
|
| "PO_APPROVED_WITH_NOTE"
|
|
| "PO_REJECTED"
|
|
| "EDITS_REQUESTED"
|
|
| "VENDOR_ID_REQUESTED"
|
|
| "VENDOR_ID_PROVIDED"
|
|
| "PAYMENT_PROCESSING"
|
|
| "PAYMENT_SENT"
|
|
| "RECEIPT_CONFIRMED"
|
|
| "PARTIAL_RECEIPT_CONFIRMED";
|
|
|
|
interface NotifyParams {
|
|
event: NotificationEvent;
|
|
po: PurchaseOrder & { submitter: User };
|
|
recipients: User[];
|
|
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);
|
|
const link = buildInAppLink(event, po, recipient);
|
|
|
|
let status = "sent";
|
|
if (isDev) {
|
|
console.log(
|
|
`\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildEmailBody(event, po, note)}\n Link: ${APP_URL}${link}\n`
|
|
);
|
|
} else {
|
|
try {
|
|
const { error } = await resend!.emails.send({
|
|
from: FROM,
|
|
to: recipient.email,
|
|
subject,
|
|
html: buildHtml(event, richPo, recipient, note),
|
|
});
|
|
if (error) status = "failed";
|
|
} catch {
|
|
status = "failed";
|
|
}
|
|
}
|
|
|
|
await db.notification.create({
|
|
data: { subject, body, link, status, poId: po.id, userId: recipient.id },
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|
|
// ── In-app message ────────────────────────────────────────────────────────────
|
|
|
|
function buildInAppBody(
|
|
event: NotificationEvent,
|
|
po: PurchaseOrder & { submitter: User },
|
|
recipient: User
|
|
): string {
|
|
const pn = po.poNumber;
|
|
const submitter = po.submitter.name;
|
|
|
|
switch (event) {
|
|
case "PO_SUBMITTED":
|
|
return recipient.id === po.submitterId
|
|
? `${pn} submitted for review`
|
|
: `${submitter} submitted ${pn} for your review`;
|
|
|
|
case "PO_APPROVED":
|
|
case "PO_APPROVED_WITH_NOTE":
|
|
return recipient.id === po.submitterId
|
|
? `${pn} approved`
|
|
: `${pn} approved — ready for payment`;
|
|
|
|
case "PO_REJECTED":
|
|
return `${pn} rejected`;
|
|
|
|
case "EDITS_REQUESTED":
|
|
return `Edits requested on ${pn}`;
|
|
|
|
case "VENDOR_ID_REQUESTED":
|
|
return `Vendor ID needed before ${pn} can be approved`;
|
|
|
|
case "VENDOR_ID_PROVIDED":
|
|
return `Vendor ID provided for ${pn} — ready to review`;
|
|
|
|
case "PAYMENT_PROCESSING":
|
|
return `Payment processing for ${pn}`;
|
|
|
|
case "PAYMENT_SENT":
|
|
return `Payment confirmed for ${pn} — please confirm receipt`;
|
|
|
|
case "RECEIPT_CONFIRMED":
|
|
return `Receipt confirmed — ${pn} closed`;
|
|
|
|
case "PARTIAL_RECEIPT_CONFIRMED":
|
|
return `Partial receipt confirmed on ${pn} — items still outstanding`;
|
|
|
|
default:
|
|
return `Update on ${pn}`;
|
|
}
|
|
}
|
|
|
|
function buildInAppLink(
|
|
event: NotificationEvent,
|
|
po: PurchaseOrder & { submitter: User },
|
|
recipient: User
|
|
): 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 ? `/approvals/${po.id}` : `/po/${po.id}`;
|
|
|
|
case "PO_APPROVED":
|
|
case "PO_APPROVED_WITH_NOTE":
|
|
return isAccounts ? `/payments` : `/po/${po.id}`;
|
|
|
|
case "EDITS_REQUESTED":
|
|
return isSubmitter ? `/po/${po.id}/edit` : `/po/${po.id}`;
|
|
|
|
case "PAYMENT_SENT":
|
|
return isSubmitter ? `/po/${po.id}/receipt` : `/po/${po.id}`;
|
|
|
|
default:
|
|
return `/po/${po.id}`;
|
|
}
|
|
}
|
|
|
|
// ── 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":
|
|
case "PARTIAL_RECEIPT_CONFIRMED":
|
|
return "View Purchase Order";
|
|
default:
|
|
return "View Purchase Order";
|
|
}
|
|
}
|
|
|
|
// ── Email subject ─────────────────────────────────────────────────────────────
|
|
|
|
function buildSubject(event: NotificationEvent, poNumber: string): string | null {
|
|
const base = `Purchase Order ${poNumber}`;
|
|
const map: Record<NotificationEvent, string> = {
|
|
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`,
|
|
PARTIAL_RECEIPT_CONFIRMED: `Partial receipt confirmed for ${base}`,
|
|
};
|
|
return map[event] ?? null;
|
|
}
|
|
|
|
// ── Email body text ───────────────────────────────────────────────────────────
|
|
|
|
function buildEmailBody(
|
|
event: NotificationEvent,
|
|
po: PurchaseOrder & { submitter: User },
|
|
note?: string
|
|
): string {
|
|
const noteHtml = note
|
|
? `<br/><br/><em style="color:#374151;">"${note}"</em>`
|
|
: "";
|
|
|
|
switch (event) {
|
|
case "PO_SUBMITTED":
|
|
return `<strong>${po.submitter.name}</strong> has submitted purchase order <strong>${po.poNumber}</strong> for your review.`;
|
|
case "PO_APPROVED":
|
|
case "PO_APPROVED_WITH_NOTE":
|
|
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#16a34a;font-weight:600;">approved</span>.${noteHtml}`;
|
|
case "PO_REJECTED":
|
|
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">rejected</span>.${noteHtml}`;
|
|
case "EDITS_REQUESTED":
|
|
return `Edits have been requested on <strong>${po.poNumber}</strong>. Please update the order and resubmit.${noteHtml}`;
|
|
case "VENDOR_ID_REQUESTED":
|
|
return `A vendor ID is required before <strong>${po.poNumber}</strong> can be approved.`;
|
|
case "VENDOR_ID_PROVIDED":
|
|
return `The vendor ID has been provided for <strong>${po.poNumber}</strong>. It is ready for your review.`;
|
|
case "PAYMENT_PROCESSING":
|
|
return `Payment is being processed for <strong>${po.poNumber}</strong>.`;
|
|
case "PAYMENT_SENT":
|
|
return `Payment has been confirmed for <strong>${po.poNumber}</strong>. Please confirm delivery/receipt when the goods arrive.`;
|
|
case "RECEIPT_CONFIRMED":
|
|
return `Receipt has been confirmed for <strong>${po.poNumber}</strong>. The order is now closed.`;
|
|
case "PARTIAL_RECEIPT_CONFIRMED":
|
|
return `A partial delivery has been confirmed for <strong>${po.poNumber}</strong>. Some items are still outstanding.`;
|
|
default:
|
|
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>`;
|
|
}
|