From 05915aa56243fe59583fd1720e00669e12663d8b Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Fri, 20 Mar 2026 00:21:34 -0600 Subject: [PATCH] fix(05-04): pre-select document client and add manual email entry - PreparePanel now receives separate assignedClientId (nullable) and defaultClientId props so it can distinguish an explicitly locked client from just a default - When document already has assignedClientId: show locked read-only display; user cannot change the primary recipient - When document has no assignedClientId: default to document owner in dropdown but allow changing; option to clear and enter email manually - Added textarea for additional/CC email addresses (comma or newline separated) that is always visible for either mode - POST /api/documents/[id]/prepare now accepts and stores emailAddresses array alongside assignedClientId - Added email_addresses jsonb column to documents table via migration 0004_military_maximus.sql Co-Authored-By: Claude Sonnet 4.6 --- .../drizzle/0004_military_maximus.sql | 1 + .../drizzle/meta/0004_snapshot.json | 295 ++++++++++++++++++ .../drizzle/meta/_journal.json | 7 + .../app/api/documents/[id]/prepare/route.ts | 3 + .../[docId]/_components/PreparePanel.tsx | 151 ++++++++- .../(protected)/documents/[docId]/page.tsx | 3 +- teressa-copeland-homes/src/lib/db/schema.ts | 2 + 7 files changed, 445 insertions(+), 17 deletions(-) create mode 100644 teressa-copeland-homes/drizzle/0004_military_maximus.sql create mode 100644 teressa-copeland-homes/drizzle/meta/0004_snapshot.json diff --git a/teressa-copeland-homes/drizzle/0004_military_maximus.sql b/teressa-copeland-homes/drizzle/0004_military_maximus.sql new file mode 100644 index 0000000..216485e --- /dev/null +++ b/teressa-copeland-homes/drizzle/0004_military_maximus.sql @@ -0,0 +1 @@ +ALTER TABLE "documents" ADD COLUMN "email_addresses" jsonb; \ No newline at end of file diff --git a/teressa-copeland-homes/drizzle/meta/0004_snapshot.json b/teressa-copeland-homes/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..4fd5f8f --- /dev/null +++ b/teressa-copeland-homes/drizzle/meta/0004_snapshot.json @@ -0,0 +1,295 @@ +{ + "id": "20190fda-b801-4baf-bcf7-e83d74af86f0", + "prevId": "0f0c2e60-e89f-41d9-8a4f-cec2b4dfe938", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "document_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'Draft'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "form_template_id": { + "name": "form_template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature_fields": { + "name": "signature_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "text_fill_data": { + "name": "text_fill_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assigned_client_id": { + "name": "assigned_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_file_path": { + "name": "prepared_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_addresses": { + "name": "email_addresses", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "documents_client_id_clients_id_fk": { + "name": "documents_client_id_clients_id_fk", + "tableFrom": "documents", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_form_template_id_form_templates_id_fk": { + "name": "documents_form_template_id_form_templates_id_fk", + "tableFrom": "documents", + "tableTo": "form_templates", + "columnsFrom": [ + "form_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form_templates": { + "name": "form_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "form_templates_filename_unique": { + "name": "form_templates_filename_unique", + "nullsNotDistinct": false, + "columns": [ + "filename" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.document_status": { + "name": "document_status", + "schema": "public", + "values": [ + "Draft", + "Sent", + "Viewed", + "Signed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/teressa-copeland-homes/drizzle/meta/_journal.json b/teressa-copeland-homes/drizzle/meta/_journal.json index 9a9524d..6ed37b2 100644 --- a/teressa-copeland-homes/drizzle/meta/_journal.json +++ b/teressa-copeland-homes/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1773985969484, "tag": "0003_cool_natasha_romanoff", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1773987640772, + "tag": "0004_military_maximus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts b/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts index 2141d97..98763ad 100644 --- a/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts +++ b/teressa-copeland-homes/src/app/api/documents/[id]/prepare/route.ts @@ -18,6 +18,8 @@ export async function POST( const body = await req.json() as { textFillData?: Record; assignedClientId?: string; + /** Email addresses to send the document to (client email + any CC addresses). */ + emailAddresses?: string[]; }; const doc = await db.query.documents.findFirst({ where: eq(documents.id, id) }); @@ -45,6 +47,7 @@ export async function POST( preparedFilePath: preparedRelPath, textFillData: body.textFillData ?? null, assignedClientId: body.assignedClientId ?? doc.assignedClientId ?? null, + emailAddresses: body.emailAddresses ?? null, status: 'Sent', sentAt: new Date(), }) diff --git a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx index 9e7a32c..6504734 100644 --- a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx +++ b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PreparePanel.tsx @@ -8,13 +8,51 @@ interface Client { id: string; name: string; email: string; } interface PreparePanelProps { docId: string; clients: Client[]; - currentClientId: string; + /** The client already explicitly assigned to this document (null if not yet assigned). */ + assignedClientId: string | null; + /** The client the document belongs to — used as the default selection when no explicit assignedClientId. */ + defaultClientId: string; currentStatus: string; } -export function PreparePanel({ docId, clients, currentClientId, currentStatus }: PreparePanelProps) { +/** + * Parse a comma/newline-separated string of email addresses into a trimmed, deduplicated array. + * Empty tokens are filtered out. + */ +function parseEmails(raw: string): string[] { + return raw + .split(/[\n,]+/) + .map((e) => e.trim()) + .filter(Boolean); +} + +/** Very light email format check — catches obvious mistakes without a full RFC 5322 parse. */ +function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +export function PreparePanel({ + docId, + clients, + assignedClientId, + defaultClientId, + currentStatus, +}: PreparePanelProps) { const router = useRouter(); - const [assignedClientId, setAssignedClientId] = useState(currentClientId); + + // If the document already has an explicit assigned client, lock to that client. + // Otherwise default to the document owner (defaultClientId) but allow changing. + const isLocked = assignedClientId !== null; + + // selectedClientId: id from the dropdown (empty string = "pick manually by email") + const [selectedClientId, setSelectedClientId] = useState( + assignedClientId ?? defaultClientId, + ); + + // emailInput: raw text for manual email entry (used when selectedClientId is '' or + // as additional CC addresses) + const [emailInput, setEmailInput] = useState(''); + const [textFillData, setTextFillData] = useState>({}); const [loading, setLoading] = useState(false); const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); @@ -22,14 +60,55 @@ export function PreparePanel({ docId, clients, currentClientId, currentStatus }: // Don't show the panel if already sent/signed const canPrepare = currentStatus === 'Draft'; + // The email addresses that will be sent with the prepare request. + // If a known client is selected, start with that client's email. + // Any manually-entered addresses are appended. + function buildEmailAddresses(): string[] { + const addresses: string[] = []; + + if (selectedClientId) { + const client = clients.find((c) => c.id === selectedClientId); + if (client?.email) addresses.push(client.email); + } + + const extras = parseEmails(emailInput); + for (const e of extras) { + if (!addresses.includes(e)) addresses.push(e); + } + + return addresses; + } + async function handlePrepare() { setLoading(true); setResult(null); + + const emailAddresses = buildEmailAddresses(); + + // Require at least one email address + if (emailAddresses.length === 0) { + setResult({ ok: false, message: 'Please select a client or enter at least one email address.' }); + setLoading(false); + return; + } + + // Validate all addresses + const invalid = emailAddresses.filter((e) => !isValidEmail(e)); + if (invalid.length > 0) { + setResult({ ok: false, message: `Invalid email address(es): ${invalid.join(', ')}` }); + setLoading(false); + return; + } + try { const res = await fetch(`/api/documents/${docId}/prepare`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ textFillData, assignedClientId }), + body: JSON.stringify({ + textFillData, + assignedClientId: selectedClientId || null, + emailAddresses, + }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Unknown error' })); @@ -57,18 +136,58 @@ export function PreparePanel({ docId, clients, currentClientId, currentStatus }:

Prepare Document

+ {/* Client / recipient selection */}
- - + + + {isLocked ? ( + // Document already has an assigned client — show read-only info +
+ {(() => { + const c = clients.find((c) => c.id === assignedClientId); + return c ? `${c.name} (${c.email})` : assignedClientId; + })()} +
+ ) : ( + // No explicit assignment yet — allow choosing from list or entering email manually + + )} +
+ + {/* Additional / manual email addresses */} +
+ +