Files
red/.planning/phases/18-template-schema-and-crud-api/18-02-PLAN.md

337 lines
12 KiB
Markdown
Raw Normal View History

---
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>