Files

16 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
20-apply-template-and-portal-nav 01 execute 1
teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx
teressa-copeland-homes/src/app/api/documents/route.ts
true
TMPL-10
TMPL-11
TMPL-12
TMPL-14
truths artifacts key_links
Agent sees a 'My Templates' tab in the Add Document modal alongside the existing Forms Library
Agent can pick a saved template and click Add Document to create a document with all template fields pre-loaded at their saved positions
Every field copied from a template has a fresh UUID — no template field ID appears in the new document
Template signer roles are auto-mapped to client contacts (first role to client email, second role to co-buyer email)
Editing a template afterward does not change the document's fields (snapshot independence)
path provides contains
teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx Two-tab modal with Forms Library + My Templates activeTab
path provides contains
teressa-copeland-homes/src/app/api/documents/route.ts Template apply branch in POST handler documentTemplateId
from to via pattern
AddDocumentModal.tsx POST /api/documents fetch with documentTemplateId in JSON body documentTemplateId.*selectedDocTemplate
from to via pattern
POST /api/documents documentTemplates table db.query.documentTemplates.findFirst documentTemplates
from to via pattern
POST /api/documents clients table db.query.clients.findFirst for role-to-email mapping clients.id.*clientId
Add "My Templates" tab to AddDocumentModal and extend POST /api/documents to apply a template — copying fields with fresh UUIDs and auto-mapping signer roles to client contacts.

Purpose: Lets the agent start a new document from a saved template instead of a blank form, eliminating repetitive field placement on commonly-used PDFs. Output: Modified AddDocumentModal.tsx with two tabs, extended POST /api/documents route with documentTemplateId branch.

<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/20-apply-template-and-portal-nav/20-CONTEXT.md @.planning/phases/20-apply-template-and-portal-nav/20-RESEARCH.md

From src/lib/db/schema.ts:

export interface SignatureFieldData {
  id: string;
  page: number;   // 1-indexed
  x: number;      // PDF user space, bottom-left origin, points
  y: number;
  width: number;
  height: number;
  type?: SignatureFieldType;
  signerEmail?: string; // In templates: carries role labels like "Buyer", "Seller"
  hint?: string;        // Optional label for client-text fields
}

export interface ClientContact {
  name: string;
  email: string;
}

export interface DocumentSigner {
  email: string;
  color: string;
}

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(),
});

export const documentTemplatesRelations = relations(documentTemplates, ({ one }) => ({
  formTemplate: one(formTemplates, { fields: [documentTemplates.formTemplateId], references: [formTemplates.id] }),
}));

From src/app/api/templates/route.ts GET response shape:

// Each row in the JSON array:
{
  id: string;
  name: string;
  formTemplateId: string;
  formName: string | null;
  fieldCount: number;
  createdAt: Date;
  updatedAt: Date;
}

From src/app/api/documents/route.ts — existing POST handler structure:

// Content-type branching:
// - multipart/form-data → custom PDF upload (reads file from FormData)
// - else (JSON) → form-library copy (reads formTemplateId, copies PDF from seeds/)
// Both paths: create destDir, copy/write PDF, INSERT into documents table
// The JSON branch is what we extend with documentTemplateId support

From src/app/portal/_components/AddDocumentModal.tsx — existing state:

type FormTemplate = { id: string; name: string; filename: string };
// Props: { clientId: string; onClose: () => void }
// State: templates (FormTemplate[]), selectedTemplate, customFile, docName, query
// Submit: customFile → FormData POST, else → JSON POST with formTemplateId
Task 1: Extend POST /api/documents with documentTemplateId branch teressa-copeland-homes/src/app/api/documents/route.ts teressa-copeland-homes/src/app/api/documents/route.ts teressa-copeland-homes/src/lib/db/schema.ts teressa-copeland-homes/src/app/api/templates/route.ts Extend the JSON body parsing in the POST handler to also extract `documentTemplateId: string | undefined` from `body`.

After the existing if (!clientId || !name) guard, add a new branch BEFORE the existing formTemplateId branch. The logic:

