Files
pn-new-crm/src/lib/services/residential.service.ts
Matt 989cc4d72b feat(uat-batch): Group I — Residential parity (4 ships)
I34–I37 from the 2026-05-21 plan.

Shipped:
  I34  Residential client header layout parity. Email / Call /
       WhatsApp action buttons mirror the main ClientDetailHeader.
       WhatsApp number resolves from phoneE164 (preferred) or strips
       the free-text phone to digits. Header surfaces "Linked to
       main client" chip when the auto-link matcher (I37) finds a
       counterpart in the main CRM.
  I35  Residential interests list rebuilt for parity with the main
       InterestList. New ResidentialInterestCard +
       getResidentialInterestColumns + residentialInterestFilter-
       Definitions; the list page drives DataTable + FilterBar +
       ColumnPicker + SavedViewsDropdown + bulkActions. List
       endpoint validator widened to accept pipelineStage as a
       string OR string[] and added a source filter. Service post-
       fetches client names via a single IN-list lookup so the
       table renders fullName in column 1 without N+1.
       New /api/v1/residential/interests/bulk supports
       change_stage + archive (100-id cap). Kanban view deferred.
  I36  Residential inquiries auto-forward to partner email(s).
       New registry entry residential_partner_recipients (comma-
       separated) under section residential.partner.
       createResidentialInterest fires
       forwardResidentialInquiryToPartner after the row lands.
       Helper uses the same branded shell other transactional
       emails use. Failures log + never block create. The
       /admin/residential-stages page picks up a registry-driven
       card so admins manage recipients alongside stages.
  I37  Auto-link residential ↔ main client. Migration 0080 adds
       residential_clients.linked_client_id (nullable FK, SET NULL
       on cascade) + partial index. New findAndLinkMatchingMainClient
       service matches by email first (case-insensitive client_contacts
       lookup) then by E.164 phone. First exact match wins. Fires
       fire-and-forget from createResidentialClient. Header surfaces
       the link via a "Linked to main client" chip. Backfill script
       + reverse-direction link from main ClientDetailHeader stay
       as follow-ups.

Verified: tsc clean, vitest 1454/1454, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:57:19 +02:00

595 lines
21 KiB
TypeScript

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<string, unknown>,
newValue: data as Record<string, unknown>,
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<void> {
const { portId, client, interest } = input;
const raw = await readSetting<string>(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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const subject = `New residential inquiry: ${client.fullName}`;
const body = `
<h1 style="font-family:Arial,sans-serif;font-size:20px;font-weight:600;color:#0f172a;margin:0 0 12px;">
New residential inquiry
</h1>
<p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.55;color:#334155;margin:0 0 12px;">
A new residential inquiry was submitted via the CRM. Details below; full record lives in the
port&apos;s CRM under Residential &gt; Interests.
</p>
<table role="presentation" style="font-family:Arial,sans-serif;font-size:13px;color:#334155;border-collapse:collapse;margin:8px 0 16px;">
<tr>
<td style="padding:4px 12px 4px 0;color:#64748b;">Name</td>
<td style="padding:4px 0;">${escapeHtml(client.fullName)}</td>
</tr>
${
client.email
? `<tr>
<td style="padding:4px 12px 4px 0;color:#64748b;">Email</td>
<td style="padding:4px 0;">${escapeHtml(client.email)}</td>
</tr>`
: ''
}
${
client.phone
? `<tr>
<td style="padding:4px 12px 4px 0;color:#64748b;">Phone</td>
<td style="padding:4px 0;">${escapeHtml(client.phone)}</td>
</tr>`
: ''
}
${
client.placeOfResidence
? `<tr>
<td style="padding:4px 12px 4px 0;color:#64748b;">Location</td>
<td style="padding:4px 0;">${escapeHtml(client.placeOfResidence)}</td>
</tr>`
: ''
}
${
interest.preferences
? `<tr>
<td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Preferences</td>
<td style="padding:4px 0;white-space:pre-wrap;">${escapeHtml(interest.preferences)}</td>
</tr>`
: ''
}
${
interest.notes
? `<tr>
<td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Notes</td>
<td style="padding:4px 0;white-space:pre-wrap;">${escapeHtml(interest.notes)}</td>
</tr>`
: ''
}
</table>
<p style="font-family:Arial,sans-serif;font-size:12px;color:#64748b;margin:0;">
Pipeline stage at submission: <span style="color:${accent};font-weight:600;">${escapeHtml(interest.pipelineStage)}</span>.
</p>
`;
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<string | null> {
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<string, unknown>,
newValue: data as Record<string, unknown>,
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 });
}