429 lines
19 KiB
Markdown
429 lines
19 KiB
Markdown
# 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.
|
|
|
|
```typescript
|
|
// 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(),
|
|
});
|
|
```
|
|
|
|
```bash
|
|
# Generate SQL migration file (creates drizzle/0007_*.sql)
|
|
npm run db:generate
|
|
|
|
# Apply to database
|
|
npm run db:migrate
|
|
```
|
|
|
|
The generated SQL will be:
|
|
```sql
|
|
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.
|
|
|
|
```typescript
|
|
// 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.
|
|
|
|
```typescript
|
|
// 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.
|
|
|
|
```typescript
|
|
// 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}
|
|
/>
|
|
```
|
|
|
|
```typescript
|
|
// 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.
|
|
|
|
```typescript
|
|
// 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.
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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)
|