Documentation Index
Fetch the complete documentation index at: https://docs-terra.withunify.org/llms.txt
Use this file to discover all available pages before exploring further.
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.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
-- 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
// 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
// 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
// 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.// 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.// 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
// 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
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.// 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
// 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",
},
});
Next Steps
Calendar Integration
Export deadlines to user calendars
Document Management
Upload and track required documents