---
description: Document and PDF patterns for Ballee using @kit/documents for document management and @kit/pdf-core for PDF generation. Use when working with file uploads, document viewers, or generating PDFs.
version: "1.1.0"
updated: "2026-01-06"
---
# Document & PDF Patterns
## @kit/documents Package
The `@kit/documents` package provides a complete document management system with types, components, hooks, and server actions.
### Exports
```typescript
import type { Document, DocumentWithUrl, EditableDocument } from '@kit/documents/types';
import { FileTypeCategory, ThumbnailSizes } from '@kit/documents/constants';
import { getFileCategory, formatFileSize, downloadDocument } from '@kit/documents/utils';
import { useDocumentViewer, useDocumentDownload, useReactPdf } from '@kit/documents/hooks';
import { DocumentList, DocumentViewerDialog, SortableDocumentList } from '@kit/documents/components';
import { getDocumentSignedUrlAction } from '@kit/documents/server';
```
### Core Types
```typescript
// Base document interface
interface Document {
id: string;
fileName: string;
fileSize: number;
mimeType: string;
storagePath: string;
bucket: string;
uploadedAt?: string | null;
description?: string | null;
}
// Document with signed URL for display
interface DocumentWithUrl extends Document {
signedUrl: string;
thumbnailUrl?: string | null;
}
// Editable document with title/order for CRUD lists
interface EditableDocument extends Document {
title: string;
displayOrder?: number | null;
documentType?: string;
}
interface EditableDocumentWithUrl extends EditableDocument {
signedUrl: string;
thumbnailUrl?: string | null;
}
// For document updates
interface DocumentUpdateInput {
title?: string;
description?: string;
}
// For reordering documents
interface DocumentReorderItem {
id: string;
displayOrder: number;
}
// For upload dialogs
interface DocumentUploadInput {
file: File;
title: string;
documentType?: string;
description?: string;
}
```
### Constants
```typescript
// File type categories
import { FileTypeCategory, getFileCategory } from '@kit/documents/constants';
FileTypeCategory.IMAGE // 'image'
FileTypeCategory.PDF // 'pdf'
FileTypeCategory.VIDEO // 'video'
FileTypeCategory.DOCUMENT // 'document'
FileTypeCategory.SPREADSHEET // 'spreadsheet'
FileTypeCategory.OTHER // 'other'
const category = getFileCategory('image/jpeg'); // 'image'
// Thumbnail sizes for image transforms
import { ThumbnailSizes } from '@kit/documents/constants';
ThumbnailSizes.SMALL // { width: 100, height: 100, quality: 80 }
ThumbnailSizes.MEDIUM // { width: 200, height: 200, quality: 80 }
ThumbnailSizes.LARGE // { width: 400, height: 400, quality: 85 }
ThumbnailSizes.XLARGE // { width: 800, height: 800, quality: 90 }
```
### Components
**Display Components:**
```typescript
import {
FileTypeIcon, // Icon based on MIME type
DocumentThumbnail, // Thumbnail with fallback icon
DocumentCard, // Card view with actions
DocumentRow, // List view row
DocumentList, // Simple list wrapper
} from '@kit/documents/components';
```
**Viewer Components:**
```typescript
import {
ImageViewer, // Image preview with pan/zoom
PdfViewer, // PDF preview using react-pdf
DocumentNavigator, // Navigation between documents
DocumentViewerDialog, // Full modal viewer with keyboard shortcuts
} from '@kit/documents/components';
```
**Editable Components:**
```typescript
import {
EditableDocumentCard, // Card with edit/delete actions
SortableDocumentList, // Full CRUD list with reordering
DocumentEditDialog, // Edit metadata dialog
DocumentUploadDialog, // Upload new document dialog
} from '@kit/documents/components';
```
### Hooks
```typescript
import {
useDocumentUrl, // Fetch signed URL for a document
useDocumentDownload, // Download document utility
useDocumentViewer, // Manage viewer state (current doc, zoom, fullscreen)
useDocumentEdit, // Manage edit dialog state
useDocumentReorder, // Manage document ordering
useReactPdf, // PDF viewer utilities
} from '@kit/documents/hooks';
```
### Server Actions
```typescript
import { getDocumentSignedUrlAction } from '@kit/documents/server';
// Get a signed URL for viewing/downloading
const result = await getDocumentSignedUrlAction({
bucket: 'venue-documents',
path: 'venue-123/photo.jpg',
expiresIn: 3600, // Optional: seconds (default 3600)
download: false, // Optional: force download
transform: { // Optional: image transforms
width: 400,
height: 400,
quality: 85,
},
});
if (result.success) {
const url = result.url;
}
```
---
## @kit/pdf-core Package
Shared PDF generation utilities, styles, and components using `@react-pdf/renderer`.
### Exports
```typescript
// Utilities
import {
sanitizeForPdf, // Deep sanitize Supabase data for PDF
nodeStreamToWebStream, // Convert Node stream to Web stream
getPdfResponseHeaders, // Get standard PDF response headers
} from '@kit/pdf-core';
// Style tokens
import {
pdfColors, // Brand colors
pdfFonts, // Font families
pdfFontSizes, // Size scale
pdfSpacing, // Spacing scale
pdfPage, // Page dimensions
} from '@kit/pdf-core';
// Pre-built style objects
import {
headerStyles,
footerStyles,
sectionStyles,
tableStyles,
badgeStyles,
textStyles,
} from '@kit/pdf-core';
// Components
import {
PdfHeader, // Document header with logo
PdfFooter, // Page footer with numbers
PdfSection, // Titled section wrapper
PdfTable, // Data table
PdfBadge, // Status badge
PdfBadgeList, // List of badges
PdfInfoSection, // Key-value info display
PdfFieldRow, // Form field row
PdfNotes, // Notes section
PdfTotals, // Totals section
PdfBankSection, // Bank details section
PdfLegend, // Legend/key section
} from '@kit/pdf-core/components';
```
### PDF Template Pattern
```typescript
// packages/features/my-feature/src/templates/pdf/my-template.tsx
import React from 'react';
import { Document, Page, View, Text } from '@react-pdf/renderer';
import {
PdfHeader,
PdfFooter,
PdfSection,
PdfTable,
pageStyles,
pdfColors,
} from '@kit/pdf-core';
interface MyPdfProps {
data: {
title: string;
items: Array<{ name: string; value: number }>;
};
}
export function MyPdfTemplate({ data }: MyPdfProps) {
return (
);
}
```
---
## Document Management Patterns
### Basic Read-Only Document List
```typescript
import { DocumentList, DocumentViewerDialog } from '@kit/documents/components';
import { useDocumentViewer } from '@kit/documents/hooks';
import type { DocumentWithUrl } from '@kit/documents/types';
interface Props {
documents: DocumentWithUrl[];
}
export function DocumentGallery({ documents }: Props) {
const viewer = useDocumentViewer(documents);
return (
<>
viewer.open(doc)}
onDownload={(doc) => window.open(doc.signedUrl)}
/>
>
);
}
```
### Editable Document List with CRUD
```typescript
import { SortableDocumentList } from '@kit/documents/components';
import type { EditableDocumentWithUrl, DocumentUploadInput } from '@kit/documents/types';
interface Props {
documents: EditableDocumentWithUrl[];
documentTypes: Array<{ value: string; label: string }>;
onUpload: (input: DocumentUploadInput) => Promise;
onEdit: (id: string, data: { title?: string; description?: string }) => Promise;
onDelete: (id: string) => Promise;
onReorder: (items: Array<{ id: string; displayOrder: number }>) => Promise;
}
export function VenueDocumentsList({
documents,
documentTypes,
onUpload,
onEdit,
onDelete,
onReorder,
}: Props) {
return (
);
}
```
### Adapter Pattern (Database to Component Types)
When your database schema doesn't match `@kit/documents` types exactly, create an adapter:
```typescript
// adapters/venue-document-adapter.ts
import type { EditableDocumentWithUrl, Document } from '@kit/documents/types';
interface VenueDocument {
id: string;
venue_id: string;
title: string;
document_type: string | null;
storage_path: string;
file_name: string;
file_size: number;
mime_type: string;
display_order: number | null;
created_at: string;
}
export function toEditableDocument(
doc: VenueDocument,
signedUrl: string,
thumbnailUrl?: string,
): EditableDocumentWithUrl {
return {
id: doc.id,
title: doc.title,
fileName: doc.file_name,
fileSize: doc.file_size,
mimeType: doc.mime_type,
storagePath: doc.storage_path,
bucket: 'venue-documents',
displayOrder: doc.display_order,
documentType: doc.document_type ?? undefined,
uploadedAt: doc.created_at,
signedUrl,
thumbnailUrl,
};
}
```
---
## PDF Generation Patterns
**CRITICAL**: Use API Routes for PDF generation, not Server Actions. See `api-patterns` skill for the complete pattern.
### Quick Reference
```typescript
// 1. Fetch data with RLS-protected client
const data = await fetchData(client, id);
// 2. Sanitize for PDF (REQUIRED!)
import { sanitizeForPdf } from '@kit/pdf-core';
const safeData = sanitizeForPdf(data);
// 3. Render to stream
import { renderToStream } from '@react-pdf/renderer';
const stream = await renderToStream();
// 4. Return streaming response
import { nodeStreamToWebStream, getPdfResponseHeaders } from '@kit/pdf-core';
return new Response(nodeStreamToWebStream(stream), {
headers: getPdfResponseHeaders('my-document.pdf'),
});
```
### Existing PDF Routes
| Route | Purpose |
|-------|---------|
| `/api/pdf/hire-order` | Admin hire order documents |
| `/api/pdf/resume` | Dancer resume/CV |
| `/api/pdf/cast-sheet` | Cast assignment sheets |
---
## Unified Storage Library (@kit/shared/storage)
The unified storage library provides centralized utilities for storage URLs, path extraction, and thumbnail generation.
### Exports
```typescript
import {
// URL Service
StorageUrlService,
createStorageUrlService,
StorageBuckets,
SignedUrlExpiry,
type StorageBucket,
type SignedUrlOptions,
// Path Service
StoragePathService,
extractStoragePath,
generatePublicThumbnailUrl,
// Constants
ThumbnailSizes,
type ThumbnailSize,
} from '@kit/shared/storage';
```
### Storage Buckets
```typescript
// Account/Profile
StorageBuckets.ACCOUNT_IMAGE // 'account_image'
StorageBuckets.PROFILE_MEDIA // 'profile-media' (public)
StorageBuckets.DANCER_MEDIA // 'dancer-media'
// Documents
StorageBuckets.VENUE_DOCUMENTS // 'venue-documents'
StorageBuckets.PRODUCTION_DOCUMENTS // 'production-documents'
StorageBuckets.LEGAL_DOCUMENTS // 'legal-documents'
StorageBuckets.REIMBURSEMENT_DOCUMENTS // 'reimbursement-documents'
// Legal/Compliance
StorageBuckets.CONTRACTS // 'contracts'
StorageBuckets.IDENTITY_DOCUMENTS // 'identity-documents'
StorageBuckets.INVOICE_PDFS // 'invoice-pdfs'
```
### Signed URL Expiry Times
```typescript
SignedUrlExpiry.IMMEDIATE_DISPLAY // 3600 (1 hour) - UI display
SignedUrlExpiry.DOWNLOAD // 86400 (24 hours) - download links
SignedUrlExpiry.PROFILE_PHOTO // 604800 (7 days) - cached URLs
SignedUrlExpiry.ADMIN_REVIEW // 86400 (24 hours) - admin views
SignedUrlExpiry.MAX // 604800 (7 days max)
SignedUrlExpiry.MIN // 60 (minimum)
```
### StorageUrlService
```typescript
import { StorageUrlService, StorageBuckets, SignedUrlExpiry } from '@kit/shared/storage';
const service = new StorageUrlService(client);
// Single signed URL
const result = await service.getSignedUrl(
StorageBuckets.VENUE_DOCUMENTS,
'venue-123/document.pdf',
{ expiresIn: SignedUrlExpiry.DOWNLOAD }
);
// Batch signed URLs (more efficient for multiple files)
const results = await service.getBatchSignedUrls(
StorageBuckets.VENUE_DOCUMENTS,
['path1.pdf', 'path2.pdf'],
{ expiresIn: SignedUrlExpiry.IMMEDIATE_DISPLAY }
);
// Enrich items with signed URLs
const docsWithUrls = await service.enrichWithSignedUrls(
StorageBuckets.VENUE_DOCUMENTS,
documents,
(doc) => doc.storage_path,
(doc, url) => ({ ...doc, signedUrl: url }),
);
// With thumbnail transform
const result = await service.getSignedUrl(
StorageBuckets.DANCER_MEDIA,
'user-123/photo.jpg',
{
expiresIn: SignedUrlExpiry.IMMEDIATE_DISPLAY,
transform: ThumbnailSizes.MEDIUM,
}
);
```
### StoragePathService (Path Extraction)
Extract storage paths from signed URLs, public URLs, or bucket-prefixed paths:
```typescript
import { StoragePathService, extractStoragePath, StorageBuckets } from '@kit/shared/storage';
// Extract from signed URL
const path = extractStoragePath(
'https://xxx.supabase.co/storage/v1/object/sign/identity-documents/user-123/doc.pdf?token=...',
StorageBuckets.IDENTITY_DOCUMENTS
);
// Result: 'user-123/doc.pdf'
// Extract from bucket-prefixed path
const path2 = extractStoragePath('identity-documents/user-123/doc.pdf');
// Result: 'user-123/doc.pdf'
// Detect bucket from path
const bucket = StoragePathService.detectBucket(url);
// Result: 'identity-documents'
// Validate path
const result = StoragePathService.validate(storagePath);
if (result.isSuccess) {
// Use validated path
}
```
### Thumbnail Generation
```typescript
import { ThumbnailSizes, generatePublicThumbnailUrl } from '@kit/shared/storage';
// Preset sizes
ThumbnailSizes.SMALL // { width: 100, height: 100, quality: 80 }
ThumbnailSizes.MEDIUM // { width: 200, height: 200, quality: 80 }
ThumbnailSizes.LARGE // { width: 400, height: 400, quality: 85 }
ThumbnailSizes.XLARGE // { width: 800, height: 800, quality: 90 }
// For signed URLs - use transform option
const result = await service.getSignedUrl(bucket, path, {
transform: ThumbnailSizes.MEDIUM,
});
// For public URLs - use generatePublicThumbnailUrl
const thumbnailUrl = generatePublicThumbnailUrl(publicUrl, {
width: 200,
height: 200,
quality: 80,
});
```
### URL Storage Best Practice
**CRITICAL**: Always store raw storage paths in the database, never signed URLs (they expire).
```typescript
// ✅ CORRECT - Store raw path
await client.from('documents').insert({
storage_path: 'user-123/photo.jpg', // Raw path only
});
// ❌ WRONG - Storing signed URL (will expire!)
await client.from('documents').insert({
file_url: signedUrl, // This will break after expiry!
});
// Generate signed URLs on-demand when displaying
const result = await service.getSignedUrl(bucket, doc.storage_path);
```
---
## Anti-Patterns
```typescript
// ❌ WRONG - Server Action for PDF generation
'use server';
import { renderToBuffer } from '@react-pdf/renderer';
export async function generatePdf(data) {
return await renderToBuffer(); // hasOwnProperty error!
}
// ✅ CORRECT - Use API Route (see api-patterns skill)
// ❌ WRONG - Raw Supabase data to PDF
const data = await client.from('table').select('*').single();
return ; // May fail with undefined/null
// ✅ CORRECT - Sanitize first
import { sanitizeForPdf } from '@kit/pdf-core';
const safeData = sanitizeForPdf(data);
return ;
// ❌ WRONG - Hardcoded bucket names
await client.storage.from('venue-documents').upload(...);
// ✅ CORRECT - Use constants
import { StorageBuckets } from '@kit/shared/storage';
await client.storage.from(StorageBuckets.VENUE_DOCUMENTS).upload(...);
```
---
## Related Skills
| Need | Skill |
|------|-------|
| Storage service patterns | `service-patterns` |
| PDF API routes | `api-patterns` |
| UI components | `ui-patterns` |
| Database migrations for document tables | `database-migration-manager` |
| RLS policies for document access | `rls-policy-generator` |