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:
18
src/lib/db/migrations/0080_residential_linked_client.sql
Normal file
18
src/lib/db/migrations/0080_residential_linked_client.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 2026-05-21: residential ↔ main-client auto-link.
|
||||
--
|
||||
-- Adds a nullable FK from `residential_clients` to the main `clients`
|
||||
-- table. When the matching service finds a main-client record with the
|
||||
-- same email / E.164 phone, the residential row is linked so reps can
|
||||
-- see the cross-record relationship from either side without manual
|
||||
-- de-duplication.
|
||||
--
|
||||
-- ON DELETE SET NULL keeps the residential record intact even if the
|
||||
-- main client is hard-deleted (GDPR wipe etc.). Index supports
|
||||
-- "what residential rows are linked to this main client" queries.
|
||||
|
||||
ALTER TABLE residential_clients
|
||||
ADD COLUMN linked_client_id text REFERENCES clients(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_residential_clients_linked_client
|
||||
ON residential_clients (linked_client_id)
|
||||
WHERE linked_client_id IS NOT NULL;
|
||||
@@ -44,6 +44,14 @@ export const residentialClients = pgTable(
|
||||
status: text('status').notNull().default('prospect'),
|
||||
source: text('source'), // website | manual | referral | broker
|
||||
notes: text('notes'),
|
||||
/**
|
||||
* Optional link to a matching record in the main `clients` table.
|
||||
* Populated by `findAndLinkMatchingMainClient` after create, or
|
||||
* manually via the admin UI. ON DELETE SET NULL — the residential
|
||||
* record outlives a GDPR wipe of the main client. Migration 0080
|
||||
* adds the FK + supporting index.
|
||||
*/
|
||||
linkedClientId: text('linked_client_id'),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -54,6 +62,9 @@ export const residentialClients = pgTable(
|
||||
index('idx_residential_clients_archived')
|
||||
.on(table.portId)
|
||||
.where(sql`${table.archivedAt} IS NULL`),
|
||||
index('idx_residential_clients_linked_client')
|
||||
.on(table.linkedClientId)
|
||||
.where(sql`${table.linkedClientId} IS NOT NULL`),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user