Two-plan wave structure for PREV-01: preview API route + modal (Plan 01, Wave 1) then PreparePanel/FieldPlacer wiring + human verification (Plan 02, Wave 2). Send button gated on previewToken staleness detection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
228 lines
11 KiB
Markdown
228 lines
11 KiB
Markdown
---
|
|
phase: 12-filled-document-preview
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- src/app/api/documents/[id]/preview/route.ts
|
|
- src/app/portal/(protected)/documents/[docId]/_components/PreviewModal.tsx
|
|
autonomous: true
|
|
requirements:
|
|
- PREV-01
|
|
|
|
must_haves:
|
|
truths:
|
|
- "POST /api/documents/[id]/preview returns PDF bytes for any document with valid field data"
|
|
- "Preview route uses a versioned _preview_{timestamp}.pdf path — never overwrites _prepared.pdf"
|
|
- "Preview route applies the same 422 guards as prepare route (agent-signature missing, agent-initials missing)"
|
|
- "Preview files are deleted immediately after reading (fire-and-forget unlink)"
|
|
- "PreviewModal renders PDF from ArrayBuffer using react-pdf Document + Page with prev/next navigation"
|
|
- "PreviewModal configures pdfjs.GlobalWorkerOptions.workerSrc independently (not inherited from PdfViewer)"
|
|
artifacts:
|
|
- path: "teressa-copeland-homes/src/app/api/documents/[id]/preview/route.ts"
|
|
provides: "POST route — generates preview PDF and returns bytes"
|
|
exports: ["POST"]
|
|
- path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreviewModal.tsx"
|
|
provides: "Modal component rendering PDF from ArrayBuffer"
|
|
exports: ["PreviewModal"]
|
|
key_links:
|
|
- from: "src/app/api/documents/[id]/preview/route.ts"
|
|
to: "src/lib/pdf/prepare-document.ts"
|
|
via: "import preparePdf"
|
|
pattern: "preparePdf\\(srcPath, previewPath"
|
|
- from: "src/app/portal/.../PreviewModal.tsx"
|
|
to: "react-pdf Document"
|
|
via: "file={pdfBytes} ArrayBuffer prop"
|
|
pattern: "file=\\{pdfBytes\\}"
|
|
---
|
|
|
|
<objective>
|
|
Create the preview API route and PreviewModal component that together enable the agent to see a fully-prepared PDF in a modal before sending.
|
|
|
|
Purpose: PREV-01 requires the agent see a live filled preview (text, signatures, stamps embedded) before the Send button becomes available. Plan 01 builds the two new artifacts. Plan 02 wires them into PreparePanel and gates the Send button.
|
|
|
|
Output:
|
|
- POST /api/documents/[id]/preview — auth-guarded route that calls preparePdf() to a versioned temp path, streams bytes back, then deletes the temp file
|
|
- PreviewModal component — react-pdf Document/Page modal with prev/next page navigation
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
|
|
<interfaces>
|
|
<!-- Key contracts the executor needs. No codebase exploration required. -->
|
|
|
|
From teressa-copeland-homes/src/lib/pdf/prepare-document.ts:
|
|
```typescript
|
|
export async function preparePdf(
|
|
srcPath: string, // Absolute path to source PDF
|
|
destPath: string, // Absolute path to write prepared PDF
|
|
textFields: Record<string, string>,
|
|
sigFields: SignatureFieldData[],
|
|
agentSignatureData: string | null = null,
|
|
agentInitialsData: string | null = null,
|
|
): Promise<void>
|
|
```
|
|
|
|
From teressa-copeland-homes/src/lib/db/schema.ts (existing pattern):
|
|
```typescript
|
|
// getFieldType coalesces missing type field to 'client-signature'
|
|
export function getFieldType(f: SignatureFieldData): SignatureFieldType
|
|
export type SignatureFieldData = { ... }
|
|
// users table columns (relevant subset):
|
|
// agentSignatureData: text()
|
|
// agentInitialsData: text()
|
|
```
|
|
|
|
From react-pdf (installed v10.4.1):
|
|
```typescript
|
|
// node_modules/react-pdf/dist/shared/types.d.ts
|
|
export type File = string | ArrayBuffer | Blob | Source | null;
|
|
// Document accepts ArrayBuffer directly as the file prop
|
|
```
|
|
|
|
From existing prepare route pattern (src/app/api/documents/[id]/prepare/route.ts):
|
|
```typescript
|
|
// Auth guard pattern
|
|
const session = await auth();
|
|
if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });
|
|
|
|
// Path construction pattern
|
|
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
|
|
const srcPath = path.join(UPLOADS_DIR, doc.filePath);
|
|
// Path traversal guard
|
|
if (!previewPath.startsWith(UPLOADS_DIR)) return new Response('Forbidden', { status: 403 });
|
|
|
|
// 422 guard pattern (mirror exactly in preview route)
|
|
const hasAgentSigFields = sigFields.some(f => getFieldType(f) === 'agent-signature');
|
|
if (hasAgentSigFields && !agentSignatureData) {
|
|
return Response.json({ error: 'agent-signature-missing', message: 'No agent signature saved.' }, { status: 422 });
|
|
}
|
|
const hasAgentInitialsFields = sigFields.some(f => getFieldType(f) === 'agent-initials');
|
|
if (hasAgentInitialsFields && !agentInitialsData) {
|
|
return Response.json({ error: 'agent-initials-missing', message: 'No agent initials saved.' }, { status: 422 });
|
|
}
|
|
```
|
|
|
|
From existing PdfViewer worker configuration (pattern to replicate in PreviewModal):
|
|
```typescript
|
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
|
import.meta.url,
|
|
).toString();
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: POST /api/documents/[id]/preview route</name>
|
|
<files>teressa-copeland-homes/src/app/api/documents/[id]/preview/route.ts</files>
|
|
<action>
|
|
Create the directory `src/app/api/documents/[id]/preview/` and write `route.ts` as a Next.js Route Handler.
|
|
|
|
The route must:
|
|
1. Auth guard: `const session = await auth(); if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });`
|
|
2. Params: `const { id } = await params;` — params is `Promise<{ id: string }>` (Next.js 15 async params)
|
|
3. Parse body: `const body = await req.json() as { textFillData?: Record<string, string> };`
|
|
4. Fetch document from DB: `db.query.documents.findFirst({ where: eq(documents.id, id) })` — return 404 if not found, 422 if no filePath
|
|
5. Build versioned preview path: `doc.filePath.replace(/\.pdf$/, \`_preview_${Date.now()}.pdf\`)` joined with UPLOADS_DIR — NOT `_prepared.pdf`
|
|
6. Path traversal guard: `if (!previewPath.startsWith(UPLOADS_DIR)) return new Response('Forbidden', { status: 403 });`
|
|
7. Fetch agent data: single `db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { agentSignatureData: true, agentInitialsData: true } })`
|
|
8. Apply 422 guards (mirror prepare route exactly):
|
|
- `hasAgentSigFields && !agentSignatureData` → 422 with `{ error: 'agent-signature-missing' }`
|
|
- `hasAgentInitialsFields && !agentInitialsData` → 422 with `{ error: 'agent-initials-missing' }`
|
|
9. Wrap in try/finally: `try { await preparePdf(srcPath, previewPath, textFields, sigFields, agentSigData, agentInitialsData); const pdfBytes = await readFile(previewPath); return new Response(pdfBytes, { headers: { 'Content-Type': 'application/pdf' } }); } finally { unlink(previewPath).catch(() => {}); }`
|
|
10. The try/finally ensures the temp file is always deleted even if readFile throws.
|
|
|
|
Imports: `import { auth } from '@/lib/auth'`, `import { db } from '@/lib/db'`, `import { documents, users } from '@/lib/db/schema'`, `import { getFieldType } from '@/lib/db/schema'`, `import type { SignatureFieldData } from '@/lib/db/schema'`, `import { eq } from 'drizzle-orm'`, `import { preparePdf } from '@/lib/pdf/prepare-document'`, `import path from 'node:path'`, `import { readFile, unlink } from 'node:fs/promises'`
|
|
|
|
Do NOT import `rename` or `writeFile` — preparePdf handles its own write internally.
|
|
Do NOT update any document DB columns (status, preparedFilePath) — this is preview only.
|
|
</action>
|
|
<verify>
|
|
```bash
|
|
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | grep "preview/route"
|
|
```
|
|
No TypeScript errors in preview/route.ts.
|
|
</verify>
|
|
<done>File exists at src/app/api/documents/[id]/preview/route.ts; exports POST; TypeScript compiles without errors in that file; does not import rename/writeFile; uses try/finally for cleanup.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: PreviewModal component</name>
|
|
<files>teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreviewModal.tsx</files>
|
|
<action>
|
|
Create `PreviewModal.tsx` as a `'use client'` component.
|
|
|
|
Props interface:
|
|
```typescript
|
|
interface PreviewModalProps {
|
|
pdfBytes: ArrayBuffer;
|
|
onClose: () => void;
|
|
}
|
|
```
|
|
|
|
Implementation requirements:
|
|
1. Configure pdfjs worker at module scope (required — PreviewModal is a new module independent of PdfViewer):
|
|
```typescript
|
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
|
import.meta.url,
|
|
).toString();
|
|
```
|
|
2. Import CSS layers that PdfViewer uses: `import 'react-pdf/dist/Page/AnnotationLayer.css'` and `import 'react-pdf/dist/Page/TextLayer.css'`
|
|
3. State: `const [numPages, setNumPages] = useState(0)` and `const [pageNumber, setPageNumber] = useState(1)`
|
|
4. Pass `pdfBytes` (the ArrayBuffer state variable from parent) directly to `<Document file={pdfBytes}>` — NEVER wrap in `new Uint8Array()` or rebuild on each render; the parent stores it in useState so it is a stable reference
|
|
5. `onLoadSuccess`: `({ numPages }) => setNumPages(numPages)`
|
|
6. Use fixed overlay: `position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 50; display: flex; align-items: center; justify-content: center` — apply as Tailwind classes or inline style (inline style matches existing PreparePanel pattern)
|
|
7. Modal inner container: white background, max-width 900px, 90vw width, max-height 90vh, overflow-y auto, padding 16px
|
|
8. Navigation: Prev button (disabled when pageNumber <= 1), page counter `{pageNumber} / {numPages || '?'}`, Next button (disabled when pageNumber >= numPages), Close button
|
|
9. Render: `<Document file={pdfBytes} onLoadSuccess={...}><Page pageNumber={pageNumber} /></Document>`
|
|
|
|
Import: `import { Document, Page, pdfjs } from 'react-pdf'`
|
|
|
|
This component does NOT need to call any API. It receives bytes from the parent.
|
|
</action>
|
|
<verify>
|
|
```bash
|
|
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | grep "PreviewModal"
|
|
```
|
|
No TypeScript errors in PreviewModal.tsx.
|
|
</verify>
|
|
<done>File exists at src/app/portal/(protected)/documents/[docId]/_components/PreviewModal.tsx; exports PreviewModal with correct props; configures pdfjs worker; renders Document with ArrayBuffer file prop; TypeScript compiles cleanly.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
```bash
|
|
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
|
|
```
|
|
Zero TypeScript errors project-wide.
|
|
|
|
```bash
|
|
ls /Users/ccopeland/temp/red/teressa-copeland-homes/src/app/api/documents/\[id\]/preview/route.ts && ls /Users/ccopeland/temp/red/teressa-copeland-homes/src/app/portal/\(protected\)/documents/\[docId\]/_components/PreviewModal.tsx
|
|
```
|
|
Both files exist.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- POST /api/documents/[id]/preview route exists, auth-guarded, uses versioned path, mirrors 422 guards from prepare route, cleans up temp file in try/finally
|
|
- PreviewModal exists, accepts ArrayBuffer prop, configures pdfjs worker independently, renders react-pdf Document/Page with prev/next navigation
|
|
- Zero TypeScript compilation errors
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/12-filled-document-preview/12-01-SUMMARY.md` following the summary template.
|
|
</output>
|