Skip to main content

Notifications System

Keep users informed about application deadlines, status changes, expiring documents, and new program matches.

Overview

Pathfinder supports three notification channels:

Notification Types

TypeChannel DefaultTiming
Application Status ChangeEmail + In-AppImmediate
Redetermination ReminderEmail + SMS30, 14, 7 days before
Document ExpiringEmail30, 7 days before
New Program MatchEmailWeekly digest
Application DeadlineEmail + SMS7, 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