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:
parent
c67afb2fff
commit
92b80dd278
9 changed files with 410 additions and 0 deletions
29
App/pelagia-portal/emails/edits-requested.tsx
Normal file
29
App/pelagia-portal/emails/edits-requested.tsx
Normal 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;
|
||||
47
App/pelagia-portal/emails/layout.tsx
Normal file
47
App/pelagia-portal/emails/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
App/pelagia-portal/emails/payment-processed.tsx
Normal file
32
App/pelagia-portal/emails/payment-processed.tsx
Normal 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;
|
||||
36
App/pelagia-portal/emails/po-approved.tsx
Normal file
36
App/pelagia-portal/emails/po-approved.tsx
Normal 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;
|
||||
33
App/pelagia-portal/emails/po-rejected.tsx
Normal file
33
App/pelagia-portal/emails/po-rejected.tsx
Normal 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;
|
||||
39
App/pelagia-portal/emails/po-submitted.tsx
Normal file
39
App/pelagia-portal/emails/po-submitted.tsx
Normal 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;
|
||||
31
App/pelagia-portal/emails/receipt-confirmed.tsx
Normal file
31
App/pelagia-portal/emails/receipt-confirmed.tsx
Normal 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;
|
||||
28
App/pelagia-portal/emails/vendor-id-needed.tsx
Normal file
28
App/pelagia-portal/emails/vendor-id-needed.tsx
Normal 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;
|
||||
135
App/pelagia-portal/lib/notifier.ts
Normal file
135
App/pelagia-portal/lib/notifier.ts
Normal 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}.`;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue