feat(notifications): email notifications via Resend with React Email templates

7 event templates: po-submitted, po-approved, po-rejected, edits-requested,
vendor-id-needed, payment-processed, receipt-confirmed.
Notifier uses Resend in production and console.log in development.
This commit is contained in:
Hardik 2026-05-05 23:24:34 +05:30
parent c67afb2fff
commit 92b80dd278
9 changed files with 410 additions and 0 deletions

View file

@ -0,0 +1,29 @@
import { Text, Section } from "@react-email/components";
import { EmailLayout } from "./layout";
interface Props {
po: { poNumber: string; title: string };
note: string;
}
export function EditsRequestedEmail({ po, note }: Props) {
return (
<EmailLayout>
<Text style={{ fontSize: "16px", color: "#111827", marginTop: 0 }}>
Your manager has requested edits on purchase order{" "}
<strong>{po.poNumber}</strong>.
</Text>
<Section style={{ backgroundColor: "#fffbeb", borderRadius: "8px", padding: "16px", border: "1px solid #fef3c7" }}>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Title</Text>
<Text style={{ margin: "0 0 12px" }}>{po.title}</Text>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Requested changes</Text>
<Text style={{ margin: 0, fontStyle: "italic" }}>{note}</Text>
</Section>
<Text style={{ fontSize: "14px", color: "#374151" }}>
Please log in to Pelagia Portal, make the requested edits, and resubmit the order.
</Text>
</EmailLayout>
);
}
export default EditsRequestedEmail;

View file

@ -0,0 +1,47 @@
import {
Html,
Head,
Body,
Container,
Section,
Text,
Hr,
} from "@react-email/components";
interface EmailLayoutProps {
children: React.ReactNode;
previewText?: string;
}
export function EmailLayout({ children, previewText }: EmailLayoutProps) {
return (
<Html>
<Head />
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "Inter, -apple-system, sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "0 auto", padding: "32px 16px" }}>
<Section
style={{
backgroundColor: "#ffffff",
borderRadius: "12px",
border: "1px solid #e5e7eb",
padding: "32px",
}}
>
<Section style={{ borderBottom: "2px solid #2563eb", paddingBottom: "12px", marginBottom: "24px" }}>
<Text style={{ fontSize: "20px", fontWeight: "700", color: "#1d4ed8", margin: 0 }}>
Pelagia Portal
</Text>
</Section>
{children}
</Section>
<Section style={{ padding: "16px 0" }}>
<Text style={{ fontSize: "12px", color: "#9ca3af", textAlign: "center" }}>
This message was sent by Pelagia Portal. You received it because you are a
stakeholder on this purchase order.
</Text>
</Section>
</Container>
</Body>
</Html>
);
}

View file

@ -0,0 +1,32 @@
import { Text, Section } from "@react-email/components";
import { EmailLayout } from "./layout";
interface Props {
po: { poNumber: string; title: string; paymentRef?: string | null };
}
export function PaymentProcessedEmail({ po }: Props) {
return (
<EmailLayout>
<Text style={{ fontSize: "16px", color: "#111827", marginTop: 0 }}>
Payment has been confirmed for purchase order <strong>{po.poNumber}</strong>.
</Text>
<Section style={{ backgroundColor: "#f0fdf4", borderRadius: "8px", padding: "16px", border: "1px solid #dcfce7" }}>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Title</Text>
<Text style={{ margin: "0 0 12px" }}>{po.title}</Text>
{po.paymentRef && (
<>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Payment Reference</Text>
<Text style={{ margin: 0, fontFamily: "monospace" }}>{po.paymentRef}</Text>
</>
)}
</Section>
<Text style={{ fontSize: "14px", color: "#374151" }}>
Please confirm receipt in Pelagia Portal once you have received the goods or services.
This will close the purchase order.
</Text>
</EmailLayout>
);
}
export default PaymentProcessedEmail;

View file

