Files
red/.planning/phases/09-client-property-address/09-RESEARCH.md
2026-03-21 12:05:08 -06:00

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]/preparepreparePdf()) 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

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 existing updateClient calls revalidatePath("/portal/clients") and revalidatePath("/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 by useActionState from react in React 19. The project already uses useActionState — do not use the old name.

Open Questions

  1. 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.
  2. 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 onChange callback
    • 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.

Sources

Primary (HIGH confidence)

  • Direct codebase inspection: src/lib/db/schema.ts — confirmed clients table 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 — confirmed useActionState + 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 — confirmed textFillData state initialization
  • Direct codebase inspection: src/lib/pdf/prepare-document.ts — confirmed textFillData: Record<string, string> accepted as-is
  • Direct codebase inspection: drizzle/meta/_journal.json — confirmed next migration will be 0007_*
  • Direct codebase inspection: drizzle.config.ts — confirmed db:generate and db:migrate scripts

Secondary (MEDIUM confidence)

  • package.json drizzle-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)