12 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 18-template-schema-and-crud-api | 02 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.mdFrom teressa-copeland-homes/src/lib/db/schema.ts (after Plan 01):
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):
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):
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;
// ...
}
GET /api/templates — list active templates:
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:
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.
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20
<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>
GET /api/templates returns active templates with formName and fieldCount; POST /api/templates creates a template from a form library entry
PATCH /api/templates/[id] — rename or save fields:
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:
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.
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20
<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>
PATCH /api/templates/[id] renames or updates fields with explicit updatedAt; DELETE /api/templates/[id] soft-deletes via archivedAt
<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>