@ -0,0 +1,36 @@
import { Text, Section } from "@react-email/components";
import { EmailLayout } from "./layout";
interface Props {
po: { poNumber: string; title: string };
note?: string;
}
export function PoApprovedEmail({ po, note }: Props) {
return (
<EmailLayout>
<Text style={{ fontSize: "16px", color: "#111827", marginTop: 0 }}>
Your purchase order has been{" "}
<span style={{ color: "#16a34a", fontWeight: "600" }}>approved</span>.
</Text>
<Section style={{ backgroundColor: "#f0fdf4", borderRadius: "8px", padding: "16px", marginTop: "16px", border: "1px solid #dcfce7" }}>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>PO Number</Text>
<Text style={{ margin: "0 0 12px", fontWeight: "600", fontFamily: "monospace" }}>{po.poNumber}</Text>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Title</Text>
<Text style={{ margin: 0 }}>{po.title}</Text>
</Section>
{note && (
<Section style={{ marginTop: "16px", borderLeft: "3px solid #2563eb", paddingLeft: "12px" }}>
<Text style={{ margin: 0, fontSize: "14px", color: "#374151", fontStyle: "italic" }}>
"{note}"
</Text>
</Section>
)}
<Text style={{ fontSize: "14px", color: "#374151" }}>
Accounts have been notified and will process payment shortly.
</Text>
</EmailLayout>
);
}
export default PoApprovedEmail;

View file

@ -0,0 +1,33 @@
import { Text, Section } from "@react-email/components";
import { EmailLayout } from "./layout";
interface Props {
po: { poNumber: string; title: string };
note: string;
}
export function PoRejectedEmail({ po, note }: Props) {
return (
<EmailLayout>
<Text style={{ fontSize: "16px", color: "#111827", marginTop: 0 }}>
Your purchase order has been{" "}
<span style={{ color: "#dc2626", fontWeight: "600" }}>rejected</span>.
</Text>
<Section style={{ backgroundColor: "#fef2f2", borderRadius: "8px", padding: "16px", border: "1px solid #fee2e2" }}>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>PO Number</Text>
<Text style={{ margin: "0 0 12px", fontWeight: "600", fontFamily: "monospace" }}>{po.poNumber}</Text>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Title</Text>
<Text style={{ margin: 0 }}>{po.title}</Text>
</Section>
<Section style={{ marginTop: "16px", borderLeft: "3px solid #dc2626", paddingLeft: "12px" }}>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Reason</Text>
<Text style={{ margin: 0, fontSize: "14px", color: "#374151" }}>{note}</Text>
</Section>
<Text style={{ fontSize: "14px", color: "#374151" }}>
If you have questions, please contact your manager directly.
</Text>
</EmailLayout>
);
}
export default PoRejectedEmail;

View file

@ -0,0 +1,39 @@
import { Text, Section } from "@react-email/components";
import { EmailLayout } from "./layout";
interface Props {
po: { poNumber: string; title: string };
submitterName: string;
}
export function PoSubmittedEmail({ po, submitterName }: Props) {
return (
<EmailLayout>
<Text style={{ fontSize: "16px", color: "#111827", marginTop: 0 }}>
A new purchase order has been submitted for your review.
</Text>
<Section
style={{
backgroundColor: "#f3f4f6",
borderRadius: "8px",
padding: "16px",
marginTop: "16px",
}}
>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>PO Number</Text>
<Text style={{ margin: "0 0 12px", fontSize: "15px", fontWeight: "600", color: "#111827", fontFamily: "monospace" }}>
{po.poNumber}
</Text>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Title</Text>
<Text style={{ margin: "0 0 12px", fontSize: "15px", color: "#111827" }}>{po.title}</Text>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Submitted by</Text>
<Text style={{ margin: 0, fontSize: "15px", color: "#111827" }}>{submitterName}</Text>
</Section>
<Text style={{ fontSize: "14px", color: "#374151" }}>
Please log in to Pelagia Portal to review and take action on this order.
</Text>
</EmailLayout>
);
}
export default PoSubmittedEmail;

View file

