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:
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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's CRM under Residential > 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,
|
||||
|
||||
Reference in New Issue
Block a user