feat(clients): multi-contact support — co-buyers, auto-seed document signers from client contacts

This commit is contained in:
Chandler Copeland
2026-04-03 17:37:39 -06:00
parent 4f25a8c124
commit 81ce0b9ab0
7 changed files with 166 additions and 76 deletions

View File

@@ -3,6 +3,7 @@
import { z } from "zod";
import { db } from "@/lib/db";
import { clients } from "@/lib/db/schema";
import type { ClientContact } from "@/lib/db/schema";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
@@ -13,14 +14,28 @@ const clientSchema = z.object({
propertyAddress: z.string().optional(),
});
/** Parse the hidden `contacts` JSON field from the form — returns null on invalid/empty */
function parseContacts(raw: FormDataEntryValue | null): ClientContact[] | null {
if (!raw || typeof raw !== 'string' || !raw.trim()) return null;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
const valid = parsed.filter((c): c is ClientContact =>
typeof c?.name === 'string' && typeof c?.email === 'string' &&
c.name.trim() !== '' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.email)
);
return valid.length > 0 ? valid : null;
} catch {
return null;
}
}
export async function createClient(
_prevState: { error?: string; success?: boolean } | null,
formData: FormData
): Promise<{ error?: string; success?: boolean }> {
const session = await auth();
if (!session) {
return { error: "Unauthorized" };
}
if (!session) return { error: "Unauthorized" };
const parsed = clientSchema.safeParse({
name: formData.get("name"),
@@ -28,14 +43,15 @@ export async function createClient(
propertyAddress: formData.get("propertyAddress"),
});
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
if (!parsed.success) return { error: parsed.error.issues[0].message };
const contacts = parseContacts(formData.get("contacts"));
await db.insert(clients).values({
name: parsed.data.name,
email: parsed.data.email,
propertyAddress: parsed.data.propertyAddress || null,
contacts,
});
revalidatePath("/portal/clients");
@@ -48,9 +64,7 @@ export async function updateClient(
formData: FormData
): Promise<{ error?: string; success?: boolean }> {
const session = await auth();
if (!session) {
return { error: "Unauthorized" };
}
if (!session) return { error: "Unauthorized" };
const parsed = clientSchema.safeParse({
name: formData.get("name"),
@@ -58,13 +72,19 @@ export async function updateClient(
propertyAddress: formData.get("propertyAddress"),
});
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
if (!parsed.success) return { error: parsed.error.issues[0].message };
const contacts = parseContacts(formData.get("contacts"));
await db
.update(clients)
.set({ name: parsed.data.name, email: parsed.data.email, propertyAddress: parsed.data.propertyAddress || null, updatedAt: new Date() })
.set({
name: parsed.data.name,
email: parsed.data.email,
propertyAddress: parsed.data.propertyAddress || null,
contacts,
updatedAt: new Date(),
})
.where(eq(clients.id, id));
revalidatePath("/portal/clients");
@@ -76,12 +96,9 @@ export async function deleteClient(
id: string
): Promise<{ error?: string; success?: boolean }> {
const session = await auth();
if (!session) {
return { error: "Unauthorized" };
}
if (!session) return { error: "Unauthorized" };
await db.delete(clients).where(eq(clients.id, id));
revalidatePath("/portal/clients");
return { success: true };
}

View File

@@ -66,10 +66,22 @@ export const documentStatusEnum = pgEnum("document_status", [
"Signed",
]);
/**
* A single person on a client record (primary or co-buyer).
* Primary contact uses the top-level clients.name / clients.email.
* Additional contacts (co-buyers, co-sellers) live in clients.contacts[].
*/
export interface ClientContact {
name: string;
email: string;
}
export const clients = pgTable("clients", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
email: text("email").notNull(),
/** Additional contacts on this client record (co-buyers, partners, etc.) */
contacts: jsonb("contacts").$type<ClientContact[]>(),
propertyAddress: text("property_address"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),