docs(04-pdf-ingest): create phase 4 plan (4 plans, 4 waves)

Plans 04-01 through 04-04 cover DOC-01, DOC-02, DOC-03:
schema/seed, API routes, UI modal + PDF viewer, human verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chandler Copeland
2026-03-19 21:28:39 -06:00
parent 521646f312
commit c896fa5e82
5 changed files with 1081 additions and 2 deletions

View File

@@ -81,9 +81,13 @@ Plans:
2. Agent can view the uploaded PDF rendered in the browser on the document detail page 2. Agent can view the uploaded PDF rendered in the browser on the document detail page
3. The forms library reflects updated forms on at least a monthly basis (automated sync or manual re-upload path defined and working) 3. The forms library reflects updated forms on at least a monthly basis (automated sync or manual re-upload path defined and working)
4. Uploaded PDFs are stored in the local uploads directory (Docker volume on home server) and survive server restarts 4. Uploaded PDFs are stored in the local uploads directory (Docker volume on home server) and survive server restarts
**Plans**: TBD **Plans**: 4 plans
Plans: none yet Plans:
- [ ] 04-01-PLAN.md — Schema (form_templates + documents columns), migration, seed script
- [ ] 04-02-PLAN.md — API routes: forms-library, documents POST, documents/[id]/file
- [ ] 04-03-PLAN.md — Add Document modal, PDF viewer page, react-pdf install
- [ ] 04-04-PLAN.md — Full Phase 4 human verification checkpoint
### Phase 5: PDF Fill and Field Mapping ### Phase 5: PDF Fill and Field Mapping
**Goal**: Agent can place signature fields on any page of a PDF and fill in client/property text fields before sending **Goal**: Agent can place signature fields on any page of a PDF and fill in client/property text fields before sending

View File

