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:Copy
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.Copy
// 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.Copy
// 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.Copy
// 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
Copy
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
Copy
// 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;
}