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
- Signed URLs: All document access uses time-limited signed URLs
- Virus Scanning: Files are scanned before storage (via external service)
- Type Validation: MIME type verified server-side, not just client
- Size Limits: 10MB per file, configurable per document type
- 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