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:
295
.planning/phases/04-pdf-ingest/04-02-PLAN.md
Normal file
295
.planning/phases/04-pdf-ingest/04-02-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user