diff --git a/App/pelagia-portal/emails/edits-requested.tsx b/App/pelagia-portal/emails/edits-requested.tsx
new file mode 100644
index 0000000..7fb859d
--- /dev/null
+++ b/App/pelagia-portal/emails/edits-requested.tsx
@@ -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 (
+
+
+ Your manager has requested edits on purchase order{" "}
+ {po.poNumber}.
+
+
+ Title
+ {po.title}
+ Requested changes
+ {note}
+
+
+ Please log in to Pelagia Portal, make the requested edits, and resubmit the order.
+
+
+ );
+}
+
+export default EditsRequestedEmail;
diff --git a/App/pelagia-portal/emails/layout.tsx b/App/pelagia-portal/emails/layout.tsx
new file mode 100644
index 0000000..8970dac
--- /dev/null
+++ b/App/pelagia-portal/emails/layout.tsx
@@ -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 (
+
+
+
+
+
+
+ This message was sent by Pelagia Portal. You received it because you are a
+ stakeholder on this purchase order.
+
+
+
+
+
+ );
+}
diff --git a/App/pelagia-portal/emails/payment-processed.tsx b/App/pelagia-portal/emails/payment-processed.tsx
new file mode 100644
index 0000000..262e193
--- /dev/null
+++ b/App/pelagia-portal/emails/payment-processed.tsx
@@ -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 (
+
+
+ Payment has been confirmed for purchase order {po.poNumber}.
+
+
+ Title
+ {po.title}
+ {po.paymentRef && (
+ <>
+ Payment Reference
+ {po.paymentRef}
+ >
+ )}
+
+
+ Please confirm receipt in Pelagia Portal once you have received the goods or services.
+ This will close the purchase order.
+
+
+ );
+}
+
+export default PaymentProcessedEmail;
diff --git a/App/pelagia-portal/emails/po-approved.tsx b/App/pelagia-portal/emails/po-approved.tsx
new file mode 100644
index 0000000..c1d4c41
--- /dev/null
+++ b/App/pelagia-portal/emails/po-approved.tsx
@@ -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 (
+
+
+ Your purchase order has been{" "}
+ approved.
+
+
+ PO Number
+ {po.poNumber}
+ Title
+ {po.title}
+
+ {note && (
+
+ )}
+
+ Accounts have been notified and will process payment shortly.
+
+
+ );
+}
+
+export default PoApprovedEmail;
diff --git a/App/pelagia-portal/emails/po-rejected.tsx b/App/pelagia-portal/emails/po-rejected.tsx
new file mode 100644
index 0000000..0e3475d
--- /dev/null
+++ b/App/pelagia-portal/emails/po-rejected.tsx
@@ -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 (
+
+
+ Your purchase order has been{" "}
+ rejected.
+
+
+ PO Number
+ {po.poNumber}
+ Title
+ {po.title}
+
+
+
+ If you have questions, please contact your manager directly.
+
+
+ );
+}
+
+export default PoRejectedEmail;
diff --git a/App/pelagia-portal/emails/po-submitted.tsx b/App/pelagia-portal/emails/po-submitted.tsx
new file mode 100644
index 0000000..491dabe
--- /dev/null
+++ b/App/pelagia-portal/emails/po-submitted.tsx
@@ -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 (
+
+
+ A new purchase order has been submitted for your review.
+
+
+ PO Number
+
+ {po.poNumber}
+
+ Title
+ {po.title}
+ Submitted by
+ {submitterName}
+
+
+ Please log in to Pelagia Portal to review and take action on this order.
+
+
+ );
+}
+
+export default PoSubmittedEmail;
diff --git a/App/pelagia-portal/emails/receipt-confirmed.tsx b/App/pelagia-portal/emails/receipt-confirmed.tsx
new file mode 100644
index 0000000..27b5411
--- /dev/null
+++ b/App/pelagia-portal/emails/receipt-confirmed.tsx
@@ -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 (
+
+
+ Receipt has been confirmed for purchase order {po.poNumber}. The order is now{" "}
+ closed.
+
+
+ PO Number
+ {po.poNumber}
+ Title
+ {po.title}
+ Confirmed by
+ {confirmedBy}
+
+
+ No further action is required. The purchase order has been fully completed and archived.
+
+
+ );
+}
+
+export default ReceiptConfirmedEmail;
diff --git a/App/pelagia-portal/emails/vendor-id-needed.tsx b/App/pelagia-portal/emails/vendor-id-needed.tsx
new file mode 100644
index 0000000..47d50f5
--- /dev/null
+++ b/App/pelagia-portal/emails/vendor-id-needed.tsx
@@ -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 (
+
+
+ A vendor ID is required before purchase order {po.poNumber} can proceed.
+
+
+ PO Number
+ {po.poNumber}
+ Title
+ {po.title}
+
+
+ Please log in to Pelagia Portal, update the vendor details on this order, and the manager
+ will be notified to continue the review.
+
+
+ );
+}
+
+export default VendorIdNeededEmail;
diff --git a/App/pelagia-portal/lib/notifier.ts b/App/pelagia-portal/lib/notifier.ts
new file mode 100644
index 0000000..1847e13
--- /dev/null
+++ b/App/pelagia-portal/lib/notifier.ts
@@ -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 = {
+ 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 `
+
+
+
+
+ Pelagia Portal
+
+ Hi ${recipient.name},
+ ${buildBody(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.
+
+
+`;
+}
+
+function buildBody(
+ 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. Please update the PO with the correct vendor details.`;
+ 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.`;
+ default:
+ return `There has been an update on purchase order ${po.poNumber}.`;
+ }
+}