Skip to main content

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

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

Calendar Integration

Export deadlines to user calendars

Document Management

Upload and track required documents