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.

Document Management

Securely upload, organize, and track the documents needed for benefit applications.

Overview

Benefit applications require supporting documents—pay stubs, ID cards, utility bills. Pathfinder provides:

Document Types

Common document types needed for benefit applications:
const DOCUMENT_TYPES = {
  // Identity
  photo_id: {
    label: "Photo ID",
    description: "Government-issued photo identification",
    examples: ["Driver's license", "State ID", "Passport"],
    mimeTypes: ["image/jpeg", "image/png", "application/pdf"],
  },
  ssn_card: {
    label: "Social Security Card",
    description: "Original or replacement Social Security card",
    mimeTypes: ["image/jpeg", "image/png", "application/pdf"],
  },
  birth_certificate: {
    label: "Birth Certificate",
    description: "Certified copy of birth certificate",
    mimeTypes: ["image/jpeg", "image/png", "application/pdf"],
  },

  // Income
  pay_stubs: {
    label: "Pay Stubs",
    description: "Recent pay stubs showing income",
    examples: ["Last 30 days of pay stubs", "4 most recent stubs"],
    mimeTypes: ["image/jpeg", "image/png", "application/pdf"],
  },
  tax_return: {
    label: "Tax Return",
    description: "Federal or state tax return",
    examples: ["Form 1040", "State tax return"],
    mimeTypes: ["application/pdf"],
  },
  employer_letter: {
    label: "Employer Letter",
    description: "Letter from employer verifying employment/income",
    mimeTypes: ["image/jpeg", "image/png", "application/pdf"],
  },

  // Residency
  utility_bill: {
    label: "Utility Bill",
    description: "Recent utility bill showing current address",
    examples: ["Electric bill", "Gas bill", "Water bill"],
    mimeTypes: ["image/jpeg", "image/png", "application/pdf"],
  },
  lease_agreement: {
    label: "Lease Agreement",
    description: "Current rental lease or agreement",
    mimeTypes: ["application/pdf"],
  },
  bank_statement: {
    label: "Bank Statement",
    description: "Recent bank statement showing address",
    mimeTypes: ["application/pdf"],
  },

  // Other
  immigration_documents: {
    label: "Immigration Documents",
    description: "Green card, work permit, or visa",
    mimeTypes: ["image/jpeg", "image/png", "application/pdf"],
  },
  disability_determination: {
    label: "Disability Determination",
    description: "SSA disability determination letter",
    mimeTypes: ["application/pdf"],
  },
  other: {
    label: "Other Document",
    description: "Other supporting documentation",
    mimeTypes: ["image/jpeg", "image/png", "application/pdf"],
  },
} as const;

Upload Component

Multi-file upload with progress tracking.
// components/documents/upload-progress.tsx
interface UploadFile {
  id: string;
  file: File;
  documentType: DocumentType;
  progress: number;
  status: "pending" | "uploading" | "complete" | "error";
  error?: string;
}