if (documentTemplateId) {
  // 1. Fetch document template with its formTemplate relation
  const docTemplate = await db.query.documentTemplates.findFirst({
    where: eq(documentTemplates.id, documentTemplateId),
    with: { formTemplate: true },
  });
  if (!docTemplate) return Response.json({ error: 'Document template not found' }, { status: 404 });

  // 2. Copy PDF from seeds dir using the form template's filename
  const srcPath = path.join(SEEDS_DIR, docTemplate.formTemplate.filename);
  await copyFile(srcPath, destPath);

  // 3. Copy fields with fresh UUIDs (D-07) — hints preserved verbatim (D-09)
  const rawFields: SignatureFieldData[] = (docTemplate.signatureFields as SignatureFieldData[] | null) ?? [];
  const copiedFields: SignatureFieldData[] = rawFields.map(f => ({
    ...f,
    id: crypto.randomUUID(),
  }));

  // 4. Role-to-email mapping (D-06)
  // Collect unique role labels in order of first appearance
  const seenRoles = new Set<string>();
  const uniqueRoles: string[] = [];
  for (const f of copiedFields) {
    if (f.signerEmail && !seenRoles.has(f.signerEmail)) {
      seenRoles.add(f.signerEmail);
      uniqueRoles.push(f.signerEmail);
    }
  }

  // Fetch client for email + contacts
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  const clientEmails = [
    client?.email,
    ...((client?.contacts as ClientContact[] | null) ?? []).map(c => c.email),
  ].filter(Boolean) as string[];

  const SIGNER_COLORS = ['#6366f1', '#f43f5e', '#10b981', '#f59e0b'];
  const mappedSigners: DocumentSigner[] = uniqueRoles.map((role, i) => ({
    email: clientEmails[i] ?? role,
    color: SIGNER_COLORS[i % SIGNER_COLORS.length],
  }));

  // 5. Override formTemplateId for the DB insert
  formTemplateId = docTemplate.formTemplateId;

  // 6. Insert with fields and signers
  const [doc] = await db.insert(documents).values({
    id: docId,
    clientId,
    name,
    formTemplateId: formTemplateId ?? null,
    filePath: relPath,
    status: 'Draft',
    signatureFields: copiedFields,
    signers: mappedSigners.length > 0 ? mappedSigners : null,
  }).returning();

  return Response.json(doc, { status: 201 });
}

The existing else path (formTemplateId from form library) and file upload path remain completely unchanged.

Add imports at top: import { documentTemplates, clients } from '@/lib/db/schema'; and import type { SignatureFieldData, ClientContact, DocumentSigner } from '@/lib/db/schema';. The clients import is new; documents and formTemplates are already imported.

Also parse documentTemplateId from body: add let documentTemplateId: string | undefined; alongside existing declarations, and in the JSON else block add documentTemplateId = body.documentTemplateId;.

IMPORTANT: The template branch has its OWN db.insert + return, so the existing insert at the bottom of the function only runs for the non-template paths. Structure it so the template branch returns early. cd teressa-copeland-homes && npx tsc --noEmit <acceptance_criteria> - grep "documentTemplateId" src/app/api/documents/route.ts returns at least 3 matches (declaration, parse, condition) - grep "crypto.randomUUID" src/app/api/documents/route.ts returns at least 2 matches (docId + field copy) - grep "signatureFields: copiedFields" src/app/api/documents/route.ts returns 1 match - grep "SIGNER_COLORS" src/app/api/documents/route.ts returns at least 1 match - grep "clientEmails" src/app/api/documents/route.ts returns at least 1 match - grep "import.*clients" src/app/api/documents/route.ts returns 1 match (clients table import) - npx tsc --noEmit exits 0 </acceptance_criteria> POST /api/documents accepts documentTemplateId, copies fields with fresh UUIDs, maps roles to client contacts, inserts document with signatureFields and signers

Task 2: Add My Templates tab to AddDocumentModal teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx teressa-copeland-homes/src/app/portal/_components/AddDocumentModal.tsx Add state variables for the template tab:
type DocumentTemplateRow = {
  id: string;
  name: string;
  formName: string | null;
  fieldCount: number;
  updatedAt: string;
};

const [activeTab, setActiveTab] = useState<'forms' | 'templates'>('forms');
const [docTemplates, setDocTemplates] = useState<DocumentTemplateRow[]>([]);
const [docTemplatesLoaded, setDocTemplatesLoaded] = useState(false);
const [selectedDocTemplate, setSelectedDocTemplate] = useState<DocumentTemplateRow | null>(null);

Add lazy fetch function — fetches templates only on first click of the My Templates tab:

function handleSwitchToTemplates() {
  setActiveTab('templates');
  if (!docTemplatesLoaded) {
    fetch('/api/templates')
      .then(r => r.json())
      .then((data: DocumentTemplateRow[]) => { setDocTemplates(data); setDocTemplatesLoaded(true); })
      .catch(console.error);
  }
}

Add mutual exclusivity to selection handlers:

  • In handleSelectTemplate (existing form-library handler): add setSelectedDocTemplate(null); at top
  • Add new handler for document template selection:
const handleSelectDocTemplate = (t: DocumentTemplateRow) => {
  setSelectedDocTemplate(t);
  setSelectedTemplate(null);
  setCustomFile(null);
  setDocName(t.name);
};

