Skip to main content

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