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 = { 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 ? `

"${note}"` : ""; switch (event) { case "PO_SUBMITTED": return `${po.submitter.name} has submitted purchase order ${po.poNumber} for your review.`; case "PO_APPROVED": case "PO_APPROVED_WITH_NOTE": return `Your purchase order ${po.poNumber} has been approved.${noteHtml}`; case "PO_REJECTED": return `Your purchase order ${po.poNumber} has been rejected.${noteHtml}`; case "EDITS_REQUESTED": return `Edits have been requested on ${po.poNumber}. Please update the order and resubmit.${noteHtml}`; case "VENDOR_ID_REQUESTED": return `A vendor ID is required before ${po.poNumber} can be approved.`; case "VENDOR_ID_PROVIDED": return `The vendor ID has been provided for ${po.poNumber}. It is ready for your review.`; case "PAYMENT_PROCESSING": return `Payment is being processed for ${po.poNumber}.`; case "PAYMENT_SENT": return `Payment has been confirmed for ${po.poNumber}. Please confirm delivery/receipt when the goods arrive.`; case "RECEIPT_CONFIRMED": return `Receipt has been confirmed for ${po.poNumber}. The order is now closed.`; case "PARTIAL_RECEIPT_CONFIRMED": return `A partial delivery has been confirmed for ${po.poNumber}. 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", `${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}
Item Qty Unit Price GST Total
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.

`; }