@ -0,0 +1,31 @@
import { Text, Section } from "@react-email/components";
import { EmailLayout } from "./layout";
interface Props {
po: { poNumber: string; title: string };
confirmedBy: string;
}
export function ReceiptConfirmedEmail({ po, confirmedBy }: Props) {
return (
<EmailLayout>
<Text style={{ fontSize: "16px", color: "#111827", marginTop: 0 }}>
Receipt has been confirmed for purchase order <strong>{po.poNumber}</strong>. The order is now{" "}
<span style={{ color: "#16a34a", fontWeight: "600" }}>closed</span>.
</Text>
<Section style={{ backgroundColor: "#f3f4f6", borderRadius: "8px", padding: "16px" }}>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>PO Number</Text>
<Text style={{ margin: "0 0 12px", fontWeight: "600", fontFamily: "monospace" }}>{po.poNumber}</Text>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Title</Text>
<Text style={{ margin: "0 0 12px" }}>{po.title}</Text>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Confirmed by</Text>
<Text style={{ margin: 0 }}>{confirmedBy}</Text>
</Section>
<Text style={{ fontSize: "14px", color: "#374151" }}>
No further action is required. The purchase order has been fully completed and archived.
</Text>
</EmailLayout>
);
}
export default ReceiptConfirmedEmail;

View file

@ -0,0 +1,28 @@
import { Text, Section } from "@react-email/components";
import { EmailLayout } from "./layout";
interface Props {
po: { poNumber: string; title: string };
}
export function VendorIdNeededEmail({ po }: Props) {
return (
<EmailLayout>
<Text style={{ fontSize: "16px", color: "#111827", marginTop: 0 }}>
A vendor ID is required before purchase order <strong>{po.poNumber}</strong> can proceed.
</Text>
<Section style={{ backgroundColor: "#fffbeb", borderRadius: "8px", padding: "16px", border: "1px solid #fef3c7" }}>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>PO Number</Text>
<Text style={{ margin: "0 0 12px", fontWeight: "600", fontFamily: "monospace" }}>{po.poNumber}</Text>
<Text style={{ margin: "0 0 4px", fontSize: "13px", color: "#6b7280" }}>Title</Text>
<Text style={{ margin: 0 }}>{po.title}</Text>
</Section>
<Text style={{ fontSize: "14px", color: "#374151" }}>
Please log in to Pelagia Portal, update the vendor details on this order, and the manager
will be notified to continue the review.
</Text>
</EmailLayout>
);
}
export default VendorIdNeededEmail;

View file

@ -0,0 +1,135 @@
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 ?? "Pelagia Portal"} <${process.env.EMAIL_FROM ?? "noreply@pelagiaportal.com"}>`;
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";
interface NotifyParams {
event: NotificationEvent;
po: PurchaseOrder & { submitter: User };
recipients: User[];
note?: string;
}
export async function notify({ event, po, recipients, note }: NotifyParams) {
const subject = buildSubject(event, po.poNumber);
if (!subject) return;
await Promise.allSettled(
recipients.map(async (recipient) => {
let status = "sent";
if (isDev) {
console.log(
`\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildBody(event, po, note)}\n`
);
} else {
try {
const { error } = await resend!.emails.send({
from: FROM,
to: recipient.email,
subject,
html: buildHtml(event, po, recipient, note),
});
if (error) status = "failed";
} catch {
status = "failed";
}
}
await db.notification.create({
data: { subject, body: subject, status, poId: po.id, userId: recipient.id },
});
})
);
}
function buildSubject(event: NotificationEvent, poNumber: string): string | null {
const base = `PO ${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`,
};
return map[event] ?? null;
}
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;">Pelagia Portal</span>
</div>
<p style="margin:0 0 16px;">Hi ${recipient.name},</p>
<p style="margin:0 0 24px;">${buildBody(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 buildBody(
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. Please update the PO with the correct vendor details.`;
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.`;
default:
return `There has been an update on purchase order ${po.poNumber}.`;
}
}