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

@@ -0,0 +1,105 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
archiveResidentialInterest,
updateResidentialInterest,
} from '@/lib/services/residential.service';
import { PIPELINE_STAGES } from '@/lib/validators/residential';
/**
* Synchronous bulk endpoint for the residential interests list — mirrors
* the `/api/v1/interests/bulk` shape (and the new `/api/v1/berths/bulk`)
* so the rep-facing UX is consistent. Per-row loop with a 100-id cap.
*
* Permission gating: every action requires `residential_interests.edit`
* except `archive` which needs `residential_interests.delete` (mirrors
* the per-row endpoints' gates).
*/
const bulkSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('change_stage'),
ids: z.array(z.string().min(1)).min(1).max(100),
pipelineStage: z.enum(PIPELINE_STAGES),
}),
z.object({
action: z.literal('archive'),
ids: z.array(z.string().min(1)).min(1).max(100),
}),
]);
interface RowResult {
id: string;
ok: boolean;
error?: string;
}
const PERMISSION_BY_ACTION: Record<
z.infer<typeof bulkSchema>['action'],
{ resource: 'residential_interests'; action: 'edit' | 'delete' }
> = {
change_stage: { resource: 'residential_interests', action: 'edit' },
archive: { resource: 'residential_interests', action: 'delete' },
};
export const POST = withAuth(async (req, ctx) => {
let body: z.infer<typeof bulkSchema>;
try {
body = await parseBody(req, bulkSchema);
} catch (error) {
return errorResponse(error);
}
const perm = PERMISSION_BY_ACTION[body.action];
const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action];
if (!allowed) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const meta = {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
};
const results: RowResult[] = [];
for (const id of body.ids) {
try {
if (body.action === 'change_stage') {
await updateResidentialInterest(
id,
ctx.portId,
{ pipelineStage: body.pipelineStage },
meta,
);
} else if (body.action === 'archive') {
await archiveResidentialInterest(id, ctx.portId, meta);
}
results.push({ id, ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
results.push({ id, ok: false, error: message });
}
}
const okCount = results.filter((r) => r.ok).length;
return NextResponse.json({
data: {
action: body.action,
total: results.length,
ok: okCount,
failed: results.length - okCount,
results,
summary: {
total: results.length,
succeeded: okCount,
failed: results.length - okCount,
},
},
});
});