docs(18): create phase plan — schema + CRUD API for document templates
This commit is contained in:
336
.planning/phases/18-template-schema-and-crud-api/18-02-PLAN.md
Normal file
336
.planning/phases/18-template-schema-and-crud-api/18-02-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user