Notifications System
Keep users informed about application deadlines, status changes, expiring documents, and new program matches.
Overview
Pathfinder supports three notification channels:Notification Types
| Type | Channel Default | Timing |
|---|---|---|
| Application Status Change | Email + In-App | Immediate |
| Redetermination Reminder | Email + SMS | 30, 14, 7 days before |
| Document Expiring | 30, 7 days before | |
| New Program Match | Weekly digest | |
| Application Deadline | Email + SMS | 7, 3, 1 days before |
User Preferences
Users control which notifications they receive and how.Copy
interface NotificationPreferences {
// Channel preferences
emailEnabled: boolean;
smsEnabled: boolean;
pushEnabled: boolean;
// Type preferences
applicationUpdates: boolean;
renewalReminders: boolean;
documentReminders: boolean;
newProgramMatches: boolean;
weeklyDigest: boolean;
// Timing
reminderDaysBefore: number[]; // [30, 14, 7]
// Contact info
email: string;
phone?: string;
// Quiet hours (no SMS between)
quietHoursStart?: string; // "22:00"
quietHoursEnd?: string; // "08:00"
}
Database Schema
Copy
-- Add to profiles table
ALTER TABLE profiles ADD COLUMN
notification_preferences JSONB DEFAULT '{
"emailEnabled": true,
"smsEnabled": false,
"pushEnabled": false,
"applicationUpdates": true,
"renewalReminders": true,
"documentReminders": true,
"newProgramMatches": true,
"weeklyDigest": true,
"reminderDaysBefore": [30, 14, 7]
}';
Email Templates
Email templates use React Email for consistent, responsive design.Application Status Change
Copy
// components/emails/application-status-change.tsx
import {
Html, Head, Body, Container, Section,
Heading, Text, Button, Hr
} from "@react-email/components";
interface StatusChangeEmailProps {
userName: string;
programName: string;
oldStatus: string;
newStatus: string;
message?: string;
actionUrl: string;
}
export function ApplicationStatusChangeEmail({
userName,
programName,
oldStatus,
newStatus,
message,
actionUrl,
}: StatusChangeEmailProps) {
const statusColors = {
approved: "#059669",
pending: "#d97706",
denied: "#dc2626",
action_required: "#2563eb",
};
return (
<Html>
<Head />
<Body style={styles.body}>
<Container style={styles.container}>
<Heading style={styles.heading}>
Application Update: {programName}
</Heading>
<Text style={styles.text}>
Hi {userName},
</Text>
<Text style={styles.text}>
Your application for <strong>{programName}</strong> has been
updated.
</Text>
<Section style={styles.statusBox}>
<Text style={styles.statusLabel}>Status changed to:</Text>
<Text style={{
...styles.statusValue,
color: statusColors[newStatus] || "#374151"
}}>
{formatStatus(newStatus)}
</Text>
</Section>
{message && (
<Section style={styles.messageBox}>
<Text style={styles.messageText}>{message}</Text>
</Section>
)}
<Button href={actionUrl} style={styles.button}>
View Application
</Button>
<Hr style={styles.hr} />
<Text style={styles.footer}>
You received this because you have an application with Pathfinder.
<br />
<a href="{unsubscribeUrl}">Manage notification preferences</a>
</Text>
</Container>
</Body>
</Html>
);
}
Document Expiring
Copy
// components/emails/document-expiring.tsx
export function DocumentExpiringEmail({
userName,
documentType,
expirationDate,
daysRemaining,
affectedApplications,
uploadUrl,
}: DocumentExpiringProps) {
return (
<Html>
<Head />
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.alertBox}>
<Text style={styles.alertIcon}>⚠️</Text>
<Heading style={styles.alertHeading}>
Document Expiring Soon
</Heading>
</Section>
<Text style={styles.text}>
Hi {userName},
</Text>
<Text style={styles.text}>
Your <strong>{documentType}</strong> expires in{" "}
<strong>{daysRemaining} days</strong> (on {formatDate(expirationDate)}).
</Text>
{affectedApplications.length > 0 && (
<Section style={styles.affectedBox}>
<Text style={styles.affectedLabel}>
This may affect your applications for:
</Text>
<ul>
{affectedApplications.map(app => (
<li key={app.id}>{app.programName}</li>
))}
</ul>
</Section>
)}
<Button href={uploadUrl} style={styles.button}>
Upload New Document
</Button>
</Container>
</Body>
</Html>
);
}
Redetermination Reminder
Copy
// components/emails/redetermination-reminder.tsx
export function RedeterminationReminderEmail({
userName,
programName,
renewalDate,
daysRemaining,
renewalUrl,
requiredDocuments,
}: RedeterminationProps) {
return (
<Html>
<Head />
<Body style={styles.body}>
<Container style={styles.container}>
<Heading style={styles.heading}>
Renewal Reminder: {programName}
</Heading>
<Text style={styles.text}>
Hi {userName},
</Text>
<Text style={styles.text}>
Your <strong>{programName}</strong> benefits need to be renewed
in <strong>{daysRemaining} days</strong>.
</Text>
<Section style={styles.dateBox}>
<Text style={styles.dateLabel}>Renewal Deadline</Text>
<Text style={styles.dateValue}>{formatDate(renewalDate)}</Text>
</Section>
{requiredDocuments.length > 0 && (
<Section style={styles.docsBox}>
<Text style={styles.docsLabel}>
Documents you may need:
</Text>
<ul>
{requiredDocuments.map(doc => (
<li key={doc}>{doc}</li>
))}
</ul>
</Section>
)}
<Button href={renewalUrl} style={styles.button}>
Start Renewal
</Button>
<Text style={styles.tipText}>
💡 <strong>Tip:</strong> Start your renewal early to avoid
any gap in benefits.
</Text>
</Container>
</Body>
</Html>
);
}
SMS Messages
SMS uses concise templates with action links.Copy
// lib/sms.ts
interface SMSTemplates {
applicationStatusChange: (params: StatusChangeParams) => string;
redeterminationReminder: (params: RedeterminationParams) => string;
documentExpiring: (params: DocumentExpiringParams) => string;
}
const smsTemplates: SMSTemplates = {
applicationStatusChange: ({ programName, newStatus }) =>
`Pathfinder: Your ${programName} application is now ${formatStatus(newStatus)}. ` +
`View details: ${SHORT_URL}/app`,
redeterminationReminder: ({ programName, daysRemaining }) =>
`Pathfinder: Your ${programName} benefits expire in ${daysRemaining} days. ` +
`Renew now to avoid gaps: ${SHORT_URL}/renew`,
documentExpiring: ({ documentType, daysRemaining }) =>
`Pathfinder: Your ${documentType} expires in ${daysRemaining} days. ` +
`Upload a new one: ${SHORT_URL}/docs`,
};
Notification Service
Core service for sending notifications across channels.Copy
// lib/notifications.ts
import { Resend } from "resend";
import twilio from "twilio";
const resend = new Resend(process.env.RESEND_API_KEY);
const twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
interface NotificationPayload {
userId: string;
type: NotificationType;
data: Record<string, any>;
}
export async function sendNotification(payload: NotificationPayload) {
// 1. Get user preferences
const prefs = await getNotificationPreferences(payload.userId);
// 2. Create in-app notification
await createInAppNotification(payload);
// 3. Send email if enabled
if (prefs.emailEnabled && shouldSendEmail(payload.type, prefs)) {
await sendEmailNotification(payload, prefs.email);
}
// 4. Send SMS if enabled (respecting quiet hours)
if (prefs.smsEnabled && prefs.phone && shouldSendSMS(payload.type, prefs)) {
if (!isQuietHours(prefs)) {
await sendSMSNotification(payload, prefs.phone);
} else {
// Queue for delivery after quiet hours
await queueSMSForLater(payload, prefs);
}
}
}
async function sendEmailNotification(
payload: NotificationPayload,
email: string
) {
const { subject, react } = getEmailTemplate(payload);
await resend.emails.send({
from: "Pathfinder <notifications@pathfinder.withunify.org>",
to: email,
subject,
react,
});
}
async function sendSMSNotification(
payload: NotificationPayload,
phone: string
) {
const body = getSMSTemplate(payload);
await twilioClient.messages.create({
body,
to: phone,
from: process.env.TWILIO_PHONE_NUMBER,
});
}
In-App Notifications
UI Components
Copy
// components/notifications/notification-bell.tsx
export function NotificationBell() {
const { notifications, unreadCount, markAsRead } = useNotifications();
const [isOpen, setIsOpen] = useState(false);
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<div className="p-3 border-b">
<h3 className="font-semibold">Notifications</h3>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-4 text-center text-stone-500">
No notifications
</div>
) : (
notifications.map(notif => (
<NotificationItem
key={notif.id}
notification={notif}
onRead={() => markAsRead(notif.id)}
/>
))
)}
</div>
<div className="p-2 border-t">
<Link href="/notifications" className="text-sm text-blue-600">
View all notifications
</Link>
</div>
</PopoverContent>
</Popover>
);
}
Notification Item
Copy
function NotificationItem({
notification,
onRead,
}: {
notification: Notification;
onRead: () => void;
}) {
const icon = getNotificationIcon(notification.type);
const timeAgo = formatDistanceToNow(notification.createdAt);
return (
<div
className={cn(
"p-3 border-b hover:bg-stone-50 cursor-pointer",
!notification.readAt && "bg-blue-50"
)}
onClick={onRead}
>
<div className="flex gap-3">
<div className={cn("p-2 rounded-full", icon.bgColor)}>
<icon.Icon className={cn("h-4 w-4", icon.color)} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-stone-900">
{notification.title}
</p>
<p className="text-sm text-stone-500 truncate">
{notification.message}
</p>
<p className="text-xs text-stone-400 mt-1">{timeAgo}</p>
</div>
</div>
</div>
);
}
Scheduled Notifications
Background job processes upcoming deadlines.Copy
// app/api/cron/notifications/route.ts
export async function GET(request: Request) {
// Verify cron secret
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
// Find upcoming renewals
const upcomingRenewals = await getUpcomingRenewals([30, 14, 7, 3, 1]);
for (const renewal of upcomingRenewals) {
// Check if we already sent this reminder
const alreadySent = await hasNotificationBeenSent(
renewal.userId,
"redetermination_reminder",
renewal.applicationId,
renewal.daysRemaining
);
if (!alreadySent) {
await sendNotification({
userId: renewal.userId,
type: "redetermination_reminder",
data: {
programName: renewal.programName,
renewalDate: renewal.renewalDate,
daysRemaining: renewal.daysRemaining,
},
});
}
}
// Find expiring documents
const expiringDocs = await getExpiringDocuments([30, 7]);
for (const doc of expiringDocs) {
await sendNotification({
userId: doc.userId,
type: "document_expiring",
data: {
documentType: doc.documentType,
expirationDate: doc.expirationDate,
daysRemaining: doc.daysRemaining,
},
});
}
return Response.json({ success: true });
}
Testing Notifications
Copy
// For development, use Resend test mode
const resend = new Resend(process.env.RESEND_API_KEY);
// Preview emails in browser
// npm run email:preview
// Send test notification
await sendNotification({
userId: "test-user-id",
type: "application_status_change",
data: {
programName: "SNAP",
oldStatus: "pending",
newStatus: "approved",
},
});