337 lines
12 KiB
Markdown
337 lines
12 KiB
Markdown
---
|
|
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>
|