19 KiB
Phase 9: Client Property Address - Research
Researched: 2026-03-21 Domain: Drizzle ORM schema migration, Next.js server actions, React form patterns, prepare-document pipeline Confidence: HIGH
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CLIENT-04 | Agent can add a property address to a client profile | DB column property_address TEXT nullable on clients table; extend ClientModal form + updateClient / createClient server actions to accept the new field; display on ClientProfileClient profile card |
| CLIENT-05 | Client property address is available as a pre-fill data source alongside client name | DocumentPage already fetches client data (name, email) for PreparePanel; extend that select to include propertyAddress; pass it to PreparePanel as a new prop; PreparePanel pre-seeds textFillData state with { propertyAddress: value } so it flows into the existing prepare pipeline untouched |
| </phase_requirements> |
Summary
Phase 9 is a narrow, well-understood CRUD extension. The clients table needs one new nullable TEXT column (property_address). No new tables, no new libraries, no new API routes. Every layer that must change is small and clearly bounded.
The existing prepare-document pipeline (/api/documents/[id]/prepare → preparePdf()) already accepts textFillData: Record<string, string> and stamps any key/value pair onto the PDF. Property address simply becomes one more key in that map. No changes to the pipeline itself are needed.
The only non-trivial design decision is where to inject the pre-fill: the DocumentPage server component already fetches client name and email for PreparePanel; it must be extended to also fetch propertyAddress and pass it as a prop. PreparePanel initializes textFillData state — it should seed a propertyAddress key from the prop when the value is non-empty, so the field arrives pre-populated without agent typing.
Primary recommendation: Add propertyAddress as a nullable TEXT column via drizzle-kit generate + drizzle-kit migrate, extend the Zod schema + server actions + ClientModal in one atomic pass, display on the profile card, and pre-seed PreparePanel state from the fetched client data.
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| drizzle-orm | ^0.45.1 (installed) | ORM and schema definition | Already the project ORM; schema changes happen here |
| drizzle-kit | ^0.31.10 (installed) | Migration generation and running | Generates SQL from schema diff; db:generate + db:migrate scripts already defined |
| zod | ^4.3.6 (installed) | Server action input validation | Already used in clients.ts action for name + email validation |
| next | 16.2.0 (installed) | Server actions, revalidatePath, useActionState |
All client management already uses this pattern |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
react useActionState |
React 19 (installed) | Form state management in client modal | Already used in ClientModal.tsx |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| Single TEXT column for full address | Structured columns (street, city, state, zip) | Single TEXT is explicitly called for in requirements as "structured data for AI pre-fill" — AI reads the whole string; structured parsing adds complexity with no benefit in v1.1 |
ALTER TABLE raw SQL migration |
drizzle-kit generate |
Use drizzle-kit — it keeps _journal.json and snapshots in sync; raw SQL bypasses snapshot tracking and breaks future diffs |
Installation: No new packages needed. All dependencies already installed.
Architecture Patterns
Recommended Project Structure
No new files/folders needed. Changes touch only existing files:
src/
├── lib/
│ ├── db/
│ │ └── schema.ts # ADD propertyAddress column to clients table
│ └── actions/
│ └── clients.ts # EXTEND Zod schema + createClient + updateClient
├── app/
│ └── portal/
│ ├── _components/
│ │ ├── ClientModal.tsx # ADD property address input field
│ │ └── ClientProfileClient.tsx # DISPLAY property address in profile card
│ └── (protected)/
│ └── documents/
│ └── [docId]/
│ ├── page.tsx # EXTEND client select to include propertyAddress
│ └── _components/
│ └── PreparePanel.tsx # ACCEPT + pre-seed propertyAddress prop
drizzle/
└── 0007_property_address.sql # Generated by drizzle-kit generate
Pattern 1: Drizzle Schema Column Addition
What: Add nullable TEXT column to existing pgTable definition, then run generate + migrate. When to use: Any time a new optional field is added to an existing table.
// src/lib/db/schema.ts — add to the clients table definition
export const clients = pgTable("clients", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
email: text("email").notNull(),
propertyAddress: text("property_address"), // nullable — no .notNull()
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
# Generate SQL migration file (creates drizzle/0007_*.sql)
npm run db:generate
# Apply to database
npm run db:migrate
The generated SQL will be:
ALTER TABLE "clients" ADD COLUMN "property_address" text;
Pattern 2: Extending a Zod Schema + Server Action
What: Add an optional field to the existing Zod clientSchema and pass it through .set().
When to use: When a new nullable DB column is added and the form should be optional.
// src/lib/actions/clients.ts
const clientSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Valid email address required"),
propertyAddress: z.string().optional(), // empty string or absent = null
});
// In createClient and updateClient, after safeParse succeeds:
await db.insert(clients).values({
name: parsed.data.name,
email: parsed.data.email,
propertyAddress: parsed.data.propertyAddress || null,
});
// updateClient .set():
await db.update(clients).set({
name: parsed.data.name,
email: parsed.data.email,
propertyAddress: parsed.data.propertyAddress || null,
updatedAt: new Date(),
}).where(eq(clients.id, id));
Pattern 3: ClientModal Form Field Addition
What: Add a new input with name="propertyAddress" and defaultValue prop, mirroring existing name/email pattern.
When to use: Every new client field that agent can edit.
// src/app/portal/_components/ClientModal.tsx
// Add to ClientModalProps:
type ClientModalProps = {
isOpen: boolean;
onClose: () => void;
mode?: "create" | "edit";
clientId?: string;
defaultName?: string;
defaultEmail?: string;
defaultPropertyAddress?: string; // new
};
// Add input element inside the form (after email):
<div>
<label htmlFor="propertyAddress" style={{ ... }}>
Property Address
</label>
<input
id="propertyAddress"
name="propertyAddress"
type="text"
defaultValue={defaultPropertyAddress}
className="block w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm ..."
placeholder="123 Main St, Salt Lake City, UT 84101"
/>
</div>
Pattern 4: Pre-seeding PreparePanel with Client Data
What: DocumentPage fetches propertyAddress from the joined client; passes it to PreparePanel; PreparePanel seeds textFillData state on mount via useEffect.
When to use: Any time a known value should be pre-populated into a text fill field.
// src/app/portal/(protected)/documents/[docId]/page.tsx
// Extend the client select:
db.select({ email: clients.email, name: clients.name, propertyAddress: clients.propertyAddress })
.from(clients)
.innerJoin(documents, eq(documents.clientId, clients.id))
.where(eq(documents.id, docId))
.limit(1)
.then(r => r[0] ?? null)
// Pass to PreparePanel:
<PreparePanel
...
clientPropertyAddress={docClient?.propertyAddress ?? null}
/>
// src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx
interface PreparePanelProps {
...
clientPropertyAddress?: string | null;
}
// In component body — seed state on first render:
const [textFillData, setTextFillData] = useState<Record<string, string>>(
() => clientPropertyAddress ? { propertyAddress: clientPropertyAddress } : {}
);
Pattern 5: ClientProfileClient — Display Property Address
What: Render propertyAddress in the header card, guarded by nullish check.
When to use: Any optional field displayed in a profile card.
// src/app/portal/_components/ClientProfileClient.tsx
// Update Props type:
type Props = {
client: { id: string; name: string; email: string; propertyAddress?: string | null };
docs: DocumentRow[];
};
// In the header card JSX, after the email paragraph:
{client.propertyAddress && (
<p style={{ color: "#6B7280", fontSize: "0.875rem" }}>
{client.propertyAddress}
</p>
)}
The ClientProfilePage server component already passes client directly from db.select() — once the schema column is added, Drizzle infers the type and client.propertyAddress is available with no other changes.
Pattern 6: Passing propertyAddress through ClientProfileClient → ClientModal
What: The "Edit" button in ClientProfileClient opens ClientModal with defaultPropertyAddress. This requires updating the ClientModal call site.
// ClientProfileClient.tsx — edit modal call:
<ClientModal
isOpen={isEditOpen}
onClose={() => setIsEditOpen(false)}
mode="edit"
clientId={client.id}
defaultName={client.name}
defaultEmail={client.email}
defaultPropertyAddress={client.propertyAddress ?? undefined}
/>
Anti-Patterns to Avoid
- Storing address as JSONB: TEXT is correct. JSONB adds no value for a flat string that AI reads as a whole.
- Making property address required (NOT NULL): Existing clients have no address. The column MUST be nullable; NOT NULL with no default breaks existing rows on migration.
- Changing the prepare API route: The route already accepts
textFillData: Record<string, string>. Passing{ propertyAddress: "..." }in that map is sufficient — zero changes to/api/documents/[id]/prepare/route.ts. - Using a separate pre-fill API endpoint: The TextFillForm + PreparePanel already handle the data flow. Seed from state, not from a new endpoint.
- Forgetting
revalidatePath: The existingupdateClientcallsrevalidatePath("/portal/clients")andrevalidatePath("/portal/clients/" + id). Both must remain; no new paths needed.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| DB schema migration | Manual ALTER TABLE SQL file |
npm run db:generate (drizzle-kit) |
drizzle-kit updates _journal.json and snapshot files that future migrations depend on; manual SQL breaks the diff chain |
| Form validation | Custom validation logic | Zod z.string().optional() |
Already the project pattern; consistent error shape |
| Cache invalidation after update | Manual cache bust | revalidatePath() — already called in updateClient |
Ensures server component re-renders with fresh data |
Key insight: Every mechanism needed for Phase 9 already exists in the codebase. The task is extension, not construction.
Common Pitfalls
Pitfall 1: Migration snapshot desync
What goes wrong: Developer manually writes ALTER TABLE SQL in drizzle/0007_*.sql without running drizzle-kit generate. The snapshot files (meta/0007_snapshot.json and _journal.json) are not updated. The next drizzle-kit generate produces a diff that re-adds the column.
Why it happens: Developer skips npm run db:generate to save time.
How to avoid: Always run npm run db:generate first. The command reads schema.ts, diffs against the latest snapshot, and produces both the SQL file and the updated snapshot.
Warning signs: After adding the column to schema.ts, running npm run db:generate again produces a non-empty migration — indicates the first migration wasn't done via drizzle-kit.
Pitfall 2: Nullable column with default value confusion
What goes wrong: Column declared as .default('') instead of truly nullable. Empty string and NULL are treated differently in WHERE clauses and TypeScript types.
Why it happens: Developer adds .default('') thinking it's "safer".
How to avoid: Declare as text("property_address") with no .notNull() and no .default(). TypeScript type will be string | null. Guard display with client.propertyAddress && ....
Pitfall 3: Empty string vs null in server action
What goes wrong: Form submits an empty string for propertyAddress. z.string().optional() returns "" (not undefined). The DB gets "" stored instead of NULL.
Why it happens: FormData returns "" for an unfilled text input, not null.
How to avoid: In the server action, coerce: propertyAddress: parsed.data.propertyAddress || null. This converts "" to null before the DB write.
Pitfall 4: PreparePanel textFillData state not seeded
What goes wrong: clientPropertyAddress prop is passed to PreparePanel but textFillData state is initialized to {}. The property address is not pre-populated. Agent must type it manually despite it being available.
Why it happens: Prop added to interface but state initialization not updated.
How to avoid: Initialize textFillData with a lazy initializer function: useState(() => clientPropertyAddress ? { propertyAddress: clientPropertyAddress } : {}). This runs once on mount with the correct value.
Pitfall 5: ClientProfileClient Props type not updated
What goes wrong: client prop type is { id: string; name: string; email: string } — no propertyAddress. TypeScript error when accessing client.propertyAddress. The server component ClientProfilePage passes the full Drizzle row which now includes propertyAddress but the client component prop type rejects it.
Why it happens: Props type not updated after schema change.
How to avoid: Update the Props type in ClientProfileClient.tsx to include propertyAddress?: string | null before attempting to use it.
Code Examples
Verified patterns from the existing codebase:
Drizzle nullable TEXT column (project pattern)
// Source: src/lib/db/schema.ts — existing nullable columns
sentAt: timestamp("sent_at"), // nullable timestamp — no .notNull()
filePath: text("file_path"), // nullable text — no .notNull()
// propertyAddress follows the same pattern:
propertyAddress: text("property_address"),
Server action Zod optional field (project pattern)
// Source: src/lib/actions/clients.ts — existing pattern extended
const clientSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Valid email address required"),
propertyAddress: z.string().optional(),
});
// After parse: parsed.data.propertyAddress || null (coerce "" → null)
Drizzle .set() with optional field (project pattern)
// Source: src/lib/actions/clients.ts updateClient — existing pattern
await db.update(clients).set({
name: parsed.data.name,
email: parsed.data.email,
propertyAddress: parsed.data.propertyAddress || null,
updatedAt: new Date(),
}).where(eq(clients.id, id));
revalidatePath in client action (project pattern)
// Source: src/lib/actions/clients.ts — already present, no change needed
revalidatePath("/portal/clients");
revalidatePath("/portal/clients/" + id);
useState lazy initializer for pre-seeding (standard React pattern)
// Lazy initializer runs once — correct for prop-driven initialization
const [textFillData, setTextFillData] = useState<Record<string, string>>(
() => clientPropertyAddress ? { propertyAddress: clientPropertyAddress } : {}
);
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Manual SQL migration files | drizzle-kit generate + migrate |
Project convention from day 1 | Must follow for snapshot integrity |
| Class-based React forms | useActionState (React 19) |
React 19 / Next.js 16 | Already used in ClientModal; follow same pattern |
Deprecated/outdated:
useFormState(React DOM): Replaced byuseActionStatefromreactin React 19. The project already usesuseActionState— do not use the old name.
Open Questions
-
Should propertyAddress be required for new clients going forward?
- What we know: Requirements say "Agent can ADD a property address" — implies optional
- What's unclear: Future phases (AI-02) need it for pre-fill — but not having it should not block document creation
- Recommendation: Keep nullable. Phase 13 (AI-02) can show a warning if address is missing when AI pre-fill is triggered.
-
How does PreparePanel handle the case where textFillData is seeded but agent clears the property address field?
- What we know: TextFillForm is a fully controlled component; changes propagate through
onChangecallback - What's unclear: If agent deletes the pre-seeded row, should it stay empty or re-populate?
- Recommendation: Normal behavior — once agent deletes it, it stays deleted. Pre-seeding is a convenience, not a lock.
- What we know: TextFillForm is a fully controlled component; changes propagate through
Sources
Primary (HIGH confidence)
- Direct codebase inspection:
src/lib/db/schema.ts— confirmedclientstable structure, existing nullable TEXT pattern - Direct codebase inspection:
src/lib/actions/clients.ts— confirmed Zod schema pattern, server action shape - Direct codebase inspection:
src/app/portal/_components/ClientModal.tsx— confirmeduseActionState+ form pattern - Direct codebase inspection:
src/app/portal/_components/ClientProfileClient.tsx— confirmed Props type, display pattern - Direct codebase inspection:
src/app/portal/(protected)/documents/[docId]/page.tsx— confirmed client data fetch shape - Direct codebase inspection:
src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx— confirmedtextFillDatastate initialization - Direct codebase inspection:
src/lib/pdf/prepare-document.ts— confirmedtextFillData: Record<string, string>accepted as-is - Direct codebase inspection:
drizzle/meta/_journal.json— confirmed next migration will be0007_* - Direct codebase inspection:
drizzle.config.ts— confirmeddb:generateanddb:migratescripts
Secondary (MEDIUM confidence)
package.jsondrizzle-orm ^0.45.1, drizzle-kit ^0.31.10 — versions confirmed from installed dependencies
Metadata
Confidence breakdown:
- Standard stack: HIGH — all dependencies confirmed installed, no new libraries needed
- Architecture: HIGH — full codebase read; every touch point identified and verified
- Pitfalls: HIGH — all pitfalls derived from direct inspection of existing code patterns
Research date: 2026-03-21 Valid until: 2026-04-20 (stable stack — Drizzle and Next.js APIs in this project are locked)