@@ -0,0 +1,218 @@
---
phase: 04-pdf-ingest
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- teressa-copeland-homes/src/lib/db/schema.ts
- teressa-copeland-homes/src/lib/db/index.ts
- teressa-copeland-homes/scripts/seed-forms.ts
- teressa-copeland-homes/seeds/forms/.gitkeep
- teressa-copeland-homes/package.json
autonomous: true
requirements:
- DOC-01
- DOC-02
must_haves:
truths:
- "form_templates table exists in the database with id, name, filename, createdAt, updatedAt columns"
- "documents table has filePath and formTemplateId columns added"
- "Running npm run seed:forms reads seeds/forms/ directory and upserts rows into form_templates"
- "seeds/forms/ directory exists and is tracked in git (via .gitkeep)"
artifacts:
- path: "teressa-copeland-homes/src/lib/db/schema.ts"
provides: "formTemplates table definition and updated documents table"
contains: "formTemplates"
- path: "teressa-copeland-homes/scripts/seed-forms.ts"
provides: "CLI seed script for populating form_templates from seeds/forms/"
exports: []
- path: "teressa-copeland-homes/seeds/forms/.gitkeep"
provides: "Tracked placeholder for manually-downloaded PDF seed files"
key_links:
- from: "scripts/seed-forms.ts"
to: "seeds/forms/"
via: "node:fs/promises readdir"
pattern: "readdir.*seeds/forms"
- from: "scripts/seed-forms.ts"
to: "src/lib/db/schema.ts formTemplates"
via: "drizzle insert onConflictDoUpdate"
pattern: "onConflictDoUpdate"
---
<objective>
Extend the database schema with a form_templates table and add missing columns to the documents table. Create the seed directory structure and seed script that fulfills the monthly-sync requirement (DOC-02) via manual re-run.
Purpose: Every subsequent Phase 4 plan depends on this schema and the seed mechanism. Getting the data layer right first prevents downstream rework.
Output: Updated schema.ts, new Drizzle migration applied, seed script at npm run seed:forms, seeds/forms/ directory tracked.
</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
@.planning/phases/04-pdf-ingest/04-CONTEXT.md
@.planning/phases/04-pdf-ingest/04-RESEARCH.md
<interfaces>
<!-- Existing schema (teressa-copeland-homes/src/lib/db/schema.ts) -->
```typescript
import { pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const documentStatusEnum = pgEnum("document_status", ["Draft","Sent","Viewed","Signed"]);
export const clients = pgTable("clients", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
email: text("email").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const documents = pgTable("documents", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
clientId: text("client_id").notNull().references(() => clients.id, { onDelete: "cascade" }),
status: documentStatusEnum("status").notNull().default("Draft"),
sentAt: timestamp("sent_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add formTemplates table and extend documents table in schema.ts</name>
<files>
teressa-copeland-homes/src/lib/db/schema.ts
teressa-copeland-homes/seeds/forms/.gitkeep
</files>
<action>
Edit teressa-copeland-homes/src/lib/db/schema.ts:
1. Add `integer` to the drizzle-orm/pg-core import.
2. Add the `formTemplates` table ABOVE the `documents` table (must precede its reference):
```typescript
export const formTemplates = pgTable("form_templates", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
filename: text("filename").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
```
3. Add two columns to the `documents` table:
```typescript
formTemplateId: text("form_template_id").references(() => formTemplates.id), // nullable: custom uploads have no template
filePath: text("file_path"), // relative path within uploads/ e.g. "clients/{clientId}/{uuid}.pdf"; nullable for legacy rows
```
NOTE: Use `text` for IDs (not `integer`) — the existing schema uses `text` PKs with crypto.randomUUID() throughout. The formTemplates.id is also text. Keep consistent.
Create `teressa-copeland-homes/seeds/forms/.gitkeep` (empty file) so the directory is tracked.
Then run the migration:
```bash
cd teressa-copeland-homes && npx drizzle-kit generate && npx drizzle-kit migrate
```
Confirm migration applied without errors.
</action>
<verify>
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx drizzle-kit migrate 2>&1 | tail -5
```
Expected: "No migrations to run" or "Applied N migrations" (no errors).
</verify>
<done>form_templates table and new documents columns exist in the database. seeds/forms/ directory tracked in git.</done>
</task>
<task type="auto">
<name>Task 2: Create seed script and wire npm run seed:forms</name>
<files>
teressa-copeland-homes/scripts/seed-forms.ts
teressa-copeland-homes/package.json
</files>
<action>
Create `teressa-copeland-homes/scripts/seed-forms.ts`:
```typescript
import 'dotenv/config';
import { readdir } from 'node:fs/promises';
import path from 'node:path';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { formTemplates } from '@/lib/db/schema';
const SEEDS_DIR = path.join(process.cwd(), 'seeds', 'forms');
async function seedForms() {
const files = await readdir(SEEDS_DIR);
const pdfs = files.filter(f => f.endsWith('.pdf'));
if (pdfs.length === 0) {
console.log('No PDF files found in seeds/forms/. Add PDFs downloaded from the SkySlope portal and re-run.');
return;
}
let seeded = 0;
for (const filename of pdfs) {
const name = filename
.replace(/\.pdf$/i, '')
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
await db.insert(formTemplates)
.values({ name, filename })
.onConflictDoUpdate({
target: formTemplates.filename,
set: { name, updatedAt: new Date() },
});
seeded++;
}
console.log(`Seeded ${seeded} forms into form_templates.`);
process.exit(0);
}
seedForms().catch(err => { console.error(err); process.exit(1); });
```
NOTE: The project uses `.env.local` not `.env`. Check how the existing seed-clients.ts (Phase 3) handles env loading — mirror that pattern exactly (likely `DOTENV_CONFIG_PATH=.env.local` prefix per the STATE.md decision). If the project uses `dotenv/config` with that env var, replace the top import accordingly.
Add to `teressa-copeland-homes/package.json` scripts:
```json
"seed:forms": "DOTENV_CONFIG_PATH=.env.local npx tsx scripts/seed-forms.ts"
```
Verify it runs cleanly on an empty seeds/forms/ dir (prints the "No PDF files" message and exits 0).
</action>
<verify>
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run seed:forms 2>&1
```
Expected: "No PDF files found in seeds/forms/" message (or seeds count if PDFs present). No unhandled errors.
</verify>
<done>npm run seed:forms runs without error. Monthly sync workflow: agent downloads PDFs to seeds/forms/, re-runs npm run seed:forms.</done>
</task>
</tasks>
<verification>
- `form_templates` table in schema.ts with id (text PK), name, filename (unique), createdAt, updatedAt
- `documents` table has `formTemplateId` (text, nullable) and `filePath` (text, nullable) columns
- Migration applied: `npx drizzle-kit migrate` reports no pending migrations
- `seeds/forms/.gitkeep` exists
- `npm run seed:forms` exits 0 (empty dir case: prints guidance message)
</verification>
<success_criteria>
Schema changes applied to the local PostgreSQL database. Seed script runnable. Monthly sync mechanism documented and working (re-run after adding PDFs to seeds/forms/).
</success_criteria>
<output>
After completion, create `.planning/phases/04-pdf-ingest/04-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,295 @@
---
phase: 04-pdf-ingest
plan: 02
type: execute
wave: 2
depends_on:
- 04-01
files_modified:
- teressa-copeland-homes/src/app/api/forms-library/route.ts
- teressa-copeland-homes/src/app/api/documents/route.ts
- teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts
autonomous: true
requirements:
- DOC-01
- DOC-02
must_haves:
truths:
- "GET /api/forms-library returns authenticated JSON list of form templates ordered by name"
- "POST /api/documents copies seed PDF to uploads/clients/{clientId}/{uuid}.pdf and inserts a documents row"
- "GET /api/documents/{id}/file streams the PDF bytes to an authenticated agent with path traversal protection"
- "Unauthenticated requests to all three routes return 401"
artifacts:
- path: "teressa-copeland-homes/src/app/api/forms-library/route.ts"
provides: "GET endpoint — authenticated forms library list"
exports: ["GET"]
- path: "teressa-copeland-homes/src/app/api/documents/route.ts"
provides: "POST endpoint — create document from template or file picker"
exports: ["POST"]
- path: "teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts"
provides: "GET endpoint — authenticated PDF file streaming with path traversal guard"
exports: ["GET"]
key_links:
- from: "src/app/api/documents/route.ts"
to: "uploads/clients/{clientId}/"
via: "node:fs/promises copyFile + mkdir"
pattern: "copyFile.*seeds/forms"
- from: "src/app/api/documents/[id]/file/route.ts"
to: "uploads/"
via: "readFile after startsWith guard"
pattern: "startsWith.*UPLOADS_BASE"
---
<objective>
Build the three API routes that back the forms library modal, document creation, and authenticated PDF serving. These routes are the server-side contract that the UI in Plan 03 calls.
Purpose: Separating API layer into its own plan keeps Plan 03 focused on UI. Executors get clean contracts to code against.
Output: Three authenticated API routes. PDF files never exposed as public static assets.
</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/phases/04-pdf-ingest/04-CONTEXT.md
@.planning/phases/04-pdf-ingest/04-RESEARCH.md
@.planning/phases/04-pdf-ingest/04-01-SUMMARY.md
<interfaces>
<!-- From schema.ts after Plan 01 -->
```typescript
// formTemplates table
export const formTemplates = pgTable("form_templates", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
filename: text("filename").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
// documents table (extended)
export const documents = pgTable("documents", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
clientId: text("client_id").notNull().references(() => clients.id, { onDelete: "cascade" }),
status: documentStatusEnum("status").notNull().default("Draft"),
formTemplateId: text("form_template_id").references(() => formTemplates.id),
filePath: text("file_path"),
sentAt: timestamp("sent_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
```
<!-- Auth pattern from existing portal routes -->
```typescript
import { auth } from '@/lib/auth';
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: GET /api/forms-library — authenticated template list</name>
<files>
teressa-copeland-homes/src/app/api/forms-library/route.ts
</files>
<action>
Create `src/app/api/forms-library/route.ts`:
```typescript
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { formTemplates } from '@/lib/db/schema';
import { asc } from 'drizzle-orm';
export async function GET() {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const forms = await db
.select({ id: formTemplates.id, name: formTemplates.name, filename: formTemplates.filename })
.from(formTemplates)
.orderBy(asc(formTemplates.name));
return Response.json(forms);
}
```
NOTE: Check the existing API routes in the project for the exact import path for `auth` and `db` — may be `@/lib/auth` or `@/auth`. Mirror exactly what Phase 3 API routes use.
</action>
<verify>
```bash
curl -s http://localhost:3000/api/forms-library | head -c 100
```
Expected: `Unauthorized` (401) — confirms auth check works. (Full test requires a valid session cookie.)
</verify>
<done>GET /api/forms-library returns 401 for unauthenticated requests. Returns JSON array when authenticated.</done>
</task>
<task type="auto">
<name>Task 2: POST /api/documents and GET /api/documents/[id]/file</name>
<files>
teressa-copeland-homes/src/app/api/documents/route.ts
teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts
</files>
<action>
Create `src/app/api/documents/route.ts`:
```typescript
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documents, formTemplates } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
const SEEDS_DIR = path.join(process.cwd(), 'seeds', 'forms');
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
export async function POST(req: Request) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const contentType = req.headers.get('content-type') ?? '';
let clientId: string;
let name: string;
let formTemplateId: string | undefined;
let fileBuffer: ArrayBuffer | undefined;
let originalFilename: string | undefined;
if (contentType.includes('multipart/form-data')) {
// File picker path — custom PDF upload
const formData = await req.formData();
clientId = formData.get('clientId') as string;
name = formData.get('name') as string;
const file = formData.get('file') as File;
fileBuffer = await file.arrayBuffer();
originalFilename = file.name;
} else {
// Library path — copy from seed template
const body = await req.json();
clientId = body.clientId;
name = body.name;
formTemplateId = body.formTemplateId;
}
if (!clientId || !name) {
return Response.json({ error: 'clientId and name are required' }, { status: 400 });
}
const docId = crypto.randomUUID();
const destDir = path.join(UPLOADS_DIR, 'clients', clientId);
const destPath = path.join(destDir, `${docId}.pdf`);
const relPath = `clients/${clientId}/${docId}.pdf`;
// Path traversal guard
if (!destPath.startsWith(UPLOADS_DIR)) {
return new Response('Forbidden', { status: 403 });
}
await mkdir(destDir, { recursive: true });
if (fileBuffer !== undefined) {
// Custom upload
await writeFile(destPath, Buffer.from(fileBuffer));
} else {
// Template copy
const template = await db.query.formTemplates.findFirst({
where: eq(formTemplates.id, formTemplateId!),
});
if (!template) return Response.json({ error: 'Template not found' }, { status: 404 });
const srcPath = path.join(SEEDS_DIR, template.filename);
await copyFile(srcPath, destPath);
}
const [doc] = await db.insert(documents).values({
id: docId,
clientId,
name,
formTemplateId: formTemplateId ?? null,
filePath: relPath,
status: 'Draft',
}).returning();
return Response.json(doc, { status: 201 });
}
```
Create `src/app/api/documents/[id]/file/route.ts`:
```typescript
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documents } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const UPLOADS_BASE = path.join(process.cwd(), 'uploads');
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { id } = await params;
const doc = await db.query.documents.findFirst({
where: eq(documents.id, id),
});
if (!doc || !doc.filePath) return new Response('Not found', { status: 404 });
const filePath = path.join(UPLOADS_BASE, doc.filePath);
// Path traversal guard — critical security check
if (!filePath.startsWith(UPLOADS_BASE)) {
return new Response('Forbidden', { status: 403 });
}
try {
const buffer = await readFile(filePath);
return new Response(buffer, {
headers: { 'Content-Type': 'application/pdf' },
});
} catch {
return new Response('File not found', { status: 404 });
}
}
```
NOTE: In Next.js 15 App Router, `params` is a Promise — always `await params` before destructuring. This matches the pattern in Phase 3's dynamic routes.
NOTE: The `documents.status` field expects the enum literal `'Draft'` — use that exact casing (capital D) to match the existing `documentStatusEnum` definition.
</action>
<verify>
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/documents -X POST -H "Content-Type: application/json" -d '{"clientId":"x","name":"test"}' && echo "" && curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/documents/fake-id/file && echo ""
```
Expected: `401` for POST (unauthed), `401` for GET file (unauthed).
</verify>
<done>POST /api/documents creates document records and copies/writes PDF files. GET /api/documents/[id]/file streams PDFs with auth + path traversal guard. Both return 401 for unauthenticated requests.</done>
</task>
</tasks>
<verification>
- GET /api/forms-library → 401 unauthenticated
- POST /api/documents → 401 unauthenticated
- GET /api/documents/[id]/file → 401 unauthenticated
- uploads/ directory is NOT under public/ (files not accessible without auth)
- No absolute paths stored in DB (relPath uses "clients/{id}/{uuid}.pdf" format)
</verification>
<success_criteria>
Three authenticated API routes deployed and responding. PDFs served only through authenticated routes. Path traversal protection in place on file serving route.
</success_criteria>
<output>
After completion, create `.planning/phases/04-pdf-ingest/04-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,428 @@
---
phase: 04-pdf-ingest
plan: 03
type: execute
wave: 3
depends_on:
- 04-01
- 04-02
files_modified:
- teressa-copeland-homes/src/app/portal/clients/[id]/page.tsx
- teressa-copeland-homes/src/app/portal/clients/[id]/_components/AddDocumentModal.tsx
- teressa-copeland-homes/src/app/portal/documents/[docId]/page.tsx
- teressa-copeland-homes/src/app/portal/documents/[docId]/_components/PdfViewer.tsx
- teressa-copeland-homes/package.json
- teressa-copeland-homes/next.config.ts
autonomous: true
requirements:
- DOC-01
- DOC-03
must_haves:
truths:
- "Client profile page has an 'Add Document' button that opens a modal"
- "Modal shows searchable list of forms from the library; agent can filter by typing"
- "Modal has a 'Browse files' option for custom PDF upload (file picker)"
- "After adding a document, the modal closes and the new document appears in the client's documents list without page reload"
- "Clicking a document name navigates to the document detail page"
- "Document detail page renders the PDF in the browser using react-pdf with page nav and zoom controls"
- "Document detail page shows document name, client name, and a back link to the client profile"
artifacts:
- path: "teressa-copeland-homes/src/app/portal/clients/[id]/_components/AddDocumentModal.tsx"
provides: "Client component — searchable forms library modal with file picker fallback"
exports: ["AddDocumentModal"]
- path: "teressa-copeland-homes/src/app/portal/documents/[docId]/_components/PdfViewer.tsx"
provides: "Client component — react-pdf canvas renderer with page nav and zoom"
exports: ["PdfViewer"]
- path: "teressa-copeland-homes/src/app/portal/documents/[docId]/page.tsx"
provides: "Document detail page — server component wrapping PdfViewer"
key_links:
- from: "AddDocumentModal.tsx"
to: "/api/forms-library"
via: "fetch on modal open"
pattern: "fetch.*forms-library"
- from: "AddDocumentModal.tsx"
to: "/api/documents"
via: "fetch POST on form submit"
pattern: "fetch.*api/documents"
- from: "PdfViewer.tsx"
to: "/api/documents/{docId}/file"
via: "react-pdf Document file prop"
pattern: "api/documents.*file"
---
<objective>
Install react-pdf, build the "Add Document" modal on the client profile page, and build the PDF viewer document detail page. This delivers the visible, agent-facing portion of Phase 4.
Purpose: Agent can browse the forms library, add a document to a client, and view it rendered in the browser.
Output: AddDocumentModal component, PdfViewer component, document detail page route.
</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/phases/04-pdf-ingest/04-CONTEXT.md
@.planning/phases/04-pdf-ingest/04-RESEARCH.md
@.planning/phases/04-pdf-ingest/04-01-SUMMARY.md
@.planning/phases/04-pdf-ingest/04-02-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Install react-pdf and configure Next.js for ESM compatibility</name>
<files>
teressa-copeland-homes/package.json
teressa-copeland-homes/next.config.ts
</files>
<action>
Run:
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm install react-pdf
```
react-pdf v9+ pulls in pdfjs-dist as a peer dep automatically.
Open `next.config.ts` and add `transpilePackages` if not already present:
```typescript
const nextConfig = {
// ... existing config ...
transpilePackages: ['react-pdf', 'pdfjs-dist'],
};
```
This is necessary because react-pdf ships as ESM and Next.js webpack needs to transpile it.
Verify the dev server still starts:
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -20
```
If build errors mention react-pdf or pdfjs-dist, confirm transpilePackages is applied. If errors persist around a specific import, check the react-pdf v9 release notes for Next.js App Router setup — the worker config in PdfViewer (Task 2) must use the `new URL(...)` pattern, not a CDN URL.
</action>
<verify>
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && node -e "require('react-pdf')" 2>&1 | head -5
```
Expected: No error (or ESM warning that resolves at build time — acceptable).
</verify>
<done>react-pdf installed. next.config.ts has transpilePackages. Build does not error on react-pdf imports.</done>
</task>
<task type="auto">
<name>Task 2: Add Document modal + PDF viewer page</name>
<files>
teressa-copeland-homes/src/app/portal/clients/[id]/_components/AddDocumentModal.tsx
teressa-copeland-homes/src/app/portal/clients/[id]/page.tsx
teressa-copeland-homes/src/app/portal/documents/[docId]/page.tsx
teressa-copeland-homes/src/app/portal/documents/[docId]/_components/PdfViewer.tsx
</files>
<action>
**A. Create AddDocumentModal.tsx**
`src/app/portal/clients/[id]/_components/AddDocumentModal.tsx`:
```typescript
'use client';
import { useState, useEffect, useTransition } from 'react';
import { useRouter } from 'next/navigation';
type FormTemplate = { id: string; name: string; filename: string };
export function AddDocumentModal({ clientId, onClose }: { clientId: string; onClose: () => void }) {
const [templates, setTemplates] = useState<FormTemplate[]>([]);
const [query, setQuery] = useState('');
const [docName, setDocName] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<FormTemplate | null>(null);
const [customFile, setCustomFile] = useState<File | null>(null);
const [isPending, startTransition] = useTransition();
const [saving, setSaving] = useState(false);
const router = useRouter();
useEffect(() => {
fetch('/api/forms-library')
.then(r => r.json())
.then(setTemplates)
.catch(console.error);
}, []);
const filtered = templates.filter(t =>
t.name.toLowerCase().includes(query.toLowerCase())
);
const handleSelectTemplate = (t: FormTemplate) => {
setSelectedTemplate(t);
setCustomFile(null);
setDocName(t.name);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setCustomFile(file);
setSelectedTemplate(null);
if (file) setDocName(file.name.replace(/\.pdf$/i, ''));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!docName.trim() || (!selectedTemplate && !customFile)) return;
setSaving(true);
try {
if (customFile) {
const fd = new FormData();
fd.append('clientId', clientId);
fd.append('name', docName.trim());
fd.append('file', customFile);
await fetch('/api/documents', { method: 'POST', body: fd });
} else {
await fetch('/api/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clientId, name: docName.trim(), formTemplateId: selectedTemplate!.id }),
});
}
startTransition(() => router.refresh());
onClose();
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6">
<h2 className="text-xl font-semibold mb-4">Add Document</h2>
<input
type="text"
placeholder="Search forms..."
value={query}
onChange={e => setQuery(e.target.value)}
className="w-full border rounded px-3 py-2 mb-3 text-sm"
/>
<ul className="border rounded max-h-48 overflow-y-auto mb-4">
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-gray-500">No forms found</li>
)}
{filtered.map(t => (
<li
key={t.id}
onClick={() => handleSelectTemplate(t)}
className={`px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 ${selectedTemplate?.id === t.id ? 'bg-blue-50 font-medium' : ''}`}
>
{t.name}
</li>
))}
</ul>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Or upload a custom PDF</label>
<input type="file" accept="application/pdf" onChange={handleFileChange} className="text-sm" />
</div>
<form onSubmit={handleSubmit}>
<label className="block text-sm font-medium mb-1">Document name</label>
<input
type="text"
value={docName}
onChange={e => setDocName(e.target.value)}
required
className="w-full border rounded px-3 py-2 mb-4 text-sm"
placeholder="e.g. 123 Main St Purchase Agreement"
/>
<div className="flex gap-3 justify-end">
<button type="button" onClick={onClose} className="px-4 py-2 text-sm border rounded hover:bg-gray-50">
Cancel
</button>
<button
type="submit"
disabled={saving || (!selectedTemplate && !customFile) || !docName.trim()}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Add Document'}
</button>
</div>
</form>
</div>
</div>
);
}
```
**B. Wire "Add Document" button into the existing client profile page**
Read `src/app/portal/clients/[id]/page.tsx` first to understand the current structure (Phase 3 built this). Then:
- Add a state-controlled "Add Document" button near the top of the documents section (or the page header)
- The button triggers a `showModal` state that renders `<AddDocumentModal clientId={client.id} onClose={() => setShowModal(false)} />`
- Since the profile page is likely a server component with a `ClientProfileClient` sub-component (per Phase 3 pattern), add the modal trigger and state inside the existing client component — do NOT convert the server page to a client component. Add the button and modal inside `_components/ClientProfileClient.tsx` or the equivalent client sub-component.
**C. Create PdfViewer.tsx**
`src/app/portal/documents/[docId]/_components/PdfViewer.tsx`:
```typescript
'use client';
import { useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
// Worker setup — must use import.meta.url for local/Docker environments (no CDN)
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
export function PdfViewer({ docId }: { docId: string }) {
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1.0);
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-3 text-sm">
<button
onClick={() => setPageNumber(p => Math.max(1, p - 1))}
disabled={pageNumber <= 1}
className="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100"
>
Prev
</button>
<span>{pageNumber} / {numPages || '?'}</span>
<button
onClick={() => setPageNumber(p => Math.min(numPages, p + 1))}
disabled={pageNumber >= numPages}
className="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100"
>
Next
</button>
<button
onClick={() => setScale(s => Math.min(3, s + 0.2))}
className="px-3 py-1 border rounded hover:bg-gray-100"
>
Zoom In
</button>
<button
onClick={() => setScale(s => Math.max(0.4, s - 0.2))}
className="px-3 py-1 border rounded hover:bg-gray-100"
>
Zoom Out
</button>
<a
href={`/api/documents/${docId}/file`}
download
className="px-3 py-1 border rounded hover:bg-gray-100"
>
Download
</a>
</div>
<Document
file={`/api/documents/${docId}/file`}
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
className="shadow-lg"
>
<Page pageNumber={pageNumber} scale={scale} />
</Document>
</div>
);
}
```
**D. Create document detail page**
`src/app/portal/documents/[docId]/page.tsx`:
```typescript
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { documents, clients } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import Link from 'next/link';
import { PdfViewer } from './_components/PdfViewer';
export default async function DocumentPage({
params,
}: {
params: Promise<{ docId: string }>;
}) {
const session = await auth();
if (!session) redirect('/login');
const { docId } = await params;
const doc = await db.query.documents.findFirst({
where: eq(documents.id, docId),
with: { client: true },
});
if (!doc) redirect('/portal/dashboard');
return (
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="mb-4 flex items-center justify-between">
<div>
<Link
href={`/portal/clients/${doc.clientId}`}
className="text-sm text-blue-600 hover:underline"
>
← Back to {doc.client?.name ?? 'Client'}
</Link>
<h1 className="text-2xl font-bold mt-1">{doc.name}</h1>
<p className="text-sm text-gray-500">{doc.client?.name}</p>
</div>
</div>
<PdfViewer docId={docId} />
</div>
);
}
```
NOTE: The `with: { client: true }` relation requires that the Drizzle relations are defined in schema.ts. If Phase 3 already defined client/documents relations, use those. If not, add the minimal relation:
```typescript
// In schema.ts, add after table definitions:
import { relations } from 'drizzle-orm';
export const documentsRelations = relations(documents, ({ one }) => ({
client: one(clients, { fields: [documents.clientId], references: [clients.id] }),
}));
```
If relations already exist in schema.ts (check the existing file), extend them rather than overwriting.
Also ensure that clicking a document name in the client profile's documents table navigates to `/portal/documents/{doc.id}` — read the existing DocumentsTable component from Phase 3 and add the link if not present.
</action>
<verify>
```bash
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "error|Error|compiled" | head -20
```
Expected: "compiled successfully" or "Compiled" with no errors referencing react-pdf, PdfViewer, or AddDocumentModal.
</verify>
<done>
- Client profile page has "Add Document" button that opens the modal.
- Modal shows searchable template list and file picker fallback.
- Submitting the modal creates a document and refreshes the documents list.
- Document name in client profile links to `/portal/documents/{id}`.
- Document detail page renders PdfViewer with page nav, zoom, and download controls.
- Build passes with no errors.
</done>
</task>
</tasks>
<verification>
- `npm run build` completes without errors
- AddDocumentModal imports from react-pdf DO NOT include CDN worker URLs
- Both CSS layers imported in PdfViewer: AnnotationLayer.css and TextLayer.css
- Document detail page uses `await params` (Next.js 15 pattern)
- PdfViewer uses `new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url)` for worker — not a CDN URL
</verification>
<success_criteria>
Agent can open the forms library modal, add a document from the library or via file picker, see it appear in the documents list, click it to navigate to the detail page, and see the PDF rendered in the browser with page navigation and zoom.
</success_criteria>
<output>
After completion, create `.planning/phases/04-pdf-ingest/04-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,134 @@
---
phase: 04-pdf-ingest
plan: 04
type: execute
wave: 4
depends_on:
- 04-01
- 04-02
- 04-03
files_modified: []
autonomous: false
requirements:
- DOC-01
- DOC-02
- DOC-03
must_haves:
truths:
- "Agent can open 'Add Document' modal from a client profile page"
- "Forms library list appears in the modal (either seeded forms or empty-state message)"
- "Agent can search/filter the forms list by name"
- "Agent can add a document via template selection and see it appear in the documents list"
- "Agent can upload a custom PDF via the file picker and see it appear in the documents list"
- "Agent can click a document name and reach the document detail page"
- "PDF renders in the browser on the document detail page — pages visible, not blank"
- "Page navigation (Prev/Next) and Zoom In/Out controls work"
- "Download button downloads the PDF"
artifacts:
- path: "teressa-copeland-homes/uploads/clients/"
provides: "Confirms files are being stored at the correct path"
key_links:
- from: "Client profile Add Document button"
to: "Document detail page PDF render"
via: "modal → POST /api/documents → documents list → /portal/documents/{id} → PdfViewer → /api/documents/{id}/file"
---
<objective>
Human verification of the complete Phase 4 PDF ingest flow. Agent (Teressa) confirms the end-to-end workflow works in the browser before Phase 4 is declared complete.
Purpose: Functional verification catches UI/UX issues that automated checks miss — blank PDFs, missing form items, broken nav controls.
Output: Phase 4 approved or issues logged for gap closure.
</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/phases/04-pdf-ingest/04-CONTEXT.md
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Complete Phase 4 PDF ingest pipeline:
- forms_templates DB table + seed script (npm run seed:forms)
- GET /api/forms-library — authenticated template list
- POST /api/documents — creates document record + copies PDF to uploads/clients/{id}/
- GET /api/documents/[id]/file — authenticated PDF streaming with path traversal protection
- "Add Document" modal on client profile page with searchable library + file picker fallback
- Document detail page with react-pdf viewer (page nav, zoom, download)
</what-built>
<how-to-verify>
Run the dev server if not already running:
```
cd teressa-copeland-homes && npm run dev
```
**Step 1 — Seed a test form (if seeds/forms/ is empty):**
- Download any real estate PDF (or use any PDF) and copy it to `teressa-copeland-homes/seeds/forms/purchase-agreement.pdf`
- Run: `npm run seed:forms`
- Confirm output: "Seeded 1 forms into form_templates."
**Step 2 — Open a client profile:**
- Log in at http://localhost:3000/login
- Navigate to Clients, click on any client
- Confirm: "Add Document" button is visible on the client profile page
**Step 3 — Add document from library:**
- Click "Add Document"
- Confirm: modal opens with a searchable list (shows "purchase agreement" or whatever was seeded, OR "No forms found" if seeds are empty)
- Type in the search box — confirm list filters
- Click a form to select it — confirm document name pre-fills
- Edit the name to something like "Test Purchase Agreement"
- Click "Add Document"
- Confirm: modal closes, new document appears in the documents list on the profile page
**Step 4 — Add custom PDF via file picker:**
- Click "Add Document" again
- Click "Browse files" / the file input
- Select any PDF from your computer
- Confirm: name pre-fills from filename
- Edit name, submit
- Confirm: document appears in the list
**Step 5 — View PDF in browser:**
- Click a document name from the documents list
- Confirm: navigates to `/portal/documents/{id}`
- Confirm: PDF renders (pages visible — NOT blank white)
- Confirm: "Prev" / "Next" buttons work for multi-page PDFs
- Confirm: "Zoom In" / "Zoom Out" buttons change page size
- Confirm: "Download" button downloads the PDF
- Confirm: "Back to [Client Name]" link returns to the client profile
**Step 6 — Verify file storage:**
- Check that `teressa-copeland-homes/uploads/clients/{clientId}/` directory contains `.pdf` files
- Confirm files persist after stopping and restarting the dev server
**Step 7 — Verify authentication:**
- In an incognito window (no session), try: http://localhost:3000/api/forms-library
- Confirm: returns "Unauthorized" (not a PDF list)
- Try: http://localhost:3000/api/documents/any-id/file
- Confirm: returns "Unauthorized" (not a file)
</how-to-verify>
<resume-signal>
Type "approved" if all 7 steps pass.
Or describe any issues found (e.g., "PDF renders blank", "modal list empty", "upload fails").
</resume-signal>
</task>
</tasks>
<verification>
All 7 verification steps pass as described above.
</verification>
<success_criteria>
Agent confirms: forms library accessible, document creation from template works, custom upload works, PDF renders with navigation controls, files stored in uploads/, unauthenticated API access blocked.
</success_criteria>
<output>
After completion, create `.planning/phases/04-pdf-ingest/04-04-SUMMARY.md`
</output>