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

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
18-01
teressa-copeland-homes/src/app/api/templates/route.ts
teressa-copeland-homes/src/app/api/templates/[id]/route.ts
true
TMPL-01
TMPL-02
TMPL-03
TMPL-04
truths artifacts key_links
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
path provides exports
teressa-copeland-homes/src/app/api/templates/route.ts GET (list) and POST (create) handlers
GET
POST
path provides exports
teressa-copeland-homes/src/app/api/templates/[id]/route.ts PATCH (update) and DELETE (soft-delete) handlers
PATCH
DELETE
from to via pattern
GET /api/templates documentTemplates + formTemplates LEFT JOIN for formName formTemplates.*name
from to via pattern
DELETE /api/templates/[id] documentTemplates.archivedAt SET archivedAt = new Date() archivedAt.*new Date
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.

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

From 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;
  // ...
}
Task 1: Create GET and POST routes at /api/templates - teressa-copeland-homes/src/lib/db/schema.ts - teressa-copeland-homes/src/app/api/documents/route.ts - teressa-copeland-homes/src/lib/auth.ts teressa-copeland-homes/src/app/api/templates/route.ts 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:

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

Task 2: Create PATCH and DELETE routes at /api/templates/[id] - teressa-copeland-homes/src/lib/db/schema.ts - teressa-copeland-homes/src/app/api/documents/[id]/route.ts teressa-copeland-homes/src/app/api/templates/[id]/route.ts 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:

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

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

<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>
After completion, create `.planning/phases/18-template-schema-and-crud-api/18-02-SUMMARY.md`