export function DocumentUploader({
  onComplete,
  allowedTypes,
}: {
  onComplete: (documents: Document[]) => void;
  allowedTypes?: DocumentType[];
}) {
  const [files, setFiles] = useState<UploadFile[]>([]);
  const [selectedType, setSelectedType] = useState<DocumentType>("other");

  const handleFilesSelected = (selectedFiles: FileList) => {
    const newFiles: UploadFile[] = Array.from(selectedFiles).map(file => ({
      id: crypto.randomUUID(),
      file,
      documentType: selectedType,
      progress: 0,
      status: "pending",
    }));

    setFiles(prev => [...prev, ...newFiles]);

    // Start uploads
    newFiles.forEach(uploadFile);
  };

  const uploadFile = async (uploadFile: UploadFile) => {
    setFiles(prev =>
      prev.map(f =>
        f.id === uploadFile.id ? { ...f, status: "uploading" } : f
      )
    );

    try {
      const formData = new FormData();
      formData.append("file", uploadFile.file);
      formData.append("documentType", uploadFile.documentType);

      const response = await fetch("/api/documents/upload", {
        method: "POST",
        body: formData,
      });

      if (!response.ok) throw new Error("Upload failed");

      const document = await response.json();

      setFiles(prev =>
        prev.map(f =>
          f.id === uploadFile.id
            ? { ...f, status: "complete", progress: 100 }
            : f
        )
      );

      onComplete([document]);
    } catch (error) {
      setFiles(prev =>
        prev.map(f =>
          f.id === uploadFile.id
            ? { ...f, status: "error", error: error.message }
            : f
        )
      );
    }
  };

  return (
    <div className="space-y-4">
      {/* Document type selector */}
      <div>
        <Label>Document Type</Label>
        <Select value={selectedType} onValueChange={setSelectedType}>
          <SelectTrigger>
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {Object.entries(DOCUMENT_TYPES)
              .filter(([key]) => !allowedTypes || allowedTypes.includes(key))
              .map(([key, type]) => (
                <SelectItem key={key} value={key}>
                  {type.label}
                </SelectItem>
              ))}
          </SelectContent>
        </Select>
      </div>

      {/* Drop zone */}
      <div
        className={cn(
          "border-2 border-dashed rounded-lg p-8 text-center",
          "hover:border-blue-500 hover:bg-blue-50 transition-colors",
          "cursor-pointer"
        )}
        onDrop={handleDrop}
        onDragOver={e => e.preventDefault()}
        onClick={() => inputRef.current?.click()}
      >
        <Upload className="h-8 w-8 mx-auto text-stone-400 mb-2" />
        <p className="text-sm text-stone-600">
          Drag files here or click to browse
        </p>
        <p className="text-xs text-stone-400 mt-1">
          PDF, PNG, or JPG up to 10MB
        </p>
        <input
          ref={inputRef}
          type="file"
          multiple
          accept=".pdf,.png,.jpg,.jpeg"
          className="hidden"
          onChange={e => e.target.files && handleFilesSelected(e.target.files)}
        />
      </div>

      {/* File list with progress */}
      {files.length > 0 && (
        <div className="space-y-2">
          {files.map(file => (
            <div
              key={file.id}
              className="flex items-center gap-3 p-3 bg-stone-50 rounded-lg"
            >
              <FileIcon type={file.file.type} />
              <div className="flex-1 min-w-0">
                <p className="text-sm font-medium truncate">{file.file.name}</p>
                <p className="text-xs text-stone-500">
                  {formatFileSize(file.file.size)}
                </p>
                {file.status === "uploading" && (
                  <div className="mt-1 h-1.5 bg-stone-200 rounded-full overflow-hidden">
                    <div
                      className="h-full bg-blue-600 transition-all"
                      style={{ width: `${file.progress}%` }}
                    />
                  </div>
                )}
              </div>
              {file.status === "complete" && (
                <CheckCircle2 className="h-5 w-5 text-emerald-500" />
              )}
              {file.status === "error" && (
                <XCircle className="h-5 w-5 text-red-500" />
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Document Preview

Preview PDFs and images without downloading.
// components/documents/document-preview.tsx
import * as pdfjsLib from "pdfjs-dist";

// Set worker path
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";

interface DocumentPreviewProps {
  document: Document;
  onClose: () => void;
}

export function DocumentPreview({ document, onClose }: DocumentPreviewProps) {
  const [pdfPages, setPdfPages] = useState<string[]>([]);
  const [currentPage, setCurrentPage] = useState(0);
  const [zoom, setZoom] = useState(1);
  const [rotation, setRotation] = useState(0);

  useEffect(() => {
    if (document.mimeType === "application/pdf") {
      loadPdf();
    }
  }, [document]);

  const loadPdf = async () => {
    const response = await fetch(document.signedUrl);
    const arrayBuffer = await response.arrayBuffer();
    const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;

    const pages: string[] = [];
    for (let i = 1; i <= pdf.numPages; i++) {
      const page = await pdf.getPage(i);
      const viewport = page.getViewport({ scale: 1.5 });

      const canvas = document.createElement("canvas");
      const context = canvas.getContext("2d")!;
      canvas.width = viewport.width;
      canvas.height = viewport.height;

      await page.render({ canvasContext: context, viewport }).promise;
      pages.push(canvas.toDataURL());
    }

    setPdfPages(pages);
  };

  const isPdf = document.mimeType === "application/pdf";
  const isImage = document.mimeType.startsWith("image/");

  return (
    <Dialog open onOpenChange={onClose}>
      <DialogContent className="max-w-4xl h-[80vh]">
        <DialogHeader>
          <DialogTitle>{document.fileName}</DialogTitle>
        </DialogHeader>

        {/* Toolbar */}
        <div className="flex items-center gap-2 p-2 bg-stone-100 rounded">
          <Button
            variant="ghost"
            size="sm"
            onClick={() => setZoom(z => Math.max(0.5, z - 0.25))}
          >
            <ZoomOut className="h-4 w-4" />
          </Button>
          <span className="text-sm">{Math.round(zoom * 100)}%</span>
          <Button
            variant="ghost"
            size="sm"
            onClick={() => setZoom(z => Math.min(3, z + 0.25))}
          >
            <ZoomIn className="h-4 w-4" />
          </Button>

          <div className="w-px h-6 bg-stone-300" />

          <Button
            variant="ghost"
            size="sm"
            onClick={() => setRotation(r => (r + 90) % 360)}
          >
            <RotateCw className="h-4 w-4" />
          </Button>

          {isPdf && pdfPages.length > 1 && (
            <>
              <div className="w-px h-6 bg-stone-300" />
              <Button
                variant="ghost"
                size="sm"
                disabled={currentPage === 0}
                onClick={() => setCurrentPage(p => p - 1)}
              >
                <ChevronLeft className="h-4 w-4" />
              </Button>
              <span className="text-sm">
                {currentPage + 1} / {pdfPages.length}
              </span>
              <Button
                variant="ghost"
                size="sm"
                disabled={currentPage === pdfPages.length - 1}
                onClick={() => setCurrentPage(p => p + 1)}
              >
                <ChevronRight className="h-4 w-4" />
              </Button>
            </>
          )}
        </div>

        {/* Preview area */}
        <div className="flex-1 overflow-auto bg-stone-200 rounded">
          <div
            className="flex items-center justify-center min-h-full p-4"
            style={{
              transform: `scale(${zoom}) rotate(${rotation}deg)`,
              transformOrigin: "center center",
            }}
          >
            {isPdf && pdfPages[currentPage] && (
              <img
                src={pdfPages[currentPage]}
                alt={`Page ${currentPage + 1}`}
                className="max-w-full shadow-lg"
              />
            )}
            {isImage && (
              <img
                src={document.signedUrl}
                alt={document.fileName}
                className="max-w-full shadow-lg"
              />
            )}
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}

Document Checklist

Per-application checklist of required documents.
// components/documents/document-checklist.tsx
interface DocumentChecklistProps {
  applicationId: string;
  requiredDocuments: DocumentRequirement[];
  uploadedDocuments: Document[];
  onUpload: (type: DocumentType) => void;
}

export function DocumentChecklist({
  applicationId,
  requiredDocuments,
  uploadedDocuments,
  onUpload,
}: DocumentChecklistProps) {
  const getDocumentStatus = (requirement: DocumentRequirement) => {
    const uploaded = uploadedDocuments.find(
      d => d.documentType === requirement.type ||
           requirement.alternatives?.includes(d.documentType)
    );

    if (!uploaded) {
      return { status: "missing", document: null };
    }

    if (uploaded.expirationDate && uploaded.expirationDate < new Date()) {
      return { status: "expired", document: uploaded };
    }

    return { status: "complete", document: uploaded };
  };

  const completedCount = requiredDocuments.filter(
    r => getDocumentStatus(r).status === "complete"
  ).length;

  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center justify-between">
          <span>Required Documents</span>
          <Badge variant={completedCount === requiredDocuments.length ? "success" : "secondary"}>
            {completedCount} / {requiredDocuments.length}
          </Badge>
        </CardTitle>
        <CardDescription>
          Upload these documents to complete your application
        </CardDescription>
      </CardHeader>

      <CardContent>
        {/* Progress bar */}
        <div className="mb-6">
          <div className="h-2 bg-stone-200 rounded-full overflow-hidden">
            <div
              className="h-full bg-emerald-500 transition-all"
              style={{
                width: `${(completedCount / requiredDocuments.length) * 100}%`
              }}
            />
          </div>
        </div>

        {/* Document list */}
        <div className="space-y-3">
          {requiredDocuments.map(requirement => {
            const { status, document } = getDocumentStatus(requirement);

            return (
              <div
                key={requirement.type}
                className={cn(
                  "flex items-start gap-3 p-3 rounded-lg border",
                  status === "complete" && "bg-emerald-50 border-emerald-200",
                  status === "missing" && "bg-white border-stone-200",
                  status === "expired" && "bg-amber-50 border-amber-200"
                )}
              >
                {/* Status icon */}
                <div className="mt-0.5">
                  {status === "complete" && (
                    <CheckCircle2 className="h-5 w-5 text-emerald-600" />
                  )}
                  {status === "missing" && (
                    <Circle className="h-5 w-5 text-stone-300" />
                  )}
                  {status === "expired" && (
                    <AlertCircle className="h-5 w-5 text-amber-600" />
                  )}
                </div>

                {/* Details */}
                <div className="flex-1">
                  <div className="flex items-center gap-2">
                    <span className="font-medium">
                      {DOCUMENT_TYPES[requirement.type].label}
                    </span>
                    {requirement.required && (
                      <Badge variant="outline" className="text-xs">
                        Required
                      </Badge>
                    )}
                  </div>
                  <p className="text-sm text-stone-500 mt-0.5">
                    {requirement.description}
                  </p>

                  {document && (
                    <div className="flex items-center gap-2 mt-2">
                      <FileText className="h-4 w-4 text-stone-400" />
                      <span className="text-sm">{document.fileName}</span>
                      {status === "expired" && (
                        <Badge variant="warning" className="text-xs">
                          Expired
                        </Badge>
                      )}
                    </div>
                  )}
                </div>

                {/* Action */}
                <Button
                  variant={status === "missing" ? "default" : "outline"}
                  size="sm"
                  onClick={() => onUpload(requirement.type)}
                >
                  {status === "missing" ? "Upload" : "Replace"}
                </Button>
              </div>
            );
          })}
        </div>
      </CardContent>
    </Card>
  );
}

Database Schema

CREATE TABLE documents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,

  -- File info
  file_name TEXT NOT NULL,
  mime_type TEXT NOT NULL,
  file_size INTEGER NOT NULL,
  storage_path TEXT NOT NULL,

  -- Classification
  document_type TEXT NOT NULL,
  description TEXT,

  -- Expiration tracking
  expiration_date DATE,
  expiration_notified_at TIMESTAMPTZ,

  -- Association
  application_id UUID REFERENCES applications(id) ON DELETE SET NULL,

  -- Timestamps
  created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
  updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);

CREATE INDEX idx_documents_user ON documents(user_id);
CREATE INDEX idx_documents_type ON documents(document_type);
CREATE INDEX idx_documents_expiration ON documents(expiration_date)
  WHERE expiration_date IS NOT NULL;

Security Considerations

  1. Signed URLs: All document access uses time-limited signed URLs
  2. Virus Scanning: Files are scanned before storage (via external service)
  3. Type Validation: MIME type verified server-side, not just client
  4. Size Limits: 10MB per file, configurable per document type
  5. Encryption: Files encrypted at rest in Supabase Storage
// Generate signed URL for document access
async function getSignedUrl(documentId: string): Promise<string> {
  const { data: document } = await supabaseAdmin
    .from("documents")
    .select("storage_path, user_id")
    .eq("id", documentId)
    .single();

  // Verify ownership
  const { user } = await safeAuth();
  if (document.user_id !== user.id) {
    throw new Error("Access denied");
  }

  const { data } = await supabaseAdmin.storage
    .from("documents")
    .createSignedUrl(document.storage_path, 3600); // 1 hour

  return data.signedUrl;
}

Next Steps

Calendar Integration

Add document expirations to calendar

Case Manager

Manage client documents