Extend handleSubmit:

  • Add a NEW branch at the top of the try block, before the existing if (customFile):
if (selectedDocTemplate) {
  await fetch('/api/documents', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      clientId,
      name: docName.trim(),
      documentTemplateId: selectedDocTemplate.id,
    }),
  });
} else if (customFile) {
  // ... existing custom file path unchanged
} else {
  // ... existing form library path unchanged
}

Update the early-return guard in handleSubmit:

if (!docName.trim() || (!selectedTemplate && !customFile && !selectedDocTemplate)) return;

Update the submit button disabled condition:

disabled={saving || (!selectedTemplate && !customFile && !selectedDocTemplate) || !docName.trim()}

Render two tab buttons between the h2 heading and the search input. Use underline-style tabs matching the project's plain Tailwind approach:

<div className="flex border-b mb-4">
  <button
    type="button"
    onClick={() => setActiveTab('forms')}
    className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
      activeTab === 'forms'
        ? 'border-blue-600 text-blue-600'
        : 'border-transparent text-gray-500 hover:text-gray-700'
    }`}
  >
    Forms Library
  </button>
  <button
    type="button"
    onClick={handleSwitchToTemplates}
    className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
      activeTab === 'templates'
        ? 'border-blue-600 text-blue-600'
        : 'border-transparent text-gray-500 hover:text-gray-700'
    }`}
  >
    My Templates
  </button>
</div>

Conditionally render tab content:

  • When activeTab === 'forms': show the existing search input + form list + custom file upload section (ALL existing markup unchanged)
  • When activeTab === 'templates': show the templates list:
{activeTab === 'templates' && (
  <div className="mb-4">
    {!docTemplatesLoaded ? (
      <p className="text-sm text-gray-500 py-4 text-center">Loading templates...</p>
    ) : docTemplates.length === 0 ? (
      <p className="text-sm text-gray-500 py-4 text-center">
        No templates saved yet. Create one from the Templates page.
      </p>
    ) : (
      <ul className="border rounded max-h-48 overflow-y-auto">
        {docTemplates.map(t => (
          <li
            key={t.id}
            onClick={() => handleSelectDocTemplate(t)}
            className={`px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 ${
              selectedDocTemplate?.id === t.id ? 'bg-blue-50 font-medium' : ''
            }`}
          >
            <span className="block">{t.name}</span>
            <span className="text-xs text-gray-400">
              {t.formName ?? 'Unknown form'} &middot; {t.fieldCount} field{t.fieldCount !== 1 ? 's' : ''}
            </span>
          </li>
        ))}
      </ul>
    )}
  </div>
)}

The {activeTab === 'forms' && ( ... )} wraps around the existing search input, form list <ul>, and custom file upload <div>. All existing elements are preserved exactly — only wrapped in a conditional. cd teressa-copeland-homes && npx tsc --noEmit <acceptance_criteria> - grep "activeTab" src/app/portal/_components/AddDocumentModal.tsx returns at least 4 matches - grep "My Templates" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match - grep "Forms Library" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match - grep "selectedDocTemplate" src/app/portal/_components/AddDocumentModal.tsx returns at least 3 matches - grep "documentTemplateId" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match - grep "/api/templates" src/app/portal/_components/AddDocumentModal.tsx returns at least 1 match - grep "No templates saved yet" src/app/portal/_components/AddDocumentModal.tsx returns 1 match (empty state) - npx tsc --noEmit exits 0 </acceptance_criteria> AddDocumentModal has two tabs (Forms Library + My Templates), lazy-loads templates on first tab click, sends documentTemplateId to POST /api/documents when a template is selected

1. `cd teressa-copeland-homes && npx tsc --noEmit` — zero type errors 2. `cd teressa-copeland-homes && npm run build` — production build succeeds 3. grep confirms: documentTemplateId in both route.ts and AddDocumentModal.tsx 4. grep confirms: crypto.randomUUID in route.ts field copy 5. grep confirms: SIGNER_COLORS in route.ts 6. grep confirms: activeTab, selectedDocTemplate in AddDocumentModal.tsx

<success_criteria>

  • POST /api/documents accepts documentTemplateId and creates a document with copied fields (fresh UUIDs), mapped signers, and correct formTemplateId
  • AddDocumentModal shows two tabs; My Templates tab fetches from GET /api/templates and displays template rows
  • Selecting a template and clicking Add Document creates a document via the template branch
  • No changes to existing Forms Library tab or custom file upload behavior
  • TypeScript compiles with zero errors </success_criteria>
After completion, create `.planning/phases/20-apply-template-and-portal-nav/20-01-SUMMARY.md`