docs(18): create phase plan — schema + CRUD API for document templates

This commit is contained in:
Chandler Copeland
2026-04-06 12:11:45 -06:00
parent 1afac9df1c
commit 421688f7f7
3 changed files with 525 additions and 1 deletions

View File

@@ -396,7 +396,11 @@ Plans:
2. Agent can rename a template and the new name is persisted and reflected immediately in the template list
3. Agent can delete a template — the row is soft-deleted (`archivedAt` set, not removed from DB) and disappears from the active list; no `ON DELETE CASCADE` touches any documents created from it
4. Agent can retrieve a list of all active templates showing form name and field count — archived templates are filtered out
**Plans**: TBD
**Plans**: 2 plans
Plans:
- [ ] 18-01-PLAN.md — Drizzle schema: documentTemplates table definition + 0012 migration
- [ ] 18-02-PLAN.md — CRUD API routes: GET/POST /api/templates, PATCH/DELETE /api/templates/[id]
**UI hint**: no
### Phase 19: Template Editor UI

View File

@@ -0,0 +1,184 @@
---
phase: 18-template-schema-and-crud-api
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- teressa-copeland-homes/src/lib/db/schema.ts
- teressa-copeland-homes/drizzle/0012_*.sql
autonomous: true
requirements: [TMPL-01]
must_haves:
truths:
- "document_templates table exists in the Drizzle schema with all 7 columns"
- "Migration file 0012_*.sql exists and contains CREATE TABLE document_templates"
- "TypeScript compiles with no errors after schema change"
artifacts:
- path: "teressa-copeland-homes/src/lib/db/schema.ts"
provides: "documentTemplates table definition and relations"
contains: "documentTemplates"
- path: "teressa-copeland-homes/drizzle/0012_*.sql"
provides: "SQL migration creating document_templates table"
contains: "document_templates"
key_links:
- from: "documentTemplates.formTemplateId"
to: "formTemplates.id"
via: "FK reference"
pattern: "references.*formTemplates\\.id"
---
<objective>
Add the `document_templates` Drizzle table to schema.ts and generate the SQL migration.
Purpose: Every subsequent template feature (editor, API, apply-to-document) depends on this table existing. This plan establishes the schema foundation.
Output: Updated schema.ts with `documentTemplates` table + `drizzle/0012_*.sql` migration file.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-template-schema-and-crud-api/18-CONTEXT.md
<interfaces>
<!-- Existing types and tables the executor needs -->
From teressa-copeland-homes/src/lib/db/schema.ts:
```typescript
export interface SignatureFieldData {
id: string;
page: number;
x: number;
y: number;
width: number;
height: number;
type?: SignatureFieldType;
signerEmail?: string;
hint?: string;
}
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(),
});
```
Existing migration files: 0000 through 0011. Next migration is 0012.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add documentTemplates table to schema.ts</name>
<read_first>
- teressa-copeland-homes/src/lib/db/schema.ts
</read_first>
<files>teressa-copeland-homes/src/lib/db/schema.ts</files>
<action>
Add the `documentTemplates` table definition to schema.ts, placed immediately after the `formTemplates` table definition (before the `documents` table). Per D-01, D-02, D-03, D-04, D-05, D-06:
```typescript
export const documentTemplates = pgTable("document_templates", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
formTemplateId: text("form_template_id").notNull().references(() => formTemplates.id),
signatureFields: jsonb("signature_fields").$type<SignatureFieldData[]>(),
archivedAt: timestamp("archived_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
```
Key points:
- `signatureFields` is nullable (per D-04) — template starts empty, editor fills it in Phase 19
- `formTemplateId` FK has NO `onDelete` option (per D-06) — no cascade
- `archivedAt` is nullable — NULL means active (per D-07)
- `updatedAt` must be set explicitly in PATCH queries, not via DB trigger (per D-05)
Also add a relation from documentTemplates to formTemplates:
```typescript
export const documentTemplatesRelations = relations(documentTemplates, ({ one }) => ({
formTemplate: one(formTemplates, { fields: [documentTemplates.formTemplateId], references: [formTemplates.id] }),
}));
```
Do NOT add `onDelete: 'cascade'` to the formTemplateId FK. Per D-06, if a form is removed, templates referencing it should remain.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- `documentTemplates` export exists in schema.ts
- Table has exactly 7 columns: id, name, formTemplateId, signatureFields, archivedAt, createdAt, updatedAt
- `formTemplateId` references `formTemplates.id` with no onDelete
- `signatureFields` typed as `SignatureFieldData[]` via `$type`
- `archivedAt` is nullable timestamp (no `.notNull()`)
- `npx tsc --noEmit` passes with zero errors
</acceptance_criteria>
<done>documentTemplates table and relations defined in schema.ts, TypeScript compiles cleanly</done>
</task>
<task type="auto">
<name>Task 2: Generate Drizzle migration</name>
<read_first>
- teressa-copeland-homes/src/lib/db/schema.ts
- teressa-copeland-homes/drizzle.config.ts
</read_first>
<files>teressa-copeland-homes/drizzle/0012_*.sql</files>
<action>
Per D-13, run the Drizzle migration generator from within the teressa-copeland-homes directory:
```bash
cd teressa-copeland-homes && npx drizzle-kit generate
```
This will produce `drizzle/0012_*.sql` (Drizzle auto-names it). The migration should contain a `CREATE TABLE "document_templates"` statement with all 7 columns and the FK constraint to `form_templates`.
After generation, read the migration file to verify it contains the expected CREATE TABLE statement and FK constraint. The migration should NOT contain any DROP or ALTER statements on existing tables.
Commit both schema.ts and the migration file.
</action>
<verify>
<automated>ls /Users/ccopeland/temp/red/teressa-copeland-homes/drizzle/0012_*.sql && grep -l "document_templates" /Users/ccopeland/temp/red/teressa-copeland-homes/drizzle/0012_*.sql</automated>
</verify>
<acceptance_criteria>
- Migration file `drizzle/0012_*.sql` exists
- File contains `CREATE TABLE` for `document_templates`
- File contains FK constraint referencing `form_templates(id)`
- File does NOT alter or drop any existing tables
- No other 0012 migration files exist (exactly one)
</acceptance_criteria>
<done>Migration 0012 generated with CREATE TABLE document_templates, FK to form_templates, all 7 columns</done>
</task>
</tasks>
<verification>
1. `npx tsc --noEmit` passes — schema.ts compiles
2. `ls drizzle/0012_*.sql` — exactly one migration file
3. `grep "document_templates" drizzle/0012_*.sql` — CREATE TABLE present
4. `grep "form_template_id" drizzle/0012_*.sql` — FK column present
</verification>
<success_criteria>
- documentTemplates table defined in schema.ts with all 7 columns per D-02
- Drizzle relation to formTemplates exists
- Migration 0012_*.sql generated and contains CREATE TABLE
- TypeScript compiles with zero errors
</success_criteria>
<output>
After completion, create `.planning/phases/18-template-schema-and-crud-api/18-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,336 @@
---
phase: 18-template-schema-and-crud-api
plan: 02
type: execute
wave: 2
depends_on: ["18-01"]
files_modified:
- teressa-copeland-homes/src/app/api/templates/route.ts
- teressa-copeland-homes/src/app/api/templates/[id]/route.ts
autonomous: true
requirements: [TMPL-01, TMPL-02, TMPL-03, TMPL-04]
must_haves:
truths:
- "POST /api/templates creates a document_templates row with name and formTemplateId"
- "GET /api/templates returns active templates with form name and field count, excluding archived"
- "PATCH /api/templates/[id] can rename a template and update signatureFields"
- "DELETE /api/templates/[id] sets archivedAt instead of deleting the row"
- "All routes return 401 when unauthenticated"
artifacts:
- path: "teressa-copeland-homes/src/app/api/templates/route.ts"
provides: "GET (list) and POST (create) handlers"
exports: ["GET", "POST"]
- path: "teressa-copeland-homes/src/app/api/templates/[id]/route.ts"
provides: "PATCH (update) and DELETE (soft-delete) handlers"
exports: ["PATCH", "DELETE"]
key_links:
- from: "GET /api/templates"
to: "documentTemplates + formTemplates"
via: "LEFT JOIN for formName"
pattern: "formTemplates.*name"
- from: "DELETE /api/templates/[id]"
to: "documentTemplates.archivedAt"
via: "SET archivedAt = new Date()"
pattern: "archivedAt.*new Date"
---
<objective>
Create all four CRUD API routes for document templates: list, create, rename/update, and soft-delete.
Purpose: These routes are the interface Phase 19 (template editor UI) and Phase 20 (apply template) will call. Without them, no template can be created, listed, edited, or removed.
Output: Two route files implementing GET, POST, PATCH, DELETE at `/api/templates`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-template-schema-and-crud-api/18-CONTEXT.md
@.planning/phases/18-template-schema-and-crud-api/18-01-SUMMARY.md
<interfaces>
<!-- Schema from Plan 01 — executor reads these, does not recreate -->
From teressa-copeland-homes/src/lib/db/schema.ts (after Plan 01):
```typescript
export const documentTemplates = pgTable("document_templates", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
formTemplateId: text("form_template_id").notNull().references(() => formTemplates.id),
signatureFields: jsonb("signature_fields").$type<SignatureFieldData[]>(),
archivedAt: timestamp("archived_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
```
Auth pattern (from teressa-copeland-homes/src/lib/auth.ts):
```typescript
import { auth } from '@/lib/auth';
// Usage in route:
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
```
Existing PATCH pattern (from teressa-copeland-homes/src/app/api/documents/[id]/route.ts):
```typescript
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { id } = await params;
// ...
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create GET and POST routes at /api/templates</name>
<read_first>
- teressa-copeland-homes/src/lib/db/schema.ts
- teressa-copeland-homes/src/app/api/documents/route.ts
- teressa-copeland-homes/src/lib/auth.ts
</read_first>
<files>teressa-copeland-homes/src/app/api/templates/route.ts</files>
<action>
Create `teressa-copeland-homes/src/app/api/templates/route.ts` with two handlers. Per D-10, D-11, D-08, D-12:
**GET /api/templates** — list active templates:
```typescript
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documentTemplates, formTemplates } from '@/lib/db/schema';
import type { SignatureFieldData } from '@/lib/db/schema';
import { eq, isNull } from 'drizzle-orm';
export async function GET() {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const rows = await db
.select({
id: documentTemplates.id,
name: documentTemplates.name,
formTemplateId: documentTemplates.formTemplateId,
formName: formTemplates.name,
signatureFields: documentTemplates.signatureFields,
createdAt: documentTemplates.createdAt,
updatedAt: documentTemplates.updatedAt,
})
.from(documentTemplates)
.leftJoin(formTemplates, eq(documentTemplates.formTemplateId, formTemplates.id))
.where(isNull(documentTemplates.archivedAt));
const result = rows.map((r) => ({
id: r.id,
name: r.name,
formTemplateId: r.formTemplateId,
formName: r.formName,
fieldCount: ((r.signatureFields as SignatureFieldData[] | null) ?? []).length,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}));
return Response.json(result);
}
```
Key: `fieldCount` is computed server-side per D-12: `(signatureFields ?? []).length`. The `archivedAt IS NULL` filter per D-08 ensures archived templates are invisible.
**POST /api/templates** — create template:
```typescript
export async function POST(req: Request) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const body = await req.json() as { name?: string; formTemplateId?: string };
if (!body.name || !body.formTemplateId) {
return Response.json(
{ error: 'name and formTemplateId are required' },
{ status: 400 }
);
}
// Verify form template exists
const form = await db.query.formTemplates.findFirst({
where: eq(formTemplates.id, body.formTemplateId),
});
if (!form) {
return Response.json({ error: 'Form template not found' }, { status: 404 });
}
const [template] = await db
.insert(documentTemplates)
.values({
name: body.name,
formTemplateId: body.formTemplateId,
})
.returning();
return Response.json(template, { status: 201 });
}
```
Key: `signatureFields` defaults to NULL on insert per D-04 — template starts empty.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- File exists at `src/app/api/templates/route.ts`
- Exports `GET` and `POST` functions
- GET filters by `isNull(documentTemplates.archivedAt)` per D-08
- GET joins formTemplates for formName per D-10
- GET computes `fieldCount` from `signatureFields.length` per D-12
- POST validates `name` and `formTemplateId` are present
- POST verifies formTemplateId FK exists before insert
- POST returns 201 with the created template
- Both handlers call `auth()` and return 401 if no session per D-11
- `npx tsc --noEmit` passes
</acceptance_criteria>
<done>GET /api/templates returns active templates with formName and fieldCount; POST /api/templates creates a template from a form library entry</done>
</task>
<task type="auto">
<name>Task 2: Create PATCH and DELETE routes at /api/templates/[id]</name>
<read_first>
- teressa-copeland-homes/src/lib/db/schema.ts
- teressa-copeland-homes/src/app/api/documents/[id]/route.ts
</read_first>
<files>teressa-copeland-homes/src/app/api/templates/[id]/route.ts</files>
<action>
Create `teressa-copeland-homes/src/app/api/templates/[id]/route.ts` with two handlers. Per D-10, D-11, D-05, D-07:
**PATCH /api/templates/[id]** — rename or save fields:
```typescript
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { documentTemplates } from '@/lib/db/schema';
import type { SignatureFieldData } from '@/lib/db/schema';
import { eq, and, isNull } from 'drizzle-orm';
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { id } = await params;
const body = await req.json() as {
name?: string;
signatureFields?: SignatureFieldData[];
};
// Only update non-archived templates
const existing = await db.query.documentTemplates.findFirst({
where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
});
if (!existing) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
const updates: Partial<typeof documentTemplates.$inferInsert> = {
updatedAt: new Date(), // per D-05: explicit updatedAt on every UPDATE
};
if (body.name !== undefined) updates.name = body.name;
if (body.signatureFields !== undefined) updates.signatureFields = body.signatureFields;
const [updated] = await db
.update(documentTemplates)
.set(updates)
.where(eq(documentTemplates.id, id))
.returning();
return Response.json(updated);
}
```
Key: `updatedAt: new Date()` is set explicitly per D-05 — no DB trigger. The PATCH handles both rename (name) and field save (signatureFields) per D-10. Phase 19 will call this with signatureFields.
**DELETE /api/templates/[id]** — soft-delete:
```typescript
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { id } = await params;
// Only soft-delete non-archived templates (idempotent)
const existing = await db.query.documentTemplates.findFirst({
where: and(eq(documentTemplates.id, id), isNull(documentTemplates.archivedAt)),
});
if (!existing) {
return Response.json({ error: 'Not found' }, { status: 404 });
}
await db
.update(documentTemplates)
.set({
archivedAt: new Date(), // per D-07: soft-delete sets archivedAt
updatedAt: new Date(), // per D-05: explicit updatedAt
})
.where(eq(documentTemplates.id, id));
return new Response(null, { status: 204 });
}
```
Key: Per D-07, DELETE never removes the row — it sets `archivedAt = new Date()`. Per D-09, there is no restore endpoint. Returns 204 No Content on success.
</action>
<verify>
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- File exists at `src/app/api/templates/[id]/route.ts`
- Exports `PATCH` and `DELETE` functions
- PATCH accepts `{ name?, signatureFields? }` body
- PATCH always sets `updatedAt: new Date()` per D-05
- PATCH returns 404 for archived or missing templates
- DELETE sets `archivedAt: new Date()` — never removes the row per D-07
- DELETE sets `updatedAt: new Date()` per D-05
- DELETE returns 204 on success
- Both handlers use `await params` pattern matching existing [id] routes
- Both handlers call `auth()` and return 401 if no session per D-11
- `npx tsc --noEmit` passes
</acceptance_criteria>
<done>PATCH /api/templates/[id] renames or updates fields with explicit updatedAt; DELETE /api/templates/[id] soft-deletes via archivedAt</done>
</task>
</tasks>
<verification>
1. `npx tsc --noEmit` passes — both route files compile
2. `grep -r "auth()" src/app/api/templates/` — all handlers auth-gated
3. `grep "archivedAt" src/app/api/templates/` — soft-delete pattern present in both GET (filter) and DELETE (set)
4. `grep "updatedAt.*new Date" src/app/api/templates/[id]/route.ts` — explicit updatedAt in PATCH and DELETE
</verification>
<success_criteria>
- All four HTTP methods implemented: GET, POST, PATCH, DELETE
- GET returns active templates with formName JOIN and computed fieldCount
- POST creates template with name + formTemplateId, validates FK exists
- PATCH handles name and signatureFields with explicit updatedAt
- DELETE soft-deletes via archivedAt, returns 204
- All routes auth-gated, return 401 when unauthenticated
- TypeScript compiles with zero errors
</success_criteria>
<output>
After completion, create `.planning/phases/18-template-schema-and-crud-api/18-02-SUMMARY.md`
</output>