import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients, clientContacts } from '@/lib/db/schema/clients'; import { residentialClients, residentialInterests } from '@/lib/db/schema/residential'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { CodedError, NotFoundError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { buildListQuery } from '@/lib/db/query-builder'; import { diffEntity } from '@/lib/entity-diff'; import { softDelete, restore } from '@/lib/db/utils'; import type { CreateResidentialClientInput, CreateResidentialInterestInput, ListResidentialClientsInput, ListResidentialInterestsInput, UpdateResidentialClientInput, UpdateResidentialInterestInput, } from '@/lib/validators/residential'; import { sendEmail } from '@/lib/email'; import { SETTING_KEYS, getPortBrandingConfig, readSetting } from '@/lib/services/port-config'; import { brandingPrimaryColor, renderShell } from '@/lib/email/shell'; // ─── Residential clients ───────────────────────────────────────────────────── export async function listResidentialClients(portId: string, query: ListResidentialClientsInput) { const { page, limit, sort, order, search, includeArchived, status, source } = query; const filters = []; if (status) filters.push(eq(residentialClients.status, status)); if (source) filters.push(eq(residentialClients.source, source)); return buildListQuery({ table: residentialClients, portIdColumn: residentialClients.portId, portId, idColumn: residentialClients.id, updatedAtColumn: residentialClients.updatedAt, filters, sort: sort ? { column: (residentialClients[sort as keyof typeof residentialClients] as never) ?? residentialClients.updatedAt, direction: order ?? 'desc', } : undefined, page, pageSize: limit, searchColumns: [ residentialClients.fullName, residentialClients.email, residentialClients.phone, residentialClients.placeOfResidence, ], searchTerm: search, includeArchived, archivedAtColumn: residentialClients.archivedAt, }); } export async function getResidentialClientById(id: string, portId: string) { const client = await db.query.residentialClients.findFirst({ where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)), }); if (!client) throw new NotFoundError('Residential client'); const interests = await db.query.residentialInterests.findMany({ where: eq(residentialInterests.residentialClientId, id), orderBy: (t, { desc }) => [desc(t.updatedAt)], }); return { ...client, interests }; } export async function createResidentialClient( portId: string, data: CreateResidentialClientInput, meta: AuditMeta, ) { const [row] = await db .insert(residentialClients) .values({ portId, ...data }) .returning(); if (!row) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Failed to create residential client', }); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'residential_client', entityId: row.id, newValue: { fullName: row.fullName, email: row.email ?? undefined }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_client:created', { id: row.id }); // Best-effort auto-link to an existing main-client record. Match by // email (cheap, single index lookup) then by E.164 phone (next-best). // Failures or no-match scenarios silently leave the row unlinked — // reps can wire it up via the admin UI later. void findAndLinkMatchingMainClient(row.id, portId).catch((err) => { console.error('[residential] auto-link match failed', err); }); return row; } export async function updateResidentialClient( id: string, portId: string, data: UpdateResidentialClientInput, meta: AuditMeta, ) { const before = await db.query.residentialClients.findFirst({ where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)), }); if (!before) throw new NotFoundError('Residential client'); const [updated] = await db .update(residentialClients) .set({ ...data, updatedAt: new Date() }) .where(and(eq(residentialClients.id, id), eq(residentialClients.portId, portId))) .returning(); if (!updated) throw new NotFoundError('Residential client'); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'residential_client', entityId: id, oldValue: diffEntity(before, updated) as Record, newValue: data as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_client:updated', { id }); return updated; } export async function archiveResidentialClient(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.residentialClients.findFirst({ where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)), }); if (!existing) throw new NotFoundError('Residential client'); await softDelete(residentialClients, residentialClients.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'residential_client', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_client:archived', { id }); } export async function restoreResidentialClient(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.residentialClients.findFirst({ where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)), }); if (!existing) throw new NotFoundError('Residential client'); await restore(residentialClients, residentialClients.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'restore', entityType: 'residential_client', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_client:restored', { id }); } // ─── Residential interests ─────────────────────────────────────────────────── export async function listResidentialInterests( portId: string, query: ListResidentialInterestsInput, ) { const { page, limit, sort, order, search, includeArchived, pipelineStage, source, assignedTo, residentialClientId, } = query; const filters = []; // Normalize string-or-array filter inputs into Drizzle `inArray` clauses // when multiple values are supplied; fall back to `eq` for the single // case so the validator's union shape doesn't change the SQL. if (pipelineStage) { const values = Array.isArray(pipelineStage) ? pipelineStage : [pipelineStage]; if (values.length > 1) filters.push(inArray(residentialInterests.pipelineStage, values)); else if (values[0]) filters.push(eq(residentialInterests.pipelineStage, values[0])); } if (source) { const values = Array.isArray(source) ? source : [source]; if (values.length > 1) filters.push(inArray(residentialInterests.source, values)); else if (values[0]) filters.push(eq(residentialInterests.source, values[0])); } if (assignedTo) filters.push(eq(residentialInterests.assignedTo, assignedTo)); if (residentialClientId) filters.push(eq(residentialInterests.residentialClientId, residentialClientId)); const result = await buildListQuery({ table: residentialInterests, portIdColumn: residentialInterests.portId, portId, idColumn: residentialInterests.id, updatedAtColumn: residentialInterests.updatedAt, filters, sort: sort ? { column: (residentialInterests[sort as keyof typeof residentialInterests] as never) ?? residentialInterests.updatedAt, direction: order ?? 'desc', } : undefined, page, pageSize: limit, searchColumns: [residentialInterests.notes, residentialInterests.preferences], searchTerm: search, includeArchived, archivedAtColumn: residentialInterests.archivedAt, }); // Per-page client-name lookup so the list table can render the // residentialClient.fullName in column 1 without a second hop per row. // Two-pass post-fetch pattern mirrors the main interests list (latest // stage / tag aggregation lives there). Page size is capped by the // validator so this stays a single bulk IN-list query. // `buildListQuery` returns `data: typeof table.$inferSelect[]` so the // row type is `residentialInterests` — known shape, but TS infers // `unknown[]` through the generic helper. Cast through `unknown` once // here so the downstream enrichment is type-clean. type InterestRow = typeof residentialInterests.$inferSelect; const typedData = result.data as unknown as InterestRow[]; if (typedData.length > 0) { const clientIds = Array.from( new Set(typedData.map((r) => r.residentialClientId).filter((v): v is string => Boolean(v))), ); if (clientIds.length > 0) { const clients = await db .select({ id: residentialClients.id, fullName: residentialClients.fullName, }) .from(residentialClients) .where( and(eq(residentialClients.portId, portId), inArray(residentialClients.id, clientIds)), ); const nameById = new Map(clients.map((c) => [c.id, c.fullName])); result.data = typedData.map((r) => ({ ...r, clientName: nameById.get(r.residentialClientId) ?? null, })) as unknown as typeof result.data; } } return result; } export async function getResidentialInterestById(id: string, portId: string) { const interest = await db.query.residentialInterests.findFirst({ where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)), }); if (!interest) throw new NotFoundError('Residential interest'); // The residentialInterest is already port-scoped; pin the client read // to the same port too so a future drift (a foreign-port residential // client id ever landing on the interest) cannot leak. const client = await db.query.residentialClients.findFirst({ where: and( eq(residentialClients.id, interest.residentialClientId), eq(residentialClients.portId, portId), ), }); return { ...interest, client }; } export async function createResidentialInterest( portId: string, data: CreateResidentialInterestInput, meta: AuditMeta, ) { // Validate the residential client belongs to this port - prevents // cross-port linking. const client = await db.query.residentialClients.findFirst({ where: and( eq(residentialClients.id, data.residentialClientId), eq(residentialClients.portId, portId), ), }); if (!client) throw new NotFoundError('Residential client'); const [row] = await db .insert(residentialInterests) .values({ portId, ...data }) .returning(); if (!row) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Failed to create residential interest', }); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'residential_interest', entityId: row.id, newValue: { residentialClientId: row.residentialClientId, pipelineStage: row.pipelineStage }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_interest:created', { id: row.id }); // Fire-and-forget partner-forward email. Failures here MUST NOT block // the create — partner notification is a courtesy. Errors are logged // server-side so the operator can see them, but the API still 201s. void forwardResidentialInquiryToPartner({ portId, client, interest: row, }).catch((err) => { console.error('[residential] partner forward failed', err); }); return row; } /** * Sends a courtesy notification to the configured partner email * recipients when a new residential inquiry lands. Recipients are * configured per-port via the `residential_partner_recipients` system * setting (comma-separated list). No-ops when the setting is blank or * the inquiry has no usable client snapshot. * * Uses the same branded shell as the rest of the transactional emails * so the partner sees a port-branded notification rather than a raw * HTML block. */ async function forwardResidentialInquiryToPartner(input: { portId: string; client: typeof residentialClients.$inferSelect; interest: typeof residentialInterests.$inferSelect; }): Promise { const { portId, client, interest } = input; const raw = await readSetting(SETTING_KEYS.residentialPartnerRecipients, portId); if (!raw) return; const recipients = raw .split(',') .map((s) => s.trim()) .filter((s) => /^.+@.+\..+$/.test(s)); if (recipients.length === 0) return; const branding = await getPortBrandingConfig(portId); const accent = brandingPrimaryColor({ logoUrl: branding.logoUrl, backgroundUrl: branding.emailBackgroundUrl, primaryColor: branding.primaryColor, emailHeaderHtml: branding.emailHeaderHtml, emailFooterHtml: branding.emailFooterHtml, }); const escapeHtml = (s: string): string => s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const subject = `New residential inquiry: ${client.fullName}`; const body = `

New residential inquiry

A new residential inquiry was submitted via the CRM. Details below; full record lives in the port's CRM under Residential > Interests.

${ client.email ? `` : '' } ${ client.phone ? `` : '' } ${ client.placeOfResidence ? `` : '' } ${ interest.preferences ? `` : '' } ${ interest.notes ? `` : '' }
Name ${escapeHtml(client.fullName)}
Email ${escapeHtml(client.email)}
Phone ${escapeHtml(client.phone)}
Location ${escapeHtml(client.placeOfResidence)}
Preferences ${escapeHtml(interest.preferences)}
Notes ${escapeHtml(interest.notes)}

Pipeline stage at submission: ${escapeHtml(interest.pipelineStage)}.

`; const html = renderShell({ title: subject, body, branding: { logoUrl: branding.logoUrl, backgroundUrl: branding.emailBackgroundUrl, primaryColor: branding.primaryColor, emailHeaderHtml: branding.emailHeaderHtml, emailFooterHtml: branding.emailFooterHtml, }, }); await sendEmail(recipients, subject, html, undefined, undefined, portId); } /** * Best-effort matcher that links a residential client to an existing * main `clients` row representing the same person. Matches by: * 1. email (residential.email matches clients.contacts of channel='email') * 2. phoneE164 (residential.phone_e164 matches clients.contacts of * channel='phone' or 'whatsapp') * * Match ordering is "exact email beats phone beats nothing" — the * first hit wins. Returns the linked main-client id when a match was * found, or null when no match exists. * * Caller is expected to handle errors / call best-effort (residential * lifecycle MUST NOT block on matching). Exported so the admin * backfill script can re-run the matcher across historical rows. */ export async function findAndLinkMatchingMainClient( residentialClientId: string, portId: string, ): Promise { const row = await db.query.residentialClients.findFirst({ where: and( eq(residentialClients.id, residentialClientId), eq(residentialClients.portId, portId), ), }); if (!row) return null; if (row.linkedClientId) return row.linkedClientId; // Try email match first. Look for a main-client contact row in the // same port whose value matches case-insensitively. Pick the most- // recently-updated client when multiple match (rare but possible). let matchedClientId: string | null = null; if (row.email) { const emailMatches = await db .select({ clientId: clientContacts.clientId }) .from(clientContacts) .innerJoin(clients, eq(clients.id, clientContacts.clientId)) .where( and( eq(clients.portId, portId), eq(clientContacts.channel, 'email'), eq(clientContacts.value, row.email.toLowerCase()), ), ) .limit(1); if (emailMatches[0]) matchedClientId = emailMatches[0].clientId; } if (!matchedClientId && row.phoneE164) { const phoneMatches = await db .select({ clientId: clientContacts.clientId }) .from(clientContacts) .innerJoin(clients, eq(clients.id, clientContacts.clientId)) .where( and( eq(clients.portId, portId), inArray(clientContacts.channel, ['phone', 'whatsapp']), eq(clientContacts.valueE164, row.phoneE164), ), ) .limit(1); if (phoneMatches[0]) matchedClientId = phoneMatches[0].clientId; } if (!matchedClientId) return null; await db .update(residentialClients) .set({ linkedClientId: matchedClientId, updatedAt: new Date() }) .where( and(eq(residentialClients.id, residentialClientId), eq(residentialClients.portId, portId)), ); emitToRoom(`port:${portId}`, 'residential_client:updated', { id: residentialClientId }); return matchedClientId; } export async function updateResidentialInterest( id: string, portId: string, data: UpdateResidentialInterestInput, meta: AuditMeta, ) { const before = await db.query.residentialInterests.findFirst({ where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)), }); if (!before) throw new NotFoundError('Residential interest'); const [updated] = await db .update(residentialInterests) .set({ ...data, updatedAt: new Date() }) .where(and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId))) .returning(); if (!updated) throw new NotFoundError('Residential interest'); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'residential_interest', entityId: id, oldValue: diffEntity(before, updated) as Record, newValue: data as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_interest:updated', { id }); return updated; } export async function archiveResidentialInterest(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.residentialInterests.findFirst({ where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)), }); if (!existing) throw new NotFoundError('Residential interest'); await softDelete(residentialInterests, residentialInterests.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'residential_interest', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_interest:archived', { id }); }