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 ( + + + + +
+
+ + Pelagia Portal + +
+ {children} +
+
+ + 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 && ( +
+ + "{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} +
+
+ Reason + {note} +
+ + 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}.`; + } +}