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>
This commit is contained in:
2026-05-21 22:57:19 +02:00
parent 94c24a123a
commit 989cc4d72b
13 changed files with 1083 additions and 169 deletions

View File

@@ -121,6 +121,12 @@ export const SETTING_KEYS = {
// `getStageAdvanceMode` — aggressive defaults match the conventional
// CRM behaviour (EOI signed → reservation auto-advances).
stageAdvanceRules: 'stage_advance_rules',
// Residential partner-forwarding recipients — comma-separated emails
// that receive a courtesy notification on every new residential
// inquiry. Blank disables. See createResidentialInterest +
// forwardResidentialInquiryToPartner for usage.
residentialPartnerRecipients: 'residential_partner_recipients',
} as const;
// ─── Stage auto-advance ──────────────────────────────────────────────────────

View File

@@ -1,6 +1,7 @@
import { and, eq } from 'drizzle-orm';
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';
@@ -16,6 +17,9 @@ import type {
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 ─────────────────────────────────────────────────────
@@ -95,6 +99,14 @@ export async function createResidentialClient(
});
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;
}
@@ -186,17 +198,30 @@ export async function listResidentialInterests(
search,
includeArchived,
pipelineStage,
source,
assignedTo,
residentialClientId,
} = query;
const filters = [];
if (pipelineStage) filters.push(eq(residentialInterests.pipelineStage, pipelineStage));
// 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));
return buildListQuery({
const result = await buildListQuery({
table: residentialInterests,
portIdColumn: residentialInterests.portId,
portId,
@@ -218,6 +243,41 @@ export async function listResidentialInterests(
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) {
@@ -275,9 +335,210 @@ export async function createResidentialInterest(
});
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,