Compare commits
14 Commits
868b1f40c0
...
feat/dedup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7574c3b575 | ||
|
|
4bcc7f8be6 | ||
|
|
18e5c124b0 | ||
|
|
8b077e1999 | ||
|
|
e2398099c4 | ||
|
|
d364b09885 | ||
|
|
57a099acc4 | ||
|
|
a391934b73 | ||
|
|
e3e0e69c04 | ||
|
|
6af2ac9680 | ||
|
|
a767652d74 | ||
|
|
c824b2df12 | ||
|
|
d197f8b321 | ||
|
|
76a7387dcc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,3 +34,4 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Mobile audit screenshots — generated locally, regenerable
|
# Mobile audit screenshots — generated locally, regenerable
|
||||||
/.audit/
|
/.audit/
|
||||||
|
.migration/
|
||||||
|
|||||||
144
scripts/migrate-from-nocodb.ts
Normal file
144
scripts/migrate-from-nocodb.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* One-shot migration: legacy NocoDB Interests → new client/interest split.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
*
|
||||||
|
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run
|
||||||
|
* Pulls the live NocoDB base, runs the transform + dedup pipeline,
|
||||||
|
* writes a report to .migration/<timestamp>/. NO database writes.
|
||||||
|
*
|
||||||
|
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug harbor-royale
|
||||||
|
* Same, but tags the planned writes with the named port (matters for
|
||||||
|
* the apply phase — every client/interest belongs to one port).
|
||||||
|
*
|
||||||
|
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
|
||||||
|
* [Not yet implemented — apply phase comes in a follow-up PR.]
|
||||||
|
*
|
||||||
|
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
|
||||||
|
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
||||||
|
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
|
||||||
|
|
||||||
|
interface CliArgs {
|
||||||
|
dryRun: boolean;
|
||||||
|
apply: boolean;
|
||||||
|
portSlug: string | null;
|
||||||
|
reportDir: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): CliArgs {
|
||||||
|
const args: CliArgs = {
|
||||||
|
dryRun: false,
|
||||||
|
apply: false,
|
||||||
|
portSlug: null,
|
||||||
|
reportDir: null,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const a = argv[i]!;
|
||||||
|
if (a === '--dry-run') args.dryRun = true;
|
||||||
|
else if (a === '--apply') args.apply = true;
|
||||||
|
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
|
||||||
|
else if (a === '--report') args.reportDir = argv[++i] ?? null;
|
||||||
|
else if (a === '-h' || a === '--help') {
|
||||||
|
printHelp();
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.error(`Unknown argument: ${a}`);
|
||||||
|
printHelp();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(`Usage:
|
||||||
|
pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug <slug>]
|
||||||
|
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
|
||||||
|
No database writes.
|
||||||
|
|
||||||
|
pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
|
||||||
|
Apply phase. (Not yet implemented.)
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--dry-run Read NocoDB, write report only.
|
||||||
|
--apply Actually write to the new DB. (Not yet supported.)
|
||||||
|
--port-slug <slug> Port slug to attach to all imported entities.
|
||||||
|
Defaults to the first available port if omitted.
|
||||||
|
--report <dir> Path to a previously-generated report dir
|
||||||
|
(only used by --apply).
|
||||||
|
-h, --help Show this help.
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (!args.dryRun && !args.apply) {
|
||||||
|
console.error('Must specify --dry-run or --apply');
|
||||||
|
printHelp();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.apply) {
|
||||||
|
console.error('--apply is not yet implemented in this version. P3 ships dry-run first.');
|
||||||
|
console.error('See docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.2.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dry-run path ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('[migrate] Loading NocoDB config…');
|
||||||
|
const config = loadNocoDbConfig();
|
||||||
|
console.log(`[migrate] Source: ${config.url}`);
|
||||||
|
|
||||||
|
console.log('[migrate] Fetching snapshot from NocoDB…');
|
||||||
|
const start = Date.now();
|
||||||
|
const snapshot = await fetchSnapshot(config);
|
||||||
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
console.log(
|
||||||
|
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[migrate] Running transform + dedup pipeline…');
|
||||||
|
const plan = transformSnapshot(snapshot);
|
||||||
|
|
||||||
|
// Resolve output paths relative to the worktree root (the script itself
|
||||||
|
// lives in scripts/; we want the .migration dir at the repo root).
|
||||||
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
|
const generatedAt = new Date().toISOString();
|
||||||
|
const paths = resolveReportPaths(repoRoot);
|
||||||
|
|
||||||
|
console.log(`[migrate] Writing report to ${paths.rootDir}…`);
|
||||||
|
await writeReport(paths, plan, generatedAt);
|
||||||
|
|
||||||
|
// ── Console summary ──────────────────────────────────────────────────────
|
||||||
|
const s = plan.stats;
|
||||||
|
console.log('');
|
||||||
|
console.log('=== Migration Plan Summary ===');
|
||||||
|
console.log(
|
||||||
|
` Input: ${s.inputInterestRows} interests, ${s.inputResidentialRows} residential interests`,
|
||||||
|
);
|
||||||
|
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
|
||||||
|
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
|
||||||
|
console.log(
|
||||||
|
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
|
||||||
|
);
|
||||||
|
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` Full report: ${paths.summaryPath}`);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('[migrate] Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
5
src/app/(dashboard)/[portSlug]/admin/duplicates/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/admin/duplicates/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DuplicatesReviewQueue } from '@/components/admin/duplicates/duplicates-review-queue';
|
||||||
|
|
||||||
|
export default function DuplicatesAdminPage() {
|
||||||
|
return <DuplicatesReviewQueue />;
|
||||||
|
}
|
||||||
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { userProfiles } from '@/lib/db/schema/users';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
|
||||||
|
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
|
||||||
|
*/
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ portSlug: string }>;
|
||||||
|
}) {
|
||||||
|
const { portSlug } = await params;
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
|
where: eq(userProfiles.userId, session.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile?.isSuperAdmin) {
|
||||||
|
redirect(`/${portSlug}/dashboard`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { BerthReservationsList } from '@/components/reservations/berth-reservations-list';
|
||||||
|
|
||||||
|
export default function BerthReservationsPage() {
|
||||||
|
return <BerthReservationsList />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-level loading UI for the client detail page. Renders while the
|
||||||
|
* server component resolves the session and the client component bootstraps
|
||||||
|
* its initial query — replaces the previous empty-header flash on direct
|
||||||
|
* URL visits.
|
||||||
|
*/
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header strip — title, badges, action buttons */}
|
||||||
|
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-7 w-56" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Skeleton className="h-9 w-20 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-20 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-24 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-32 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab strip */}
|
||||||
|
<div className="flex gap-2 border-b border-border pb-1">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-8 w-20 rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column overview */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -97,6 +97,28 @@ export default function NewInvoicePage() {
|
|||||||
const watchedValues = watch();
|
const watchedValues = watch();
|
||||||
const isDepositInvoice = watchedValues.kind === 'deposit';
|
const isDepositInvoice = watchedValues.kind === 'deposit';
|
||||||
|
|
||||||
|
// Resolve the selected billing entity to a human name so the review step
|
||||||
|
// shows "Acme Yacht Charters" instead of "company 4f2a1b…".
|
||||||
|
const billingEntityRef = watchedValues.billingEntity ?? null;
|
||||||
|
const { data: billingEntityName } = useQuery<{ name: string }>({
|
||||||
|
queryKey: ['billing-entity-name', billingEntityRef?.type, billingEntityRef?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!billingEntityRef) return { name: '' };
|
||||||
|
const path =
|
||||||
|
billingEntityRef.type === 'company'
|
||||||
|
? `/api/v1/companies/${billingEntityRef.id}`
|
||||||
|
: `/api/v1/clients/${billingEntityRef.id}`;
|
||||||
|
const res = await apiFetch<{
|
||||||
|
data: { fullName?: string; name?: string };
|
||||||
|
}>(path);
|
||||||
|
return {
|
||||||
|
name: res?.data?.fullName ?? res?.data?.name ?? '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!billingEntityRef?.id,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
// Pre-fill the billing entity from the linked interest's client on launch.
|
// Pre-fill the billing entity from the linked interest's client on launch.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prefilledInterest?.data && !watchedValues.billingEntity) {
|
if (prefilledInterest?.data && !watchedValues.billingEntity) {
|
||||||
@@ -356,9 +378,13 @@ export default function NewInvoicePage() {
|
|||||||
<p className="font-medium mt-0.5">
|
<p className="font-medium mt-0.5">
|
||||||
{watchedValues.billingEntity ? (
|
{watchedValues.billingEntity ? (
|
||||||
<>
|
<>
|
||||||
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '}
|
{billingEntityName?.name ? (
|
||||||
<span className="text-xs opacity-60">
|
<span>{billingEntityName.name}</span>
|
||||||
{watchedValues.billingEntity.id.slice(0, 12)}
|
) : (
|
||||||
|
<span className="text-muted-foreground">Loading…</span>
|
||||||
|
)}{' '}
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
({watchedValues.billingEntity.type})
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PermissionsProvider } from '@/providers/permissions-provider';
|
|||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
import { Topbar } from '@/components/layout/topbar';
|
import { Topbar } from '@/components/layout/topbar';
|
||||||
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
||||||
|
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
||||||
|
|
||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
@@ -38,6 +39,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||||
<PermissionsProvider>
|
<PermissionsProvider>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
|
<RealtimeToasts />
|
||||||
{/* Desktop shell — hidden by CSS on mobile */}
|
{/* Desktop shell — hidden by CSS on mobile */}
|
||||||
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|||||||
4
src/app/api/v1/admin/duplicates/[id]/dismiss/route.ts
Normal file
4
src/app/api/v1/admin/duplicates/[id]/dismiss/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { dismissHandler } from '../../handlers';
|
||||||
|
|
||||||
|
export const POST = withAuth(withPermission('clients', 'edit', dismissHandler));
|
||||||
4
src/app/api/v1/admin/duplicates/[id]/merge/route.ts
Normal file
4
src/app/api/v1/admin/duplicates/[id]/merge/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { confirmMergeHandler } from '../../handlers';
|
||||||
|
|
||||||
|
export const POST = withAuth(withPermission('clients', 'edit', confirmMergeHandler));
|
||||||
160
src/app/api/v1/admin/duplicates/handlers.ts
Normal file
160
src/app/api/v1/admin/duplicates/handlers.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import type { AuthContext } from '@/lib/api/helpers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { clients, clientMergeCandidates } from '@/lib/db/schema/clients';
|
||||||
|
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||||
|
import {
|
||||||
|
listPendingMergeCandidates,
|
||||||
|
mergeClients,
|
||||||
|
type MergeFieldChoices,
|
||||||
|
} from '@/lib/services/client-merge.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/duplicates
|
||||||
|
*
|
||||||
|
* Pending merge candidates for the current port, sorted by score.
|
||||||
|
* Each row hydrates its two client summaries so the review-queue UI
|
||||||
|
* can render side-by-side cards without an N+1 fetch.
|
||||||
|
*/
|
||||||
|
export async function listHandler(_req: Request, ctx: AuthContext): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const pairs = await listPendingMergeCandidates(ctx.portId);
|
||||||
|
if (pairs.length === 0) return NextResponse.json({ data: [] });
|
||||||
|
|
||||||
|
const ids = Array.from(new Set(pairs.flatMap((p) => [p.clientAId, p.clientBId])));
|
||||||
|
const clientRows = await db
|
||||||
|
.select({
|
||||||
|
id: clients.id,
|
||||||
|
fullName: clients.fullName,
|
||||||
|
archivedAt: clients.archivedAt,
|
||||||
|
mergedIntoClientId: clients.mergedIntoClientId,
|
||||||
|
createdAt: clients.createdAt,
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(inArray(clients.id, ids));
|
||||||
|
const clientById = new Map(clientRows.map((c) => [c.id, c]));
|
||||||
|
|
||||||
|
const data = pairs
|
||||||
|
.map((p) => {
|
||||||
|
const a = clientById.get(p.clientAId);
|
||||||
|
const b = clientById.get(p.clientBId);
|
||||||
|
if (!a || !b) return null; // FK orphan — shouldn't happen, but be defensive
|
||||||
|
// Skip pairs where one side has already been merged or archived.
|
||||||
|
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
score: p.score,
|
||||||
|
reasons: p.reasons,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
clientA: { id: a.id, fullName: a.fullName, createdAt: a.createdAt },
|
||||||
|
clientB: { id: b.id, fullName: b.fullName, createdAt: b.createdAt },
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||||
|
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/duplicates/[id]/merge
|
||||||
|
*
|
||||||
|
* Body: { winnerId: string, fieldChoices?: MergeFieldChoices }
|
||||||
|
*
|
||||||
|
* Confirms a merge candidate. The winner is the one the user picked
|
||||||
|
* to keep; the other side becomes the loser. Calls into the merge
|
||||||
|
* service which is the only path that touches client_merge_log.
|
||||||
|
*/
|
||||||
|
export async function confirmMergeHandler(
|
||||||
|
req: Request,
|
||||||
|
ctx: AuthContext,
|
||||||
|
params: { id?: string },
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const id = params.id ?? '';
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
winnerId?: string;
|
||||||
|
fieldChoices?: MergeFieldChoices;
|
||||||
|
};
|
||||||
|
if (!body.winnerId) {
|
||||||
|
return NextResponse.json({ error: 'winnerId required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [candidate] = await db
|
||||||
|
.select()
|
||||||
|
.from(clientMergeCandidates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientMergeCandidates.id, id),
|
||||||
|
eq(clientMergeCandidates.portId, ctx.portId),
|
||||||
|
eq(clientMergeCandidates.status, 'pending'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!candidate) throw new NotFoundError('Merge candidate');
|
||||||
|
|
||||||
|
const loserId =
|
||||||
|
body.winnerId === candidate.clientAId
|
||||||
|
? candidate.clientBId
|
||||||
|
: body.winnerId === candidate.clientBId
|
||||||
|
? candidate.clientAId
|
||||||
|
: null;
|
||||||
|
if (!loserId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'winnerId must match one of the candidate clients' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await mergeClients({
|
||||||
|
winnerId: body.winnerId,
|
||||||
|
loserId,
|
||||||
|
mergedBy: ctx.userId,
|
||||||
|
fieldChoices: body.fieldChoices,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/admin/duplicates/[id]/dismiss
|
||||||
|
*
|
||||||
|
* Mark a merge candidate as dismissed. The background scoring job
|
||||||
|
* skips dismissed pairs on subsequent runs (a future score increase
|
||||||
|
* can re-create them).
|
||||||
|
*/
|
||||||
|
export async function dismissHandler(
|
||||||
|
_req: Request,
|
||||||
|
ctx: AuthContext,
|
||||||
|
params: { id?: string },
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const id = params.id ?? '';
|
||||||
|
const result = await db
|
||||||
|
.update(clientMergeCandidates)
|
||||||
|
.set({
|
||||||
|
status: 'dismissed',
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
resolvedBy: ctx.userId,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientMergeCandidates.id, id),
|
||||||
|
eq(clientMergeCandidates.portId, ctx.portId),
|
||||||
|
eq(clientMergeCandidates.status, 'pending'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: clientMergeCandidates.id });
|
||||||
|
|
||||||
|
if (result.length === 0) throw new NotFoundError('Merge candidate');
|
||||||
|
return NextResponse.json({ data: { id: result[0]!.id, status: 'dismissed' } });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/app/api/v1/admin/duplicates/route.ts
Normal file
4
src/app/api/v1/admin/duplicates/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { listHandler } from './handlers';
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('clients', 'view', listHandler));
|
||||||
35
src/app/api/v1/berth-reservations/handlers.ts
Normal file
35
src/app/api/v1/berth-reservations/handlers.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import type { AuthContext } from '@/lib/api/helpers';
|
||||||
|
import { parseQuery } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { listReservations } from '@/lib/services/berth-reservations.service';
|
||||||
|
import { listReservationsSchema } from '@/lib/validators/reservations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port-scoped global list of reservations across all berths. Inner handler
|
||||||
|
* lives here so it can be invoked directly from integration tests without
|
||||||
|
* the `withAuth(withPermission(...))` wrappers (matches the convention
|
||||||
|
* used throughout `src/app/api/v1/*`).
|
||||||
|
*/
|
||||||
|
export async function listHandler(req: Request, ctx: AuthContext): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const query = parseQuery(req as never, listReservationsSchema);
|
||||||
|
const result = await listReservations(ctx.portId, query);
|
||||||
|
const { page, limit } = query;
|
||||||
|
const totalPages = Math.ceil(result.total / limit);
|
||||||
|
return NextResponse.json({
|
||||||
|
data: result.data,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
total: result.total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/app/api/v1/berth-reservations/route.ts
Normal file
4
src/app/api/v1/berth-reservations/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { listHandler } from './handlers';
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
|
||||||
160
src/app/api/v1/clients/match-candidates/handlers.ts
Normal file
160
src/app/api/v1/clients/match-candidates/handlers.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import type { AuthContext } from '@/lib/api/helpers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { findClientMatches, type MatchCandidate } from '@/lib/dedup/find-matches';
|
||||||
|
import { normalizeEmail, normalizeName, normalizePhone } from '@/lib/dedup/normalize';
|
||||||
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/clients/match-candidates
|
||||||
|
*
|
||||||
|
* Query parameters (any combination):
|
||||||
|
* email Free-text email; gets normalized server-side.
|
||||||
|
* phone Free-text phone; gets normalized to E.164 server-side.
|
||||||
|
* name Free-text full name; used for surname-token blocking.
|
||||||
|
* country Optional ISO country hint (default: AI for Port Nimara).
|
||||||
|
*
|
||||||
|
* Returns the top candidates that scored above the soft-warn threshold,
|
||||||
|
* each with a small client summary the form's suggestion card can
|
||||||
|
* render. Confidence tiers and rules are applied server-side from the
|
||||||
|
* port's `system_settings` (when wired) or sensible defaults otherwise.
|
||||||
|
*
|
||||||
|
* Used by `useDedupSuggestion` in the new-client form. Debounced on
|
||||||
|
* the client; this endpoint must be cheap (single port pool fetch +
|
||||||
|
* an in-memory dedup pass).
|
||||||
|
*/
|
||||||
|
export async function getMatchCandidatesHandler(
|
||||||
|
req: Request,
|
||||||
|
ctx: AuthContext,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const rawEmail = url.searchParams.get('email');
|
||||||
|
const rawPhone = url.searchParams.get('phone');
|
||||||
|
const rawName = url.searchParams.get('name');
|
||||||
|
const country = (url.searchParams.get('country') ?? 'AI') as CountryCode;
|
||||||
|
|
||||||
|
const email = rawEmail ? normalizeEmail(rawEmail) : null;
|
||||||
|
const phoneResult = rawPhone ? normalizePhone(rawPhone, country) : null;
|
||||||
|
const nameResult = rawName ? normalizeName(rawName) : null;
|
||||||
|
|
||||||
|
// If the caller didn't give us anything useful to match on, return empty
|
||||||
|
// — short-circuit rather than scan every client for nothing.
|
||||||
|
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the input candidate.
|
||||||
|
const input: MatchCandidate = {
|
||||||
|
id: '__incoming__',
|
||||||
|
fullName: nameResult?.display ?? null,
|
||||||
|
surnameToken: nameResult?.surnameToken ?? null,
|
||||||
|
emails: email ? [email] : [],
|
||||||
|
phonesE164: phoneResult?.e164 ? [phoneResult.e164] : [],
|
||||||
|
countryIso: country,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch the live pool for this port. We keep this O(N) over clients
|
||||||
|
// since the dedup library does its own blocking; for ports with
|
||||||
|
// thousands of clients we can later restrict by surname-token /
|
||||||
|
// contact lookups, but for current scale the simple full-pool fetch
|
||||||
|
// is fine.
|
||||||
|
const liveClients = await db
|
||||||
|
.select({
|
||||||
|
id: clients.id,
|
||||||
|
fullName: clients.fullName,
|
||||||
|
nationalityIso: clients.nationalityIso,
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(and(eq(clients.portId, ctx.portId)));
|
||||||
|
|
||||||
|
if (liveClients.length === 0) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIds = liveClients.map((c) => c.id);
|
||||||
|
const contactRows = await db
|
||||||
|
.select({
|
||||||
|
clientId: clientContacts.clientId,
|
||||||
|
channel: clientContacts.channel,
|
||||||
|
value: clientContacts.value,
|
||||||
|
valueE164: clientContacts.valueE164,
|
||||||
|
})
|
||||||
|
.from(clientContacts)
|
||||||
|
.where(inArray(clientContacts.clientId, clientIds));
|
||||||
|
|
||||||
|
// Group contacts by client for the candidate map.
|
||||||
|
const emailsByClient = new Map<string, string[]>();
|
||||||
|
const phonesByClient = new Map<string, string[]>();
|
||||||
|
for (const c of contactRows) {
|
||||||
|
if (c.channel === 'email') {
|
||||||
|
const arr = emailsByClient.get(c.clientId) ?? [];
|
||||||
|
arr.push(c.value.toLowerCase());
|
||||||
|
emailsByClient.set(c.clientId, arr);
|
||||||
|
} else if (c.channel === 'phone' || c.channel === 'whatsapp') {
|
||||||
|
if (c.valueE164) {
|
||||||
|
const arr = phonesByClient.get(c.clientId) ?? [];
|
||||||
|
arr.push(c.valueE164);
|
||||||
|
phonesByClient.set(c.clientId, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool: MatchCandidate[] = liveClients.map((c) => {
|
||||||
|
const named = normalizeName(c.fullName);
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
fullName: c.fullName,
|
||||||
|
surnameToken: named.surnameToken ?? null,
|
||||||
|
emails: emailsByClient.get(c.id) ?? [],
|
||||||
|
phonesE164: phonesByClient.get(c.id) ?? [],
|
||||||
|
countryIso: (c.nationalityIso as CountryCode | null) ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const matches = findClientMatches(input, pool, {
|
||||||
|
highScore: 90,
|
||||||
|
mediumScore: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only return medium+ — low-confidence noise isn't useful at the
|
||||||
|
// create-form layer (background scoring queue picks those up).
|
||||||
|
const useful = matches.filter((m) => m.confidence !== 'low');
|
||||||
|
if (useful.length === 0) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull a quick summary for each surfaced candidate so the suggestion
|
||||||
|
// card has enough to render ("Marcus Laurent · 2 interests · last
|
||||||
|
// contact 9d ago").
|
||||||
|
const summarizedIds = useful.map((m) => m.candidate.id);
|
||||||
|
const interestCounts = await db
|
||||||
|
.select({ clientId: interests.clientId })
|
||||||
|
.from(interests)
|
||||||
|
.where(inArray(interests.clientId, summarizedIds));
|
||||||
|
const interestsByClient = new Map<string, number>();
|
||||||
|
for (const r of interestCounts) {
|
||||||
|
interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = useful.map((m) => ({
|
||||||
|
clientId: m.candidate.id,
|
||||||
|
fullName: m.candidate.fullName,
|
||||||
|
score: m.score,
|
||||||
|
confidence: m.confidence,
|
||||||
|
reasons: m.reasons,
|
||||||
|
interestCount: interestsByClient.get(m.candidate.id) ?? 0,
|
||||||
|
emails: m.candidate.emails,
|
||||||
|
phonesE164: m.candidate.phonesE164,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/app/api/v1/clients/match-candidates/route.ts
Normal file
4
src/app/api/v1/clients/match-candidates/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { getMatchCandidatesHandler } from './handlers';
|
||||||
|
|
||||||
|
export const GET = withAuth(withPermission('clients', 'view', getMatchCandidatesHandler));
|
||||||
@@ -7,6 +7,7 @@ import { db } from '@/lib/db';
|
|||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { auditLogs } from '@/lib/db/schema/system';
|
import { auditLogs } from '@/lib/db/schema/system';
|
||||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||||
|
import { user } from '@/lib/db/schema/users';
|
||||||
import { stageLabel } from '@/lib/constants';
|
import { stageLabel } from '@/lib/constants';
|
||||||
|
|
||||||
const OUTCOME_LABELS: Record<string, string> = {
|
const OUTCOME_LABELS: Record<string, string> = {
|
||||||
@@ -33,6 +34,10 @@ interface TimelineEvent {
|
|||||||
action: string;
|
action: string;
|
||||||
description: string;
|
description: string;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
|
/** Resolved display name for `userId`. `'system'` for auto-events; null when
|
||||||
|
* the user has been deleted or the event has no actor. Falls back to
|
||||||
|
* email-localpart if the user has no display name. */
|
||||||
|
userName: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,27 @@ export const GET = withAuth(
|
|||||||
|
|
||||||
const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));
|
const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));
|
||||||
|
|
||||||
|
// Resolve display names for any `userId` that is a real user row (the
|
||||||
|
// sentinel value 'system' is used for auto-events and isn't joined).
|
||||||
|
const realUserIds = Array.from(
|
||||||
|
new Set(auditRows.map((r) => r.userId).filter((u): u is string => !!u && u !== 'system')),
|
||||||
|
);
|
||||||
|
const userRows =
|
||||||
|
realUserIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({ id: user.id, name: user.name, email: user.email })
|
||||||
|
.from(user)
|
||||||
|
.where(inArray(user.id, realUserIds))
|
||||||
|
: [];
|
||||||
|
const userNameById = new Map<string, string>(
|
||||||
|
userRows.map((u) => [u.id, u.name?.trim() || u.email.split('@')[0] || 'User']),
|
||||||
|
);
|
||||||
|
const resolveUserName = (userId: string | null): string | null => {
|
||||||
|
if (!userId) return null;
|
||||||
|
if (userId === 'system') return 'system';
|
||||||
|
return userNameById.get(userId) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
// Union and sort
|
// Union and sort
|
||||||
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
|
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -93,6 +119,7 @@ export const GET = withAuth(
|
|||||||
row.userId,
|
row.userId,
|
||||||
),
|
),
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
|
userName: resolveUserName(row.userId),
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
||||||
}));
|
}));
|
||||||
@@ -106,12 +133,35 @@ export const GET = withAuth(
|
|||||||
action: row.eventType,
|
action: row.eventType,
|
||||||
description: `Document "${title}" ${action}`,
|
description: `Document "${title}" ${action}`,
|
||||||
userId: null,
|
userId: null,
|
||||||
|
userName: null,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const allEvents = [...auditEvents, ...docEvents];
|
const allEvents = [...auditEvents, ...docEvents];
|
||||||
|
|
||||||
|
// Fallback: when no audit-log entries exist for this interest (typical
|
||||||
|
// for seed/imported data inserted directly into the table without going
|
||||||
|
// through the service), synthesize a "Created at <stage>" event so the
|
||||||
|
// tab isn't empty when the interest is clearly past `open`.
|
||||||
|
const hasCreateAudit = allEvents.some((e) => e.action === 'create');
|
||||||
|
if (!hasCreateAudit) {
|
||||||
|
const stage = stageLabel(interest.pipelineStage);
|
||||||
|
const created = interest.createdAt ?? new Date();
|
||||||
|
allEvents.push({
|
||||||
|
id: `synth-${interest.id}-create`,
|
||||||
|
type: 'audit',
|
||||||
|
action: 'create',
|
||||||
|
description:
|
||||||
|
interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`,
|
||||||
|
userId: null,
|
||||||
|
userName: null,
|
||||||
|
createdAt: created,
|
||||||
|
metadata: { synthetic: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
|
||||||
return NextResponse.json({ data: allEvents.slice(0, 50) });
|
return NextResponse.json({ data: allEvents.slice(0, 50) });
|
||||||
|
|||||||
68
src/app/api/v1/saved-views/[id]/handlers.ts
Normal file
68
src/app/api/v1/saved-views/[id]/handlers.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import type { AuthContext } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { savedViews } from '@/lib/db/schema';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||||
|
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the view and enforces ownership before mutating.
|
||||||
|
*
|
||||||
|
* Returns a 404 when the view does not exist (or lives in a different port)
|
||||||
|
* and a 403 when it belongs to a different user. The 404-before-403 split
|
||||||
|
* matches the rest of the API and avoids leaking the existence of another
|
||||||
|
* user's saved view via timing or status code.
|
||||||
|
*/
|
||||||
|
async function assertViewOwner(
|
||||||
|
id: string,
|
||||||
|
portId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<NextResponse | null> {
|
||||||
|
const view = await db.query.savedViews.findFirst({
|
||||||
|
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
|
||||||
|
});
|
||||||
|
if (!view) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (view.userId !== userId) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchHandler(
|
||||||
|
req: Request,
|
||||||
|
ctx: AuthContext,
|
||||||
|
params: { id?: string },
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const id = params.id ?? '';
|
||||||
|
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||||
|
if (denied) return denied;
|
||||||
|
const body = await parseBody(req as never, updateSavedViewSchema);
|
||||||
|
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
||||||
|
return NextResponse.json({ data: view });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHandler(
|
||||||
|
_req: Request,
|
||||||
|
ctx: AuthContext,
|
||||||
|
params: { id?: string },
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const id = params.id ?? '';
|
||||||
|
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||||
|
if (denied) return denied;
|
||||||
|
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
||||||
|
return NextResponse.json({ data: null }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { patchHandler, deleteHandler } from './handlers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { savedViewsService } from '@/lib/services/saved-views.service';
|
|
||||||
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
|
||||||
|
|
||||||
export const PATCH = withAuth(async (req, ctx, params) => {
|
export const PATCH = withAuth(patchHandler);
|
||||||
try {
|
export const DELETE = withAuth(deleteHandler);
|
||||||
const id = params.id ?? '';
|
|
||||||
const body = await parseBody(req, updateSavedViewSchema);
|
|
||||||
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
|
||||||
return NextResponse.json({ data: view });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DELETE = withAuth(async (_req, ctx, params) => {
|
|
||||||
try {
|
|
||||||
const id = params.id ?? '';
|
|
||||||
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
|
||||||
return NextResponse.json({ data: null }, { status: 200 });
|
|
||||||
} catch (error) {
|
|
||||||
return errorResponse(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
|
|||||||
import {
|
import {
|
||||||
handleRecipientSigned,
|
handleRecipientSigned,
|
||||||
handleDocumentCompleted,
|
handleDocumentCompleted,
|
||||||
|
handleDocumentExpired,
|
||||||
handleDocumentOpened,
|
handleDocumentOpened,
|
||||||
handleDocumentRejected,
|
handleDocumentRejected,
|
||||||
handleDocumentCancelled,
|
handleDocumentCancelled,
|
||||||
@@ -139,6 +140,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
|
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'DOCUMENT_EXPIRED':
|
||||||
|
await handleDocumentExpired({ documentId: documensoId });
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.info({ event }, 'Unhandled Documenso webhook event type');
|
logger.info({ event }, 'Unhandled Documenso webhook event type');
|
||||||
}
|
}
|
||||||
|
|||||||
215
src/components/admin/duplicates/duplicates-review-queue.tsx
Normal file
215
src/components/admin/duplicates/duplicates-review-queue.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowRight, GitMerge, X } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface CandidatePair {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
reasons: string[];
|
||||||
|
createdAt: string;
|
||||||
|
clientA: { id: string; fullName: string; createdAt: string };
|
||||||
|
clientB: { id: string; fullName: string; createdAt: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin review queue for the dedup background scoring job.
|
||||||
|
*
|
||||||
|
* Lists every pending merge candidate (pairs where score >=
|
||||||
|
* `dedup_review_queue_threshold`). For each pair the admin can:
|
||||||
|
* - Pick a winner via the side-by-side card → confirms a merge
|
||||||
|
* - Dismiss → removes from the queue (a future score increase
|
||||||
|
* re-creates the pair on the next scoring run)
|
||||||
|
*
|
||||||
|
* Only minimal merge UI here: the user picks which side is the winner
|
||||||
|
* (no per-field choice), and the loser archives. A richer side-by-side
|
||||||
|
* field-merge dialog is a future enhancement.
|
||||||
|
*/
|
||||||
|
export function DuplicatesReviewQueue() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ data: CandidatePair[] }>({
|
||||||
|
queryKey: ['admin', 'duplicates'],
|
||||||
|
queryFn: () => apiFetch<{ data: CandidatePair[] }>('/api/v1/admin/duplicates'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pairs = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Duplicate clients"
|
||||||
|
description={
|
||||||
|
pairs.length === 0
|
||||||
|
? 'No pending pairs to review.'
|
||||||
|
: `${pairs.length} pair${pairs.length === 1 ? '' : 's'} flagged for review.`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-32 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : pairs.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="All clear"
|
||||||
|
description="The background scoring job hasn't surfaced any potential duplicates yet."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{pairs.map((pair) => (
|
||||||
|
<li key={pair.id}>
|
||||||
|
<CandidateRow pair={pair} queryClient={queryClient} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CandidateRow({
|
||||||
|
pair,
|
||||||
|
queryClient,
|
||||||
|
}: {
|
||||||
|
pair: CandidatePair;
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState<'merge' | 'dismiss' | null>(null);
|
||||||
|
const [winnerId, setWinnerId] = useState<string>(pair.clientA.id);
|
||||||
|
|
||||||
|
const mergeMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch(`/api/v1/admin/duplicates/${pair.id}/merge`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { winnerId },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
const loserName =
|
||||||
|
winnerId === pair.clientA.id ? pair.clientB.fullName : pair.clientA.fullName;
|
||||||
|
const winnerName =
|
||||||
|
winnerId === pair.clientA.id ? pair.clientA.fullName : pair.clientB.fullName;
|
||||||
|
toast.success(`Merged "${loserName}" into "${winnerName}"`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'duplicates'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err instanceof Error ? err.message : 'Merge failed'),
|
||||||
|
onSettled: () => setBusy(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissMutation = useMutation({
|
||||||
|
mutationFn: () => apiFetch(`/api/v1/admin/duplicates/${pair.id}/dismiss`, { method: 'POST' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.message('Dismissed');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'duplicates'] });
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err instanceof Error ? err.message : 'Dismiss failed'),
|
||||||
|
onSettled: () => setBusy(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
score {pair.score}
|
||||||
|
</span>{' '}
|
||||||
|
<span className="text-xs text-muted-foreground">{pair.reasons.join(' · ')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
flagged {new Date(pair.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_auto_1fr]">
|
||||||
|
<ClientCard
|
||||||
|
client={pair.clientA}
|
||||||
|
isSelected={winnerId === pair.clientA.id}
|
||||||
|
onSelect={() => setWinnerId(pair.clientA.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center text-muted-foreground">
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<ClientCard
|
||||||
|
client={pair.clientB}
|
||||||
|
isSelected={winnerId === pair.clientB.id}
|
||||||
|
onSelect={() => setWinnerId(pair.clientB.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setBusy('merge');
|
||||||
|
mergeMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={busy !== null}
|
||||||
|
>
|
||||||
|
<GitMerge className="mr-1 size-3.5" aria-hidden />
|
||||||
|
Merge into selected
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setBusy('dismiss');
|
||||||
|
dismissMutation.mutate();
|
||||||
|
}}
|
||||||
|
disabled={busy !== null}
|
||||||
|
>
|
||||||
|
<X className="mr-1 size-3.5" aria-hidden />
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The unselected card becomes the loser; its interests + contacts move to the selected
|
||||||
|
client and the original is archived.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientCard({
|
||||||
|
client,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
client: CandidatePair['clientA'];
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border p-3 text-left transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
|
||||||
|
: 'border-border hover:bg-muted/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{client.fullName}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||||
|
Created {new Date(client.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
{isSelected ? (
|
||||||
|
<span className="mt-1 inline-block rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
|
||||||
|
KEEP
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
@@ -8,6 +9,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
|
|||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { BerthDetailHeader } from './berth-detail-header';
|
import { BerthDetailHeader } from './berth-detail-header';
|
||||||
|
import { BerthForm } from './berth-form';
|
||||||
import { buildBerthTabs } from './berth-tabs';
|
import { buildBerthTabs } from './berth-tabs';
|
||||||
|
|
||||||
interface BerthDetailProps {
|
interface BerthDetailProps {
|
||||||
@@ -35,15 +37,38 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
|||||||
return () => setChrome({ title: null, showBackButton: false });
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
}, [titleForChrome, setChrome]);
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
|
// Auto-open edit sheet when ?edit=true is present in the URL
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('edit') === 'true') {
|
||||||
|
setEditOpen(true);
|
||||||
|
// Strip the param without adding a history entry
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete('edit');
|
||||||
|
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
|
||||||
|
// typedRoutes can't statically validate this dynamic path; cast is safe
|
||||||
|
// because we're always replacing within the same route segment.
|
||||||
|
router.replace(newUrl as never);
|
||||||
|
}
|
||||||
|
// Only run once on mount / when searchParams changes
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const berth = data as any;
|
const berth = data as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DetailLayout
|
<DetailLayout
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
||||||
tabs={berth ? buildBerthTabs(berth) : []}
|
tabs={berth ? buildBerthTabs(berth) : []}
|
||||||
defaultTab="overview"
|
defaultTab="overview"
|
||||||
/>
|
/>
|
||||||
|
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
166
src/components/berths/berth-interest-pulse.tsx
Normal file
166
src/components/berths/berth-interest-pulse.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ChevronRight, Users } from 'lucide-react';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
|
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||||
|
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface InterestsResponse {
|
||||||
|
data: InterestRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW_LIMIT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-of-overview pulse for the berth detail page. Lists the active
|
||||||
|
* interested parties with their stage + last activity, so the rep can do
|
||||||
|
* berth-level triage ("who's on this slip and how warm are they?")
|
||||||
|
* without clicking into the Interests tab.
|
||||||
|
*
|
||||||
|
* Borrows from the old Nuxt CRM's BerthDetailsModal "Interested Parties"
|
||||||
|
* pattern but uses the new at-a-glance signals (urgency badges, last
|
||||||
|
* activity).
|
||||||
|
*/
|
||||||
|
export function BerthInterestPulse({ berthId }: { berthId: string }) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<InterestsResponse>({
|
||||||
|
queryKey: ['interests', { berthId, sort: 'dateLastContact', order: 'desc' }],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<InterestsResponse>(
|
||||||
|
`/api/v1/interests?berthId=${berthId}&limit=10&sort=dateLastContact&order=desc`,
|
||||||
|
),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = data?.data ?? [];
|
||||||
|
const active = all.filter((i) => !i.archivedAt && !i.outcome);
|
||||||
|
const preview = active.slice(0, PREVIEW_LIMIT);
|
||||||
|
const more = active.length - preview.length;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium">Interested parties</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-10 animate-pulse rounded-md bg-muted/40" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Users className="size-3.5" />
|
||||||
|
Interested parties
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<p className="text-sm text-muted-foreground">No active interests on this berth.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-3 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Users className="size-3.5" />
|
||||||
|
Interested parties
|
||||||
|
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{active.length}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{preview.map((i) => {
|
||||||
|
const lastIso = i.dateLastContact ?? i.updatedAt ?? null;
|
||||||
|
const lastActivity = lastIso
|
||||||
|
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
||||||
|
: null;
|
||||||
|
const urgency = computeUrgencyBadges(i);
|
||||||
|
const initials = (i.clientName ?? '?')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((p) => p[0]!.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
return (
|
||||||
|
<li key={i.id}>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/${i.id}`}
|
||||||
|
className="group flex items-center gap-3 px-1 py-2.5 transition-colors hover:bg-foreground/5 rounded-md -mx-1"
|
||||||
|
>
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700">
|
||||||
|
{initials || '?'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">
|
||||||
|
{i.clientName ?? 'Unknown'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||||
|
stageBadgeClass(i.pipelineStage),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stageLabel(i.pipelineStage)}
|
||||||
|
</span>
|
||||||
|
{urgency.map((b) => (
|
||||||
|
<span
|
||||||
|
key={b.id}
|
||||||
|
title={b.detail}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
b.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{lastActivity ? (
|
||||||
|
<p className="text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
Last activity {lastActivity}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{more > 0 ? (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/berths/${berthId}?tab=interests`}
|
||||||
|
className="mt-2 inline-flex text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View all {active.length} interests →
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { BerthReservationsTab } from './berth-reservations-tab';
|
import { BerthReservationsTab } from './berth-reservations-tab';
|
||||||
import { BerthInterestsTab } from './berth-interests-tab';
|
import { BerthInterestsTab } from './berth-interests-tab';
|
||||||
|
import { BerthInterestPulse } from './berth-interest-pulse';
|
||||||
|
|
||||||
type BerthData = {
|
type BerthData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,6 +73,11 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Sales pulse — top-of-page so reps doing berth-level triage can see
|
||||||
|
who's interested + how warm without clicking into the Interests tab. */}
|
||||||
|
<BerthInterestPulse berthId={berth.id} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Specifications */}
|
{/* Specifications */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -161,6 +167,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { TagPicker } from '@/components/shared/tag-picker';
|
|||||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||||
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
||||||
import { PhoneInput } from '@/components/shared/phone-input';
|
import { PhoneInput } from '@/components/shared/phone-input';
|
||||||
|
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
@@ -30,6 +31,12 @@ import type { CountryCode } from '@/lib/i18n/countries';
|
|||||||
interface ClientFormProps {
|
interface ClientFormProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Optional callback fired when the dedup suggestion panel reports
|
||||||
|
* the user picked an existing client. The form closes; parent is
|
||||||
|
* responsible for navigating to the existing client's detail page
|
||||||
|
* or opening the create-interest dialog pre-filled with that
|
||||||
|
* clientId. Skipped in edit mode. */
|
||||||
|
onUseExistingClient?: (clientId: string) => void;
|
||||||
/** If provided, form is in edit mode */
|
/** If provided, form is in edit mode */
|
||||||
client?: {
|
client?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,7 +60,7 @@ interface ClientFormProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
export function ClientForm({ open, onOpenChange, client, onUseExistingClient }: ClientFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isEdit = !!client;
|
const isEdit = !!client;
|
||||||
|
|
||||||
@@ -143,6 +150,26 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
||||||
|
{/* Dedup suggestion — only on the create path. Watches the
|
||||||
|
live form values for email / phone / name and surfaces
|
||||||
|
an existing client when one matches. The user can
|
||||||
|
attach the new interest to that client instead of
|
||||||
|
creating a duplicate. */}
|
||||||
|
{!isEdit ? (
|
||||||
|
<DedupSuggestionPanel
|
||||||
|
email={watch('contacts')?.find((c) => c?.channel === 'email')?.value ?? null}
|
||||||
|
phone={
|
||||||
|
watch('contacts')?.find((c) => c?.channel === 'phone' || c?.channel === 'whatsapp')
|
||||||
|
?.valueE164 ?? null
|
||||||
|
}
|
||||||
|
name={watch('fullName') ?? null}
|
||||||
|
onUseExisting={(match) => {
|
||||||
|
onUseExistingClient?.(match.clientId);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
|||||||
311
src/components/clients/client-pipeline-summary.tsx
Normal file
311
src/components/clients/client-pipeline-summary.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ArrowRight, ChevronRight } from 'lucide-react';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
PIPELINE_STAGES,
|
||||||
|
STAGE_BADGE,
|
||||||
|
STAGE_DOT,
|
||||||
|
STAGE_LABELS,
|
||||||
|
safeStage,
|
||||||
|
type PipelineStage,
|
||||||
|
} from '@/components/clients/pipeline-constants';
|
||||||
|
|
||||||
|
export interface ClientInterestRow {
|
||||||
|
id: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
archivedAt: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
dateLastContact: string | null;
|
||||||
|
berthMooringNumber?: string | null;
|
||||||
|
yachtName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InterestsResponse {
|
||||||
|
data: ClientInterestRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClientInterests(clientId: string) {
|
||||||
|
return useQuery<InterestsResponse>({
|
||||||
|
queryKey: ['interests', { clientId }],
|
||||||
|
queryFn: () => apiFetch<InterestsResponse>(`/api/v1/interests?clientId=${clientId}&limit=50`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StageStepper({
|
||||||
|
current,
|
||||||
|
size = 'sm',
|
||||||
|
}: {
|
||||||
|
current: PipelineStage;
|
||||||
|
size?: 'xs' | 'sm';
|
||||||
|
}) {
|
||||||
|
const idx = PIPELINE_STAGES.indexOf(current);
|
||||||
|
// Segmented progress bar: each stage is a slice of equal width that
|
||||||
|
// lights up once the interest has reached it. Reads at-a-glance, scales
|
||||||
|
// to any container width, and works with 9 stages without becoming
|
||||||
|
// micro-dots that vanish under cramped layouts.
|
||||||
|
const height = size === 'xs' ? 'h-1' : 'h-1.5';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex w-full overflow-hidden rounded-full bg-muted', height)}
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Pipeline progress"
|
||||||
|
aria-valuenow={idx + 1}
|
||||||
|
aria-valuemin={1}
|
||||||
|
aria-valuemax={PIPELINE_STAGES.length}
|
||||||
|
>
|
||||||
|
{PIPELINE_STAGES.map((stage, i) => {
|
||||||
|
const isReached = i <= idx;
|
||||||
|
const isCurrent = i === idx;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stage}
|
||||||
|
title={`${STAGE_LABELS[stage]}${isCurrent ? ' (current)' : ''}`}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 transition-colors',
|
||||||
|
isReached ? STAGE_DOT[stage] : 'bg-transparent',
|
||||||
|
i > 0 ? 'border-l border-card' : '',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickHighest(interests: ClientInterestRow[]): ClientInterestRow | null {
|
||||||
|
const active = interests.filter((i) => !i.archivedAt);
|
||||||
|
if (active.length === 0) return null;
|
||||||
|
return [...active].sort((a, b) => {
|
||||||
|
const ai = PIPELINE_STAGES.indexOf(safeStage(a.pipelineStage));
|
||||||
|
const bi = PIPELINE_STAGES.indexOf(safeStage(b.pipelineStage));
|
||||||
|
if (ai !== bi) return bi - ai;
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
})[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastActivityLabel(interests: ClientInterestRow[]): string | null {
|
||||||
|
const candidates = interests
|
||||||
|
.flatMap((i) => [i.dateLastContact, i.updatedAt])
|
||||||
|
.filter((v): v is string => Boolean(v))
|
||||||
|
.map((v) => new Date(v).getTime())
|
||||||
|
.filter((t) => !Number.isNaN(t));
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
const latest = new Date(Math.max(...candidates));
|
||||||
|
return `${formatDistanceToNowStrict(latest)} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineSummaryProps {
|
||||||
|
clientId: string;
|
||||||
|
/**
|
||||||
|
* `hero` — single-line pulse for the detail header (highest active stage only).
|
||||||
|
* `panel` — compact list of every active interest, for the Overview tab.
|
||||||
|
*/
|
||||||
|
variant?: 'hero' | 'panel';
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { data, isLoading } = useClientInterests(clientId);
|
||||||
|
const interests = data?.data ?? [];
|
||||||
|
const top = pickHighest(interests);
|
||||||
|
const activeCount = interests.filter((i) => !i.archivedAt).length;
|
||||||
|
const activity = lastActivityLabel(interests);
|
||||||
|
const interestsTabHref = `${pathname}?tab=interests` as Route;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-2 w-48" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!top) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">No active interests</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Start one to begin tracking the sales process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/new` as Route}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Start interest <ArrowRight className="size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = safeStage(top.pipelineStage);
|
||||||
|
const berthLabel = top.berthMooringNumber
|
||||||
|
? `Berth ${top.berthMooringNumber}`
|
||||||
|
: 'General interest';
|
||||||
|
const detailsHref = `/${portSlug}/interests/${top.id}` as Route;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Sales pipeline
|
||||||
|
</span>
|
||||||
|
{activeCount > 1 ? (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground">
|
||||||
|
· {activeCount} active
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={detailsHref}
|
||||||
|
className="group -m-1 block rounded-lg p-1 transition-colors hover:bg-foreground/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="truncate text-sm font-semibold text-foreground">{berthLabel}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
|
||||||
|
STAGE_BADGE[stage],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STAGE_LABELS[stage]}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<StageStepper current={stage} size="xs" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{activity ? `Last activity ${activity}` : 'No activity recorded'}</span>
|
||||||
|
{activeCount > 1 ? (
|
||||||
|
<Link
|
||||||
|
href={interestsTabHref}
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
View all {activeCount}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { data, isLoading } = useClientInterests(clientId);
|
||||||
|
const interests = (data?.data ?? []).filter((i) => !i.archivedAt);
|
||||||
|
const interestsTabHref = `${pathname}?tab=interests` as Route;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-2 w-48" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interests.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">No active interests</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Start one to begin tracking the sales process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/new` as Route}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Start interest <ArrowRight className="size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...interests].sort(
|
||||||
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Sales pipeline · {interests.length} active
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={interestsTabHref}
|
||||||
|
className="text-xs font-medium text-primary hover:underline"
|
||||||
|
scroll={false}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{sorted.map((i) => {
|
||||||
|
const stage = safeStage(i.pipelineStage);
|
||||||
|
const berthLabel = i.berthMooringNumber
|
||||||
|
? `Berth ${i.berthMooringNumber}`
|
||||||
|
: 'General interest';
|
||||||
|
const href = `/${portSlug}/interests/${i.id}` as Route;
|
||||||
|
return (
|
||||||
|
<li key={i.id}>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group flex items-center gap-3 rounded-lg p-2 -m-2 transition-colors hover:bg-foreground/5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">
|
||||||
|
{berthLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||||
|
STAGE_BADGE[stage],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STAGE_LABELS[stage]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<StageStepper current={stage} size="xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientPipelineSummary({ clientId, variant = 'panel' }: PipelineSummaryProps) {
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
|
|
||||||
|
return variant === 'hero' ? (
|
||||||
|
<HeroVariant clientId={clientId} portSlug={portSlug} />
|
||||||
|
) : (
|
||||||
|
<PanelVariant clientId={clientId} portSlug={portSlug} />
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/components/clients/dedup-suggestion-panel.tsx
Normal file
183
src/components/clients/dedup-suggestion-panel.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AlertCircle, ArrowRight, Briefcase, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface MatchData {
|
||||||
|
clientId: string;
|
||||||
|
fullName: string;
|
||||||
|
score: number;
|
||||||
|
confidence: 'high' | 'medium' | 'low';
|
||||||
|
reasons: string[];
|
||||||
|
interestCount: number;
|
||||||
|
emails: string[];
|
||||||
|
phonesE164: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DedupSuggestionPanelProps {
|
||||||
|
/** Free-text inputs from the in-flight new-client form. The panel
|
||||||
|
* debounces them and queries /api/v1/clients/match-candidates. */
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
/** Caller wants to attach the new interest to an existing client
|
||||||
|
* rather than creating a new one. The form switches to
|
||||||
|
* interest-only mode and pre-fills the client. */
|
||||||
|
onUseExisting: (match: MatchData) => void;
|
||||||
|
/** User explicitly said "create new anyway." Hide the panel until
|
||||||
|
* they change input again. */
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surfaces existing clients that match the form's in-flight inputs.
|
||||||
|
*
|
||||||
|
* Renders nothing while inputs are short / no useful match found.
|
||||||
|
* On a high-confidence match, the panel interrupts visually with a
|
||||||
|
* solid border and a primary "Use this client" button.
|
||||||
|
*
|
||||||
|
* Wired into the new-client form. Skipped in edit mode.
|
||||||
|
*/
|
||||||
|
export function DedupSuggestionPanel({
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
name,
|
||||||
|
onUseExisting,
|
||||||
|
onDismiss,
|
||||||
|
}: DedupSuggestionPanelProps) {
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
// Debounce inputs by 300ms so we don't fire on every keystroke. Keep
|
||||||
|
// the latest debounced values in component state.
|
||||||
|
const [debounced, setDebounced] = useState({
|
||||||
|
email: email ?? '',
|
||||||
|
phone: phone ?? '',
|
||||||
|
name: name ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
|
||||||
|
// Clear the dismissed flag when inputs change — the user typed
|
||||||
|
// something new, so the prior dismissal no longer applies.
|
||||||
|
setDismissed(false);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [email, phone, name]);
|
||||||
|
|
||||||
|
const hasSomething =
|
||||||
|
debounced.email.length > 3 || debounced.phone.length > 3 || debounced.name.length > 2;
|
||||||
|
|
||||||
|
const { data, isFetching } = useQuery<{ data: MatchData[] }>({
|
||||||
|
queryKey: ['dedup-match-candidates', debounced],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (debounced.email) params.set('email', debounced.email);
|
||||||
|
if (debounced.phone) params.set('phone', debounced.phone);
|
||||||
|
if (debounced.name) params.set('name', debounced.name);
|
||||||
|
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
|
||||||
|
},
|
||||||
|
enabled: hasSomething && !dismissed,
|
||||||
|
// Same query is fine to cache for a minute — moves are slow at this layer.
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dismissed) return null;
|
||||||
|
if (!hasSomething) return null;
|
||||||
|
if (isFetching && !data) return null;
|
||||||
|
const matches = data?.data ?? [];
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
const top = matches[0]!;
|
||||||
|
const isHigh = top.confidence === 'high';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border p-3 mb-3 transition-colors',
|
||||||
|
isHigh
|
||||||
|
? 'border-amber-300 bg-amber-50/60 dark:bg-amber-950/30'
|
||||||
|
: 'border-border bg-muted/40',
|
||||||
|
)}
|
||||||
|
data-testid="dedup-suggestion"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<AlertCircle
|
||||||
|
className={cn(
|
||||||
|
'size-5',
|
||||||
|
isHigh ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold leading-tight">
|
||||||
|
{isHigh
|
||||||
|
? 'This looks like an existing client'
|
||||||
|
: 'Possible match — check before creating'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="truncate text-sm font-medium">{top.fullName}</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||||
|
isHigh
|
||||||
|
? 'bg-amber-200 text-amber-900 dark:bg-amber-800 dark:text-amber-100'
|
||||||
|
: 'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{top.confidence}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{top.emails[0] ? <span className="truncate">{top.emails[0]}</span> : null}
|
||||||
|
{top.phonesE164[0] ? <span>{top.phonesE164[0]}</span> : null}
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Briefcase className="size-3" aria-hidden />
|
||||||
|
{top.interestCount} {top.interestCount === 1 ? 'interest' : 'interests'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[11px] text-muted-foreground">{top.reasons.join(' · ')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onUseExisting(top)}
|
||||||
|
data-testid="dedup-use-existing"
|
||||||
|
>
|
||||||
|
Use this client
|
||||||
|
<ArrowRight className="ml-1 size-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setDismissed(true);
|
||||||
|
onDismiss?.();
|
||||||
|
}}
|
||||||
|
data-testid="dedup-dismiss"
|
||||||
|
>
|
||||||
|
<X className="mr-1 size-3.5" aria-hidden />
|
||||||
|
Create new anyway
|
||||||
|
</Button>
|
||||||
|
{matches.length > 1 ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
+{matches.length - 1} other possible{' '}
|
||||||
|
{matches.length - 1 === 1 ? 'match' : 'matches'}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,7 +57,10 @@ function ActivityFeedInner() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No recent activity.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No recent activity yet — your team's actions (interests created, stages changed,
|
||||||
|
invoices sent) will appear here.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
|
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
|||||||
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||||
import { LeadSourceChart } from './lead-source-chart';
|
import { LeadSourceChart } from './lead-source-chart';
|
||||||
|
import { MyRemindersRail } from './my-reminders-rail';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||||
import type { DateRange } from '@/lib/services/analytics.service';
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
@@ -49,7 +50,7 @@ export function DashboardShell() {
|
|||||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 grid-cols-2 sm:gap-4 lg:grid-cols-4">
|
||||||
<KpiCardsWithBoundary />
|
<KpiCardsWithBoundary />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,7 +69,10 @@ export function DashboardShell() {
|
|||||||
<LeadSourceChart range={range} />
|
<LeadSourceChart range={range} />
|
||||||
</WidgetErrorBoundary>
|
</WidgetErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<aside className="min-w-0">
|
<aside className="min-w-0 space-y-4">
|
||||||
|
<WidgetErrorBoundary>
|
||||||
|
<MyRemindersRail />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
<WidgetErrorBoundary>
|
<WidgetErrorBoundary>
|
||||||
<AlertRail />
|
<AlertRail />
|
||||||
</WidgetErrorBoundary>
|
</WidgetErrorBoundary>
|
||||||
|
|||||||
@@ -54,18 +54,24 @@ export function LeadSourceChart({ range }: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
) : !slices.length ? (
|
) : !slices.length ? (
|
||||||
<EmptyState title="No interests in range" />
|
<EmptyState
|
||||||
|
title="No interests in range"
|
||||||
|
description="Lights up once new interests are created — tracks where each came from (website, referral, broker)."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
// Percentage radii + center-anchored chart so the pie scales with
|
||||||
|
// the container instead of being clipped to a constant 90px ring at
|
||||||
|
// narrow widths. Legend is reserved a fixed footer height.
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={chartData}
|
data={chartData}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
nameKey="name"
|
nameKey="name"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="45%"
|
||||||
outerRadius={90}
|
outerRadius="70%"
|
||||||
innerRadius={50}
|
innerRadius="40%"
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
>
|
>
|
||||||
{chartData.map((_, i) => (
|
{chartData.map((_, i) => (
|
||||||
@@ -80,7 +86,11 @@ export function LeadSourceChart({ range }: Props) {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend
|
||||||
|
verticalAlign="bottom"
|
||||||
|
height={40}
|
||||||
|
wrapperStyle={{ fontSize: 12, paddingTop: 4 }}
|
||||||
|
/>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
|
|||||||
153
src/components/dashboard/my-reminders-rail.tsx
Normal file
153
src/components/dashboard/my-reminders-rail.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { formatDistanceToNowStrict, isAfter, isBefore } from 'date-fns';
|
||||||
|
import { AlarmClock, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ReminderRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
dueAt: string;
|
||||||
|
status: string;
|
||||||
|
priority?: string | null;
|
||||||
|
interestId?: string | null;
|
||||||
|
clientId?: string | null;
|
||||||
|
entityType?: string | null;
|
||||||
|
entityId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyRemindersResponse {
|
||||||
|
data: ReminderRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_BADGE: Record<string, string> = {
|
||||||
|
high: 'bg-rose-100 text-rose-700',
|
||||||
|
medium: 'bg-amber-100 text-amber-700',
|
||||||
|
low: 'bg-slate-100 text-slate-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
|
||||||
|
* to the current user (overdue first, then upcoming). Each item links to its
|
||||||
|
* subject — interest preferred, then client, then the generic entity ref.
|
||||||
|
*
|
||||||
|
* Limited to 6 items; "View all" routes to /reminders.
|
||||||
|
*/
|
||||||
|
export function MyRemindersRail() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<MyRemindersResponse>({
|
||||||
|
queryKey: ['reminders', 'my'],
|
||||||
|
queryFn: () => apiFetch<MyRemindersResponse>('/api/v1/reminders/my'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.data ?? [];
|
||||||
|
const now = new Date();
|
||||||
|
// Overdue first, then upcoming, capped at 6 for the rail.
|
||||||
|
const sorted = [...items]
|
||||||
|
.sort((a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime())
|
||||||
|
.slice(0, 6);
|
||||||
|
const overdueCount = items.filter((r) => isBefore(new Date(r.dueAt), now)).length;
|
||||||
|
|
||||||
|
function hrefFor(r: ReminderRow): string {
|
||||||
|
if (r.interestId) return `/${portSlug}/interests/${r.interestId}`;
|
||||||
|
if (r.clientId) return `/${portSlug}/clients/${r.clientId}`;
|
||||||
|
if (r.entityType === 'client' && r.entityId) return `/${portSlug}/clients/${r.entityId}`;
|
||||||
|
if (r.entityType === 'interest' && r.entityId) return `/${portSlug}/interests/${r.entityId}`;
|
||||||
|
if (r.entityType === 'berth' && r.entityId) return `/${portSlug}/berths/${r.entityId}`;
|
||||||
|
return `/${portSlug}/reminders`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||||
|
<AlarmClock className="size-4" />
|
||||||
|
Reminders
|
||||||
|
</CardTitle>
|
||||||
|
{overdueCount > 0 ? (
|
||||||
|
<p className="text-xs text-rose-700">{overdueCount} overdue</p>
|
||||||
|
) : items.length > 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{items.length} pending</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/reminders` as never}
|
||||||
|
className="text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-9 animate-pulse rounded-md bg-muted/40" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : sorted.length === 0 ? (
|
||||||
|
<p className="py-3 text-center text-sm text-muted-foreground">
|
||||||
|
All caught up — no reminders.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{sorted.map((r) => {
|
||||||
|
const due = new Date(r.dueAt);
|
||||||
|
const isOverdue = isBefore(due, now);
|
||||||
|
const isUpcoming = isAfter(due, now);
|
||||||
|
return (
|
||||||
|
<li key={r.id}>
|
||||||
|
<Link
|
||||||
|
href={hrefFor(r) as never}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||||
|
'hover:bg-foreground/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'size-1.5 shrink-0 rounded-full',
|
||||||
|
isOverdue ? 'bg-rose-500' : 'bg-amber-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{r.title}</span>
|
||||||
|
{r.priority && r.priority !== 'low' ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'border-transparent text-[10px]',
|
||||||
|
PRIORITY_BADGE[r.priority] ?? 'bg-muted text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.priority}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{isOverdue
|
||||||
|
? formatDistanceToNowStrict(due) + ' overdue'
|
||||||
|
: isUpcoming
|
||||||
|
? 'in ' + formatDistanceToNowStrict(due)
|
||||||
|
: 'now'}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
|
|||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { stageLabel } from '@/lib/constants';
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
|
import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
|
|
||||||
interface PipelineRow {
|
interface PipelineRow {
|
||||||
@@ -15,6 +16,7 @@ interface PipelineRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PipelineChartInner() {
|
function PipelineChartInner() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { data, isLoading } = useQuery<PipelineRow[]>({
|
const { data, isLoading } = useQuery<PipelineRow[]>({
|
||||||
queryKey: ['dashboard', 'pipeline'],
|
queryKey: ['dashboard', 'pipeline'],
|
||||||
queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'),
|
queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'),
|
||||||
@@ -27,7 +29,7 @@ function PipelineChartInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chartData = (data ?? []).map((row) => ({
|
const chartData = (data ?? []).map((row) => ({
|
||||||
stage: stageLabel(row.stage),
|
stage: isMobile ? STAGE_SHORT_LABELS[safeStage(row.stage)] : stageLabel(row.stage),
|
||||||
count: row.count,
|
count: row.count,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
|
|||||||
|
|
||||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
import { stageLabel } from '@/lib/constants';
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
|
import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
|
||||||
import { ChartCard } from './chart-card';
|
import { ChartCard } from './chart-card';
|
||||||
import { useFunnel } from './use-analytics';
|
import { useFunnel } from './use-analytics';
|
||||||
import type { DateRange } from '@/lib/services/analytics.service';
|
import type { DateRange } from '@/lib/services/analytics.service';
|
||||||
@@ -15,10 +16,12 @@ interface Props {
|
|||||||
|
|
||||||
export function PipelineFunnelChart({ range }: Props) {
|
export function PipelineFunnelChart({ range }: Props) {
|
||||||
const { data, isLoading } = useFunnel(range);
|
const { data, isLoading } = useFunnel(range);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const stages = data?.stages ?? [];
|
const stages = data?.stages ?? [];
|
||||||
|
// Use short labels on mobile so the rotated axis isn't a wall of overlap.
|
||||||
const chartData = stages.map((s) => ({
|
const chartData = stages.map((s) => ({
|
||||||
stage: stageLabel(s.stage),
|
stage: isMobile ? STAGE_SHORT_LABELS[safeStage(s.stage)] : stageLabel(s.stage),
|
||||||
count: s.count,
|
count: s.count,
|
||||||
conversionPct: s.conversionPct,
|
conversionPct: s.conversionPct,
|
||||||
}));
|
}));
|
||||||
@@ -41,7 +44,10 @@ export function PipelineFunnelChart({ range }: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
) : allZero ? (
|
) : allZero ? (
|
||||||
<EmptyState title="No interests in range" description="Try a longer date range." />
|
<EmptyState
|
||||||
|
title="No interests in range"
|
||||||
|
description="Conversion through Open → EOI → Deposit → Contract appears here. Try a longer date range, or add an interest to see it."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ export function RevenueBreakdownChart({ range }: Props) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
) : !bars.length ? (
|
) : !bars.length ? (
|
||||||
<EmptyState title="No invoices in range" description="Invoices appear here once issued." />
|
<EmptyState
|
||||||
|
title="No invoices in range"
|
||||||
|
description="Issued, paid, and overdue totals appear here once you create invoices."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ interface DocumentRow {
|
|||||||
interface DocumentListProps {
|
interface DocumentListProps {
|
||||||
interestId?: string;
|
interestId?: string;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
|
/** Override the default empty state ("No documents yet.") with a contextual
|
||||||
|
* CTA — e.g. on the interest Documents tab we render a Generate EOI prompt. */
|
||||||
|
emptyState?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
@@ -44,7 +47,7 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
other: 'Other',
|
other: 'Other',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DocumentList({ interestId, clientId }: DocumentListProps) {
|
export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
@@ -83,10 +86,13 @@ export function DocumentList({ interestId, clientId }: DocumentListProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>;
|
return (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
|
if (emptyState) return <>{emptyState}</>;
|
||||||
return <div className="py-8 text-center text-sm text-muted-foreground">No documents yet.</div>;
|
return <div className="py-8 text-center text-sm text-muted-foreground">No documents yet.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ const STATUS_PILL_MAP: Record<string, StatusPillStatus> = {
|
|||||||
rejected: 'rejected',
|
rejected: 'rejected',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SIGNER_STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
sent: 'Sent',
|
||||||
|
signed: 'Signed',
|
||||||
|
declined: 'Declined',
|
||||||
|
expired: 'Expired',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
interface DocumentsHubProps {
|
interface DocumentsHubProps {
|
||||||
portSlug: string;
|
portSlug: string;
|
||||||
initialTab?: DocumentsHubTab;
|
initialTab?: DocumentsHubTab;
|
||||||
@@ -187,7 +196,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
|
|||||||
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
||||||
{signer.status}
|
{SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ import {
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
/** Required for the EOI's top paragraph (Section 2) — without these the
|
||||||
|
* document is unsignable, so generation is blocked. Yacht and berth fields
|
||||||
|
* belong to Section 3 and may be left blank. */
|
||||||
interface EoiPrerequisites {
|
interface EoiPrerequisites {
|
||||||
hasName: boolean;
|
hasName: boolean;
|
||||||
|
hasEmail: boolean;
|
||||||
|
hasAddress: boolean;
|
||||||
|
/** Optional — info-only checks. Generation proceeds without them. */
|
||||||
hasYacht: boolean;
|
hasYacht: boolean;
|
||||||
hasBerth: boolean;
|
hasBerth: boolean;
|
||||||
}
|
}
|
||||||
@@ -35,10 +41,15 @@ interface EoiGenerateDialogProps {
|
|||||||
prerequisites: EoiPrerequisites;
|
prerequisites: EoiPrerequisites;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
const REQUIRED_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||||
{ key: 'hasName', label: 'Client has full name' },
|
{ key: 'hasName', label: 'Client name' },
|
||||||
{ key: 'hasYacht', label: 'Yacht linked to interest' },
|
{ key: 'hasAddress', label: 'Client address' },
|
||||||
{ key: 'hasBerth', label: 'Berth linked to interest' },
|
{ key: 'hasEmail', label: 'Client email' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPTIONAL_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||||
|
{ key: 'hasYacht', label: 'Yacht linked (name + dimensions)' },
|
||||||
|
{ key: 'hasBerth', label: 'Berth linked (mooring number)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
||||||
@@ -65,7 +76,7 @@ export function EoiGenerateDialog({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
|
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
|
||||||
|
|
||||||
const allMet = Object.values(prerequisites).every(Boolean);
|
const requiredMet = REQUIRED_LABELS.every(({ key }) => prerequisites[key]);
|
||||||
|
|
||||||
// Load in-app EOI templates so the operator can pick one as an alternative
|
// Load in-app EOI templates so the operator can pick one as an alternative
|
||||||
// to the Documenso external-signing flow.
|
// to the Documenso external-signing flow.
|
||||||
@@ -79,7 +90,7 @@ export function EoiGenerateDialog({
|
|||||||
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!allMet) return;
|
if (!requiredMet) return;
|
||||||
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -98,7 +109,13 @@ export function EoiGenerateDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] });
|
// Invalidate all document list queries (hub counts + per-interest lists).
|
||||||
|
// The DocumentList component uses ['documents', { interestId, clientId }]
|
||||||
|
// and the hub uses ['documents', 'hub', ...] / ['documents', 'hub-counts'].
|
||||||
|
// Using a predicate avoids key-shape drift between callers.
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (q) => q.queryKey[0] === 'documents',
|
||||||
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
|
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
|
||||||
@@ -138,10 +155,13 @@ export function EoiGenerateDialog({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground">Prerequisites</p>
|
<div className="space-y-1.5">
|
||||||
{PREREQUISITE_LABELS.map(({ key, label }) => (
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
<div key={key} className="flex items-center gap-3">
|
Required (Section 2 of the EOI)
|
||||||
|
</p>
|
||||||
|
{REQUIRED_LABELS.map(({ key, label }) => (
|
||||||
|
<div key={key} className="flex items-center gap-3 text-sm">
|
||||||
<span
|
<span
|
||||||
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||||
@@ -149,12 +169,46 @@ export function EoiGenerateDialog({
|
|||||||
>
|
>
|
||||||
{prerequisites[key] ? '✓' : '✗'}
|
{prerequisites[key] ? '✓' : '✗'}
|
||||||
</span>
|
</span>
|
||||||
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}>
|
<span
|
||||||
|
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
Optional (Section 3 — left blank if absent)
|
||||||
|
</p>
|
||||||
|
{OPTIONAL_LABELS.map(({ key, label }) => (
|
||||||
|
<div key={key} className="flex items-center gap-3 text-sm">
|
||||||
|
<span
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
||||||
|
prerequisites[key]
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{prerequisites[key] ? '✓' : '–'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!requiredMet ? (
|
||||||
|
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||||
|
Add the missing required details on the client's record before generating the
|
||||||
|
EOI.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
@@ -163,7 +217,7 @@ export function EoiGenerateDialog({
|
|||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}>
|
<Button onClick={handleGenerate} disabled={!requiredMet || isGenerating}>
|
||||||
{isGenerating ? 'Generating…' : 'Generate EOI'}
|
{isGenerating ? 'Generating…' : 'Generate EOI'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
163
src/components/interests/inline-stage-picker.tsx
Normal file
163
src/components/interests/inline-stage-picker.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Check, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
PIPELINE_STAGES,
|
||||||
|
STAGE_BADGE,
|
||||||
|
STAGE_DOT,
|
||||||
|
STAGE_LABELS,
|
||||||
|
safeStage,
|
||||||
|
type PipelineStage,
|
||||||
|
} from '@/components/clients/pipeline-constants';
|
||||||
|
|
||||||
|
interface InlineStagePickerProps {
|
||||||
|
interestId: string;
|
||||||
|
currentStage: string;
|
||||||
|
/** Whether to render the chevron after the stage label. Default true. */
|
||||||
|
showChevron?: boolean;
|
||||||
|
/** Stop the parent's click propagation when used inside a clickable card. */
|
||||||
|
stopPropagation?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker
|
||||||
|
* for inline editing — user clicks the chip, picks a new stage from the
|
||||||
|
* popover (with optional reason), commits in one click. The popover stays
|
||||||
|
* compact: a small reason field above the stage list, and clicking any stage
|
||||||
|
* fires the mutation immediately.
|
||||||
|
*/
|
||||||
|
export function InlineStagePicker({
|
||||||
|
interestId,
|
||||||
|
currentStage,
|
||||||
|
showChevron = true,
|
||||||
|
stopPropagation = false,
|
||||||
|
className,
|
||||||
|
}: InlineStagePickerProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [pendingStage, setPendingStage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const stage = safeStage(currentStage);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (next: PipelineStage) =>
|
||||||
|
apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { pipelineStage: next, reason: reason.trim() || undefined },
|
||||||
|
}),
|
||||||
|
onSuccess: (_data, next) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||||
|
setOpen(false);
|
||||||
|
setReason('');
|
||||||
|
setPendingStage(null);
|
||||||
|
toast.success(`Stage moved to ${STAGE_LABELS[next]}`);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setPendingStage(null);
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to change stage');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function pick(next: PipelineStage) {
|
||||||
|
if (next === stage) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingStage(next);
|
||||||
|
mutation.mutate(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!mutation.isPending) setOpen(o);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (stopPropagation) e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-medium',
|
||||||
|
'transition-colors hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
STAGE_BADGE[stage],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={`Pipeline stage: ${STAGE_LABELS[stage]}. Click to change.`}
|
||||||
|
>
|
||||||
|
<span>{STAGE_LABELS[stage]}</span>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
) : showChevron ? (
|
||||||
|
<ChevronDown className="size-3 opacity-70" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-64 p-0"
|
||||||
|
onClick={(e) => stopPropagation && e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="border-b px-2 py-1">
|
||||||
|
<Textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="Reason (optional)…"
|
||||||
|
rows={1}
|
||||||
|
className="min-h-0 resize-none border-none bg-transparent px-0 py-0.5 text-xs leading-tight shadow-none focus-visible:ring-0"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
|
||||||
|
{PIPELINE_STAGES.map((s) => {
|
||||||
|
const isCurrent = s === stage;
|
||||||
|
const isPending = pendingStage === s && mutation.isPending;
|
||||||
|
return (
|
||||||
|
<li key={s}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isCurrent}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() => pick(s)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
|
||||||
|
'transition-colors hover:bg-muted/60 disabled:opacity-60',
|
||||||
|
isCurrent && 'font-medium',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Colored chip (mirrors the inline stage badge) — turns
|
||||||
|
the picker into a visual scan rather than just a list. */}
|
||||||
|
<span
|
||||||
|
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{STAGE_LABELS[s]}</span>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
) : isCurrent ? (
|
||||||
|
<Check className="size-3.5 text-muted-foreground" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Anchor, Archive, Compass, MoreHorizontal, Pencil } from 'lucide-react';
|
import { Anchor, Archive, Compass, MessageSquare, MoreHorizontal, Pencil } from 'lucide-react';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
} from '@/components/shared/list-card';
|
} from '@/components/shared/list-card';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants';
|
import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants';
|
||||||
|
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||||
import type { InterestRow } from './interest-columns';
|
import type { InterestRow } from './interest-columns';
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
@@ -48,9 +50,15 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
|||||||
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null;
|
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null;
|
||||||
const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null;
|
const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null;
|
||||||
const tags = interest.tags ?? [];
|
const tags = interest.tags ?? [];
|
||||||
|
const notesCount = interest.notesCount ?? 0;
|
||||||
|
const urgencyBadges = computeUrgencyBadges(interest);
|
||||||
|
|
||||||
const clientName = interest.clientName ?? 'Unknown client';
|
const clientName = interest.clientName ?? 'Unknown client';
|
||||||
const berthLabel = interest.berthMooringNumber;
|
const berthLabel = interest.berthMooringNumber;
|
||||||
|
const lastIso = interest.dateLastContact ?? interest.updatedAt ?? null;
|
||||||
|
const lastActivity = lastIso
|
||||||
|
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListCard
|
<ListCard
|
||||||
@@ -86,11 +94,22 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<ListCardAvatar initials={deriveInitials(clientName)} />
|
<ListCardAvatar initials={deriveInitials(clientName)} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{/* Title row: name + spacer for the absolutely-positioned actions menu */}
|
{/* Title row: name + comment-icon when notes exist + spacer for actions */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||||
{clientName}
|
{clientName}
|
||||||
</h3>
|
</h3>
|
||||||
|
{notesCount > 0 ? (
|
||||||
|
<span
|
||||||
|
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||||
|
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||||
|
className="inline-flex shrink-0 items-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,6 +154,23 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{urgencyBadges.length > 0 ? (
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{urgencyBadges.map((b) => (
|
||||||
|
<span
|
||||||
|
key={b.id}
|
||||||
|
title={b.detail}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
b.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{tags.length > 0 ? (
|
{tags.length > 0 ? (
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
{tags.slice(0, 2).map((tag) => (
|
{tags.slice(0, 2).map((tag) => (
|
||||||
@@ -147,6 +183,12 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{lastActivity ? (
|
||||||
|
<p className="mt-1.5 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
Last activity {lastActivity}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ListCard>
|
</ListCard>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { format } from 'date-fns';
|
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
|
import { MoreHorizontal, Pencil, Archive, MessageSquare } from 'lucide-react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
|
import { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
|
||||||
|
|
||||||
export interface InterestRow {
|
export interface InterestRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +28,15 @@ export interface InterestRow {
|
|||||||
source: string | null;
|
source: string | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
/** Surfaced by listInterests for the row-level sales-triage signals
|
||||||
|
* (last-activity relative time, comment-icon, urgency badges). */
|
||||||
|
updatedAt?: string;
|
||||||
|
dateLastContact?: string | null;
|
||||||
|
dateEoiSent?: string | null;
|
||||||
|
dateDepositReceived?: string | null;
|
||||||
|
eoiStatus?: string | null;
|
||||||
|
outcome?: string | null;
|
||||||
|
notesCount?: number;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,15 +69,29 @@ export function getInterestColumns({
|
|||||||
id: 'clientName',
|
id: 'clientName',
|
||||||
accessorKey: 'clientName',
|
accessorKey: 'clientName',
|
||||||
header: 'Client',
|
header: 'Client',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
|
const notesCount = row.original.notesCount ?? 0;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Link
|
<Link
|
||||||
href={`/${portSlug}/clients/${row.original.clientId}`}
|
href={`/${portSlug}/clients/${row.original.clientId}`}
|
||||||
className="font-medium text-primary hover:underline"
|
className="truncate font-medium text-primary hover:underline"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{row.original.clientName ?? '—'}
|
{row.original.clientName ?? '—'}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
{notesCount > 0 ? (
|
||||||
|
<span
|
||||||
|
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||||
|
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||||
|
className="inline-flex items-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'berthMooringNumber',
|
id: 'berthMooringNumber',
|
||||||
@@ -92,14 +116,31 @@ export function getInterestColumns({
|
|||||||
id: 'pipelineStage',
|
id: 'pipelineStage',
|
||||||
accessorKey: 'pipelineStage',
|
accessorKey: 'pipelineStage',
|
||||||
header: 'Stage',
|
header: 'Stage',
|
||||||
cell: ({ getValue }) => {
|
cell: ({ row }) => {
|
||||||
const stage = getValue() as string;
|
const stage = row.original.pipelineStage;
|
||||||
|
const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput);
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-1 items-start">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
|
||||||
>
|
>
|
||||||
{stageLabel(stage)}
|
{stageLabel(stage)}
|
||||||
</span>
|
</span>
|
||||||
|
{badges.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{badges.map((b) => (
|
||||||
|
<span
|
||||||
|
key={b.id}
|
||||||
|
title={b.detail}
|
||||||
|
aria-label={b.detail}
|
||||||
|
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${b.className}`}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -153,14 +194,24 @@ export function getInterestColumns({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'createdAt',
|
// Sales-triage default: prefer the explicit dateLastContact, fall back
|
||||||
accessorKey: 'createdAt',
|
// to updatedAt. Sortable on dateLastContact server-side; the column
|
||||||
header: 'Created',
|
// header label ("Last activity") makes the fallback semantics clear.
|
||||||
cell: ({ getValue }) => (
|
id: 'dateLastContact',
|
||||||
<span className="text-muted-foreground text-sm">
|
accessorKey: 'dateLastContact',
|
||||||
{format(new Date(getValue() as string), 'MMM d, yyyy')}
|
header: 'Last activity',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
|
||||||
|
if (!lastIso) {
|
||||||
|
return <span className="text-muted-foreground text-sm">—</span>;
|
||||||
|
}
|
||||||
|
const d = new Date(lastIso);
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
|
||||||
|
{formatDistanceToNowStrict(d, { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
|
|||||||
@@ -2,9 +2,21 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Pencil, Archive, RotateCcw, Trophy, XCircle, RefreshCcw } from 'lucide-react';
|
import {
|
||||||
|
Pencil,
|
||||||
|
Archive,
|
||||||
|
RotateCcw,
|
||||||
|
Trophy,
|
||||||
|
XCircle,
|
||||||
|
RefreshCcw,
|
||||||
|
Mail,
|
||||||
|
MessageCircle,
|
||||||
|
Phone,
|
||||||
|
AlarmClock,
|
||||||
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TagBadge } from '@/components/shared/tag-badge';
|
import { TagBadge } from '@/components/shared/tag-badge';
|
||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
@@ -24,6 +36,20 @@ const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
|||||||
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Catch-all so an unknown outcome (e.g. a future `lost_no_berth` enum) still
|
||||||
|
// renders as a closed-state badge instead of falling back to the open-state
|
||||||
|
// stage picker. Lost-* gets a rose tint; everything else gets neutral slate.
|
||||||
|
function resolveOutcomeBadge(outcome: string | null | undefined) {
|
||||||
|
if (!outcome) return null;
|
||||||
|
const known = OUTCOME_BADGE[outcome];
|
||||||
|
if (known) return known;
|
||||||
|
const isLoss = outcome.startsWith('lost');
|
||||||
|
return {
|
||||||
|
label: outcome.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
|
||||||
|
className: isLoss ? 'bg-rose-100 text-rose-700' : 'bg-slate-200 text-slate-700',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
general_interest: 'General',
|
general_interest: 'General',
|
||||||
specific_qualified: 'Specific Qualified',
|
specific_qualified: 'Specific Qualified',
|
||||||
@@ -36,6 +62,16 @@ interface InterestDetailHeaderProps {
|
|||||||
id: string;
|
id: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
|
/** Primary contact channels resolved from the linked client. The header
|
||||||
|
* uses these to render Email / Call / WhatsApp buttons so the rep
|
||||||
|
* doesn't have to navigate to the client page just to reach out. */
|
||||||
|
clientPrimaryEmail?: string | null;
|
||||||
|
clientPrimaryPhone?: string | null;
|
||||||
|
clientPrimaryPhoneE164?: string | null;
|
||||||
|
/** Pending/snoozed reminders attached to this interest. Drives the
|
||||||
|
* alarm-bell badge on the header — surfaces follow-ups so the rep
|
||||||
|
* doesn't have to remember to check /reminders. */
|
||||||
|
activeReminderCount?: number;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
@@ -47,10 +83,20 @@ interface InterestDetailHeaderProps {
|
|||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
outcome?: string | null;
|
outcome?: string | null;
|
||||||
outcomeReason?: string | null;
|
outcomeReason?: string | null;
|
||||||
|
dateLastContact?: string | null;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLastContactAge(iso: string): string {
|
||||||
|
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||||
|
if (days <= 0) return 'today';
|
||||||
|
if (days === 1) return 'yesterday';
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
||||||
|
return `${Math.floor(days / 365)}y ago`;
|
||||||
|
}
|
||||||
|
|
||||||
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
|
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
@@ -58,9 +104,19 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
|
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
|
||||||
|
|
||||||
const isArchived = !!interest.archivedAt;
|
const isArchived = !!interest.archivedAt;
|
||||||
const outcomeBadge = interest.outcome ? OUTCOME_BADGE[interest.outcome] : null;
|
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||||
const isClosed = !!interest.outcome;
|
const isClosed = !!interest.outcome;
|
||||||
|
|
||||||
|
// Contact deep-links — resolved from the linked client's primary channels.
|
||||||
|
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
|
||||||
|
// stripping non-digits from the display value when the canonical form is
|
||||||
|
// missing.
|
||||||
|
const whatsappNumber = interest.clientPrimaryPhoneE164
|
||||||
|
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
|
||||||
|
: interest.clientPrimaryPhone
|
||||||
|
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
|
||||||
|
: null;
|
||||||
|
|
||||||
const reopenMutation = useMutation({
|
const reopenMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
||||||
@@ -114,6 +170,16 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
node: <span className="capitalize">{interest.source}</span>,
|
node: <span className="capitalize">{interest.source}</span>,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (interest.dateLastContact) {
|
||||||
|
meta.push({
|
||||||
|
key: 'last',
|
||||||
|
node: (
|
||||||
|
<span className="text-foreground/70">
|
||||||
|
Last contact {formatLastContactAge(interest.dateLastContact)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -156,6 +222,17 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
/>
|
/>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
)}
|
)}
|
||||||
|
{(interest.activeReminderCount ?? 0) > 0 ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
|
||||||
|
title={`${interest.activeReminderCount} pending reminder${
|
||||||
|
interest.activeReminderCount === 1 ? '' : 's'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlarmClock className="size-3" />
|
||||||
|
{interest.activeReminderCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{meta.length > 0 ? (
|
{meta.length > 0 ? (
|
||||||
@@ -180,25 +257,85 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Contact deep-links — let the rep email / call / WhatsApp the
|
||||||
|
client without leaving the interest workspace. Resolved from
|
||||||
|
the linked client's primary contact channels (server-side
|
||||||
|
fetch in getInterestById). */}
|
||||||
|
{interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
|
{interest.clientPrimaryEmail ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`mailto:${interest.clientPrimaryEmail}`}
|
||||||
|
aria-label={`Email ${interest.clientPrimaryEmail}`}
|
||||||
|
>
|
||||||
|
<Mail />
|
||||||
|
Email
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{interest.clientPrimaryPhone ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`tel:${interest.clientPrimaryPhone}`}
|
||||||
|
aria-label={`Call ${interest.clientPrimaryPhone}`}
|
||||||
|
>
|
||||||
|
<Phone />
|
||||||
|
Call
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{whatsappNumber ? (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/${whatsappNumber}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`Message on WhatsApp`}
|
||||||
|
>
|
||||||
|
<MessageCircle />
|
||||||
|
WhatsApp
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top-right icon-only actions — no stacking, no labels eating room. */}
|
{/* Top-right actions. Won/Lost are sales-critical and read as text
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
buttons on desktop; Edit/Archive stay icon-only. On mobile,
|
||||||
|
Won/Lost shrink to icon buttons to keep the cluster from
|
||||||
|
wrapping. */}
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<PermissionGate resource="interests" action="change_stage">
|
<PermissionGate resource="interests" action="change_stage">
|
||||||
{isClosed ? (
|
{isClosed ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => reopenMutation.mutate()}
|
onClick={() => reopenMutation.mutate()}
|
||||||
disabled={reopenMutation.isPending}
|
disabled={reopenMutation.isPending}
|
||||||
aria-label="Reopen interest"
|
|
||||||
title="Reopen interest"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground transition-colors',
|
||||||
'hover:bg-foreground/5 hover:text-foreground',
|
'hover:bg-foreground/5 disabled:opacity-50',
|
||||||
'disabled:opacity-50',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RefreshCcw className="size-4" />
|
<RefreshCcw className="size-3.5" />
|
||||||
|
Reopen
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -206,25 +343,27 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOutcomeDialog('won')}
|
onClick={() => setOutcomeDialog('won')}
|
||||||
aria-label="Mark as won"
|
aria-label="Mark as won"
|
||||||
title="Mark as won"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
'hover:bg-emerald-50 hover:text-emerald-700',
|
'border border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
|
'hover:bg-emerald-100',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Trophy className="size-4" />
|
<Trophy className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">Mark won</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOutcomeDialog('lost')}
|
onClick={() => setOutcomeDialog('lost')}
|
||||||
aria-label="Close as lost"
|
aria-label="Close as lost"
|
||||||
title="Close as lost"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
'hover:bg-rose-50 hover:text-rose-700',
|
'border border-rose-200 text-rose-700',
|
||||||
|
'hover:bg-rose-50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<XCircle className="size-4" />
|
<XCircle className="size-3.5" />
|
||||||
|
<span className="hidden sm:inline">Close as lost</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
||||||
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
||||||
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
@@ -14,6 +16,29 @@ interface InterestData {
|
|||||||
portId: string;
|
portId: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
|
/** Linked client's primary email (display value). Powers the header
|
||||||
|
* "Email" button and the EOI prereq checklist. */
|
||||||
|
clientPrimaryEmail: string | null;
|
||||||
|
/** Linked client's primary phone (display value). Powers the header
|
||||||
|
* "Call" button. */
|
||||||
|
clientPrimaryPhone: string | null;
|
||||||
|
/** Linked client's primary phone in E.164 form ("+1XXXXXXXXXX"). Used
|
||||||
|
* by wa.me to assemble the WhatsApp deep-link. */
|
||||||
|
clientPrimaryPhoneE164: string | null;
|
||||||
|
/** True when the linked client has any primary address row. Used by
|
||||||
|
* the EOI prereq checklist on the Documents tab. */
|
||||||
|
clientHasAddress: boolean;
|
||||||
|
/** Surfaced for the bell badge on the detail header (pending/snoozed
|
||||||
|
* reminders linked to this interest). */
|
||||||
|
activeReminderCount?: number;
|
||||||
|
/** Surfaced for the most-recent-note teaser on the Overview tab. */
|
||||||
|
notesCount?: number;
|
||||||
|
recentNote?: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
authorId: string;
|
||||||
|
createdAt: string;
|
||||||
|
} | null;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
@@ -37,6 +62,8 @@ interface InterestData {
|
|||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
outcome?: string | null;
|
||||||
|
outcomeReason?: string | null;
|
||||||
tags: Array<{ id: string; name: string; color: string }>;
|
tags: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +79,7 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
|||||||
const { data, isLoading } = useQuery<InterestData>({
|
const { data, isLoading } = useQuery<InterestData>({
|
||||||
queryKey: ['interests', interestId],
|
queryKey: ['interests', interestId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then(
|
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
||||||
(r) => r.data,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useRealtimeInvalidation({
|
useRealtimeInvalidation({
|
||||||
@@ -65,17 +90,18 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
|||||||
'interest:berthUnlinked': [['interests', interestId]],
|
'interest:berthUnlinked': [['interests', interestId]],
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabs = data
|
const { setChrome } = useMobileChrome();
|
||||||
? getInterestTabs({ interestId, currentUserId, interest: data })
|
const titleForChrome: string | null = data?.clientName ?? null;
|
||||||
: [];
|
useEffect(() => {
|
||||||
|
setChrome({ title: titleForChrome, showBackButton: true });
|
||||||
|
return () => setChrome({ title: null, showBackButton: false });
|
||||||
|
}, [titleForChrome, setChrome]);
|
||||||
|
|
||||||
|
const tabs = data ? getInterestTabs({ interestId, currentUserId, interest: data }) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailLayout
|
<DetailLayout
|
||||||
header={
|
header={data ? <InterestDetailHeader portSlug={portSlug} interest={data} /> : null}
|
||||||
data ? (
|
|
||||||
<InterestDetailHeader portSlug={portSlug} interest={data} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
defaultTab="overview"
|
defaultTab="overview"
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { FileSignature } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { DocumentList } from '@/components/documents/document-list';
|
import { DocumentList } from '@/components/documents/document-list';
|
||||||
@@ -17,20 +18,29 @@ interface InterestData {
|
|||||||
yachtId?: string | null;
|
yachtId?: string | null;
|
||||||
berthId?: string | null;
|
berthId?: string | null;
|
||||||
clientName?: string | null;
|
clientName?: string | null;
|
||||||
|
/** Surfaced by getInterestById for the EOI prerequisites checklist. */
|
||||||
|
clientPrimaryEmail?: string | null;
|
||||||
|
clientHasAddress?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
|
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
|
||||||
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
|
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { data: interestRes } = useQuery({
|
// Same query key + queryFn shape as InterestDetail's parent query, so the
|
||||||
|
// cache is consistent. (Mismatched shapes on the same key clobber each other
|
||||||
|
// and the parent header degenerates to "Unknown Client".)
|
||||||
|
const { data: interest } = useQuery<InterestData>({
|
||||||
queryKey: ['interests', interestId],
|
queryKey: ['interests', interestId],
|
||||||
queryFn: () => apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
const interest = interestRes?.data;
|
|
||||||
|
|
||||||
const prerequisites = {
|
const prerequisites = {
|
||||||
|
// Required (EOI Section 2 — top paragraph): name, address, email.
|
||||||
hasName: Boolean(interest?.clientName),
|
hasName: Boolean(interest?.clientName),
|
||||||
|
hasEmail: Boolean(interest?.clientPrimaryEmail),
|
||||||
|
hasAddress: Boolean(interest?.clientHasAddress),
|
||||||
|
// Optional (EOI Section 3): yacht + berth. Render blank when absent.
|
||||||
hasYacht: Boolean(interest?.yachtId),
|
hasYacht: Boolean(interest?.yachtId),
|
||||||
hasBerth: Boolean(interest?.berthId),
|
hasBerth: Boolean(interest?.berthId),
|
||||||
};
|
};
|
||||||
@@ -39,12 +49,30 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||||
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
|
||||||
Generate EOI
|
Generate EOI
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentList interestId={interestId} />
|
<DocumentList
|
||||||
|
interestId={interestId}
|
||||||
|
emptyState={
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-10 text-center">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-full bg-background text-muted-foreground">
|
||||||
|
<FileSignature className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">No documents yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Generate the EOI to send it for signing in one click.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
|
||||||
|
Generate EOI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<EoiGenerateDialog
|
<EoiGenerateDialog
|
||||||
interestId={interestId}
|
interestId={interestId}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Plus, LayoutList, Kanban } from 'lucide-react';
|
import { Plus, LayoutList, Kanban, Archive } from 'lucide-react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -69,6 +69,18 @@ export function InterestList() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bulkArchiveMutation = useMutation({
|
||||||
|
mutationFn: async (ids: string[]) => {
|
||||||
|
// Concurrent fan-out — small batches in practice (page size cap = 100).
|
||||||
|
// If a single delete fails the others still run; the rejected one
|
||||||
|
// surfaces a toast via the standard apiFetch error path.
|
||||||
|
await Promise.all(ids.map((id) => apiFetch(`/api/v1/interests/${id}`, { method: 'DELETE' })));
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns = getInterestColumns({
|
const columns = getInterestColumns({
|
||||||
portSlug,
|
portSlug,
|
||||||
onEdit: (interest) => setEditInterest(interest),
|
onEdit: (interest) => setEditInterest(interest),
|
||||||
@@ -146,6 +158,24 @@ export function InterestList() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
isLoading={isFetching && !isLoading}
|
isLoading={isFetching && !isLoading}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
|
bulkActions={[
|
||||||
|
{
|
||||||
|
label: 'Archive',
|
||||||
|
icon: Archive,
|
||||||
|
variant: 'destructive',
|
||||||
|
onClick: (ids) => {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Archive ${ids.length} interest${ids.length === 1 ? '' : 's'}? This can be undone from the archived list.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bulkArchiveMutation.mutate(ids);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
cardRender={(row) => (
|
cardRender={(row) => (
|
||||||
<InterestCard
|
<InterestCard
|
||||||
interest={row.original}
|
interest={row.original}
|
||||||
@@ -164,6 +194,20 @@ export function InterestList() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mobile FAB — primary "New interest" affordance for the bottom-tab UX.
|
||||||
|
Sits above the bottom nav (pb-safe-bottom + 70px tab height + 16px
|
||||||
|
gap). Hidden on lg+ where the header button already does the job. */}
|
||||||
|
<PermissionGate resource="interests" action="create">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
aria-label="New interest"
|
||||||
|
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 lg:hidden"
|
||||||
|
>
|
||||||
|
<Plus className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
<InterestForm open={createOpen} onOpenChange={setCreateOpen} />
|
<InterestForm open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
|
|
||||||
{editInterest && (
|
{editInterest && (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { RecommendationList } from '@/components/interests/recommendation-list';
|
|||||||
import { InterestTimeline } from '@/components/interests/interest-timeline';
|
import { InterestTimeline } from '@/components/interests/interest-timeline';
|
||||||
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
|
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
|
||||||
import { InterestFilesTab } from '@/components/interests/interest-files-tab';
|
import { InterestFilesTab } from '@/components/interests/interest-files-tab';
|
||||||
import { LEAD_CATEGORIES } from '@/lib/constants';
|
import { LEAD_CATEGORIES, PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -26,10 +26,17 @@ const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
|
|||||||
label: c.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
|
label: c.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Convert raw enum values like `waiting_for_signatures` → `Waiting For Signatures`.
|
||||||
|
function humanizeStatus(value: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
return value.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
interface InterestTabsOptions {
|
interface InterestTabsOptions {
|
||||||
interestId: string;
|
interestId: string;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
interest: {
|
interest: {
|
||||||
|
pipelineStage: string;
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
eoiStatus: string | null;
|
eoiStatus: string | null;
|
||||||
@@ -47,6 +54,15 @@ interface InterestTabsOptions {
|
|||||||
reminderDays: number | null;
|
reminderDays: number | null;
|
||||||
reminderLastFired: string | null;
|
reminderLastFired: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
/** Surfaced by getInterestById for the Overview "most recent note"
|
||||||
|
* teaser — saves a click into the Notes tab to peek at the latest. */
|
||||||
|
notesCount?: number;
|
||||||
|
recentNote?: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
authorId: string;
|
||||||
|
createdAt: string;
|
||||||
|
} | null;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -120,10 +136,23 @@ interface MilestoneSectionProps {
|
|||||||
advanceStage?: string;
|
advanceStage?: string;
|
||||||
/** Optional override for the action label. */
|
/** Optional override for the action label. */
|
||||||
actionLabel?: string;
|
actionLabel?: string;
|
||||||
|
/** Suppress the inline "Mark as…" button for this step. Use when the
|
||||||
|
* parent supplies a richer CTA via `footer` (e.g. Deposit, where we
|
||||||
|
* want the invoice flow to be the primary path). */
|
||||||
|
hideAutoButton?: boolean;
|
||||||
}>;
|
}>;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
onAdvance: (stage: string) => void;
|
onAdvance: (stage: string) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
/** Current pipelineStage. Used to mark steps as done when the pipeline has
|
||||||
|
* moved past their advanceStage even if the date stamp is missing — e.g.
|
||||||
|
* a seed-data interest that started already at eoi_signed will show both
|
||||||
|
* EOI sub-steps as done. Stage truth > date truth. */
|
||||||
|
currentStage: string;
|
||||||
|
/** When true, this milestone is the next one the user should act on:
|
||||||
|
* card gets a brand-accent ring and the next-step CTA becomes a primary
|
||||||
|
* button. Computed by the parent based on currentStage. */
|
||||||
|
isActive?: boolean;
|
||||||
/** Extra nodes (e.g. "Create deposit invoice" link) rendered below the steps. */
|
/** Extra nodes (e.g. "Create deposit invoice" link) rendered below the steps. */
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -143,27 +172,51 @@ function MilestoneSection({
|
|||||||
status,
|
status,
|
||||||
onAdvance,
|
onAdvance,
|
||||||
isPending,
|
isPending,
|
||||||
|
currentStage,
|
||||||
|
isActive,
|
||||||
footer,
|
footer,
|
||||||
}: MilestoneSectionProps) {
|
}: MilestoneSectionProps) {
|
||||||
const firstUnsetIdx = steps.findIndex((s) => !s.date);
|
const currentStageIdx = PIPELINE_STAGES.indexOf(currentStage as PipelineStage);
|
||||||
|
// A step counts as done if either:
|
||||||
|
// (a) its `advanceStage` is at or behind the current pipeline stage, OR
|
||||||
|
// (b) it has an explicit date stamp (from a manual mark or webhook).
|
||||||
|
// (a) handles seeded/imported interests that arrived at a later stage
|
||||||
|
// without per-step dates.
|
||||||
|
const doneFlags = steps.map((step) => {
|
||||||
|
if (step.date) return true;
|
||||||
|
if (!step.advanceStage) return false;
|
||||||
|
const stepIdx = PIPELINE_STAGES.indexOf(step.advanceStage as PipelineStage);
|
||||||
|
return stepIdx !== -1 && currentStageIdx !== -1 && currentStageIdx >= stepIdx;
|
||||||
|
});
|
||||||
|
const firstUnsetIdx = doneFlags.findIndex((d) => !d);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
<section
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border bg-card p-4 shadow-sm transition-colors',
|
||||||
|
isActive ? 'border-brand-300 bg-brand-50/40 ring-1 ring-brand-200' : 'border-border',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<header className="mb-3 flex items-center justify-between gap-2">
|
<header className="mb-3 flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="size-4 text-muted-foreground" />
|
<Icon className={cn('size-4', isActive ? 'text-brand-600' : 'text-muted-foreground')} />
|
||||||
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
|
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
|
||||||
|
{isActive ? (
|
||||||
|
<span className="rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-700">
|
||||||
|
Next
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{status ? (
|
{status ? (
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{status.replace(/_/g, ' ')}
|
{humanizeStatus(status)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ol className="space-y-2">
|
<ol className="space-y-2">
|
||||||
{steps.map((step, i) => {
|
{steps.map((step, i) => {
|
||||||
const done = !!step.date;
|
const done = doneFlags[i] ?? false;
|
||||||
const isNext = !done && i === firstUnsetIdx;
|
const isNext = !done && i === firstUnsetIdx;
|
||||||
return (
|
return (
|
||||||
<li key={step.label} className="flex items-start gap-2 text-sm">
|
<li key={step.label} className="flex items-start gap-2 text-sm">
|
||||||
@@ -197,10 +250,10 @@ function MilestoneSection({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{isNext && step.advanceStage ? (
|
{isNext && step.advanceStage && !step.hideAutoButton ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant={isActive ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => onAdvance(step.advanceStage!)}
|
onClick={() => onAdvance(step.advanceStage!)}
|
||||||
@@ -236,6 +289,23 @@ function OverviewTab({
|
|||||||
const advance = (stage: string) =>
|
const advance = (stage: string) =>
|
||||||
stageMutation.mutate({ stage, reason: 'Marked from overview' });
|
stageMutation.mutate({ stage, reason: 'Marked from overview' });
|
||||||
|
|
||||||
|
// Which milestone is the next one to act on? "EOI Signed" → Deposit is next;
|
||||||
|
// "Deposit 10%" → Contract is next; "Contract Signed" / "Completed" → none.
|
||||||
|
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
|
||||||
|
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
|
||||||
|
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
|
||||||
|
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
|
||||||
|
let activeMilestone: 'eoi' | 'deposit' | 'contract' | null = null;
|
||||||
|
if (stageIdx === -1 || stageIdx >= contractSignedIdx) {
|
||||||
|
activeMilestone = null;
|
||||||
|
} else if (stageIdx < eoiSignedIdx) {
|
||||||
|
activeMilestone = 'eoi';
|
||||||
|
} else if (stageIdx < depositIdx) {
|
||||||
|
activeMilestone = 'deposit';
|
||||||
|
} else {
|
||||||
|
activeMilestone = 'contract';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Sales-process milestones — the heart of the system. Each section is a
|
{/* Sales-process milestones — the heart of the system. Each section is a
|
||||||
@@ -250,6 +320,8 @@ function OverviewTab({
|
|||||||
status={interest.eoiStatus}
|
status={interest.eoiStatus}
|
||||||
isPending={stageMutation.isPending}
|
isPending={stageMutation.isPending}
|
||||||
onAdvance={advance}
|
onAdvance={advance}
|
||||||
|
currentStage={interest.pipelineStage}
|
||||||
|
isActive={activeMilestone === 'eoi'}
|
||||||
steps={[
|
steps={[
|
||||||
{
|
{
|
||||||
label: 'EOI sent',
|
label: 'EOI sent',
|
||||||
@@ -271,23 +343,36 @@ function OverviewTab({
|
|||||||
status={interest.depositStatus}
|
status={interest.depositStatus}
|
||||||
isPending={stageMutation.isPending}
|
isPending={stageMutation.isPending}
|
||||||
onAdvance={advance}
|
onAdvance={advance}
|
||||||
|
currentStage={interest.pipelineStage}
|
||||||
|
isActive={activeMilestone === 'deposit'}
|
||||||
steps={[
|
steps={[
|
||||||
{
|
{
|
||||||
label: 'Deposit received',
|
label: 'Deposit received',
|
||||||
date: interest.dateDepositReceived,
|
date: interest.dateDepositReceived,
|
||||||
advanceStage: 'deposit_10pct',
|
advanceStage: 'deposit_10pct',
|
||||||
actionLabel: 'Mark deposit received',
|
// The richer invoice-first CTA lives in `footer`. We still pass
|
||||||
|
// advanceStage so the milestone derives its done-state correctly.
|
||||||
|
hideAutoButton: true,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
footer={
|
footer={
|
||||||
!interest.dateDepositReceived ? (
|
!interest.dateDepositReceived ? (
|
||||||
<Link
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}
|
<Button asChild size="sm" className="h-7 px-2.5 text-xs">
|
||||||
className="inline-flex items-center gap-1.5 text-foreground/80 hover:text-foreground"
|
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
|
||||||
>
|
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
Create deposit invoice
|
Create deposit invoice
|
||||||
</Link>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => advance('deposit_10pct')}
|
||||||
|
disabled={stageMutation.isPending}
|
||||||
|
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Mark received manually
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -297,6 +382,8 @@ function OverviewTab({
|
|||||||
status={interest.contractStatus}
|
status={interest.contractStatus}
|
||||||
isPending={stageMutation.isPending}
|
isPending={stageMutation.isPending}
|
||||||
onAdvance={advance}
|
onAdvance={advance}
|
||||||
|
currentStage={interest.pipelineStage}
|
||||||
|
isActive={activeMilestone === 'contract'}
|
||||||
steps={[
|
steps={[
|
||||||
{
|
{
|
||||||
label: 'Contract sent',
|
label: 'Contract sent',
|
||||||
@@ -359,6 +446,37 @@ function OverviewTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Most-recent threaded note teaser. Saves a click into the Notes
|
||||||
|
tab when the rep just wants to peek at "what was discussed last."
|
||||||
|
Hidden when there's nothing to show. */}
|
||||||
|
{interest.recentNote ? (
|
||||||
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium">Latest note</h3>
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/${interestId}?tab=notes`}
|
||||||
|
className="text-xs font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
{interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||||
|
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
|
||||||
|
{interest.recentNote.content}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
{interest.recentNote.authorId
|
||||||
|
? ` · ${interest.recentNote.authorId === 'system' ? 'system' : interest.recentNote.authorId}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Notes (editable, multiline) */}
|
{/* Notes (editable, multiline) */}
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface TimelineEvent {
|
|||||||
action: string;
|
action: string;
|
||||||
description: string;
|
description: string;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
|
/** Resolved display name (server-side join). Falls back to userId when null. */
|
||||||
|
userName?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -56,10 +58,14 @@ function eventIcon(event: TimelineEvent) {
|
|||||||
return <Pencil className="h-4 w-4 text-muted-foreground" />;
|
return <Pencil className="h-4 w-4 text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function actorLabel(userId: string | null): string | null {
|
function actorLabel(event: TimelineEvent): string | null {
|
||||||
if (!userId) return null;
|
if (event.userName) return event.userName;
|
||||||
if (userId === 'system') return 'system';
|
if (!event.userId) return null;
|
||||||
return userId;
|
if (event.userId === 'system') return 'system';
|
||||||
|
// Last-resort fallback when the user row was deleted: show a short token
|
||||||
|
// instead of a 36-char UUID. The server-side join is authoritative; this
|
||||||
|
// path should be rare in practice.
|
||||||
|
return 'a teammate';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
||||||
@@ -100,7 +106,7 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
|||||||
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
|
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
|
||||||
|
|
||||||
{events.map((event) => {
|
{events.map((event) => {
|
||||||
const actor = actorLabel(event.userId);
|
const actor = actorLabel(event);
|
||||||
const isAuto = event.userId === 'system';
|
const isAuto = event.userId === 'system';
|
||||||
return (
|
return (
|
||||||
<div key={event.id} className="relative flex gap-4 pb-6">
|
<div key={event.id} className="relative flex gap-4 pb-6">
|
||||||
|
|||||||
91
src/components/interests/urgency.ts
Normal file
91
src/components/interests/urgency.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Sales-triage urgency badges for interest list rows + cards.
|
||||||
|
*
|
||||||
|
* Derived purely from the dates we already return on the row, so this is a
|
||||||
|
* pure function — no DB hits, no extra fetch. Mirrors the logic the
|
||||||
|
* server-side alert-rules engine uses, but for at-a-glance rendering on
|
||||||
|
* the list itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SILENT_DAYS_THRESHOLD = 7;
|
||||||
|
const EOI_AWAITING_DAYS_THRESHOLD = 14;
|
||||||
|
const DEPOSIT_PENDING_DAYS_THRESHOLD = 21;
|
||||||
|
|
||||||
|
const ACTIVE_MID_FUNNEL_STAGES = new Set(['details_sent', 'in_communication']);
|
||||||
|
|
||||||
|
export interface InterestUrgencyInput {
|
||||||
|
pipelineStage: string;
|
||||||
|
outcome?: string | null;
|
||||||
|
archivedAt?: string | null;
|
||||||
|
dateLastContact?: string | null;
|
||||||
|
updatedAt?: string;
|
||||||
|
dateEoiSent?: string | null;
|
||||||
|
eoiStatus?: string | null;
|
||||||
|
dateDepositReceived?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UrgencyBadge {
|
||||||
|
/** Stable id for keying. */
|
||||||
|
id: 'silent' | 'eoi_awaiting' | 'deposit_pending';
|
||||||
|
label: string;
|
||||||
|
/** Long form for tooltip / aria-label. */
|
||||||
|
detail: string;
|
||||||
|
/** Tailwind classes for the pill. */
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysSince(iso: string | null | undefined): number | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(t)) return null;
|
||||||
|
return Math.floor((Date.now() - t) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeUrgencyBadges(row: InterestUrgencyInput): UrgencyBadge[] {
|
||||||
|
// Closed / archived interests don't need triage signals.
|
||||||
|
if (row.archivedAt || row.outcome) return [];
|
||||||
|
|
||||||
|
const badges: UrgencyBadge[] = [];
|
||||||
|
|
||||||
|
// Silent in mid-funnel stages — most actionable.
|
||||||
|
if (ACTIVE_MID_FUNNEL_STAGES.has(row.pipelineStage)) {
|
||||||
|
const lastTouchIso = row.dateLastContact ?? row.updatedAt ?? null;
|
||||||
|
const days = daysSince(lastTouchIso);
|
||||||
|
if (days !== null && days >= SILENT_DAYS_THRESHOLD) {
|
||||||
|
badges.push({
|
||||||
|
id: 'silent',
|
||||||
|
label: `Silent ${days}d`,
|
||||||
|
detail: `No contact in ${days} days`,
|
||||||
|
className: 'bg-amber-100 text-amber-800 border border-amber-200',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EOI sent but not signed for too long.
|
||||||
|
if (row.eoiStatus === 'waiting_for_signatures') {
|
||||||
|
const days = daysSince(row.dateEoiSent);
|
||||||
|
if (days !== null && days >= EOI_AWAITING_DAYS_THRESHOLD) {
|
||||||
|
badges.push({
|
||||||
|
id: 'eoi_awaiting',
|
||||||
|
label: `EOI ${days}d`,
|
||||||
|
detail: `EOI awaiting signature for ${days} days`,
|
||||||
|
className: 'bg-orange-100 text-orange-800 border border-orange-200',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EOI signed but deposit not received.
|
||||||
|
if (row.pipelineStage === 'eoi_signed' && !row.dateDepositReceived && row.dateEoiSent) {
|
||||||
|
const days = daysSince(row.dateEoiSent);
|
||||||
|
if (days !== null && days >= DEPOSIT_PENDING_DAYS_THRESHOLD) {
|
||||||
|
badges.push({
|
||||||
|
id: 'deposit_pending',
|
||||||
|
label: `Deposit ${days}d`,
|
||||||
|
detail: `Awaiting deposit for ${days} days since EOI sent`,
|
||||||
|
className: 'bg-rose-100 text-rose-800 border border-rose-200',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
@@ -6,12 +6,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { Loader2, Send, CreditCard } from 'lucide-react';
|
import { Loader2, Send, CreditCard } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { InvoicePdfPreview } from './invoice-pdf-preview';
|
import { InvoicePdfPreview } from './invoice-pdf-preview';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
@@ -26,6 +34,40 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
cancelled: 'bg-gray-100 text-gray-500 border-gray-200',
|
cancelled: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Display labels for snake_case enum values stored in the DB.
|
||||||
|
const PAYMENT_METHOD_LABELS: Record<string, string> = {
|
||||||
|
bank_transfer: 'Bank transfer',
|
||||||
|
credit_card: 'Credit card',
|
||||||
|
cash: 'Cash',
|
||||||
|
cheque: 'Cheque',
|
||||||
|
check: 'Check',
|
||||||
|
wire: 'Wire',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYMENT_METHOD_OPTIONS: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: 'bank_transfer', label: 'Bank transfer' },
|
||||||
|
{ value: 'credit_card', label: 'Credit card' },
|
||||||
|
{ value: 'cash', label: 'Cash' },
|
||||||
|
{ value: 'cheque', label: 'Cheque' },
|
||||||
|
{ value: 'wire', label: 'Wire' },
|
||||||
|
{ value: 'other', label: 'Other' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatPaymentMethod(method: string | null | undefined): string {
|
||||||
|
if (!method) return '—';
|
||||||
|
return PAYMENT_METHOD_LABELS[method] ?? method.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateOnly(value: string | null | undefined): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
// Stored values are typically YYYY-MM-DD or ISO. Treat as date-only to avoid TZ shift.
|
||||||
|
const isoDate = value.length === 10 ? value + 'T00:00:00' : value;
|
||||||
|
const d = new Date(isoDate);
|
||||||
|
if (Number.isNaN(d.getTime())) return value;
|
||||||
|
return format(d, 'MMM d, yyyy');
|
||||||
|
}
|
||||||
|
|
||||||
interface InvoiceDetailProps {
|
interface InvoiceDetailProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
}
|
}
|
||||||
@@ -155,7 +197,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
<CardTitle className="text-sm font-medium">Due Date</CardTitle>
|
<CardTitle className="text-sm font-medium">Due Date</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm">{invoice.dueDate}</p>
|
<p className="text-sm">{formatDateOnly(invoice.dueDate)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -291,11 +333,11 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Payment Date</span>
|
<span className="text-muted-foreground">Payment Date</span>
|
||||||
<p className="mt-0.5">{invoice.paymentDate ?? '—'}</p>
|
<p className="mt-0.5">{formatDateOnly(invoice.paymentDate)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Method</span>
|
<span className="text-muted-foreground">Method</span>
|
||||||
<p className="mt-0.5 capitalize">{invoice.paymentMethod ?? '—'}</p>
|
<p className="mt-0.5">{formatPaymentMethod(invoice.paymentMethod)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Reference</span>
|
<span className="text-muted-foreground">Reference</span>
|
||||||
@@ -325,11 +367,23 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="paymentMethod">Payment Method</Label>
|
<Label htmlFor="paymentMethod">Payment Method</Label>
|
||||||
<Input
|
<Select
|
||||||
id="paymentMethod"
|
value={paymentForm.watch('paymentMethod') ?? ''}
|
||||||
placeholder="e.g. bank_transfer, credit_card"
|
onValueChange={(v) =>
|
||||||
{...paymentForm.register('paymentMethod')}
|
paymentForm.setValue('paymentMethod', v, { shouldValidate: true })
|
||||||
/>
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="paymentMethod">
|
||||||
|
<SelectValue placeholder="Select a method" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PAYMENT_METHOD_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="paymentReference">Reference / Transaction ID</Label>
|
<Label htmlFor="paymentReference">Reference / Transaction ID</Label>
|
||||||
|
|||||||
54
src/components/reservations/berth-reservations-list.tsx
Normal file
54
src/components/reservations/berth-reservations-list.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
|
||||||
|
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface ReservationsApiResponse {
|
||||||
|
data: ReservationRow[];
|
||||||
|
pagination: { total: number; page: number; pageSize: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BerthReservationsList() {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ReservationsApiResponse>({
|
||||||
|
queryKey: ['berth-reservations', 'list'],
|
||||||
|
queryFn: () => apiFetch('/api/v1/berth-reservations?page=1&limit=100&order=desc'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Marina"
|
||||||
|
title="Berth Reservations"
|
||||||
|
description="All reservations across all berths"
|
||||||
|
actions={
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/berths`}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
View berths
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : (
|
||||||
|
<ReservationList
|
||||||
|
reservations={data?.data ?? []}
|
||||||
|
showBerth
|
||||||
|
portSlug={portSlug}
|
||||||
|
emptyMessage="No reservations found."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ArrowLeft, Bell, Download, FileSignature, Mail } from 'lucide-react';
|
import { ArrowLeft, Bell, Download, FileSignature, Mail, StopCircle } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||||
import { EmptyState } from '@/components/ui/empty-state';
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { ClientLink, YachtLink, BerthLink } from '@/components/reservations/reservation-list';
|
||||||
|
|
||||||
interface ReservationDoc {
|
interface ReservationDoc {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,12 +53,77 @@ const RESERVATION_PILL: Record<string, StatusPillStatus> = {
|
|||||||
cancelled: 'cancelled',
|
cancelled: 'cancelled',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EndReservationDialogProps {
|
||||||
|
reservationId: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EndReservationDialog({ reservationId, open, onOpenChange }: EndReservationDialogProps) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [endDate, setEndDate] = useState(todayIso);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/v1/berth-reservations/${reservationId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { action: 'end', endDate },
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ['reservation', reservationId] });
|
||||||
|
toast.success('Reservation ended');
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to end reservation');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>End reservation</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="end-date">End date</Label>
|
||||||
|
<Input
|
||||||
|
id="end-date"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="destructive" disabled={submitting}>
|
||||||
|
{submitting ? 'Ending…' : 'End reservation'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ReservationDetailProps {
|
interface ReservationDetailProps {
|
||||||
reservationId: string;
|
reservationId: string;
|
||||||
portSlug: string;
|
portSlug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) {
|
export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) {
|
||||||
|
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||||
const reservation = useQuery<{ data: ReservationData }>({
|
const reservation = useQuery<{ data: ReservationData }>({
|
||||||
queryKey: ['reservation', reservationId],
|
queryKey: ['reservation', reservationId],
|
||||||
queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`),
|
queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`),
|
||||||
@@ -215,11 +291,19 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{res.status === 'active' && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
|
||||||
|
<StopCircle className="mr-1.5 h-4 w-4" />
|
||||||
|
End reservation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={`/${portSlug}/berths`}>
|
<Link href={`/${portSlug}/berths`}>
|
||||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
|
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
/>
|
/>
|
||||||
@@ -233,35 +317,20 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
|
|||||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-muted-foreground">Berth</dt>
|
<dt className="text-xs text-muted-foreground">Berth</dt>
|
||||||
<dd>
|
<dd className="font-medium">
|
||||||
<Link
|
<BerthLink berthId={res.berthId} portSlug={portSlug} />
|
||||||
href={`/${portSlug}/berths/${res.berthId}` as Route}
|
|
||||||
className="font-medium text-brand hover:underline"
|
|
||||||
>
|
|
||||||
{res.berthId.slice(0, 8)}…
|
|
||||||
</Link>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-muted-foreground">Yacht</dt>
|
<dt className="text-xs text-muted-foreground">Yacht</dt>
|
||||||
<dd>
|
<dd className="font-medium">
|
||||||
<Link
|
<YachtLink yachtId={res.yachtId} portSlug={portSlug} />
|
||||||
href={`/${portSlug}/yachts/${res.yachtId}` as Route}
|
|
||||||
className="font-medium text-brand hover:underline"
|
|
||||||
>
|
|
||||||
{res.yachtId.slice(0, 8)}…
|
|
||||||
</Link>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs text-muted-foreground">Client</dt>
|
<dt className="text-xs text-muted-foreground">Client</dt>
|
||||||
<dd>
|
<dd className="font-medium">
|
||||||
<Link
|
<ClientLink clientId={res.clientId} portSlug={portSlug} />
|
||||||
href={`/${portSlug}/clients/${res.clientId}` as Route}
|
|
||||||
className="font-medium text-brand hover:underline"
|
|
||||||
>
|
|
||||||
{res.clientId.slice(0, 8)}…
|
|
||||||
</Link>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -287,6 +356,12 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EndReservationDialog
|
||||||
|
reservationId={reservationId}
|
||||||
|
open={endDialogOpen}
|
||||||
|
onOpenChange={setEndDialogOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export interface ReservationListProps {
|
|||||||
* Renders a client's name as a link by fetching the client record.
|
* Renders a client's name as a link by fetching the client record.
|
||||||
* Uses TanStack Query cache for memoization of repeated clientId queries.
|
* Uses TanStack Query cache for memoization of repeated clientId queries.
|
||||||
*/
|
*/
|
||||||
function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
export function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||||
const { data } = useQuery<{ fullName: string }>({
|
const { data } = useQuery<{ fullName: string }>({
|
||||||
queryKey: ['clients', clientId, 'name-only'],
|
queryKey: ['clients', clientId, 'name-only'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -62,7 +62,7 @@ function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string
|
|||||||
/**
|
/**
|
||||||
* Renders a yacht's name as a link by fetching the yacht record.
|
* Renders a yacht's name as a link by fetching the yacht record.
|
||||||
*/
|
*/
|
||||||
function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
|
export function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
|
||||||
const { data } = useQuery<{ name: string }>({
|
const { data } = useQuery<{ name: string }>({
|
||||||
queryKey: ['yachts', yachtId, 'name-only'],
|
queryKey: ['yachts', yachtId, 'name-only'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -83,7 +83,7 @@ function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string })
|
|||||||
/**
|
/**
|
||||||
* Renders a berth's mooring number as a link by fetching the berth record.
|
* Renders a berth's mooring number as a link by fetching the berth record.
|
||||||
*/
|
*/
|
||||||
function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
|
export function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
|
||||||
const { data } = useQuery<{ mooringNumber: string }>({
|
const { data } = useQuery<{ mooringNumber: string }>({
|
||||||
queryKey: ['berths', berthId, 'name-only'],
|
queryKey: ['berths', berthId, 'name-only'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function DetailHeaderStrip({ children, className }: DetailHeaderStripProp
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border border-border bg-gradient-brand-soft px-5 py-4 shadow-xs',
|
'rounded-xl border border-border bg-gradient-brand-soft px-5 py-3 shadow-xs',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -262,7 +262,9 @@ function ReadButton({
|
|||||||
{!disabled && (
|
{!disabled && (
|
||||||
<Pencil
|
<Pencil
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50',
|
// Show the pencil faintly at rest so users discover the field is
|
||||||
|
// editable without having to hover-and-test every label.
|
||||||
|
'h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60',
|
||||||
multiline && 'mt-1 shrink-0',
|
multiline && 'mt-1 shrink-0',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
84
src/components/shared/realtime-toasts.tsx
Normal file
84
src/components/shared/realtime-toasts.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { useSocket } from '@/providers/socket-provider';
|
||||||
|
import { stageLabel } from '@/lib/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-wide subscriber that surfaces high-signal sales events as sonner
|
||||||
|
* toasts. Mounted once inside SocketProvider so reps see "EOI signed",
|
||||||
|
* "Deposit recorded", "Stage advanced" without having to refresh.
|
||||||
|
*
|
||||||
|
* Render-only — no children. Intentionally narrow in scope: only toast on
|
||||||
|
* events that are noteworthy *to a user staring at any page*. Per-page
|
||||||
|
* cache invalidations stay in `useRealtimeInvalidation`.
|
||||||
|
*/
|
||||||
|
export function RealtimeToasts() {
|
||||||
|
const socket = useSocket();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
function onStageChanged(payload: {
|
||||||
|
newStage?: string;
|
||||||
|
oldStage?: string | null;
|
||||||
|
clientName?: string;
|
||||||
|
}) {
|
||||||
|
if (!payload?.newStage) return;
|
||||||
|
const who = payload.clientName?.trim() || 'an interest';
|
||||||
|
toast.success(`${who} → ${stageLabel(payload.newStage)}`, {
|
||||||
|
description: payload.oldStage
|
||||||
|
? `Advanced from ${stageLabel(payload.oldStage)}.`
|
||||||
|
: 'Pipeline stage advanced.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentCompleted(payload: { type?: string }) {
|
||||||
|
// Kick a generic "fully signed" — the type-specific message is
|
||||||
|
// friendlier when we can identify it as an EOI.
|
||||||
|
if (payload?.type === 'eoi') {
|
||||||
|
toast.success('EOI fully signed', {
|
||||||
|
description: 'All required signatures received.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success('Document fully signed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSignerSigned(payload: { signerName?: string; documentTitle?: string }) {
|
||||||
|
const who = payload?.signerName?.trim();
|
||||||
|
const title = payload?.documentTitle?.trim();
|
||||||
|
if (who && title) {
|
||||||
|
toast.message(`${who} signed "${title}"`);
|
||||||
|
} else if (who) {
|
||||||
|
toast.message(`${who} signed a document`);
|
||||||
|
} else {
|
||||||
|
toast.message('Signer added a signature');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOutcomeSet(payload: { outcome?: string }) {
|
||||||
|
if (!payload?.outcome) return;
|
||||||
|
const isWon = payload.outcome === 'won';
|
||||||
|
const label = payload.outcome.replace(/_/g, ' ');
|
||||||
|
const fn = isWon ? toast.success : toast.message;
|
||||||
|
fn(`Interest closed — ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('interest:stageChanged', onStageChanged);
|
||||||
|
socket.on('document:completed', onDocumentCompleted);
|
||||||
|
socket.on('document:signer:signed', onSignerSigned);
|
||||||
|
socket.on('interest:outcomeSet', onOutcomeSet);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('interest:stageChanged', onStageChanged);
|
||||||
|
socket.off('document:completed', onDocumentCompleted);
|
||||||
|
socket.off('document:signer:signed', onSignerSigned);
|
||||||
|
socket.off('interest:outcomeSet', onOutcomeSet);
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -38,7 +38,15 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 z-50 grid w-full gap-4 border-0 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:left-[50%] sm:top-[50%] sm:inset-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%]',
|
// Mobile: full-screen sheet anchored to all four sides via individual
|
||||||
|
// top/right/bottom/left utilities. Desktop (sm+): override each side
|
||||||
|
// individually so tailwind-merge doesn't collapse our centering classes.
|
||||||
|
// (Don't use `inset-0` + `sm:inset-auto` here — twMerge sees that as a
|
||||||
|
// conflict and silently strips `sm:left-[50%]` / `sm:top-[50%]`.)
|
||||||
|
'fixed top-0 right-0 bottom-0 left-0 z-50 grid w-full gap-4 border-0 bg-background p-6 shadow-lg duration-200',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg',
|
||||||
|
'sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
|
||||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
|
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
|
||||||
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
|
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
@@ -206,6 +207,70 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function YachtInterestsTab({ yachtId }: { yachtId: string }) {
|
||||||
|
const { data, isLoading } = useQuery<{
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
clientName: string | null;
|
||||||
|
berthMooringNumber: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}>;
|
||||||
|
}>({
|
||||||
|
queryKey: ['interests', 'by-yacht', yachtId],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/interests?yachtId=${yachtId}&limit=50&order=desc`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const interests = data?.data ?? [];
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||||
|
if (interests.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No interests linked to this yacht.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{interests.map((i) => (
|
||||||
|
<li
|
||||||
|
key={i.id}
|
||||||
|
className="flex items-center gap-3 rounded-md border bg-muted/30 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<span className="w-36 shrink-0 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
{i.pipelineStage.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{i.clientName ?? '—'}</span>
|
||||||
|
{i.berthMooringNumber && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
Berth {i.berthMooringNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function YachtReservationsTab({ yachtId }: { yachtId: string }) {
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ data: ReservationRow[] }>({
|
||||||
|
queryKey: ['berth-reservations', 'by-yacht', yachtId],
|
||||||
|
queryFn: () => apiFetch(`/api/v1/berth-reservations?yachtId=${yachtId}&limit=50&order=desc`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReservationList
|
||||||
|
reservations={data?.data ?? []}
|
||||||
|
showBerth
|
||||||
|
portSlug={portSlug}
|
||||||
|
emptyMessage="No reservations for this yacht."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
|
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -221,12 +286,12 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
|
|||||||
{
|
{
|
||||||
id: 'interests',
|
id: 'interests',
|
||||||
label: 'Interests',
|
label: 'Interests',
|
||||||
content: <EmptyState title="Interests" description="Coming soon" />,
|
content: <YachtInterestsTab yachtId={yachtId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'reservations',
|
id: 'reservations',
|
||||||
label: 'Reservations',
|
label: 'Reservations',
|
||||||
content: <EmptyState title="Reservations" description="Coming soon" />,
|
content: <YachtReservationsTab yachtId={yachtId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
|
|||||||
52
src/hooks/realtime-invalidation-core.ts
Normal file
52
src/hooks/realtime-invalidation-core.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
/** Minimum surface of socket.io's client we use here. Kept loose so the
|
||||||
|
* helper can be unit-tested with a stub object without dragging the full
|
||||||
|
* socket.io dependency into the test runtime. */
|
||||||
|
export interface SocketLike {
|
||||||
|
on(event: string, handler: (...args: unknown[]) => void): unknown;
|
||||||
|
off(event: string, handler: (...args: unknown[]) => void): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventMap = Record<string, QueryKey[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure subscription logic for `useRealtimeInvalidation`. Registers one
|
||||||
|
* handler per event key. Each handler reads the latest eventMap from the
|
||||||
|
* supplied getter so callers can pass a fresh object literal on every render
|
||||||
|
* without re-subscribing.
|
||||||
|
*
|
||||||
|
* Returns a cleanup function that removes the registered handlers.
|
||||||
|
*
|
||||||
|
* Lives in its own JSX-free file so it can be unit-tested under vitest's
|
||||||
|
* node environment without dragging the React provider into the bundle.
|
||||||
|
*/
|
||||||
|
export function subscribeRealtimeInvalidations(
|
||||||
|
socket: SocketLike,
|
||||||
|
eventKeys: string[],
|
||||||
|
queryClient: Pick<QueryClient, 'invalidateQueries'>,
|
||||||
|
getEventMap: () => EventMap,
|
||||||
|
): () => void {
|
||||||
|
const handlers: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];
|
||||||
|
|
||||||
|
for (const event of eventKeys) {
|
||||||
|
const handler = () => {
|
||||||
|
// Read the LATEST map at fire-time — not at subscription time — so
|
||||||
|
// callers passing inline `{ 'client:created': [...] }` literals don't
|
||||||
|
// bind a stale snapshot if they re-render.
|
||||||
|
const queryKeys = getEventMap()[event];
|
||||||
|
if (!queryKeys) return;
|
||||||
|
for (const key of queryKeys) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: key });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.on(event, handler);
|
||||||
|
handlers.push({ event, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const { event, handler } of handlers) {
|
||||||
|
socket.off(event, handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useSocket } from '@/providers/socket-provider';
|
import { useSocket } from '@/providers/socket-provider';
|
||||||
|
import { subscribeRealtimeInvalidations, type EventMap } from '@/hooks/realtime-invalidation-core';
|
||||||
|
|
||||||
|
// Re-export for convenience so callers don't need to know about the split.
|
||||||
|
export type { EventMap, SocketLike } from '@/hooks/realtime-invalidation-core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes to socket events and invalidates React Query caches.
|
* Subscribes to socket events and invalidates React Query caches.
|
||||||
*
|
*
|
||||||
* @param eventMap - Maps socket event names to arrays of query keys to invalidate.
|
* Safe to call with an inline-literal `eventMap` — the hook only re-subscribes
|
||||||
|
* when the SET of event keys actually changes (not when the object identity
|
||||||
|
* changes). The latest query-key list is read at event fire-time via a ref.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* useRealtimeInvalidation({
|
* useRealtimeInvalidation({
|
||||||
@@ -17,31 +23,29 @@ import { useSocket } from '@/providers/socket-provider';
|
|||||||
* 'client:archived': [['clients']],
|
* 'client:archived': [['clients']],
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
export function useRealtimeInvalidation(
|
export function useRealtimeInvalidation(eventMap: EventMap) {
|
||||||
eventMap: Record<string, QueryKey[]>,
|
|
||||||
) {
|
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Stash the latest map in a ref so handlers always see fresh queryKeys
|
||||||
|
// without re-subscribing.
|
||||||
|
const eventMapRef = useRef(eventMap);
|
||||||
|
eventMapRef.current = eventMap;
|
||||||
|
|
||||||
|
// Re-subscribe ONLY when the set of event names changes. Object identity
|
||||||
|
// of `eventMap` flips on every caller render; the joined key signature
|
||||||
|
// doesn't.
|
||||||
|
const eventKeysSig = Object.keys(eventMap).sort().join('|');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
|
// eventMapRef is intentionally not in deps — it's a ref; we only want to
|
||||||
const handlers: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];
|
// re-run when the socket, queryClient, or the event-key SET changes.
|
||||||
|
return subscribeRealtimeInvalidations(
|
||||||
for (const [event, queryKeys] of Object.entries(eventMap)) {
|
socket,
|
||||||
const handler = () => {
|
eventKeysSig.length > 0 ? eventKeysSig.split('|') : [],
|
||||||
for (const key of queryKeys) {
|
queryClient,
|
||||||
queryClient.invalidateQueries({ queryKey: key });
|
() => eventMapRef.current,
|
||||||
}
|
);
|
||||||
};
|
}, [socket, queryClient, eventKeysSig]);
|
||||||
socket.on(event, handler);
|
|
||||||
handlers.push({ event, handler });
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
for (const { event, handler } of handlers) {
|
|
||||||
socket.off(event, handler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [socket, queryClient, eventMap]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,10 +181,15 @@ export function withAuth(
|
|||||||
}
|
}
|
||||||
} else if (profile.isSuperAdmin && portId) {
|
} else if (profile.isSuperAdmin && portId) {
|
||||||
// Super admin still needs portSlug for response context.
|
// Super admin still needs portSlug for response context.
|
||||||
|
// We also validate the portId actually exists — a super-admin session
|
||||||
|
// must not be able to operate against a fabricated portId.
|
||||||
const port = await db.query.ports.findFirst({
|
const port = await db.query.ports.findFirst({
|
||||||
where: eq(ports.id, portId),
|
where: eq(ports.id, portId),
|
||||||
});
|
});
|
||||||
portSlug = port?.slug ?? '';
|
if (!port) {
|
||||||
|
return NextResponse.json({ error: 'Port not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
portSlug = port.slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx: AuthContext = {
|
const ctx: AuthContext = {
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ export const STAGE_LABELS: Record<PipelineStage, string> = {
|
|||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Compact labels for cramped contexts (mobile chart axes, dense tables).
|
||||||
|
export const STAGE_SHORT_LABELS: Record<PipelineStage, string> = {
|
||||||
|
open: 'Open',
|
||||||
|
details_sent: 'Details',
|
||||||
|
in_communication: 'Comms',
|
||||||
|
eoi_sent: 'EOI →',
|
||||||
|
eoi_signed: 'EOI ✓',
|
||||||
|
deposit_10pct: 'Dep.',
|
||||||
|
contract_sent: 'Ctr →',
|
||||||
|
contract_signed: 'Ctr ✓',
|
||||||
|
completed: 'Done',
|
||||||
|
};
|
||||||
|
|
||||||
export const STAGE_BADGE: Record<PipelineStage, string> = {
|
export const STAGE_BADGE: Record<PipelineStage, string> = {
|
||||||
open: 'bg-slate-100 text-slate-700',
|
open: 'bg-slate-100 text-slate-700',
|
||||||
details_sent: 'bg-blue-100 text-blue-700',
|
details_sent: 'bg-blue-100 text-blue-700',
|
||||||
|
|||||||
30
src/lib/db/migrations/0021_unusual_azazel.sql
Normal file
30
src/lib/db/migrations/0021_unusual_azazel.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE "client_merge_candidates" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"port_id" text NOT NULL,
|
||||||
|
"client_a_id" text NOT NULL,
|
||||||
|
"client_b_id" text NOT NULL,
|
||||||
|
"score" integer NOT NULL,
|
||||||
|
"reasons" jsonb NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"resolved_at" timestamp with time zone,
|
||||||
|
"resolved_by" text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "migration_source_links" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"source_system" text NOT NULL,
|
||||||
|
"source_id" text NOT NULL,
|
||||||
|
"target_entity_type" text NOT NULL,
|
||||||
|
"target_entity_id" text NOT NULL,
|
||||||
|
"applied_id" text NOT NULL,
|
||||||
|
"applied_by" text,
|
||||||
|
"applied_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "client_merge_candidates" ADD CONSTRAINT "client_merge_candidates_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "client_merge_candidates" ADD CONSTRAINT "client_merge_candidates_client_a_id_clients_id_fk" FOREIGN KEY ("client_a_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "client_merge_candidates" ADD CONSTRAINT "client_merge_candidates_client_b_id_clients_id_fk" FOREIGN KEY ("client_b_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_cmc_port_status" ON "client_merge_candidates" USING btree ("port_id","status");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_cmc_pair" ON "client_merge_candidates" USING btree ("port_id","client_a_id","client_b_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_msl_source_target" ON "migration_source_links" USING btree ("source_system","source_id","target_entity_type");
|
||||||
2
src/lib/db/migrations/0022_magenta_madame_hydra.sql
Normal file
2
src/lib/db/migrations/0022_magenta_madame_hydra.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "clients" ADD COLUMN "merged_into_client_id" text;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_clients_merged_into" ON "clients" USING btree ("merged_into_client_id");
|
||||||
10482
src/lib/db/migrations/meta/0021_snapshot.json
Normal file
10482
src/lib/db/migrations/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
10503
src/lib/db/migrations/meta/0022_snapshot.json
Normal file
10503
src/lib/db/migrations/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,20 @@
|
|||||||
"when": 1777671562738,
|
"when": 1777671562738,
|
||||||
"tag": "0019_lazy_vampiro",
|
"tag": "0019_lazy_vampiro",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777811835982,
|
||||||
|
"tag": "0021_unusual_azazel",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777812671833,
|
||||||
|
"tag": "0022_magenta_madame_hydra",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
boolean,
|
boolean,
|
||||||
|
integer,
|
||||||
timestamp,
|
timestamp,
|
||||||
jsonb,
|
jsonb,
|
||||||
index,
|
index,
|
||||||
@@ -30,6 +31,11 @@ export const clients = pgTable(
|
|||||||
source: text('source'), // website, manual, referral, broker
|
source: text('source'), // website, manual, referral, broker
|
||||||
sourceDetails: text('source_details'),
|
sourceDetails: text('source_details'),
|
||||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||||
|
/** When this client was merged into another (the "loser" of a dedup
|
||||||
|
* merge), this points at the surviving client. Used by the
|
||||||
|
* /admin/duplicates review queue to redirect any stragglers, and by
|
||||||
|
* the unmerge flow to restore. Null for live clients. */
|
||||||
|
mergedIntoClientId: text('merged_into_client_id'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
@@ -38,6 +44,7 @@ export const clients = pgTable(
|
|||||||
index('idx_clients_name').on(table.portId, table.fullName),
|
index('idx_clients_name').on(table.portId, table.fullName),
|
||||||
index('idx_clients_archived').on(table.portId, table.archivedAt),
|
index('idx_clients_archived').on(table.portId, table.archivedAt),
|
||||||
index('idx_clients_nationality_iso').on(table.nationalityIso),
|
index('idx_clients_nationality_iso').on(table.nationalityIso),
|
||||||
|
index('idx_clients_merged_into').on(table.mergedIntoClientId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -145,6 +152,54 @@ export const clientMergeLog = pgTable(
|
|||||||
(table) => [index('idx_cml_port').on(table.portId)],
|
(table) => [index('idx_cml_port').on(table.portId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairs of clients flagged by the background scoring job as potential
|
||||||
|
* duplicates. The `/admin/duplicates` review queue reads from here.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* - Background job inserts a row when a pair scores >= the
|
||||||
|
* `dedup_review_queue_threshold` system setting.
|
||||||
|
* - User reviews in the admin UI and either merges (status='merged')
|
||||||
|
* or dismisses (status='dismissed').
|
||||||
|
* - Subsequent runs of the scoring job skip pairs already
|
||||||
|
* `dismissed` so the same false-positive doesn't keep reappearing.
|
||||||
|
* A future score increase recreates the row.
|
||||||
|
*
|
||||||
|
* Pairs are stored canonically with `clientAId < clientBId` (string
|
||||||
|
* comparison) so the same pair only generates one row regardless of
|
||||||
|
* scoring direction.
|
||||||
|
*/
|
||||||
|
export const clientMergeCandidates = pgTable(
|
||||||
|
'client_merge_candidates',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id),
|
||||||
|
clientAId: text('client_a_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||||
|
clientBId: text('client_b_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||||
|
score: integer('score').notNull(),
|
||||||
|
/** Human-readable rule list, e.g. ["email match", "phone match"]. */
|
||||||
|
reasons: jsonb('reasons').notNull(),
|
||||||
|
status: text('status').notNull().default('pending'), // pending | dismissed | merged
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||||
|
resolvedBy: text('resolved_by'),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_cmc_port_status').on(table.portId, table.status),
|
||||||
|
// Same pair shouldn't surface twice — enforce uniqueness on the
|
||||||
|
// canonical (a < b) ordering.
|
||||||
|
uniqueIndex('idx_cmc_pair').on(table.portId, table.clientAId, table.clientBId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const clientAddresses = pgTable(
|
export const clientAddresses = pgTable(
|
||||||
'client_addresses',
|
'client_addresses',
|
||||||
{
|
{
|
||||||
@@ -190,3 +245,5 @@ export type ClientMergeLog = typeof clientMergeLog.$inferSelect;
|
|||||||
export type NewClientMergeLog = typeof clientMergeLog.$inferInsert;
|
export type NewClientMergeLog = typeof clientMergeLog.$inferInsert;
|
||||||
export type ClientAddress = typeof clientAddresses.$inferSelect;
|
export type ClientAddress = typeof clientAddresses.$inferSelect;
|
||||||
export type NewClientAddress = typeof clientAddresses.$inferInsert;
|
export type NewClientAddress = typeof clientAddresses.$inferInsert;
|
||||||
|
export type ClientMergeCandidate = typeof clientMergeCandidates.$inferSelect;
|
||||||
|
export type NewClientMergeCandidate = typeof clientMergeCandidates.$inferInsert;
|
||||||
|
|||||||
@@ -56,5 +56,8 @@ export * from './ai-usage';
|
|||||||
// GDPR export tracking (Phase 3d)
|
// GDPR export tracking (Phase 3d)
|
||||||
export * from './gdpr';
|
export * from './gdpr';
|
||||||
|
|
||||||
|
// Migration ledger (one-shot scripts — NocoDB import etc.)
|
||||||
|
export * from './migration';
|
||||||
|
|
||||||
// Relations (must come last — references all tables)
|
// Relations (must come last — references all tables)
|
||||||
export * from './relations';
|
export * from './relations';
|
||||||
|
|||||||
48
src/lib/db/schema/migration.ts
Normal file
48
src/lib/db/schema/migration.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotency ledger for one-shot data migrations from external sources
|
||||||
|
* (e.g. the legacy NocoDB Interests table).
|
||||||
|
*
|
||||||
|
* Every entity created during a migration script's `--apply` run gets a
|
||||||
|
* row here mapping the source-system row identifier to the new-system
|
||||||
|
* entity id. Re-running `--apply` against the same report skips rows
|
||||||
|
* already linked, so partial-failure resumption is just "run again."
|
||||||
|
*
|
||||||
|
* One source row can generate multiple new entities (e.g. one NocoDB
|
||||||
|
* Interests row → one client + one interest + one yacht), so the
|
||||||
|
* uniqueness constraint includes `target_entity_type`.
|
||||||
|
*/
|
||||||
|
export const migrationSourceLinks = pgTable(
|
||||||
|
'migration_source_links',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
/** e.g. 'nocodb_interests', 'nocodb_residences', 'nocodb_website_submissions'. */
|
||||||
|
sourceSystem: text('source_system').notNull(),
|
||||||
|
/** Source row identifier as a string (NocoDB IDs are integers; we keep
|
||||||
|
* text here for forward compat with other sources). */
|
||||||
|
sourceId: text('source_id').notNull(),
|
||||||
|
/** e.g. 'client', 'interest', 'yacht', 'document'. */
|
||||||
|
targetEntityType: text('target_entity_type').notNull(),
|
||||||
|
/** UUID of the new-system entity (clients.id, interests.id, etc.). */
|
||||||
|
targetEntityId: text('target_entity_id').notNull(),
|
||||||
|
/** Apply-id from the migration run that created this link — pairs with
|
||||||
|
* the on-disk apply manifest so `--rollback --apply-id <id>` knows
|
||||||
|
* exactly which links to remove. */
|
||||||
|
appliedId: text('applied_id').notNull(),
|
||||||
|
appliedBy: text('applied_by'),
|
||||||
|
appliedAt: timestamp('applied_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex('idx_msl_source_target').on(
|
||||||
|
table.sourceSystem,
|
||||||
|
table.sourceId,
|
||||||
|
table.targetEntityType,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MigrationSourceLink = typeof migrationSourceLinks.$inferSelect;
|
||||||
|
export type NewMigrationSourceLink = typeof migrationSourceLinks.$inferInsert;
|
||||||
255
src/lib/dedup/find-matches.ts
Normal file
255
src/lib/dedup/find-matches.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Client-match finder — pure scoring logic.
|
||||||
|
*
|
||||||
|
* Compares one input candidate against a pool of existing candidates and
|
||||||
|
* returns scored matches. Used by:
|
||||||
|
* - the at-create suggestion in client/interest forms (Layer 1)
|
||||||
|
* - the public-form auto-link path (when score >= block threshold)
|
||||||
|
* - the nightly background scoring job (Layer 3)
|
||||||
|
* - the migration script's dedup pass
|
||||||
|
*
|
||||||
|
* Performance shape: blocking via email / phone / surname-token reduces
|
||||||
|
* the pairwise scan from O(n²) to ~O(n) for any pool size we'll see in
|
||||||
|
* production. See `findClientMatches` for the blocking implementation.
|
||||||
|
*
|
||||||
|
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §4.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parsePhoneScriptSafe as parsePhone } from './phone-parse';
|
||||||
|
|
||||||
|
import { levenshtein } from './normalize';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MatchCandidate {
|
||||||
|
id: string;
|
||||||
|
fullName: string | null;
|
||||||
|
/** Lowercased last non-particle token from `normalizeName(...).surnameToken`.
|
||||||
|
* Used as a blocking key. */
|
||||||
|
surnameToken: string | null;
|
||||||
|
/** Already lowercased + validated via `normalizeEmail`. */
|
||||||
|
emails: string[];
|
||||||
|
/** Already canonical E.164 via `normalizePhone`. */
|
||||||
|
phonesE164: string[];
|
||||||
|
/** Address country (NOT phone country) — used for tiebreaking, not scoring. */
|
||||||
|
countryIso: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MatchConfidence = 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
|
export interface MatchResult {
|
||||||
|
candidate: MatchCandidate;
|
||||||
|
/** 0–100 after capping. */
|
||||||
|
score: number;
|
||||||
|
/** Human-readable list of which rules contributed. Useful for the
|
||||||
|
* review queue UI ("matched on email + phone + surname token"). */
|
||||||
|
reasons: string[];
|
||||||
|
confidence: MatchConfidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DedupThresholds {
|
||||||
|
/** Inclusive lower bound for `'high'` confidence. */
|
||||||
|
highScore: number;
|
||||||
|
/** Inclusive lower bound for `'medium'` confidence. Below this is `'low'`. */
|
||||||
|
mediumScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public entry point ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare `input` against every reachable candidate in `pool` and return
|
||||||
|
* scored matches, sorted by score descending. The result list includes
|
||||||
|
* low-confidence hits — caller filters by `confidence` or `score`
|
||||||
|
* depending on use case.
|
||||||
|
*
|
||||||
|
* Self-matches (an entry with `id === input.id`, e.g. when re-scoring an
|
||||||
|
* existing client during a background job) are excluded.
|
||||||
|
*/
|
||||||
|
export function findClientMatches(
|
||||||
|
input: MatchCandidate,
|
||||||
|
pool: MatchCandidate[],
|
||||||
|
thresholds: DedupThresholds,
|
||||||
|
): MatchResult[] {
|
||||||
|
if (pool.length === 0) return [];
|
||||||
|
|
||||||
|
// ── Phase 1: build blocking indexes off the pool. ─────────────────────────
|
||||||
|
//
|
||||||
|
// Three indexes mean any candidate that shares ANY of (email / phone /
|
||||||
|
// surname-token) with the input shows up in the comparison set. Anything
|
||||||
|
// that shares NONE is structurally too different to be a duplicate and
|
||||||
|
// is skipped — this is what keeps the algorithm O(n) at scale.
|
||||||
|
const byEmail = new Map<string, MatchCandidate[]>();
|
||||||
|
const byPhone = new Map<string, MatchCandidate[]>();
|
||||||
|
const bySurnameToken = new Map<string, MatchCandidate[]>();
|
||||||
|
|
||||||
|
for (const c of pool) {
|
||||||
|
if (c.id === input.id) continue;
|
||||||
|
for (const email of c.emails) {
|
||||||
|
pushTo(byEmail, email, c);
|
||||||
|
}
|
||||||
|
for (const phone of c.phonesE164) {
|
||||||
|
pushTo(byPhone, phone, c);
|
||||||
|
}
|
||||||
|
if (c.surnameToken) {
|
||||||
|
pushTo(bySurnameToken, c.surnameToken, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2: gather the comparison set via the blocking indexes. ─────────
|
||||||
|
const comparisonSet = new Map<string, MatchCandidate>();
|
||||||
|
for (const email of input.emails) {
|
||||||
|
for (const c of byEmail.get(email) ?? []) {
|
||||||
|
comparisonSet.set(c.id, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const phone of input.phonesE164) {
|
||||||
|
for (const c of byPhone.get(phone) ?? []) {
|
||||||
|
comparisonSet.set(c.id, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.surnameToken) {
|
||||||
|
for (const c of bySurnameToken.get(input.surnameToken) ?? []) {
|
||||||
|
comparisonSet.set(c.id, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: score every candidate that survived blocking. ───────────────
|
||||||
|
const results: MatchResult[] = [];
|
||||||
|
for (const candidate of comparisonSet.values()) {
|
||||||
|
const r = scorePair(input, candidate);
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 4: sort by score desc + assign confidence tier. ────────────────
|
||||||
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
for (const r of results) {
|
||||||
|
r.confidence = classify(r.score, thresholds);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scoring ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score one (input, candidate) pair against the rule set in design §4.2.
|
||||||
|
* Compounding: positive rules sum, negative rules subtract; the result is
|
||||||
|
* clamped to [0, 100]. Reasons accumulate in the order rules fire so the
|
||||||
|
* review-queue UI can show "matched on email + phone".
|
||||||
|
*/
|
||||||
|
function scorePair(a: MatchCandidate, b: MatchCandidate): MatchResult {
|
||||||
|
let score = 0;
|
||||||
|
const reasons: string[] = [];
|
||||||
|
|
||||||
|
// ── Positive rules. ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const sharedEmail = a.emails.find((e) => b.emails.includes(e));
|
||||||
|
const emailMatch = !!sharedEmail;
|
||||||
|
if (emailMatch) {
|
||||||
|
score += 60;
|
||||||
|
reasons.push('email match');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedPhone = a.phonesE164.find((p) => b.phonesE164.includes(p) && countDigits(p) >= 8);
|
||||||
|
const phoneMatch = !!sharedPhone;
|
||||||
|
if (phoneMatch) {
|
||||||
|
score += 50;
|
||||||
|
reasons.push('phone match');
|
||||||
|
}
|
||||||
|
|
||||||
|
const aNameNorm = (a.fullName ?? '').toLowerCase().trim();
|
||||||
|
const bNameNorm = (b.fullName ?? '').toLowerCase().trim();
|
||||||
|
const nameExactMatch = aNameNorm.length > 0 && aNameNorm === bNameNorm;
|
||||||
|
if (nameExactMatch) {
|
||||||
|
score += 20;
|
||||||
|
reasons.push('name match');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surname + given-name fuzzy. Only fires when names are NOT exactly
|
||||||
|
// equal — avoids double-counting with the rule above. Catches
|
||||||
|
// 'Constanzo' / 'Costanzo', 'Marc' / 'Marcus' etc. when other contact
|
||||||
|
// signals confirm them.
|
||||||
|
if (!nameExactMatch && a.surnameToken && b.surnameToken && a.surnameToken === b.surnameToken) {
|
||||||
|
const aGiven = (a.fullName ?? '').toLowerCase().split(/\s+/)[0] ?? '';
|
||||||
|
const bGiven = (b.fullName ?? '').toLowerCase().split(/\s+/)[0] ?? '';
|
||||||
|
if (aGiven && bGiven && levenshtein(aGiven, bGiven) <= 1) {
|
||||||
|
score += 15;
|
||||||
|
reasons.push('surname + given-name fuzzy match');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Negative rules. ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Same email but the two parties' phone numbers belong to different
|
||||||
|
// countries. Common when one inbox is shared by spouses / coworkers
|
||||||
|
// and the actual phone owners are distinct people. Don't auto-merge.
|
||||||
|
if (emailMatch && !phoneMatch && a.phonesE164.length > 0 && b.phonesE164.length > 0) {
|
||||||
|
const aCountries = phoneCountriesOf(a);
|
||||||
|
const bCountries = phoneCountriesOf(b);
|
||||||
|
const overlap = [...aCountries].some((c) => bCountries.has(c));
|
||||||
|
if (!overlap && aCountries.size > 0 && bCountries.size > 0) {
|
||||||
|
score -= 15;
|
||||||
|
reasons.push('phone country mismatch (negative)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same name but no contact match. Two distinct people with the same
|
||||||
|
// name (common for "John Smith") sneak through name-based blocking;
|
||||||
|
// penalize so the score lands below the auto-merge threshold.
|
||||||
|
if (nameExactMatch && !emailMatch && !phoneMatch) {
|
||||||
|
score -= 20;
|
||||||
|
reasons.push('name match but no shared contact (negative)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidate: b,
|
||||||
|
score: clamp(score, 0, 100),
|
||||||
|
reasons,
|
||||||
|
confidence: 'low', // assigned by caller after threshold lookup
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pushTo<K, V>(map: Map<K, V[]>, key: K, value: V): void {
|
||||||
|
const existing = map.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(value);
|
||||||
|
} else {
|
||||||
|
map.set(key, [value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classify(score: number, thresholds: DedupThresholds): MatchConfidence {
|
||||||
|
if (score >= thresholds.highScore) return 'high';
|
||||||
|
if (score >= thresholds.mediumScore) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
if (value < min) return min;
|
||||||
|
if (value > max) return max;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countDigits(s: string): number {
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < s.length; i += 1) {
|
||||||
|
const code = s.charCodeAt(i);
|
||||||
|
if (code >= 48 && code <= 57) count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve each phone in a candidate to its ISO country code (via
|
||||||
|
* libphonenumber-js). Cached per call; the surrounding caller doesn't
|
||||||
|
* batch so we accept the parse cost.
|
||||||
|
*/
|
||||||
|
function phoneCountriesOf(c: MatchCandidate): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const p of c.phonesE164) {
|
||||||
|
const parsed = parsePhone(p);
|
||||||
|
if (parsed.country) out.add(parsed.country);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
274
src/lib/dedup/migration-report.ts
Normal file
274
src/lib/dedup/migration-report.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Migration report writer — turns a `MigrationPlan` (from
|
||||||
|
* `migration-transform.ts`) into a CSV + a human-readable Markdown
|
||||||
|
* summary on disk under `.migration/<timestamp>/`.
|
||||||
|
*
|
||||||
|
* The CSV format is intentionally machine-friendly (one row per
|
||||||
|
* planned operation) so it can be diffed across runs and inspected
|
||||||
|
* by hand. The summary is designed for "open this in your editor and
|
||||||
|
* eyeball it for 5 minutes before --apply."
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { MigrationPlan } from './migration-transform';
|
||||||
|
|
||||||
|
// ─── Output directory ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ReportPaths {
|
||||||
|
rootDir: string;
|
||||||
|
csvPath: string;
|
||||||
|
summaryPath: string;
|
||||||
|
planJsonPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve report paths relative to the worktree root. The timestamped
|
||||||
|
* directory is created lazily by `writeReport`. */
|
||||||
|
export function resolveReportPaths(
|
||||||
|
rootDir: string,
|
||||||
|
timestamp: string = new Date().toISOString().replace(/[:.]/g, '-'),
|
||||||
|
): ReportPaths {
|
||||||
|
const dir = path.join(rootDir, '.migration', timestamp);
|
||||||
|
return {
|
||||||
|
rootDir: dir,
|
||||||
|
csvPath: path.join(dir, 'report.csv'),
|
||||||
|
summaryPath: path.join(dir, 'summary.md'),
|
||||||
|
planJsonPath: path.join(dir, 'plan.json'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CSV row shape ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CsvRow {
|
||||||
|
op: string; // create_client / create_contact / create_interest / auto_link / flag / needs_review
|
||||||
|
reason: string;
|
||||||
|
source_id: string;
|
||||||
|
target_table: string;
|
||||||
|
target_value: string;
|
||||||
|
confidence: string;
|
||||||
|
manual_review: 'true' | 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trivial CSV escape: quote any cell that contains comma / quote / newline,
|
||||||
|
// double up internal quotes per RFC 4180. No need for a dependency.
|
||||||
|
function csvEscape(s: string): string {
|
||||||
|
if (/[",\n\r]/.test(s)) {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToCsvLine(r: CsvRow): string {
|
||||||
|
return [
|
||||||
|
r.op,
|
||||||
|
r.reason,
|
||||||
|
r.source_id,
|
||||||
|
r.target_table,
|
||||||
|
r.target_value,
|
||||||
|
r.confidence,
|
||||||
|
r.manual_review,
|
||||||
|
]
|
||||||
|
.map(csvEscape)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Build CSV ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildCsv(plan: MigrationPlan): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
'op',
|
||||||
|
'reason',
|
||||||
|
'source_id',
|
||||||
|
'target_table',
|
||||||
|
'target_value',
|
||||||
|
'confidence',
|
||||||
|
'manual_review',
|
||||||
|
].join(','),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const client of plan.clients) {
|
||||||
|
lines.push(
|
||||||
|
rowToCsvLine({
|
||||||
|
op: 'create_client',
|
||||||
|
reason: client.sourceIds.length > 1 ? 'auto-merged cluster' : 'new',
|
||||||
|
source_id: client.sourceIds.join('|'),
|
||||||
|
target_table: 'clients.fullName',
|
||||||
|
target_value: client.fullName,
|
||||||
|
confidence: 'N/A',
|
||||||
|
manual_review: 'false',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const c of client.contacts) {
|
||||||
|
lines.push(
|
||||||
|
rowToCsvLine({
|
||||||
|
op: 'create_contact',
|
||||||
|
reason: c.flagged ?? 'new',
|
||||||
|
source_id: client.sourceIds.join('|'),
|
||||||
|
target_table: `clientContacts.${c.channel}`,
|
||||||
|
target_value: c.value,
|
||||||
|
confidence: 'N/A',
|
||||||
|
manual_review: c.flagged ? 'true' : 'false',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const a of client.addresses) {
|
||||||
|
lines.push(
|
||||||
|
rowToCsvLine({
|
||||||
|
op: 'create_address',
|
||||||
|
reason: 'address text present',
|
||||||
|
source_id: client.sourceIds.join('|'),
|
||||||
|
target_table: 'clientAddresses.countryIso',
|
||||||
|
target_value: a.countryIso ?? '(unresolved)',
|
||||||
|
confidence: a.countryConfidence ?? 'fallback',
|
||||||
|
manual_review: a.countryConfidence === 'fallback' || !a.countryIso ? 'true' : 'false',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const interest of plan.interests) {
|
||||||
|
lines.push(
|
||||||
|
rowToCsvLine({
|
||||||
|
op: 'create_interest',
|
||||||
|
reason: `pipelineStage=${interest.pipelineStage}`,
|
||||||
|
source_id: String(interest.sourceId),
|
||||||
|
target_table: 'interests',
|
||||||
|
target_value: `${interest.berthMooringNumber ?? '(no berth)'} / ${interest.yachtName ?? '(no yacht)'}`,
|
||||||
|
confidence: 'N/A',
|
||||||
|
manual_review: 'false',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const link of plan.autoLinks) {
|
||||||
|
lines.push(
|
||||||
|
rowToCsvLine({
|
||||||
|
op: 'auto_link',
|
||||||
|
reason: link.reasons.join(' + '),
|
||||||
|
source_id: `${link.leadSourceId}<-${link.mergedSourceIds.join(',')}`,
|
||||||
|
target_table: 'clients',
|
||||||
|
target_value: '(merged into lead)',
|
||||||
|
confidence: `score=${link.score}`,
|
||||||
|
manual_review: 'false',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pair of plan.needsReview) {
|
||||||
|
lines.push(
|
||||||
|
rowToCsvLine({
|
||||||
|
op: 'needs_review',
|
||||||
|
reason: pair.reasons.join(' + '),
|
||||||
|
source_id: `${pair.aSourceId}<->${pair.bSourceId}`,
|
||||||
|
target_table: 'clients',
|
||||||
|
target_value: '(human review required)',
|
||||||
|
confidence: `score=${pair.score}`,
|
||||||
|
manual_review: 'true',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const flag of plan.flags) {
|
||||||
|
lines.push(
|
||||||
|
rowToCsvLine({
|
||||||
|
op: 'flag',
|
||||||
|
reason: flag.reason,
|
||||||
|
source_id: String(flag.sourceId),
|
||||||
|
target_table: flag.sourceTable,
|
||||||
|
target_value: JSON.stringify(flag.details ?? {}),
|
||||||
|
confidence: 'N/A',
|
||||||
|
manual_review: 'true',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Build summary markdown ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildSummary(plan: MigrationPlan, generatedAt: string): string {
|
||||||
|
const s = plan.stats;
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`# Migration Dry-Run — ${generatedAt}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('## Input');
|
||||||
|
lines.push(`- ${s.inputInterestRows} NocoDB Interests`);
|
||||||
|
lines.push(`- ${s.inputResidentialRows} NocoDB Residential Interests`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('## Outcome');
|
||||||
|
lines.push(`- ${s.outputClients} clients`);
|
||||||
|
lines.push(`- ${s.outputInterests} interests (one per source row, linked to deduped client)`);
|
||||||
|
lines.push(`- ${s.outputContacts} client_contacts`);
|
||||||
|
lines.push(`- ${s.outputAddresses} client_addresses`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('## Auto-linked clusters');
|
||||||
|
if (plan.autoLinks.length === 0) {
|
||||||
|
lines.push('_None — every input row maps to a unique client._');
|
||||||
|
} else {
|
||||||
|
for (const link of plan.autoLinks) {
|
||||||
|
const merged = link.mergedSourceIds.length;
|
||||||
|
lines.push(
|
||||||
|
`- Lead row \`${link.leadSourceId}\` ← merged ${merged} other row${merged === 1 ? '' : 's'} (\`${link.mergedSourceIds.join(', ')}\`) — score ${link.score} via ${link.reasons.join(' + ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('## Pairs flagged for human review');
|
||||||
|
if (plan.needsReview.length === 0) {
|
||||||
|
lines.push('_None._');
|
||||||
|
} else {
|
||||||
|
for (const pair of plan.needsReview) {
|
||||||
|
lines.push(
|
||||||
|
`- Rows \`${pair.aSourceId}\` ↔ \`${pair.bSourceId}\` — score ${pair.score} (${pair.reasons.join(' + ')})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('## Data quality flags');
|
||||||
|
if (plan.flags.length === 0) {
|
||||||
|
lines.push('_No quality issues._');
|
||||||
|
} else {
|
||||||
|
const byReason = new Map<string, number>();
|
||||||
|
for (const f of plan.flags) {
|
||||||
|
byReason.set(f.reason, (byReason.get(f.reason) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const [reason, count] of [...byReason].sort((a, b) => b[1] - a[1])) {
|
||||||
|
lines.push(`- **${count}× ${reason}**`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('### Detail');
|
||||||
|
for (const f of plan.flags.slice(0, 30)) {
|
||||||
|
lines.push(
|
||||||
|
`- \`${f.sourceTable}#${f.sourceId}\`: ${f.reason}${f.details ? ` — \`${JSON.stringify(f.details)}\`` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (plan.flags.length > 30) {
|
||||||
|
lines.push(`- _… and ${plan.flags.length - 30} more (see report.csv for full list)_`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('## Next step');
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Eyeball the auto-linked + flagged-for-review pairs above.');
|
||||||
|
lines.push('When satisfied, re-run the script with `--apply --report .migration/<this-dir>/`.');
|
||||||
|
lines.push('Apply will refuse to run if the source NocoDB has changed since this dry-run.');
|
||||||
|
|
||||||
|
return lines.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Write to disk ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function writeReport(
|
||||||
|
paths: ReportPaths,
|
||||||
|
plan: MigrationPlan,
|
||||||
|
generatedAt: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await fs.mkdir(paths.rootDir, { recursive: true });
|
||||||
|
await fs.writeFile(paths.csvPath, buildCsv(plan), 'utf-8');
|
||||||
|
await fs.writeFile(paths.summaryPath, buildSummary(plan, generatedAt), 'utf-8');
|
||||||
|
await fs.writeFile(paths.planJsonPath, JSON.stringify(plan, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
576
src/lib/dedup/migration-transform.ts
Normal file
576
src/lib/dedup/migration-transform.ts
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
/**
|
||||||
|
* Pure transform: NocoDB snapshot → planned new-system entities + dedup result.
|
||||||
|
*
|
||||||
|
* Used by the migration script's `--dry-run` (to produce the report) and
|
||||||
|
* `--apply` (to actually write). Keeping this pure means the same code
|
||||||
|
* runs in both modes, in tests against the frozen fixture, and in the
|
||||||
|
* one-off CLI run against the live base.
|
||||||
|
*
|
||||||
|
* No side effects, no DB calls, no external services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeName,
|
||||||
|
normalizeEmail,
|
||||||
|
normalizePhone,
|
||||||
|
resolveCountry,
|
||||||
|
type NormalizedPhone,
|
||||||
|
} from './normalize';
|
||||||
|
import { findClientMatches, type MatchCandidate } from './find-matches';
|
||||||
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
import type { NocoDbRow, NocoDbSnapshot } from './nocodb-source';
|
||||||
|
|
||||||
|
// ─── Plan output ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PlannedClient {
|
||||||
|
/** Stable id derived from the deduped cluster's lead row. Used by the
|
||||||
|
* apply phase to reference newly-created clients before they exist
|
||||||
|
* in the DB. */
|
||||||
|
tempId: string;
|
||||||
|
/** Source row IDs that contributed to this client (one if no duplicates,
|
||||||
|
* many if dedup merged a cluster). */
|
||||||
|
sourceIds: number[];
|
||||||
|
fullName: string;
|
||||||
|
surnameToken?: string;
|
||||||
|
countryIso: CountryCode | null;
|
||||||
|
preferredContactMethod: string | null;
|
||||||
|
source: string | null;
|
||||||
|
contacts: PlannedContact[];
|
||||||
|
addresses: PlannedAddress[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlannedContact {
|
||||||
|
channel: 'email' | 'phone' | 'whatsapp' | 'other';
|
||||||
|
value: string;
|
||||||
|
valueE164?: string | null;
|
||||||
|
valueCountry?: CountryCode | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
flagged?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlannedAddress {
|
||||||
|
streetAddress: string | null;
|
||||||
|
city: string | null;
|
||||||
|
countryIso: CountryCode | null;
|
||||||
|
/** When confidence is low, the migration script flags the row for
|
||||||
|
* human review. */
|
||||||
|
countryConfidence: 'exact' | 'fuzzy' | 'city' | 'fallback' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlannedInterest {
|
||||||
|
/** NocoDB row id this interest came from. */
|
||||||
|
sourceId: number;
|
||||||
|
/** tempId of the planned client this interest hangs off. */
|
||||||
|
clientTempId: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
leadCategory: string | null;
|
||||||
|
source: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
/** Mooring number; the apply phase resolves this to a berthId via the
|
||||||
|
* new-system Berths table. */
|
||||||
|
berthMooringNumber: string | null;
|
||||||
|
yachtName: string | null;
|
||||||
|
/** Date stamps for milestone columns. ISO strings if parseable. */
|
||||||
|
dateEoiSent: string | null;
|
||||||
|
dateEoiSigned: string | null;
|
||||||
|
dateDepositReceived: string | null;
|
||||||
|
dateContractSent: string | null;
|
||||||
|
dateContractSigned: string | null;
|
||||||
|
dateLastContact: string | null;
|
||||||
|
/** Documenso linkage carried forward when present so the document
|
||||||
|
* record can be stitched up downstream. */
|
||||||
|
documensoId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationFlag {
|
||||||
|
sourceTable: 'interests' | 'residential_interests' | 'website_interest_submissions';
|
||||||
|
sourceId: number;
|
||||||
|
reason: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationPlan {
|
||||||
|
clients: PlannedClient[];
|
||||||
|
interests: PlannedInterest[];
|
||||||
|
flags: MigrationFlag[];
|
||||||
|
/** Pairs that the migration would auto-link (high score). */
|
||||||
|
autoLinks: Array<{
|
||||||
|
leadSourceId: number;
|
||||||
|
mergedSourceIds: number[];
|
||||||
|
score: number;
|
||||||
|
reasons: string[];
|
||||||
|
}>;
|
||||||
|
/** Pairs that need human review (medium score). Each pair shows up
|
||||||
|
* in the migration report; the user resolves before --apply. */
|
||||||
|
needsReview: Array<{ aSourceId: number; bSourceId: number; score: number; reasons: string[] }>;
|
||||||
|
stats: MigrationStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationStats {
|
||||||
|
inputInterestRows: number;
|
||||||
|
inputResidentialRows: number;
|
||||||
|
outputClients: number;
|
||||||
|
outputInterests: number;
|
||||||
|
outputContacts: number;
|
||||||
|
outputAddresses: number;
|
||||||
|
flaggedRows: number;
|
||||||
|
autoLinkedClusters: number;
|
||||||
|
needsReviewPairs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformOptions {
|
||||||
|
/** ISO country used when a phone has no prefix and the row has no
|
||||||
|
* Place of Residence. Defaults to AI (Anguilla / Port Nimara's home). */
|
||||||
|
defaultPhoneCountry: CountryCode;
|
||||||
|
/** Score thresholds for auto-link vs human review. Should match the
|
||||||
|
* per-port `system_settings` values once the runtime UI is in place. */
|
||||||
|
thresholds: {
|
||||||
|
autoLink: number;
|
||||||
|
needsReview: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: TransformOptions = {
|
||||||
|
defaultPhoneCountry: 'AI',
|
||||||
|
thresholds: { autoLink: 90, needsReview: 50 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Stage mapping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STAGE_MAP: Record<string, string> = {
|
||||||
|
'General Qualified Interest': 'open',
|
||||||
|
'Specific Qualified Interest': 'details_sent',
|
||||||
|
'EOI and NDA Sent': 'eoi_sent',
|
||||||
|
'Signed EOI and NDA': 'eoi_signed',
|
||||||
|
'Made Reservation': 'deposit_10pct',
|
||||||
|
'Contract Negotiation': 'contract_sent',
|
||||||
|
'Contract Negotiations Finalized': 'contract_sent',
|
||||||
|
'Contract Signed': 'contract_signed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEAD_CATEGORY_MAP: Record<string, string> = {
|
||||||
|
General: 'general_interest',
|
||||||
|
'Friends and Family': 'general_interest',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_MAP: Record<string, string> = {
|
||||||
|
portal: 'website',
|
||||||
|
Form: 'website',
|
||||||
|
External: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Date parsing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a date the legacy NocoDB might have stored in DD-MM-YYYY,
|
||||||
|
* DD/MM/YYYY, YYYY-MM-DD, or ISO format. Returns ISO string or null.
|
||||||
|
*/
|
||||||
|
function parseFlexibleDate(input: unknown): string | null {
|
||||||
|
if (typeof input !== 'string' || input.trim() === '') return null;
|
||||||
|
const s = input.trim();
|
||||||
|
|
||||||
|
// Already ISO
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
||||||
|
const d = new Date(s);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// DD-MM-YYYY or DD/MM/YYYY
|
||||||
|
const m = s.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
|
||||||
|
if (m) {
|
||||||
|
const [, day, month, year] = m;
|
||||||
|
const iso = `${year}-${month!.padStart(2, '0')}-${day!.padStart(2, '0')}`;
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything else: try Date constructor as a last resort
|
||||||
|
const d = new Date(s);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main transform ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full transform pipeline against a NocoDB snapshot. Pure
|
||||||
|
* function — same input always produces the same plan.
|
||||||
|
*/
|
||||||
|
export function transformSnapshot(
|
||||||
|
snapshot: NocoDbSnapshot,
|
||||||
|
options: Partial<TransformOptions> = {},
|
||||||
|
): MigrationPlan {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
|
||||||
|
const flags: MigrationFlag[] = [];
|
||||||
|
// Build per-row candidates first so we can run dedup before assigning
|
||||||
|
// tempIds (clients with multiple source rows merge into one tempId).
|
||||||
|
const perRow = snapshot.interests.map((row) => rowToCandidate(row, 'interests', opts, flags));
|
||||||
|
|
||||||
|
// Dedup pass 1: every row scored against every other row (within the
|
||||||
|
// same pool). The blocking strategy in `findClientMatches` keeps this
|
||||||
|
// cheap even for the full 252-row dataset.
|
||||||
|
const clusters = clusterByDedup(perRow, opts);
|
||||||
|
|
||||||
|
// Build the planned clients + interests from the clusters.
|
||||||
|
const clients: PlannedClient[] = [];
|
||||||
|
const interests: PlannedInterest[] = [];
|
||||||
|
const autoLinks: MigrationPlan['autoLinks'] = [];
|
||||||
|
const needsReview: MigrationPlan['needsReview'] = [];
|
||||||
|
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
const lead = cluster.leadCandidate;
|
||||||
|
const tempId = `client-${lead.row.Id}`;
|
||||||
|
|
||||||
|
// Build the client record from the lead row, then merge in any
|
||||||
|
// contact info / address info from the other rows in the cluster.
|
||||||
|
const planned = buildPlannedClient(tempId, cluster, opts);
|
||||||
|
clients.push(planned);
|
||||||
|
|
||||||
|
// Each row in the cluster becomes its own interest record.
|
||||||
|
for (const member of cluster.members) {
|
||||||
|
const interest = buildPlannedInterest(member.row, tempId);
|
||||||
|
interests.push(interest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cluster.members.length > 1) {
|
||||||
|
autoLinks.push({
|
||||||
|
leadSourceId: lead.row.Id,
|
||||||
|
mergedSourceIds: cluster.members.filter((m) => m !== lead).map((m) => m.row.Id),
|
||||||
|
score: cluster.maxScore,
|
||||||
|
reasons: cluster.reasons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pair of cluster.reviewPairs) {
|
||||||
|
needsReview.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clients,
|
||||||
|
interests,
|
||||||
|
flags,
|
||||||
|
autoLinks,
|
||||||
|
needsReview,
|
||||||
|
stats: {
|
||||||
|
inputInterestRows: snapshot.interests.length,
|
||||||
|
inputResidentialRows: snapshot.residentialInterests.length,
|
||||||
|
outputClients: clients.length,
|
||||||
|
outputInterests: interests.length,
|
||||||
|
outputContacts: clients.reduce((sum, c) => sum + c.contacts.length, 0),
|
||||||
|
outputAddresses: clients.reduce((sum, c) => sum + c.addresses.length, 0),
|
||||||
|
flaggedRows: flags.length,
|
||||||
|
autoLinkedClusters: autoLinks.length,
|
||||||
|
needsReviewPairs: needsReview.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RowCandidate {
|
||||||
|
row: NocoDbRow;
|
||||||
|
candidate: MatchCandidate;
|
||||||
|
/** Phone normalize result for the row's primary phone; used downstream
|
||||||
|
* to attach valueE164 + country to the planned contact. */
|
||||||
|
phoneResult: NormalizedPhone | null;
|
||||||
|
/** Country resolved from "Place of Residence". */
|
||||||
|
countryIso: CountryCode | null;
|
||||||
|
countryConfidence: 'exact' | 'fuzzy' | 'city' | null;
|
||||||
|
/** Normalized email or null. */
|
||||||
|
email: string | null;
|
||||||
|
/** Display name from `normalizeName`. */
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToCandidate(
|
||||||
|
row: NocoDbRow,
|
||||||
|
sourceTable: MigrationFlag['sourceTable'],
|
||||||
|
opts: TransformOptions,
|
||||||
|
flags: MigrationFlag[],
|
||||||
|
): RowCandidate {
|
||||||
|
const rawName = (row['Full Name'] as string | undefined) ?? '';
|
||||||
|
const rawEmail = (row['Email Address'] as string | undefined) ?? '';
|
||||||
|
const rawPhone = (row['Phone Number'] as string | undefined) ?? '';
|
||||||
|
const rawCountry = (row['Place of Residence'] as string | undefined) ?? '';
|
||||||
|
|
||||||
|
const normName = normalizeName(rawName);
|
||||||
|
const email = normalizeEmail(rawEmail);
|
||||||
|
const country = resolveCountry(rawCountry);
|
||||||
|
const phoneCountry = country.iso ?? opts.defaultPhoneCountry;
|
||||||
|
const phoneResult = normalizePhone(rawPhone, phoneCountry as CountryCode);
|
||||||
|
|
||||||
|
// Surface anything weird so the report can show it.
|
||||||
|
if (rawPhone && !phoneResult?.e164) {
|
||||||
|
flags.push({
|
||||||
|
sourceTable,
|
||||||
|
sourceId: row.Id,
|
||||||
|
reason: phoneResult?.flagged ? `phone ${phoneResult.flagged}` : 'phone unparseable',
|
||||||
|
details: { rawPhone },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rawEmail && !email) {
|
||||||
|
flags.push({
|
||||||
|
sourceTable,
|
||||||
|
sourceId: row.Id,
|
||||||
|
reason: 'email invalid',
|
||||||
|
details: { rawEmail },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rawCountry && !country.iso) {
|
||||||
|
flags.push({
|
||||||
|
sourceTable,
|
||||||
|
sourceId: row.Id,
|
||||||
|
reason: 'country unresolved',
|
||||||
|
details: { rawCountry },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate: MatchCandidate = {
|
||||||
|
id: String(row.Id),
|
||||||
|
fullName: normName.display || null,
|
||||||
|
surnameToken: normName.surnameToken ?? null,
|
||||||
|
emails: email ? [email] : [],
|
||||||
|
phonesE164: phoneResult?.e164 ? [phoneResult.e164] : [],
|
||||||
|
countryIso: country.iso ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
candidate,
|
||||||
|
phoneResult,
|
||||||
|
countryIso: country.iso ?? null,
|
||||||
|
countryConfidence: country.confidence,
|
||||||
|
email,
|
||||||
|
displayName: normName.display,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cluster {
|
||||||
|
/** The cluster's "lead" row (most complete + most recent). */
|
||||||
|
leadCandidate: RowCandidate;
|
||||||
|
members: RowCandidate[];
|
||||||
|
maxScore: number;
|
||||||
|
reasons: string[];
|
||||||
|
/** Pairs in this cluster that scored medium (need review). */
|
||||||
|
reviewPairs: Array<{ aSourceId: number; bSourceId: number; score: number; reasons: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clusterByDedup(rows: RowCandidate[], opts: TransformOptions): Cluster[] {
|
||||||
|
// Use a union-find structure indexed by row id. Every pair with a
|
||||||
|
// score >= autoLink threshold gets unioned. Pairs in [needsReview,
|
||||||
|
// autoLink) accumulate onto the cluster's reviewPairs list — they're
|
||||||
|
// surfaced for human triage but not auto-merged.
|
||||||
|
const parent = new Map<string, string>();
|
||||||
|
for (const r of rows) parent.set(r.candidate.id, r.candidate.id);
|
||||||
|
const find = (id: string): string => {
|
||||||
|
let cur = id;
|
||||||
|
while (parent.get(cur) !== cur) {
|
||||||
|
const next = parent.get(cur)!;
|
||||||
|
parent.set(cur, parent.get(next)!); // path compression
|
||||||
|
cur = parent.get(cur)!;
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
};
|
||||||
|
const union = (a: string, b: string) => {
|
||||||
|
const rootA = find(a);
|
||||||
|
const rootB = find(b);
|
||||||
|
if (rootA !== rootB) parent.set(rootA, rootB);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clusterReasons = new Map<string, string[]>();
|
||||||
|
const clusterMaxScore = new Map<string, number>();
|
||||||
|
const clusterReviewPairs = new Map<string, Cluster['reviewPairs']>();
|
||||||
|
|
||||||
|
// Score every candidate against every other candidate. The find-matches
|
||||||
|
// function does its own blocking so this is cheap.
|
||||||
|
for (let i = 0; i < rows.length; i += 1) {
|
||||||
|
const left = rows[i]!;
|
||||||
|
const remainingPool = rows.slice(i + 1).map((r) => r.candidate);
|
||||||
|
if (remainingPool.length === 0) continue;
|
||||||
|
const matches = findClientMatches(left.candidate, remainingPool, {
|
||||||
|
highScore: opts.thresholds.autoLink,
|
||||||
|
mediumScore: opts.thresholds.needsReview,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const m of matches) {
|
||||||
|
if (m.score >= opts.thresholds.autoLink) {
|
||||||
|
union(left.candidate.id, m.candidate.id);
|
||||||
|
const root = find(left.candidate.id);
|
||||||
|
clusterMaxScore.set(root, Math.max(clusterMaxScore.get(root) ?? 0, m.score));
|
||||||
|
const existing = clusterReasons.get(root) ?? [];
|
||||||
|
for (const reason of m.reasons) {
|
||||||
|
if (!existing.includes(reason)) existing.push(reason);
|
||||||
|
}
|
||||||
|
clusterReasons.set(root, existing);
|
||||||
|
} else if (m.score >= opts.thresholds.needsReview) {
|
||||||
|
// Medium — track on whichever cluster `left` belongs to.
|
||||||
|
const root = find(left.candidate.id);
|
||||||
|
const list = clusterReviewPairs.get(root) ?? [];
|
||||||
|
list.push({
|
||||||
|
aSourceId: parseInt(left.candidate.id, 10),
|
||||||
|
bSourceId: parseInt(m.candidate.id, 10),
|
||||||
|
score: m.score,
|
||||||
|
reasons: m.reasons,
|
||||||
|
});
|
||||||
|
clusterReviewPairs.set(root, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group rows by their cluster root.
|
||||||
|
const byRoot = new Map<string, RowCandidate[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const root = find(r.candidate.id);
|
||||||
|
const list = byRoot.get(root) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
byRoot.set(root, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build cluster objects, choosing the most-complete row as the lead.
|
||||||
|
const clusters: Cluster[] = [];
|
||||||
|
for (const [root, members] of byRoot) {
|
||||||
|
const lead = pickLead(members);
|
||||||
|
clusters.push({
|
||||||
|
leadCandidate: lead,
|
||||||
|
members,
|
||||||
|
maxScore: clusterMaxScore.get(root) ?? 0,
|
||||||
|
reasons: clusterReasons.get(root) ?? [],
|
||||||
|
reviewPairs: clusterReviewPairs.get(root) ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLead(rows: RowCandidate[]): RowCandidate {
|
||||||
|
// Pick the row with the most populated fields, breaking ties by
|
||||||
|
// recency (highest Id, since NocoDB IDs are monotonic).
|
||||||
|
return rows.reduce((best, current) => {
|
||||||
|
const bestScore = completenessScore(best);
|
||||||
|
const currentScore = completenessScore(current);
|
||||||
|
if (currentScore > bestScore) return current;
|
||||||
|
if (currentScore === bestScore && current.row.Id > best.row.Id) return current;
|
||||||
|
return best;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function completenessScore(r: RowCandidate): number {
|
||||||
|
let score = 0;
|
||||||
|
if (r.email) score += 1;
|
||||||
|
if (r.phoneResult?.e164) score += 1;
|
||||||
|
if (r.row['Address']) score += 0.5;
|
||||||
|
if (r.row['Yacht Name']) score += 0.5;
|
||||||
|
if (r.row['Source']) score += 0.25;
|
||||||
|
if (r.row['Lead Category']) score += 0.25;
|
||||||
|
if (r.row['Internal Notes']) score += 0.25;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlannedClient(
|
||||||
|
tempId: string,
|
||||||
|
cluster: Cluster,
|
||||||
|
opts: TransformOptions,
|
||||||
|
): PlannedClient {
|
||||||
|
const lead = cluster.leadCandidate;
|
||||||
|
|
||||||
|
// Collect distinct emails + phones from across the cluster — duplicate
|
||||||
|
// submissions often come with different contact methods we want to
|
||||||
|
// preserve as multiple rows in `client_contacts`.
|
||||||
|
const seenEmails = new Set<string>();
|
||||||
|
const seenPhones = new Set<string>();
|
||||||
|
const contacts: PlannedContact[] = [];
|
||||||
|
|
||||||
|
for (const member of cluster.members) {
|
||||||
|
if (member.email && !seenEmails.has(member.email)) {
|
||||||
|
seenEmails.add(member.email);
|
||||||
|
contacts.push({
|
||||||
|
channel: 'email',
|
||||||
|
value: member.email,
|
||||||
|
isPrimary: contacts.length === 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (member.phoneResult?.e164 && !seenPhones.has(member.phoneResult.e164)) {
|
||||||
|
seenPhones.add(member.phoneResult.e164);
|
||||||
|
const isFirstPhone = !contacts.some((c) => c.channel === 'phone');
|
||||||
|
contacts.push({
|
||||||
|
channel: 'phone',
|
||||||
|
value: member.phoneResult.e164,
|
||||||
|
valueE164: member.phoneResult.e164,
|
||||||
|
valueCountry: member.phoneResult.country,
|
||||||
|
isPrimary: isFirstPhone && contacts.every((c) => !c.isPrimary || c.channel === 'email'),
|
||||||
|
flagged: member.phoneResult.flagged,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demote the email-primary if a more-completable phone exists.
|
||||||
|
// Simpler invariant: the first contact is primary unless the row
|
||||||
|
// explicitly preferred phone.
|
||||||
|
const preferredMethod = (lead.row['Contact Method Preferred'] as string | undefined)
|
||||||
|
?.toLowerCase()
|
||||||
|
?.trim();
|
||||||
|
|
||||||
|
// Address: only build if the lead row has a meaningful address text.
|
||||||
|
const rawAddress = (lead.row['Address'] as string | undefined)?.trim();
|
||||||
|
const addresses: PlannedAddress[] = [];
|
||||||
|
if (rawAddress) {
|
||||||
|
addresses.push({
|
||||||
|
streetAddress: rawAddress,
|
||||||
|
city: null,
|
||||||
|
countryIso: lead.countryIso ?? opts.defaultPhoneCountry,
|
||||||
|
countryConfidence: lead.countryConfidence ?? 'fallback',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFromRow = (lead.row['Source'] as string | undefined) ?? null;
|
||||||
|
const mappedSource = sourceFromRow ? (SOURCE_MAP[sourceFromRow] ?? 'manual') : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tempId,
|
||||||
|
sourceIds: cluster.members.map((m) => m.row.Id),
|
||||||
|
fullName: lead.displayName,
|
||||||
|
surnameToken: lead.candidate.surnameToken ?? undefined,
|
||||||
|
countryIso: lead.countryIso,
|
||||||
|
preferredContactMethod: preferredMethod ?? null,
|
||||||
|
source: mappedSource,
|
||||||
|
contacts,
|
||||||
|
addresses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlannedInterest(row: NocoDbRow, clientTempId: string): PlannedInterest {
|
||||||
|
const stage = (row['Sales Process Level'] as string | undefined) ?? '';
|
||||||
|
const cat = (row['Lead Category'] as string | undefined) ?? '';
|
||||||
|
|
||||||
|
const notesParts: string[] = [];
|
||||||
|
const internalNotes = row['Internal Notes'] as string | undefined;
|
||||||
|
const extraComments = row['Extra Comments'] as string | undefined;
|
||||||
|
if (internalNotes?.trim()) notesParts.push(internalNotes.trim());
|
||||||
|
if (extraComments?.trim()) notesParts.push(`Extra Comments: ${extraComments.trim()}`);
|
||||||
|
const berthSize = row['Berth Size Desired'] as string | undefined;
|
||||||
|
if (berthSize?.trim()) notesParts.push(`Berth size desired: ${berthSize.trim()}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceId: row.Id,
|
||||||
|
clientTempId,
|
||||||
|
pipelineStage: STAGE_MAP[stage] ?? 'open',
|
||||||
|
leadCategory: LEAD_CATEGORY_MAP[cat] ?? null,
|
||||||
|
source: ((row['Source'] as string | undefined) ?? null) || null,
|
||||||
|
notes: notesParts.join('\n\n') || null,
|
||||||
|
berthMooringNumber: (row['Berth Number'] as string | undefined) ?? null,
|
||||||
|
yachtName: (() => {
|
||||||
|
const n = (row['Yacht Name'] as string | undefined)?.trim();
|
||||||
|
// Filter placeholder values used by sales reps for "we don't know yet".
|
||||||
|
if (!n) return null;
|
||||||
|
if (['TBC', 'Na', 'NA', 'na', 'N/A', 'TBD', 'tbd'].includes(n)) return null;
|
||||||
|
return n;
|
||||||
|
})(),
|
||||||
|
dateEoiSent: parseFlexibleDate(row['EOI Time Sent']),
|
||||||
|
dateEoiSigned: parseFlexibleDate(row['all_signed_notified_at'] ?? row['developerSignTime']),
|
||||||
|
dateDepositReceived: null, // not directly tracked in legacy schema
|
||||||
|
dateContractSent: parseFlexibleDate(row['Time LOI Sent']),
|
||||||
|
dateContractSigned: parseFlexibleDate(row['developerSignTime']),
|
||||||
|
dateLastContact: parseFlexibleDate(row['Created At'] ?? row['Date Added']),
|
||||||
|
documensoId: (row['documensoID'] as string | undefined) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
152
src/lib/dedup/nocodb-source.ts
Normal file
152
src/lib/dedup/nocodb-source.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Read-only adapter for the legacy NocoDB Port Nimara base.
|
||||||
|
*
|
||||||
|
* Used by the one-shot migration script (`scripts/migrate-from-nocodb.ts`)
|
||||||
|
* to pull every Interest, Residential Interest, and Website Submission
|
||||||
|
* row from the source-of-truth NocoDB tables. No mutations.
|
||||||
|
*
|
||||||
|
* Auth: `xc-token` header per NocoDB v2 API.
|
||||||
|
*
|
||||||
|
* The shape returned is a verbatim record of the row's fields — caller
|
||||||
|
* is responsible for mapping to the new schema via `nocodb-transform.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ConfigSchema = z.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
token: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface NocoDbConfig {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadNocoDbConfig(env: NodeJS.ProcessEnv = process.env): NocoDbConfig {
|
||||||
|
return ConfigSchema.parse({
|
||||||
|
url: env.NOCODB_URL,
|
||||||
|
token: env.NOCODB_TOKEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Table identifiers ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// These IDs are stable per the NocoDB base — they were captured during the
|
||||||
|
// 2026-05-03 audit and won't change unless the base is rebuilt. If the
|
||||||
|
// base is reset, regenerate them from `getTablesList`.
|
||||||
|
export const NOCO_TABLES = {
|
||||||
|
interests: 'mbs9hjauug4eseo',
|
||||||
|
residentialInterests: 'mscfpwwwjuds4nt',
|
||||||
|
websiteInterestSubmissions: 'mevkpcih67c6jsm',
|
||||||
|
websiteContactFormSubmissions: 'mxk5cd0pmwnwlcl',
|
||||||
|
websiteBerthEoiSupplements: 'mglmioo0ku8zgqj',
|
||||||
|
berths: 'mczgos9hr3oa9qc',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── HTTP shape ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NocoDbListResponse<T> {
|
||||||
|
list: T[];
|
||||||
|
pageInfo: {
|
||||||
|
totalRows: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
isFirstPage: boolean;
|
||||||
|
isLastPage: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row's `Id` is always present. The rest of the fields vary per table. */
|
||||||
|
export type NocoDbRow = Record<string, unknown> & { Id: number };
|
||||||
|
|
||||||
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all rows from a NocoDB table. Auto-paginates until the API
|
||||||
|
* reports `isLastPage`. The legacy base is small (252 Interests rows
|
||||||
|
* being the largest table) so we keep this simple — no streaming.
|
||||||
|
*/
|
||||||
|
export async function fetchAllRows(
|
||||||
|
tableId: string,
|
||||||
|
config: NocoDbConfig,
|
||||||
|
pageSize = 250,
|
||||||
|
): Promise<NocoDbRow[]> {
|
||||||
|
const all: NocoDbRow[] = [];
|
||||||
|
let page = 1;
|
||||||
|
// Hard cap to prevent infinite-loop bugs if pageInfo lies. Each page
|
||||||
|
// pulls up to `pageSize` rows, so 200 pages * 250 = 50k rows is the
|
||||||
|
// maximum we'll ever fetch from one table.
|
||||||
|
const MAX_PAGES = 200;
|
||||||
|
|
||||||
|
while (page <= MAX_PAGES) {
|
||||||
|
const url = new URL(`${config.url}/api/v2/tables/${tableId}/records`);
|
||||||
|
url.searchParams.set('limit', String(pageSize));
|
||||||
|
url.searchParams.set('offset', String((page - 1) * pageSize));
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.token,
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`NocoDB fetch failed: ${res.status} ${res.statusText} — table ${tableId} page ${page}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json()) as NocoDbListResponse<NocoDbRow>;
|
||||||
|
all.push(...json.list);
|
||||||
|
|
||||||
|
if (json.pageInfo.isLastPage || json.list.length === 0) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience snapshot — pulls every table the migration cares about
|
||||||
|
* in parallel. Returned shape is the input the transform layer expects.
|
||||||
|
*/
|
||||||
|
export interface NocoDbSnapshot {
|
||||||
|
interests: NocoDbRow[];
|
||||||
|
residentialInterests: NocoDbRow[];
|
||||||
|
websiteInterestSubmissions: NocoDbRow[];
|
||||||
|
websiteContactFormSubmissions: NocoDbRow[];
|
||||||
|
websiteBerthEoiSupplements: NocoDbRow[];
|
||||||
|
berths: NocoDbRow[];
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSnapshot(config: NocoDbConfig): Promise<NocoDbSnapshot> {
|
||||||
|
const [
|
||||||
|
interests,
|
||||||
|
residentialInterests,
|
||||||
|
websiteInterestSubmissions,
|
||||||
|
websiteContactFormSubmissions,
|
||||||
|
websiteBerthEoiSupplements,
|
||||||
|
berths,
|
||||||
|
] = await Promise.all([
|
||||||
|
fetchAllRows(NOCO_TABLES.interests, config),
|
||||||
|
fetchAllRows(NOCO_TABLES.residentialInterests, config),
|
||||||
|
fetchAllRows(NOCO_TABLES.websiteInterestSubmissions, config),
|
||||||
|
fetchAllRows(NOCO_TABLES.websiteContactFormSubmissions, config),
|
||||||
|
fetchAllRows(NOCO_TABLES.websiteBerthEoiSupplements, config),
|
||||||
|
fetchAllRows(NOCO_TABLES.berths, config),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
interests,
|
||||||
|
residentialInterests,
|
||||||
|
websiteInterestSubmissions,
|
||||||
|
websiteContactFormSubmissions,
|
||||||
|
websiteBerthEoiSupplements,
|
||||||
|
berths,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
418
src/lib/dedup/normalize.ts
Normal file
418
src/lib/dedup/normalize.ts
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
/**
|
||||||
|
* Normalization helpers for the dedup pipeline.
|
||||||
|
*
|
||||||
|
* Pure functions (no DB, no React). Used by both the runtime at-create
|
||||||
|
* surfaces and the one-shot NocoDB migration script. Every transform
|
||||||
|
* here has a fixture in `tests/unit/dedup/normalize.test.ts` drawn from
|
||||||
|
* real dirty values observed in the legacy NocoDB Interests table.
|
||||||
|
*
|
||||||
|
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ALL_COUNTRY_CODES, getCountryName, type CountryCode } from '@/lib/i18n/countries';
|
||||||
|
import { parsePhoneScriptSafe as parsePhone } from './phone-parse';
|
||||||
|
|
||||||
|
// ─── Names ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens that should stay lowercase mid-name. Covers the common Romance,
|
||||||
|
* Germanic, and Iberian particles seen in client records. The first token
|
||||||
|
* of a name is always title-cased even if it appears in this set.
|
||||||
|
*/
|
||||||
|
const PARTICLES: ReadonlySet<string> = new Set([
|
||||||
|
'van',
|
||||||
|
'von',
|
||||||
|
'de',
|
||||||
|
'del',
|
||||||
|
'da',
|
||||||
|
'das',
|
||||||
|
'do',
|
||||||
|
'dos',
|
||||||
|
'di',
|
||||||
|
'le',
|
||||||
|
'la',
|
||||||
|
'el',
|
||||||
|
'al',
|
||||||
|
'der',
|
||||||
|
'den',
|
||||||
|
'des',
|
||||||
|
'du',
|
||||||
|
'dalla',
|
||||||
|
'della',
|
||||||
|
'st',
|
||||||
|
'st.',
|
||||||
|
'y',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface NormalizedName {
|
||||||
|
/** Human-readable form preserved for UI display. Trims, collapses
|
||||||
|
* whitespace, fixes case, but never destroys the user's intent —
|
||||||
|
* slash-with-company structure ("Daniel Wainstein / 7 Knots, LLC")
|
||||||
|
* is left intact. */
|
||||||
|
display: string;
|
||||||
|
/** Lowercased form for matching. */
|
||||||
|
normalized: string;
|
||||||
|
/** Last non-particle token, lowercased. Used as a blocking key by the
|
||||||
|
* dedup algorithm so we only compare candidates with similar surnames. */
|
||||||
|
surnameToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a free-text full name. Trims and collapses whitespace,
|
||||||
|
* replaces \r/\n/\t with single spaces, intelligently title-cases
|
||||||
|
* ALL-CAPS surnames while keeping particles (van / de / dalla / etc.)
|
||||||
|
* lowercase mid-name, and preserves Irish O' surnames as O'Brien.
|
||||||
|
*
|
||||||
|
* If the input contains a `/` (slash-with-company structure like
|
||||||
|
* "Daniel Wainstein / 7 Knots, LLC"), the trailing company text is
|
||||||
|
* preserved verbatim — it's signal, not noise.
|
||||||
|
*/
|
||||||
|
export function normalizeName(raw: string | null | undefined): NormalizedName {
|
||||||
|
const safe = (raw ?? '').toString();
|
||||||
|
// Replace \r, \n, \t with single spaces, then collapse runs of whitespace.
|
||||||
|
const cleaned = safe
|
||||||
|
.replace(/[\r\n\t]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!cleaned) {
|
||||||
|
return { display: '', normalized: '', surnameToken: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash-with-company: title-case the part before the slash, leave the
|
||||||
|
// company segment untouched (it's typically already a brand we shouldn't
|
||||||
|
// mangle: "SAS TIKI", "7 Knots, LLC").
|
||||||
|
const slashIdx = cleaned.indexOf('/');
|
||||||
|
let displayCore: string;
|
||||||
|
if (slashIdx !== -1) {
|
||||||
|
const personPart = cleaned.slice(0, slashIdx).trim();
|
||||||
|
const companyPart = cleaned.slice(slashIdx + 1).trim();
|
||||||
|
displayCore = `${titleCaseTokens(personPart)} / ${companyPart}`;
|
||||||
|
} else {
|
||||||
|
displayCore = titleCaseTokens(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
const display = displayCore;
|
||||||
|
const normalized = display.toLowerCase();
|
||||||
|
const surnameToken = computeSurnameToken(slashIdx !== -1 ? cleaned.slice(0, slashIdx) : cleaned);
|
||||||
|
|
||||||
|
return { display, normalized, surnameToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCaseTokens(s: string): string {
|
||||||
|
const tokens = s.split(' ').filter(Boolean);
|
||||||
|
if (tokens.length === 0) return '';
|
||||||
|
return tokens.map((tok, idx) => titleCaseOneToken(tok, idx === 0)).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCaseOneToken(token: string, isFirst: boolean): string {
|
||||||
|
if (!token) return '';
|
||||||
|
const lower = token.toLowerCase();
|
||||||
|
if (!isFirst && PARTICLES.has(lower)) return lower;
|
||||||
|
// O'Brien / D'Angelo / l'Estrange — capitalize the segment after each
|
||||||
|
// apostrophe so a lowercased input round-trips to readable Irish caps.
|
||||||
|
if (lower.includes("'")) {
|
||||||
|
return lower
|
||||||
|
.split("'")
|
||||||
|
.map((part) => (part.length > 0 ? part[0]!.toUpperCase() + part.slice(1) : part))
|
||||||
|
.join("'");
|
||||||
|
}
|
||||||
|
return lower[0]!.toUpperCase() + lower.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSurnameToken(personPart: string): string | undefined {
|
||||||
|
const cleaned = personPart
|
||||||
|
.replace(/[\r\n\t]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (!cleaned) return undefined;
|
||||||
|
const tokens = cleaned.split(' ').map((t) => t.toLowerCase());
|
||||||
|
// Walk from the right past particles to find the last "real" surname token.
|
||||||
|
for (let i = tokens.length - 1; i >= 0; i -= 1) {
|
||||||
|
const tok = tokens[i]!;
|
||||||
|
if (!PARTICLES.has(tok)) return tok;
|
||||||
|
}
|
||||||
|
// All tokens are particles? Fall back to the last token verbatim.
|
||||||
|
return tokens[tokens.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Emails ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const emailSchema = z.string().email();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a free-text email. Trims + lowercases. Returns null for empty
|
||||||
|
* or malformed input — caller decides whether to flag, store, or drop.
|
||||||
|
*
|
||||||
|
* Plus-aliases (`user+tag@domain.com`) are NOT stripped: they're real
|
||||||
|
* distinct addresses, and stripping them would auto-merge legitimately
|
||||||
|
* separate accounts.
|
||||||
|
*/
|
||||||
|
export function normalizeEmail(raw: string | null | undefined): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
const trimmed = raw.toString().trim().toLowerCase();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const result = emailSchema.safeParse(trimmed);
|
||||||
|
return result.success ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Phones ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type PhoneFlag = 'multi_number' | 'placeholder' | 'unparseable';
|
||||||
|
|
||||||
|
export interface NormalizedPhone {
|
||||||
|
/** Canonical E.164 form, e.g. '+15742740548'. Null when unparseable
|
||||||
|
* or flagged as placeholder. */
|
||||||
|
e164: string | null;
|
||||||
|
/** ISO-3166-1 alpha-2 of the country the number was parsed against. */
|
||||||
|
country: CountryCode | null;
|
||||||
|
/** Display-friendly international format. Useful for migration reports. */
|
||||||
|
display: string | null;
|
||||||
|
/** Set when the input had a quirk worth surfacing in the migration
|
||||||
|
* report or runtime audit log. Absent on clean parses. */
|
||||||
|
flagged?: PhoneFlag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw user-entered phone string for comparison + storage.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. strip leading apostrophe (spreadsheet copy-paste artifact)
|
||||||
|
* 2. strip \r / \n / \t (real values seen in NocoDB had carriage returns)
|
||||||
|
* 3. detect multi-number fields ("+33611111111;+33622222222",
|
||||||
|
* "0677580750/0690511494") — flag and take first segment
|
||||||
|
* 4. strip whitespace, dots, dashes, parens, single quotes
|
||||||
|
* 5. convert leading "00" → "+" (international dialling code)
|
||||||
|
* 6. detect placeholder fakes (8+ consecutive zeros) — flag, return null e164
|
||||||
|
* 7. parse via libphonenumber-js
|
||||||
|
* 8. on parse failure or invalid number → flag 'unparseable'
|
||||||
|
*
|
||||||
|
* Returns null for empty inputs (cheaper to short-circuit than to wrap).
|
||||||
|
*/
|
||||||
|
export function normalizePhone(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
defaultCountry?: CountryCode,
|
||||||
|
): NormalizedPhone | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
let cleaned = raw.toString().trim();
|
||||||
|
if (!cleaned) return null;
|
||||||
|
|
||||||
|
// 1. Spreadsheet apostrophe prefix.
|
||||||
|
if (cleaned.startsWith("'")) cleaned = cleaned.slice(1);
|
||||||
|
|
||||||
|
// 2. Strip carriage returns / newlines / tabs.
|
||||||
|
cleaned = cleaned.replace(/[\r\n\t]/g, '');
|
||||||
|
|
||||||
|
// 3. Multi-number detection — split on /, ;, , (in that order of priority).
|
||||||
|
let flagged: PhoneFlag | undefined;
|
||||||
|
if (/[/;,]/.test(cleaned)) {
|
||||||
|
flagged = 'multi_number';
|
||||||
|
cleaned = cleaned.split(/[/;,]/)[0]!.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Strip whitespace, dots, dashes, parens. Keep + for E.164 prefix.
|
||||||
|
cleaned = cleaned.replace(/[\s.\-()]/g, '');
|
||||||
|
if (!cleaned) return { e164: null, country: null, display: null, flagged: 'unparseable' };
|
||||||
|
|
||||||
|
// 5. 00 international prefix → +.
|
||||||
|
if (cleaned.startsWith('00')) {
|
||||||
|
cleaned = '+' + cleaned.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Placeholder fakes — runs of 8+ consecutive zeros, e.g. +447000000000.
|
||||||
|
if (/0{8,}/.test(cleaned)) {
|
||||||
|
return { e164: null, country: null, display: null, flagged: 'placeholder' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Parse via the existing i18n helper (libphonenumber-js under the hood).
|
||||||
|
const parsed = parsePhone(cleaned, defaultCountry);
|
||||||
|
if (!parsed.e164) {
|
||||||
|
// Couldn't even produce a canonical form — genuinely garbage.
|
||||||
|
return { e164: null, country: null, display: null, flagged: 'unparseable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: we deliberately don't gate on `parsed.isValid`. The
|
||||||
|
// libphonenumber-js `min` build returns isValid=false for many real
|
||||||
|
// numbers (NANP territories share +1; some country metadata is
|
||||||
|
// truncated). For dedup we only need a canonical E.164 string to
|
||||||
|
// compare; strict validity is the form layer's problem, not ours.
|
||||||
|
// If a string-only test (e.g. \"abc-not-a-phone\") gets here, parse
|
||||||
|
// returns null e164 anyway and the branch above handles it.
|
||||||
|
return {
|
||||||
|
e164: parsed.e164,
|
||||||
|
country: parsed.country,
|
||||||
|
display: parsed.international,
|
||||||
|
flagged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Countries ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aliases for canonical country names that don't match
|
||||||
|
* `Intl.DisplayNames(en)` output verbatim. Keys are pre-normalized
|
||||||
|
* (lowercase, diacritic-free, hyphens/dots → spaces, collapsed whitespace).
|
||||||
|
*
|
||||||
|
* Kept opinionated and small — only entries we've actually seen in legacy
|
||||||
|
* data. Adding a new alias is cheap; trying to be exhaustive isn't.
|
||||||
|
*/
|
||||||
|
const COUNTRY_ALIASES: Record<string, CountryCode> = {
|
||||||
|
// Generic abbreviations
|
||||||
|
usa: 'US',
|
||||||
|
us: 'US',
|
||||||
|
uk: 'GB',
|
||||||
|
// Saint-Barthélemy variants seen in production
|
||||||
|
'saint barthelemy': 'BL',
|
||||||
|
'saint barth': 'BL',
|
||||||
|
'st barth': 'BL',
|
||||||
|
'st barths': 'BL',
|
||||||
|
'st barthelemy': 'BL',
|
||||||
|
// Caribbean short-forms whose canonical Intl names are awkward
|
||||||
|
// ("Antigua and Barbuda", "Saint Vincent and the Grenadines", etc.).
|
||||||
|
antigua: 'AG',
|
||||||
|
barbuda: 'AG',
|
||||||
|
'st kitts': 'KN',
|
||||||
|
'saint kitts': 'KN',
|
||||||
|
nevis: 'KN',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-frequency cities → country, used as a last-resort fallback when
|
||||||
|
* exact / alias / fuzzy country matching all miss. Keys are normalized.
|
||||||
|
*
|
||||||
|
* Order matters: an entry's key is also matched as a substring of the
|
||||||
|
* input ("Sag Harbor Y" contains "sag harbor"), so the most specific
|
||||||
|
* city appears first to avoid a wrong partial hit.
|
||||||
|
*/
|
||||||
|
const CITY_TO_COUNTRY: Record<string, CountryCode> = {
|
||||||
|
'kansas city': 'US',
|
||||||
|
'sag harbor': 'US',
|
||||||
|
'new york': 'US',
|
||||||
|
// Cities that came out unresolved from the 2026-05-03 NocoDB dry-run.
|
||||||
|
// Using lowercase (post-normalize keys).
|
||||||
|
boston: 'US',
|
||||||
|
tampa: 'US',
|
||||||
|
'fort lauderdale': 'US',
|
||||||
|
'port jefferson': 'US',
|
||||||
|
nantucket: 'US',
|
||||||
|
// US state abbreviations that often appear standalone or as suffix:
|
||||||
|
' fl': 'US',
|
||||||
|
' ma': 'US',
|
||||||
|
' ny': 'US',
|
||||||
|
' tx': 'US',
|
||||||
|
' ca': 'US',
|
||||||
|
// International
|
||||||
|
london: 'GB',
|
||||||
|
paris: 'FR',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CountryConfidence = 'exact' | 'fuzzy' | 'city';
|
||||||
|
|
||||||
|
export interface ResolvedCountry {
|
||||||
|
iso: CountryCode | null;
|
||||||
|
confidence: CountryConfidence | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map free-text country / region input to an ISO-3166-1 alpha-2 code.
|
||||||
|
*
|
||||||
|
* Lookup order: alias → exact (vs. all locale country names) → city →
|
||||||
|
* fuzzy (Levenshtein ≤ 2). Anything beyond fuzzy returns null and the
|
||||||
|
* migration script flags the row for human review.
|
||||||
|
*/
|
||||||
|
export function resolveCountry(text: string | null | undefined): ResolvedCountry {
|
||||||
|
if (text == null) return { iso: null, confidence: null };
|
||||||
|
const normalized = normalizeForLookup(text.toString());
|
||||||
|
if (!normalized) return { iso: null, confidence: null };
|
||||||
|
|
||||||
|
// 1. Aliases — covers USA / UK / St Barth and friends.
|
||||||
|
const alias = COUNTRY_ALIASES[normalized];
|
||||||
|
if (alias) return { iso: alias, confidence: 'exact' };
|
||||||
|
|
||||||
|
// 2. Exact match against Intl-derived country names. We compare against
|
||||||
|
// diacritic-stripped + lowercased canonical names so 'United States'
|
||||||
|
// and 'united states' both resolve.
|
||||||
|
for (const code of ALL_COUNTRY_CODES) {
|
||||||
|
const cleanName = normalizeForLookup(getCountryName(code, 'en'));
|
||||||
|
if (cleanName === normalized) return { iso: code, confidence: 'exact' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. City → country fallback, exact or substring.
|
||||||
|
const cityExact = CITY_TO_COUNTRY[normalized];
|
||||||
|
if (cityExact) return { iso: cityExact, confidence: 'city' };
|
||||||
|
for (const [city, iso] of Object.entries(CITY_TO_COUNTRY)) {
|
||||||
|
if (normalized.includes(city)) return { iso, confidence: 'city' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fuzzy fallback (Levenshtein ≤ 2). Skipped for short inputs because
|
||||||
|
// a 4-char string like "Mars" sits within distance 2 of multiple
|
||||||
|
// short country names (Mali, Laos, Iran, …) — false-positive city.
|
||||||
|
if (normalized.length >= 6) {
|
||||||
|
let bestCode: CountryCode | null = null;
|
||||||
|
let bestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
for (const code of ALL_COUNTRY_CODES) {
|
||||||
|
const cleanName = normalizeForLookup(getCountryName(code, 'en'));
|
||||||
|
const d = levenshtein(cleanName, normalized);
|
||||||
|
if (d < bestDistance) {
|
||||||
|
bestDistance = d;
|
||||||
|
bestCode = code;
|
||||||
|
if (d === 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestDistance <= 2 && bestCode) {
|
||||||
|
return { iso: bestCode, confidence: 'fuzzy' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { iso: null, confidence: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase + strip diacritics + replace hyphens/dots with spaces +
|
||||||
|
* collapse whitespace. Used by both the input and the canonical-name
|
||||||
|
* side of the country comparison so they meet on the same shape. */
|
||||||
|
function normalizeForLookup(s: string): string {
|
||||||
|
return s
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[-.]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Levenshtein ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard iterative Levenshtein. Used by the country fuzzy match and by
|
||||||
|
* the dedup algorithm's name-similarity rule. Allocates O(n*m) so callers
|
||||||
|
* shouldn't run it against pathological inputs — the dedup blocking
|
||||||
|
* strategy keeps comparison sets small.
|
||||||
|
*
|
||||||
|
* Exported so the find-matches module can reuse the same implementation
|
||||||
|
* without relying on an external dep.
|
||||||
|
*/
|
||||||
|
export function levenshtein(a: string, b: string): number {
|
||||||
|
if (a === b) return 0;
|
||||||
|
if (!a) return b.length;
|
||||||
|
if (!b) return a.length;
|
||||||
|
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
// Two rolling rows is enough — keeps memory at O(n) instead of O(n*m).
|
||||||
|
let prev = new Array<number>(n + 1);
|
||||||
|
let curr = new Array<number>(n + 1);
|
||||||
|
for (let j = 0; j <= n; j += 1) prev[j] = j;
|
||||||
|
|
||||||
|
for (let i = 1; i <= m; i += 1) {
|
||||||
|
curr[0] = i;
|
||||||
|
for (let j = 1; j <= n; j += 1) {
|
||||||
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||||
|
curr[j] = Math.min(curr[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost);
|
||||||
|
}
|
||||||
|
[prev, curr] = [curr, prev];
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev[n]!;
|
||||||
|
}
|
||||||
66
src/lib/dedup/phone-parse.ts
Normal file
66
src/lib/dedup/phone-parse.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Script-safe phone parser.
|
||||||
|
*
|
||||||
|
* The project's existing `src/lib/i18n/phone.ts` imports from
|
||||||
|
* `libphonenumber-js`, which under Node 25 + tsx loader hits a
|
||||||
|
* metadata-shape interop bug (`{ default }` wrapping the JSON). It
|
||||||
|
* works fine in Next.js + vitest, but a `tsx scripts/...` invocation
|
||||||
|
* blows up.
|
||||||
|
*
|
||||||
|
* This wrapper bypasses the bundled `index.cjs.js` and calls
|
||||||
|
* `libphonenumber-js/core` directly with metadata loaded as raw JSON.
|
||||||
|
* Same surface as the i18n helper; usable from both runtimes.
|
||||||
|
*
|
||||||
|
* Used by the dedup library's `normalizePhone`. The runtime UI still
|
||||||
|
* imports `i18n/phone` directly — no reason to touch a working path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const core: typeof import('libphonenumber-js/core') = require('libphonenumber-js/core');
|
||||||
|
// Load the JSON directly. The bundled `index.cjs.js` does the same
|
||||||
|
// thing but its `require('../metadata.min.json')` hits a Node 25 ESM
|
||||||
|
// interop bug that wraps the JSON in `{ default }`. Importing the
|
||||||
|
// JSON file by absolute path through the package root sidesteps it.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
|
||||||
|
const metadata: any = require('libphonenumber-js/metadata.min.json');
|
||||||
|
|
||||||
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
|
||||||
|
export interface ParsedPhone {
|
||||||
|
e164: string | null;
|
||||||
|
country: CountryCode | null;
|
||||||
|
national: string | null;
|
||||||
|
international: string | null;
|
||||||
|
isValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: ParsedPhone = {
|
||||||
|
e164: null,
|
||||||
|
country: null,
|
||||||
|
national: null,
|
||||||
|
international: null,
|
||||||
|
isValid: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parsePhoneScriptSafe(raw: string, defaultCountry?: CountryCode): ParsedPhone {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return EMPTY;
|
||||||
|
try {
|
||||||
|
// The core entry expects its own `CountryCode` type from
|
||||||
|
// libphonenumber-js. Our `CountryCode` type is the same set of ISO
|
||||||
|
// alpha-2 codes (we re-derive from the same Intl source) so this
|
||||||
|
// cast is structural-equivalent, not lossy.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const parsed = core.parsePhoneNumberFromString(trimmed, defaultCountry as any, metadata);
|
||||||
|
if (!parsed) return EMPTY;
|
||||||
|
return {
|
||||||
|
e164: parsed.number,
|
||||||
|
country: (parsed.country ?? null) as CountryCode | null,
|
||||||
|
national: parsed.formatNational(),
|
||||||
|
international: parsed.formatInternational(),
|
||||||
|
isValid: parsed.isValid(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,11 +80,13 @@ export async function fillEoiFormFields(
|
|||||||
setText(form, 'Name', context.client.fullName);
|
setText(form, 'Name', context.client.fullName);
|
||||||
setText(form, 'Email', context.client.primaryEmail ?? '');
|
setText(form, 'Email', context.client.primaryEmail ?? '');
|
||||||
setText(form, 'Address', formatAddress(context.client.address));
|
setText(form, 'Address', formatAddress(context.client.address));
|
||||||
setText(form, 'Yacht Name', context.yacht.name);
|
// Yacht + berth (EOI Section 3) are optional — leave the AcroForm fields
|
||||||
setText(form, 'Length', context.yacht.lengthFt ?? '');
|
// blank when the interest hasn't been linked to either.
|
||||||
setText(form, 'Width', context.yacht.widthFt ?? '');
|
setText(form, 'Yacht Name', context.yacht?.name ?? '');
|
||||||
setText(form, 'Draft', context.yacht.draftFt ?? '');
|
setText(form, 'Length', context.yacht?.lengthFt ?? '');
|
||||||
setText(form, 'Berth Number', context.berth.mooringNumber);
|
setText(form, 'Width', context.yacht?.widthFt ?? '');
|
||||||
|
setText(form, 'Draft', context.yacht?.draftFt ?? '');
|
||||||
|
setText(form, 'Berth Number', context.berth?.mooringNumber ?? '');
|
||||||
|
|
||||||
setCheckbox(form, 'Purchase', true);
|
setCheckbox(form, 'Purchase', true);
|
||||||
setCheckbox(form, 'Lease_10', false);
|
setCheckbox(form, 'Lease_10', false);
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { cookies } from 'next/headers';
|
|||||||
const PORTAL_SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET);
|
const PORTAL_SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET);
|
||||||
export const PORTAL_COOKIE = 'portal_session';
|
export const PORTAL_COOKIE = 'portal_session';
|
||||||
|
|
||||||
|
// BREAKING CHANGE (intentional): tokens issued before this change lack aud/iss
|
||||||
|
// and will be rejected by verifyPortalToken. Portal tokens are 24h-lived so
|
||||||
|
// existing sessions will be invalidated on deploy. Users simply re-login.
|
||||||
|
const PORTAL_AUD = 'portal';
|
||||||
|
const PORTAL_ISS = 'pn-crm';
|
||||||
|
|
||||||
export interface PortalSession {
|
export interface PortalSession {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
portId: string;
|
portId: string;
|
||||||
@@ -13,6 +19,8 @@ export interface PortalSession {
|
|||||||
export async function createPortalToken(session: PortalSession): Promise<string> {
|
export async function createPortalToken(session: PortalSession): Promise<string> {
|
||||||
return new SignJWT(session as unknown as Record<string, unknown>)
|
return new SignJWT(session as unknown as Record<string, unknown>)
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setAudience(PORTAL_AUD)
|
||||||
|
.setIssuer(PORTAL_ISS)
|
||||||
.setExpirationTime('24h')
|
.setExpirationTime('24h')
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.sign(PORTAL_SECRET);
|
.sign(PORTAL_SECRET);
|
||||||
@@ -20,7 +28,10 @@ export async function createPortalToken(session: PortalSession): Promise<string>
|
|||||||
|
|
||||||
export async function verifyPortalToken(token: string): Promise<PortalSession | null> {
|
export async function verifyPortalToken(token: string): Promise<PortalSession | null> {
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, PORTAL_SECRET);
|
const { payload } = await jwtVerify(token, PORTAL_SECRET, {
|
||||||
|
audience: PORTAL_AUD,
|
||||||
|
issuer: PORTAL_ISS,
|
||||||
|
});
|
||||||
return payload as unknown as PortalSession;
|
return payload as unknown as PortalSession;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
393
src/lib/services/client-merge.service.ts
Normal file
393
src/lib/services/client-merge.service.ts
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
/**
|
||||||
|
* Client merge service — atomically combines two client records.
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - /admin/duplicates review queue (when an admin confirms a merge)
|
||||||
|
* - the at-create suggestion path ("use existing client") — though
|
||||||
|
* that path uses the lighter `attachInterestToClient` and never
|
||||||
|
* actually merges two pre-existing clients
|
||||||
|
* - the migration script's `--apply` (eventually)
|
||||||
|
*
|
||||||
|
* Reversibility: every merge writes a `client_merge_log` row containing
|
||||||
|
* the loser's full pre-merge state. Within the configured undo window
|
||||||
|
* (default 7 days, see `dedup_undo_window_days` in system_settings) a
|
||||||
|
* follow-up `unmergeClients` call can restore the loser and detach
|
||||||
|
* everything that was reattached.
|
||||||
|
*
|
||||||
|
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §6.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
clients,
|
||||||
|
clientContacts,
|
||||||
|
clientAddresses,
|
||||||
|
clientNotes,
|
||||||
|
clientTags,
|
||||||
|
clientRelationships,
|
||||||
|
clientMergeLog,
|
||||||
|
clientMergeCandidates,
|
||||||
|
} from '@/lib/db/schema/clients';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||||
|
import { auditLogs } from '@/lib/db/schema/system';
|
||||||
|
|
||||||
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface MergeFieldChoices {
|
||||||
|
/** Per-field overrides — `winner` keeps the surviving client's value;
|
||||||
|
* `loser` copies the loser's value over. Fields not listed default
|
||||||
|
* to `winner` (no change). */
|
||||||
|
fullName?: 'winner' | 'loser';
|
||||||
|
nationalityIso?: 'winner' | 'loser';
|
||||||
|
preferredContactMethod?: 'winner' | 'loser';
|
||||||
|
preferredLanguage?: 'winner' | 'loser';
|
||||||
|
timezone?: 'winner' | 'loser';
|
||||||
|
source?: 'winner' | 'loser';
|
||||||
|
sourceDetails?: 'winner' | 'loser';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergeOptions {
|
||||||
|
winnerId: string;
|
||||||
|
loserId: string;
|
||||||
|
/** ID of the user performing the merge (for audit + clientMergeLog.mergedBy). */
|
||||||
|
mergedBy: string;
|
||||||
|
/** Per-field choice overrides. Multi-value fields (contacts, addresses,
|
||||||
|
* notes, tags) are always preserved from both sides; this only
|
||||||
|
* affects single-value scalar fields on the `clients` row. */
|
||||||
|
fieldChoices?: MergeFieldChoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergeResult {
|
||||||
|
mergeLogId: string;
|
||||||
|
movedRows: {
|
||||||
|
interests: number;
|
||||||
|
contacts: number;
|
||||||
|
addresses: number;
|
||||||
|
notes: number;
|
||||||
|
tags: number;
|
||||||
|
relationships: number;
|
||||||
|
reservations: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically merge `loserId` into `winnerId`. Throws if:
|
||||||
|
* - either id doesn't exist or belongs to a different port
|
||||||
|
* - the loser has already been merged (mergedIntoClientId set)
|
||||||
|
* - the winner is itself archived
|
||||||
|
*/
|
||||||
|
export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
||||||
|
if (opts.winnerId === opts.loserId) {
|
||||||
|
throw new Error('Cannot merge a client into itself');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
// ── Lock both rows for the duration. The first FOR UPDATE that
|
||||||
|
// arrives wins; a concurrent second merge of the same loser
|
||||||
|
// will see `mergedIntoClientId` set and bail. ──────────────────────
|
||||||
|
const [winnerRow] = await tx
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, opts.winnerId))
|
||||||
|
.for('update');
|
||||||
|
const [loserRow] = await tx
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, opts.loserId))
|
||||||
|
.for('update');
|
||||||
|
|
||||||
|
if (!winnerRow) throw new Error(`Winner client ${opts.winnerId} not found`);
|
||||||
|
if (!loserRow) throw new Error(`Loser client ${opts.loserId} not found`);
|
||||||
|
if (winnerRow.portId !== loserRow.portId) {
|
||||||
|
throw new Error('Cannot merge clients across different ports');
|
||||||
|
}
|
||||||
|
if (loserRow.mergedIntoClientId) {
|
||||||
|
throw new Error(`Loser ${opts.loserId} already merged into ${loserRow.mergedIntoClientId}`);
|
||||||
|
}
|
||||||
|
if (winnerRow.archivedAt) {
|
||||||
|
throw new Error('Cannot merge into an archived client');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Snapshot the loser's full state before any mutation. Used by
|
||||||
|
// `unmergeClients` to restore within the undo window. ──────────────
|
||||||
|
const loserContacts = await tx
|
||||||
|
.select()
|
||||||
|
.from(clientContacts)
|
||||||
|
.where(eq(clientContacts.clientId, opts.loserId));
|
||||||
|
const loserAddresses = await tx
|
||||||
|
.select()
|
||||||
|
.from(clientAddresses)
|
||||||
|
.where(eq(clientAddresses.clientId, opts.loserId));
|
||||||
|
const loserNotes = await tx
|
||||||
|
.select()
|
||||||
|
.from(clientNotes)
|
||||||
|
.where(eq(clientNotes.clientId, opts.loserId));
|
||||||
|
const loserTags = await tx
|
||||||
|
.select()
|
||||||
|
.from(clientTags)
|
||||||
|
.where(eq(clientTags.clientId, opts.loserId));
|
||||||
|
const loserInterests = await tx
|
||||||
|
.select({ id: interests.id })
|
||||||
|
.from(interests)
|
||||||
|
.where(eq(interests.clientId, opts.loserId));
|
||||||
|
const loserReservations = await tx
|
||||||
|
.select({ id: berthReservations.id })
|
||||||
|
.from(berthReservations)
|
||||||
|
.where(eq(berthReservations.clientId, opts.loserId));
|
||||||
|
const loserRelationshipsAsA = await tx
|
||||||
|
.select()
|
||||||
|
.from(clientRelationships)
|
||||||
|
.where(eq(clientRelationships.clientAId, opts.loserId));
|
||||||
|
const loserRelationshipsAsB = await tx
|
||||||
|
.select()
|
||||||
|
.from(clientRelationships)
|
||||||
|
.where(eq(clientRelationships.clientBId, opts.loserId));
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
loser: loserRow,
|
||||||
|
contacts: loserContacts,
|
||||||
|
addresses: loserAddresses,
|
||||||
|
notes: loserNotes,
|
||||||
|
tags: loserTags,
|
||||||
|
interests: loserInterests.map((r) => r.id),
|
||||||
|
reservations: loserReservations.map((r) => r.id),
|
||||||
|
relationshipsAsA: loserRelationshipsAsA,
|
||||||
|
relationshipsAsB: loserRelationshipsAsB,
|
||||||
|
fieldChoices: opts.fieldChoices ?? {},
|
||||||
|
mergedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Apply field choices on the winner. We only touch fields the
|
||||||
|
// caller explicitly asked to copy from the loser; everything
|
||||||
|
// else stays as-is. ────────────────────────────────────────────────
|
||||||
|
const fieldUpdates: Partial<typeof winnerRow> = {};
|
||||||
|
if (opts.fieldChoices?.fullName === 'loser') fieldUpdates.fullName = loserRow.fullName;
|
||||||
|
if (opts.fieldChoices?.nationalityIso === 'loser')
|
||||||
|
fieldUpdates.nationalityIso = loserRow.nationalityIso;
|
||||||
|
if (opts.fieldChoices?.preferredContactMethod === 'loser')
|
||||||
|
fieldUpdates.preferredContactMethod = loserRow.preferredContactMethod;
|
||||||
|
if (opts.fieldChoices?.preferredLanguage === 'loser')
|
||||||
|
fieldUpdates.preferredLanguage = loserRow.preferredLanguage;
|
||||||
|
if (opts.fieldChoices?.timezone === 'loser') fieldUpdates.timezone = loserRow.timezone;
|
||||||
|
if (opts.fieldChoices?.source === 'loser') fieldUpdates.source = loserRow.source;
|
||||||
|
if (opts.fieldChoices?.sourceDetails === 'loser')
|
||||||
|
fieldUpdates.sourceDetails = loserRow.sourceDetails;
|
||||||
|
|
||||||
|
if (Object.keys(fieldUpdates).length > 0) {
|
||||||
|
await tx
|
||||||
|
.update(clients)
|
||||||
|
.set({ ...fieldUpdates, updatedAt: new Date() })
|
||||||
|
.where(eq(clients.id, opts.winnerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reattach. Each table that points at the loser via clientId
|
||||||
|
// gets pointed at the winner instead. ─────────────────────────────
|
||||||
|
|
||||||
|
const movedInterests = (
|
||||||
|
await tx
|
||||||
|
.update(interests)
|
||||||
|
.set({ clientId: opts.winnerId, updatedAt: new Date() })
|
||||||
|
.where(eq(interests.clientId, opts.loserId))
|
||||||
|
.returning({ id: interests.id })
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const movedReservations = (
|
||||||
|
await tx
|
||||||
|
.update(berthReservations)
|
||||||
|
.set({ clientId: opts.winnerId, updatedAt: new Date() })
|
||||||
|
.where(eq(berthReservations.clientId, opts.loserId))
|
||||||
|
.returning({ id: berthReservations.id })
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Contacts: move loser's contacts to winner, but DON'T duplicate any
|
||||||
|
// already-present (channel, value) pair. Loser-only ones get
|
||||||
|
// demoted to non-primary so the winner's primary stays intact.
|
||||||
|
const winnerContacts = await tx
|
||||||
|
.select({ channel: clientContacts.channel, value: clientContacts.value })
|
||||||
|
.from(clientContacts)
|
||||||
|
.where(eq(clientContacts.clientId, opts.winnerId));
|
||||||
|
const winnerContactKeys = new Set(
|
||||||
|
winnerContacts.map((c) => `${c.channel}::${c.value.toLowerCase()}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
let movedContacts = 0;
|
||||||
|
for (const c of loserContacts) {
|
||||||
|
const key = `${c.channel}::${c.value.toLowerCase()}`;
|
||||||
|
if (winnerContactKeys.has(key)) {
|
||||||
|
// Winner already has this contact — drop loser's row (cascade
|
||||||
|
// will clean up when loser is archived). But we keep snapshot
|
||||||
|
// so undo restores it.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await tx
|
||||||
|
.update(clientContacts)
|
||||||
|
.set({ clientId: opts.winnerId, isPrimary: false, updatedAt: new Date() })
|
||||||
|
.where(eq(clientContacts.id, c.id));
|
||||||
|
movedContacts += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addresses: same shape as contacts, but uniqueness is harder to
|
||||||
|
// detect cleanly (free-text street). Just move them all and let the
|
||||||
|
// user dedupe in the UI later.
|
||||||
|
const movedAddresses = (
|
||||||
|
await tx
|
||||||
|
.update(clientAddresses)
|
||||||
|
.set({ clientId: opts.winnerId, isPrimary: false, updatedAt: new Date() })
|
||||||
|
.where(eq(clientAddresses.clientId, opts.loserId))
|
||||||
|
.returning({ id: clientAddresses.id })
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const movedNotes = (
|
||||||
|
await tx
|
||||||
|
.update(clientNotes)
|
||||||
|
.set({ clientId: opts.winnerId, updatedAt: new Date() })
|
||||||
|
.where(eq(clientNotes.clientId, opts.loserId))
|
||||||
|
.returning({ id: clientNotes.id })
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Tags: copy any loser-only tag to the winner; drop overlap.
|
||||||
|
const winnerTags = await tx
|
||||||
|
.select({ tagId: clientTags.tagId })
|
||||||
|
.from(clientTags)
|
||||||
|
.where(eq(clientTags.clientId, opts.winnerId));
|
||||||
|
const winnerTagSet = new Set(winnerTags.map((t) => t.tagId));
|
||||||
|
let movedTags = 0;
|
||||||
|
for (const t of loserTags) {
|
||||||
|
if (!winnerTagSet.has(t.tagId)) {
|
||||||
|
await tx.insert(clientTags).values({ clientId: opts.winnerId, tagId: t.tagId });
|
||||||
|
movedTags += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tx.delete(clientTags).where(eq(clientTags.clientId, opts.loserId));
|
||||||
|
|
||||||
|
// Relationships: rewrite each FK side to point at the winner. Keep
|
||||||
|
// both sides regardless — even if A and B both end up as the same
|
||||||
|
// person, the row is preserved for audit; the UI hides self-loops.
|
||||||
|
const movedRelationships =
|
||||||
|
(
|
||||||
|
await tx
|
||||||
|
.update(clientRelationships)
|
||||||
|
.set({ clientAId: opts.winnerId })
|
||||||
|
.where(eq(clientRelationships.clientAId, opts.loserId))
|
||||||
|
.returning({ id: clientRelationships.id })
|
||||||
|
).length +
|
||||||
|
(
|
||||||
|
await tx
|
||||||
|
.update(clientRelationships)
|
||||||
|
.set({ clientBId: opts.winnerId })
|
||||||
|
.where(eq(clientRelationships.clientBId, opts.loserId))
|
||||||
|
.returning({ id: clientRelationships.id })
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// ── Archive the loser. Row stays in DB for the undo window;
|
||||||
|
// `mergedIntoClientId` is the redirect pointer for any stragglers
|
||||||
|
// (links / direct queries / saved views). ──────────────────────────
|
||||||
|
await tx
|
||||||
|
.update(clients)
|
||||||
|
.set({
|
||||||
|
archivedAt: new Date(),
|
||||||
|
mergedIntoClientId: opts.winnerId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(clients.id, opts.loserId));
|
||||||
|
|
||||||
|
// ── Mark any open merge candidate row for this pair as resolved. ───
|
||||||
|
await tx
|
||||||
|
.update(clientMergeCandidates)
|
||||||
|
.set({
|
||||||
|
status: 'merged',
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
resolvedBy: opts.mergedBy,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientMergeCandidates.portId, winnerRow.portId),
|
||||||
|
// pair stored in canonical order — match either direction
|
||||||
|
sql`(
|
||||||
|
(${clientMergeCandidates.clientAId} = ${opts.winnerId}
|
||||||
|
AND ${clientMergeCandidates.clientBId} = ${opts.loserId})
|
||||||
|
OR
|
||||||
|
(${clientMergeCandidates.clientAId} = ${opts.loserId}
|
||||||
|
AND ${clientMergeCandidates.clientBId} = ${opts.winnerId})
|
||||||
|
)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Write the merge log + audit log. ────────────────────────────────
|
||||||
|
const [logRow] = await tx
|
||||||
|
.insert(clientMergeLog)
|
||||||
|
.values({
|
||||||
|
portId: winnerRow.portId,
|
||||||
|
survivingClientId: opts.winnerId,
|
||||||
|
mergedClientId: opts.loserId,
|
||||||
|
mergedBy: opts.mergedBy,
|
||||||
|
mergeDetails: snapshot,
|
||||||
|
})
|
||||||
|
.returning({ id: clientMergeLog.id });
|
||||||
|
|
||||||
|
await tx.insert(auditLogs).values({
|
||||||
|
portId: winnerRow.portId,
|
||||||
|
userId: opts.mergedBy,
|
||||||
|
entityType: 'client',
|
||||||
|
entityId: opts.winnerId,
|
||||||
|
action: 'merge',
|
||||||
|
newValue: {
|
||||||
|
loserId: opts.loserId,
|
||||||
|
loserName: loserRow.fullName,
|
||||||
|
movedInterests,
|
||||||
|
movedReservations,
|
||||||
|
movedContacts,
|
||||||
|
movedAddresses,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
mergeLogId: logRow!.id,
|
||||||
|
movedRows: {
|
||||||
|
interests: movedInterests,
|
||||||
|
contacts: movedContacts,
|
||||||
|
addresses: movedAddresses,
|
||||||
|
notes: movedNotes,
|
||||||
|
tags: movedTags,
|
||||||
|
relationships: movedRelationships,
|
||||||
|
reservations: movedReservations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Convenience: list merge candidates for a port ──────────────────────────
|
||||||
|
|
||||||
|
export interface MergeCandidatePair {
|
||||||
|
id: string;
|
||||||
|
clientAId: string;
|
||||||
|
clientBId: string;
|
||||||
|
score: number;
|
||||||
|
reasons: string[];
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch pending merge candidate pairs for the admin review queue. */
|
||||||
|
export async function listPendingMergeCandidates(portId: string): Promise<MergeCandidatePair[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(clientMergeCandidates)
|
||||||
|
.where(
|
||||||
|
and(eq(clientMergeCandidates.portId, portId), eq(clientMergeCandidates.status, 'pending')),
|
||||||
|
)
|
||||||
|
.orderBy(sql`${clientMergeCandidates.score} DESC`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
clientAId: r.clientAId,
|
||||||
|
clientBId: r.clientBId,
|
||||||
|
score: r.score,
|
||||||
|
reasons: Array.isArray(r.reasons) ? (r.reasons as string[]) : [],
|
||||||
|
status: r.status,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -27,9 +27,11 @@ export async function getKpis(portId: string) {
|
|||||||
.from(interests)
|
.from(interests)
|
||||||
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest));
|
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest));
|
||||||
|
|
||||||
// Pipeline value: SUM berths.price via JOIN from non-archived interests with berthId
|
// Pipeline value: SUM each berth's price ONCE regardless of how many active
|
||||||
|
// interests reference it. A berth with multiple interests would otherwise be
|
||||||
|
// counted multiple times, inflating the total.
|
||||||
const pipelineRows = await db
|
const pipelineRows = await db
|
||||||
.select({ price: berths.price })
|
.selectDistinct({ berthId: interests.berthId, price: berths.price })
|
||||||
.from(interests)
|
.from(interests)
|
||||||
.innerJoin(berths, eq(interests.berthId, berths.id))
|
.innerJoin(berths, eq(interests.berthId, berths.id))
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -128,11 +128,13 @@ export function buildDocumensoPayload(
|
|||||||
Name: context.client.fullName,
|
Name: context.client.fullName,
|
||||||
Email: context.client.primaryEmail ?? '',
|
Email: context.client.primaryEmail ?? '',
|
||||||
Address: formatAddress(context.client.address),
|
Address: formatAddress(context.client.address),
|
||||||
'Yacht Name': context.yacht.name,
|
// Yacht + berth are optional EOI fields; when not linked, render as
|
||||||
Length: context.yacht.lengthFt ?? '',
|
// empty strings so the corresponding template inputs stay blank.
|
||||||
Width: context.yacht.widthFt ?? '',
|
'Yacht Name': context.yacht?.name ?? '',
|
||||||
Draft: context.yacht.draftFt ?? '',
|
Length: context.yacht?.lengthFt ?? '',
|
||||||
'Berth Number': context.berth.mooringNumber,
|
Width: context.yacht?.widthFt ?? '',
|
||||||
|
Draft: context.yacht?.draftFt ?? '',
|
||||||
|
'Berth Number': context.berth?.mooringNumber ?? '',
|
||||||
Lease_10: false,
|
Lease_10: false,
|
||||||
Purchase: true,
|
Purchase: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -237,18 +237,20 @@ export async function resolveTemplate(
|
|||||||
tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? '';
|
tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? '';
|
||||||
tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? '';
|
tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? '';
|
||||||
|
|
||||||
// Yacht tokens
|
// Yacht tokens — `eoi.yacht` is null when no yacht is linked
|
||||||
tokenMap['{{yacht.name}}'] = eoi.yacht.name;
|
// (Section 3 of the EOI is optional). Tokens render as empty strings
|
||||||
tokenMap['{{yacht.hullNumber}}'] = eoi.yacht.hullNumber ?? '';
|
// in that case so the template still produces output.
|
||||||
tokenMap['{{yacht.flag}}'] = eoi.yacht.flag ?? '';
|
tokenMap['{{yacht.name}}'] = eoi.yacht?.name ?? '';
|
||||||
|
tokenMap['{{yacht.hullNumber}}'] = eoi.yacht?.hullNumber ?? '';
|
||||||
|
tokenMap['{{yacht.flag}}'] = eoi.yacht?.flag ?? '';
|
||||||
tokenMap['{{yacht.yearBuilt}}'] =
|
tokenMap['{{yacht.yearBuilt}}'] =
|
||||||
eoi.yacht.yearBuilt != null ? String(eoi.yacht.yearBuilt) : '';
|
eoi.yacht?.yearBuilt != null ? String(eoi.yacht.yearBuilt) : '';
|
||||||
tokenMap['{{yacht.lengthFt}}'] = eoi.yacht.lengthFt ?? '';
|
tokenMap['{{yacht.lengthFt}}'] = eoi.yacht?.lengthFt ?? '';
|
||||||
tokenMap['{{yacht.widthFt}}'] = eoi.yacht.widthFt ?? '';
|
tokenMap['{{yacht.widthFt}}'] = eoi.yacht?.widthFt ?? '';
|
||||||
tokenMap['{{yacht.draftFt}}'] = eoi.yacht.draftFt ?? '';
|
tokenMap['{{yacht.draftFt}}'] = eoi.yacht?.draftFt ?? '';
|
||||||
tokenMap['{{yacht.lengthM}}'] = eoi.yacht.lengthM ?? '';
|
tokenMap['{{yacht.lengthM}}'] = eoi.yacht?.lengthM ?? '';
|
||||||
tokenMap['{{yacht.widthM}}'] = eoi.yacht.widthM ?? '';
|
tokenMap['{{yacht.widthM}}'] = eoi.yacht?.widthM ?? '';
|
||||||
tokenMap['{{yacht.draftM}}'] = eoi.yacht.draftM ?? '';
|
tokenMap['{{yacht.draftM}}'] = eoi.yacht?.draftM ?? '';
|
||||||
|
|
||||||
// EoiContext doesn't expose the yacht.registration column — look it up
|
// EoiContext doesn't expose the yacht.registration column — look it up
|
||||||
// separately (cheap, indexed fetch) so the token resolves when present.
|
// separately (cheap, indexed fetch) so the token resolves when present.
|
||||||
@@ -281,29 +283,31 @@ export async function resolveTemplate(
|
|||||||
tokenMap['{{owner.name}}'] = eoi.owner.name;
|
tokenMap['{{owner.name}}'] = eoi.owner.name;
|
||||||
tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? '';
|
tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? '';
|
||||||
|
|
||||||
// Berth tokens (from EoiContext)
|
// Berth tokens — also optional. Render empty when no berth is linked.
|
||||||
tokenMap['{{berth.mooringNumber}}'] = eoi.berth.mooringNumber;
|
tokenMap['{{berth.mooringNumber}}'] = eoi.berth?.mooringNumber ?? '';
|
||||||
tokenMap['{{berth.area}}'] = eoi.berth.area ?? '';
|
tokenMap['{{berth.area}}'] = eoi.berth?.area ?? '';
|
||||||
tokenMap['{{berth.lengthFt}}'] = eoi.berth.lengthFt ?? '';
|
tokenMap['{{berth.lengthFt}}'] = eoi.berth?.lengthFt ?? '';
|
||||||
tokenMap['{{berth.price}}'] = eoi.berth.price ?? '';
|
tokenMap['{{berth.price}}'] = eoi.berth?.price ?? '';
|
||||||
tokenMap['{{berth.priceCurrency}}'] = eoi.berth.priceCurrency;
|
tokenMap['{{berth.priceCurrency}}'] = eoi.berth?.priceCurrency ?? '';
|
||||||
tokenMap['{{berth.tenureType}}'] = eoi.berth.tenureType;
|
tokenMap['{{berth.tenureType}}'] = eoi.berth?.tenureType ?? '';
|
||||||
|
|
||||||
// Interest tokens
|
// Interest tokens
|
||||||
tokenMap['{{interest.stage}}'] = eoi.interest.stage;
|
tokenMap['{{interest.stage}}'] = eoi.interest.stage;
|
||||||
tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? '';
|
tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? '';
|
||||||
tokenMap['{{interest.berthNumber}}'] = eoi.berth.mooringNumber;
|
tokenMap['{{interest.berthNumber}}'] = eoi.berth?.mooringNumber ?? '';
|
||||||
tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact
|
tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact
|
||||||
? eoi.interest.dateFirstContact.toLocaleDateString('en-GB')
|
? eoi.interest.dateFirstContact.toLocaleDateString('en-GB')
|
||||||
: '';
|
: '';
|
||||||
tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? '';
|
tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? '';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// buildEoiContext throws ValidationError when the interest has no yacht
|
// buildEoiContext throws ValidationError when the EOI's required client
|
||||||
// or berth; non-EOI templates don't need those. Fall through to the
|
// fields (name/email/address — Section 2) are missing. For non-EOI
|
||||||
// legacy resolution path below. Re-throw anything else.
|
// templates (correspondence, welcome letters, etc.) those gates don't
|
||||||
|
// apply — fall through to the legacy resolution path below. Re-throw
|
||||||
|
// anything else.
|
||||||
if (
|
if (
|
||||||
!(err instanceof ValidationError) ||
|
!(err instanceof ValidationError) ||
|
||||||
!/interest has no (yacht|berth)/i.test(err.message)
|
!/missing required client details|interest has no (yacht|berth)/i.test(err.message)
|
||||||
) {
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { minioClient, buildStoragePath } from '@/lib/minio';
|
|||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { evaluateRule } from '@/lib/services/berth-rules-engine';
|
import { evaluateRule } from '@/lib/services/berth-rules-engine';
|
||||||
|
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||||
import { advanceStageIfBehind } from '@/lib/services/interests.service';
|
import { advanceStageIfBehind } from '@/lib/services/interests.service';
|
||||||
import {
|
import {
|
||||||
createDocument as documensoCreate,
|
createDocument as documensoCreate,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
downloadSignedPdf,
|
downloadSignedPdf,
|
||||||
voidDocument as documensoVoid,
|
voidDocument as documensoVoid,
|
||||||
} from '@/lib/services/documenso-client';
|
} from '@/lib/services/documenso-client';
|
||||||
|
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
|
||||||
import type {
|
import type {
|
||||||
CreateDocumentInput,
|
CreateDocumentInput,
|
||||||
UpdateDocumentInput,
|
UpdateDocumentInput,
|
||||||
@@ -506,6 +508,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
|||||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||||
if (!port) throw new NotFoundError('Port');
|
if (!port) throw new NotFoundError('Port');
|
||||||
|
|
||||||
|
// Resolve port-configured signer emails from system settings; fall back to
|
||||||
|
// legacy defaults only if the setting is absent. Fabricated slug-based
|
||||||
|
// addresses (developer@{slug}.com) are no longer used here because they
|
||||||
|
// never match real port users and cause silent no-ops in handleRecipientSigned.
|
||||||
|
const eoiSigners = await getPortEoiSigners(portId);
|
||||||
|
|
||||||
// BR-021: Create 3 signers — client (1), developer (2), sales/approver (3)
|
// BR-021: Create 3 signers — client (1), developer (2), sales/approver (3)
|
||||||
const signerRecords = await db
|
const signerRecords = await db
|
||||||
.insert(documentSigners)
|
.insert(documentSigners)
|
||||||
@@ -520,16 +528,16 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
documentId,
|
documentId,
|
||||||
signerName: port.name,
|
signerName: eoiSigners.developer.name,
|
||||||
signerEmail: `developer@${port.slug}.com`,
|
signerEmail: eoiSigners.developer.email,
|
||||||
signerRole: 'developer',
|
signerRole: 'developer',
|
||||||
signingOrder: 2,
|
signingOrder: 2,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
documentId,
|
documentId,
|
||||||
signerName: `${port.name} Sales`,
|
signerName: eoiSigners.approver.name,
|
||||||
signerEmail: `sales@${port.slug}.com`,
|
signerEmail: eoiSigners.approver.email,
|
||||||
signerRole: 'approver',
|
signerRole: 'approver',
|
||||||
signingOrder: 3,
|
signingOrder: 3,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
@@ -552,10 +560,15 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
|||||||
// Create document in Documenso + send
|
// Create document in Documenso + send
|
||||||
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
|
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
|
||||||
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
|
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
|
||||||
{ name: port.name, email: `developer@${port.slug}.com`, role: 'SIGNER', signingOrder: 2 },
|
|
||||||
{
|
{
|
||||||
name: `${port.name} Sales`,
|
name: eoiSigners.developer.name,
|
||||||
email: `sales@${port.slug}.com`,
|
email: eoiSigners.developer.email,
|
||||||
|
role: 'SIGNER',
|
||||||
|
signingOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: eoiSigners.approver.name,
|
||||||
|
email: eoiSigners.approver.email,
|
||||||
role: 'SIGNER',
|
role: 'SIGNER',
|
||||||
signingOrder: 3,
|
signingOrder: 3,
|
||||||
},
|
},
|
||||||
@@ -788,6 +801,22 @@ export async function handleRecipientSigned(eventData: {
|
|||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
if (!signer) {
|
||||||
|
// Email mismatch: the address Documenso has on the recipient doesn't match
|
||||||
|
// any row in documentSigners. This happens when the local signers were
|
||||||
|
// created with fabricated / stale addresses. Log a warning so operators can
|
||||||
|
// investigate and fix the port's eoi_signers system setting.
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
documensoId: eventData.documentId,
|
||||||
|
documentId: doc.id,
|
||||||
|
recipientEmail: eventData.recipientEmail,
|
||||||
|
},
|
||||||
|
'handleRecipientSigned: no matching signer row for recipient email — ' +
|
||||||
|
'check eoi_signers system setting for this port',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Update document to partially_signed if eoi type
|
// Update document to partially_signed if eoi type
|
||||||
if (doc.documentType === 'eoi' && doc.status === 'sent') {
|
if (doc.documentType === 'eoi' && doc.status === 'sent') {
|
||||||
await db
|
await db
|
||||||
@@ -896,7 +925,19 @@ export async function handleDocumentCompleted(eventData: { documentId: string })
|
|||||||
ipAddress: '0.0.0.0',
|
ipAddress: '0.0.0.0',
|
||||||
userAgent: 'webhook',
|
userAgent: 'webhook',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple times
|
||||||
|
// (webhook retries) or follow a DOCUMENT_SIGNED that already advanced the
|
||||||
|
// stage. advanceStageIfBehind handles the pipeline guard internally, but
|
||||||
|
// evaluateRule has no idempotency — skip it if the interest is already at
|
||||||
|
// eoi_signed or beyond to prevent duplicate berth-rule side effects.
|
||||||
|
const currentStageIdx = PIPELINE_STAGES.indexOf(
|
||||||
|
interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
|
||||||
|
);
|
||||||
|
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
|
||||||
|
if (currentStageIdx < eoiSignedIdx) {
|
||||||
void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta);
|
void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta);
|
||||||
|
}
|
||||||
|
|
||||||
// Advance to eoi_signed (no-op if interest already past it).
|
// Advance to eoi_signed (no-op if interest already past it).
|
||||||
void advanceStageIfBehind(
|
void advanceStageIfBehind(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type EoiContext = {
|
|||||||
primaryPhone: string | null;
|
primaryPhone: string | null;
|
||||||
address: { street: string; city: string; country: string } | null;
|
address: { street: string; city: string; country: string } | null;
|
||||||
};
|
};
|
||||||
|
/** Optional. The EOI's Section 3 yacht block is left blank when null. */
|
||||||
yacht: {
|
yacht: {
|
||||||
name: string;
|
name: string;
|
||||||
lengthFt: string | null;
|
lengthFt: string | null;
|
||||||
@@ -31,18 +32,22 @@ export type EoiContext = {
|
|||||||
hullNumber: string | null;
|
hullNumber: string | null;
|
||||||
flag: string | null;
|
flag: string | null;
|
||||||
yearBuilt: number | null;
|
yearBuilt: number | null;
|
||||||
};
|
} | null;
|
||||||
company: {
|
company: {
|
||||||
name: string;
|
name: string;
|
||||||
legalName: string | null;
|
legalName: string | null;
|
||||||
taxId: string | null;
|
taxId: string | null;
|
||||||
billingAddress: string | null;
|
billingAddress: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
/** Inferred from the yacht's polymorphic owner. Falls back to the interest's
|
||||||
|
* client when no yacht is linked (so the EOI's signing party is still
|
||||||
|
* resolvable). */
|
||||||
owner: {
|
owner: {
|
||||||
type: 'client' | 'company';
|
type: 'client' | 'company';
|
||||||
name: string;
|
name: string;
|
||||||
legalName?: string;
|
legalName?: string;
|
||||||
};
|
};
|
||||||
|
/** Optional. The EOI's Section 3 berth-number is left blank when null. */
|
||||||
berth: {
|
berth: {
|
||||||
mooringNumber: string;
|
mooringNumber: string;
|
||||||
area: string | null;
|
area: string | null;
|
||||||
@@ -50,7 +55,7 @@ export type EoiContext = {
|
|||||||
price: string | null;
|
price: string | null;
|
||||||
priceCurrency: string;
|
priceCurrency: string;
|
||||||
tenureType: string;
|
tenureType: string;
|
||||||
};
|
} | null;
|
||||||
interest: {
|
interest: {
|
||||||
stage: string;
|
stage: string;
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
@@ -77,8 +82,10 @@ export type EoiContext = {
|
|||||||
* Pure read-only: no audit logs, no socket emits, no mutations.
|
* Pure read-only: no audit logs, no socket emits, no mutations.
|
||||||
*
|
*
|
||||||
* Tenant-scoped: every fetch is gated by `portId`, and missing rows surface
|
* Tenant-scoped: every fetch is gated by `portId`, and missing rows surface
|
||||||
* as NotFoundError. Missing yacht/berth references on the interest surface as
|
* as NotFoundError. The hard gate matches the EOI document's top paragraph
|
||||||
* ValidationError, because EOI flows cannot proceed without them.
|
* (Section 2 — name, address, email): without those the EOI is unsignable
|
||||||
|
* and we throw. Yacht and berth (Section 3) are optional — the rendered PDF
|
||||||
|
* leaves those fields blank when not set.
|
||||||
*/
|
*/
|
||||||
export async function buildEoiContext(interestId: string, portId: string): Promise<EoiContext> {
|
export async function buildEoiContext(interestId: string, portId: string): Promise<EoiContext> {
|
||||||
// 1. Interest (tenant-scoped)
|
// 1. Interest (tenant-scoped)
|
||||||
@@ -89,24 +96,19 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
|||||||
throw new NotFoundError('Interest');
|
throw new NotFoundError('Interest');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Yacht reference must exist on the interest
|
// Parallelise independent reads. Yacht and berth are both nullable —
|
||||||
if (!interest.yachtId) {
|
// the EOI's Section 3 stays blank when they're absent.
|
||||||
throw new ValidationError('interest has no yacht');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Berth reference must exist on the interest
|
|
||||||
if (!interest.berthId) {
|
|
||||||
throw new ValidationError('interest has no berth');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2 + 3 + 4 + 9: parallelise independent reads.
|
|
||||||
const [yacht, berth, client, port] = await Promise.all([
|
const [yacht, berth, client, port] = await Promise.all([
|
||||||
db.query.yachts.findFirst({
|
interest.yachtId
|
||||||
|
? db.query.yachts.findFirst({
|
||||||
where: and(eq(yachts.id, interest.yachtId), eq(yachts.portId, portId)),
|
where: and(eq(yachts.id, interest.yachtId), eq(yachts.portId, portId)),
|
||||||
}),
|
})
|
||||||
db.query.berths.findFirst({
|
: Promise.resolve(undefined),
|
||||||
|
interest.berthId
|
||||||
|
? db.query.berths.findFirst({
|
||||||
where: and(eq(berths.id, interest.berthId), eq(berths.portId, portId)),
|
where: and(eq(berths.id, interest.berthId), eq(berths.portId, portId)),
|
||||||
}),
|
})
|
||||||
|
: Promise.resolve(undefined),
|
||||||
db.query.clients.findFirst({
|
db.query.clients.findFirst({
|
||||||
where: and(eq(clients.id, interest.clientId), eq(clients.portId, portId)),
|
where: and(eq(clients.id, interest.clientId), eq(clients.portId, portId)),
|
||||||
}),
|
}),
|
||||||
@@ -115,8 +117,6 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!yacht) throw new NotFoundError('Yacht');
|
|
||||||
if (!berth) throw new NotFoundError('Berth');
|
|
||||||
if (!client) throw new NotFoundError('Client');
|
if (!client) throw new NotFoundError('Client');
|
||||||
if (!port) throw new NotFoundError('Port');
|
if (!port) throw new NotFoundError('Port');
|
||||||
|
|
||||||
@@ -157,11 +157,28 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 7 + 8. Yacht owner (polymorphic) + optional company billing address.
|
// EOI hard gate: the document's top paragraph (Section 2) requires Name,
|
||||||
|
// Address, and Email. Without these the rendered EOI is unsignable. Yacht
|
||||||
|
// and berth (Section 3) are intentionally optional and may be left blank.
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!client.fullName?.trim()) missing.push('client name');
|
||||||
|
if (!firstEmail?.value?.trim()) missing.push('client email');
|
||||||
|
if (!clientAddress || !clientAddress.street.trim()) missing.push('client address');
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Cannot generate EOI — missing required client details: ${missing.join(', ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner block. When a yacht is linked, derive from the yacht's polymorphic
|
||||||
|
// owner. When no yacht is linked, fall back to the interest's client so the
|
||||||
|
// EOI's signing party is still resolvable.
|
||||||
let ownerBlock: EoiContext['owner'];
|
let ownerBlock: EoiContext['owner'];
|
||||||
let companyBlock: EoiContext['company'] = null;
|
let companyBlock: EoiContext['company'] = null;
|
||||||
|
|
||||||
if (yacht.currentOwnerType === 'client') {
|
if (!yacht) {
|
||||||
|
ownerBlock = { type: 'client', name: client.fullName };
|
||||||
|
} else if (yacht.currentOwnerType === 'client') {
|
||||||
// The yacht-owning client may or may not be the same as the interest's client.
|
// The yacht-owning client may or may not be the same as the interest's client.
|
||||||
const ownerClient =
|
const ownerClient =
|
||||||
yacht.currentOwnerId === client.id
|
yacht.currentOwnerId === client.id
|
||||||
@@ -228,7 +245,8 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
|||||||
primaryPhone: firstPhone?.value ?? null,
|
primaryPhone: firstPhone?.value ?? null,
|
||||||
address: clientAddress,
|
address: clientAddress,
|
||||||
},
|
},
|
||||||
yacht: {
|
yacht: yacht
|
||||||
|
? {
|
||||||
name: yacht.name,
|
name: yacht.name,
|
||||||
lengthFt: yacht.lengthFt,
|
lengthFt: yacht.lengthFt,
|
||||||
widthFt: yacht.widthFt,
|
widthFt: yacht.widthFt,
|
||||||
@@ -239,17 +257,20 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
|||||||
hullNumber: yacht.hullNumber,
|
hullNumber: yacht.hullNumber,
|
||||||
flag: yacht.flag,
|
flag: yacht.flag,
|
||||||
yearBuilt: yacht.yearBuilt,
|
yearBuilt: yacht.yearBuilt,
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
company: companyBlock,
|
company: companyBlock,
|
||||||
owner: ownerBlock,
|
owner: ownerBlock,
|
||||||
berth: {
|
berth: berth
|
||||||
|
? {
|
||||||
mooringNumber: berth.mooringNumber,
|
mooringNumber: berth.mooringNumber,
|
||||||
area: berth.area,
|
area: berth.area,
|
||||||
lengthFt: berth.lengthFt,
|
lengthFt: berth.lengthFt,
|
||||||
price: berth.price,
|
price: berth.price,
|
||||||
priceCurrency: berth.priceCurrency,
|
priceCurrency: berth.priceCurrency,
|
||||||
tenureType: berth.tenureType,
|
tenureType: berth.tenureType,
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
interest: {
|
interest: {
|
||||||
stage: interest.pipelineStage,
|
stage: interest.pipelineStage,
|
||||||
leadCategory: interest.leadCategory,
|
leadCategory: interest.leadCategory,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { interests, interestTags } from '@/lib/db/schema/interests';
|
import { interests, interestTags, interestNotes } from '@/lib/db/schema/interests';
|
||||||
import { clients } from '@/lib/db/schema/clients';
|
import { reminders } from '@/lib/db/schema/operations';
|
||||||
|
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { companyMemberships } from '@/lib/db/schema/companies';
|
import { companyMemberships } from '@/lib/db/schema/companies';
|
||||||
@@ -182,6 +183,11 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
|
|||||||
return interests.leadCategory;
|
return interests.leadCategory;
|
||||||
case 'createdAt':
|
case 'createdAt':
|
||||||
return interests.createdAt;
|
return interests.createdAt;
|
||||||
|
case 'dateLastContact':
|
||||||
|
// Postgres sorts NULLs last on DESC by default, which is the right
|
||||||
|
// behaviour for triage (recently-contacted first, never-contacted
|
||||||
|
// at the bottom).
|
||||||
|
return interests.dateLastContact;
|
||||||
default:
|
default:
|
||||||
return interests.updatedAt;
|
return interests.updatedAt;
|
||||||
}
|
}
|
||||||
@@ -221,6 +227,7 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
|
|||||||
let clientsMap: Record<string, string> = {};
|
let clientsMap: Record<string, string> = {};
|
||||||
let berthsMap: Record<string, string> = {};
|
let berthsMap: Record<string, string> = {};
|
||||||
const tagsByInterestId: Record<string, Array<{ id: string; name: string; color: string }>> = {};
|
const tagsByInterestId: Record<string, Array<{ id: string; name: string; color: string }>> = {};
|
||||||
|
const notesCountByInterestId: Record<string, number> = {};
|
||||||
|
|
||||||
if (clientIds.length > 0) {
|
if (clientIds.length > 0) {
|
||||||
const clientRows = await db
|
const clientRows = await db
|
||||||
@@ -254,6 +261,19 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
|
|||||||
if (!tagsByInterestId[row.interestId]) tagsByInterestId[row.interestId] = [];
|
if (!tagsByInterestId[row.interestId]) tagsByInterestId[row.interestId] = [];
|
||||||
tagsByInterestId[row.interestId]!.push({ id: row.id, name: row.name, color: row.color });
|
tagsByInterestId[row.interestId]!.push({ id: row.id, name: row.name, color: row.color });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note counts per interest, for the comment-icon row affordance.
|
||||||
|
const noteCountRows = await db
|
||||||
|
.select({
|
||||||
|
interestId: interestNotes.interestId,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(interestNotes)
|
||||||
|
.where(inArray(interestNotes.interestId, interestIds))
|
||||||
|
.groupBy(interestNotes.interestId);
|
||||||
|
for (const row of noteCountRows) {
|
||||||
|
notesCountByInterestId[row.interestId] = row.count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (result.data as Array<Record<string, unknown>>).map((i) => ({
|
const data = (result.data as Array<Record<string, unknown>>).map((i) => ({
|
||||||
@@ -261,6 +281,7 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
|
|||||||
clientName: clientsMap[i.clientId as string] ?? null,
|
clientName: clientsMap[i.clientId as string] ?? null,
|
||||||
berthMooringNumber: i.berthId ? (berthsMap[i.berthId as string] ?? null) : null,
|
berthMooringNumber: i.berthId ? (berthsMap[i.berthId as string] ?? null) : null,
|
||||||
tags: tagsByInterestId[i.id as string] ?? [],
|
tags: tagsByInterestId[i.id as string] ?? [],
|
||||||
|
notesCount: notesCountByInterestId[i.id as string] ?? 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { data, total: result.total };
|
return { data, total: result.total };
|
||||||
@@ -282,6 +303,37 @@ export async function getInterestById(id: string, portId: string) {
|
|||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.id, interest.clientId));
|
.where(eq(clients.id, interest.clientId));
|
||||||
|
|
||||||
|
// EOI prerequisites + interest-detail header contact actions: surface the
|
||||||
|
// linked client's primary email/phone (and the canonical E.164 form for
|
||||||
|
// wa.me) so the header can render Email / Call / WhatsApp buttons without
|
||||||
|
// a second fetch, and the Documents tab can show the EOI prereq checklist.
|
||||||
|
const [emailContact] = await db
|
||||||
|
.select({ value: clientContacts.value })
|
||||||
|
.from(clientContacts)
|
||||||
|
.where(and(eq(clientContacts.clientId, interest.clientId), eq(clientContacts.channel, 'email')))
|
||||||
|
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const [phoneContact] = await db
|
||||||
|
.select({ value: clientContacts.value, valueE164: clientContacts.valueE164 })
|
||||||
|
.from(clientContacts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientContacts.clientId, interest.clientId),
|
||||||
|
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const [addressRow] = await db
|
||||||
|
.select({ id: clientAddresses.id })
|
||||||
|
.from(clientAddresses)
|
||||||
|
.where(
|
||||||
|
and(eq(clientAddresses.clientId, interest.clientId), eq(clientAddresses.isPrimary, true)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
let berthMooringNumber: string | null = null;
|
let berthMooringNumber: string | null = null;
|
||||||
if (interest.berthId) {
|
if (interest.berthId) {
|
||||||
const [berthRow] = await db
|
const [berthRow] = await db
|
||||||
@@ -297,11 +349,46 @@ export async function getInterestById(id: string, portId: string) {
|
|||||||
.innerJoin(tags, eq(interestTags.tagId, tags.id))
|
.innerJoin(tags, eq(interestTags.tagId, tags.id))
|
||||||
.where(eq(interestTags.interestId, id));
|
.where(eq(interestTags.interestId, id));
|
||||||
|
|
||||||
|
// Most-recent note preview for the Overview tab (the "do you have anything
|
||||||
|
// outstanding on this lead?" peek). Returns the latest note's truncated
|
||||||
|
// content + author/timestamp so the UI can render a one-line teaser.
|
||||||
|
const [recentNote] = await db
|
||||||
|
.select({
|
||||||
|
id: interestNotes.id,
|
||||||
|
content: interestNotes.content,
|
||||||
|
authorId: interestNotes.authorId,
|
||||||
|
createdAt: interestNotes.createdAt,
|
||||||
|
})
|
||||||
|
.from(interestNotes)
|
||||||
|
.where(eq(interestNotes.interestId, id))
|
||||||
|
.orderBy(desc(interestNotes.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const [{ count: notesCount } = { count: 0 }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(interestNotes)
|
||||||
|
.where(eq(interestNotes.interestId, id));
|
||||||
|
|
||||||
|
// Active reminder count for the interest's bell badge. Counts reminders
|
||||||
|
// directly linked via interestId — `pending` and `snoozed` only;
|
||||||
|
// completed/dismissed don't surface.
|
||||||
|
const [{ count: activeReminderCount } = { count: 0 }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(reminders)
|
||||||
|
.where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed'])));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...interest,
|
...interest,
|
||||||
clientName: clientRow?.fullName ?? null,
|
clientName: clientRow?.fullName ?? null,
|
||||||
|
clientPrimaryEmail: emailContact?.value ?? null,
|
||||||
|
clientPrimaryPhone: phoneContact?.value ?? null,
|
||||||
|
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
|
||||||
|
clientHasAddress: !!addressRow,
|
||||||
berthMooringNumber,
|
berthMooringNumber,
|
||||||
tags: tagRows,
|
tags: tagRows,
|
||||||
|
notesCount,
|
||||||
|
recentNote: recentNote ?? null,
|
||||||
|
activeReminderCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,20 @@ import { io, type Socket } from 'socket.io-client';
|
|||||||
import { useSession } from '@/lib/auth/client';
|
import { useSession } from '@/lib/auth/client';
|
||||||
import { usePortStore } from '@/stores/ui-store';
|
import { usePortStore } from '@/stores/ui-store';
|
||||||
|
|
||||||
const SocketContext = createContext<Socket | null>(null);
|
interface SocketContextValue {
|
||||||
|
/** Stable socket instance reference. Persists across reconnects — socket.io's
|
||||||
|
* built-in reconnection re-establishes the underlying transport without
|
||||||
|
* changing the JS object, so this stays valid as long as the session and
|
||||||
|
* port are unchanged. Consumers should NOT null-check this for "is online";
|
||||||
|
* use `isConnected` instead. */
|
||||||
|
socket: Socket | null;
|
||||||
|
/** Live transport state. Flips false on disconnect and back to true on
|
||||||
|
* reconnect. Use this if you need to surface offline UX; the socket itself
|
||||||
|
* stays subscribed to the same event handlers. */
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocketContext = createContext<SocketContextValue>({ socket: null, isConnected: false });
|
||||||
|
|
||||||
/** Returns true once the component has mounted on the client. Avoids calling
|
/** Returns true once the component has mounted on the client. Avoids calling
|
||||||
* better-auth's `useSession()` (which dispatches React hooks via nanostores)
|
* better-auth's `useSession()` (which dispatches React hooks via nanostores)
|
||||||
@@ -32,7 +45,9 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
|||||||
return hasMounted ? (
|
return hasMounted ? (
|
||||||
<SocketProviderClient>{children}</SocketProviderClient>
|
<SocketProviderClient>{children}</SocketProviderClient>
|
||||||
) : (
|
) : (
|
||||||
<SocketContext.Provider value={null}>{children}</SocketContext.Provider>
|
<SocketContext.Provider value={{ socket: null, isConnected: false }}>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,9 +55,14 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const currentPortId = usePortStore((s) => s.currentPortId);
|
const currentPortId = usePortStore((s) => s.currentPortId);
|
||||||
const [socket, setSocket] = useState<Socket | null>(null);
|
const [socket, setSocket] = useState<Socket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.user || !currentPortId) return;
|
if (!session?.user || !currentPortId) {
|
||||||
|
setSocket(null);
|
||||||
|
setIsConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const s = io(process.env.NEXT_PUBLIC_APP_URL!, {
|
const s = io(process.env.NEXT_PUBLIC_APP_URL!, {
|
||||||
path: '/socket.io/',
|
path: '/socket.io/',
|
||||||
@@ -51,18 +71,38 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
|
|||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
});
|
});
|
||||||
|
|
||||||
s.on('connect', () => setSocket(s));
|
// Set the socket reference immediately and keep it stable across the
|
||||||
s.on('disconnect', () => setSocket(null));
|
// session+port lifetime. socket.io reconnects internally; the same
|
||||||
|
// instance survives transient drops, and any handlers registered via
|
||||||
|
// `socket.on(...)` stay attached. Previously we set/unset `socket` on
|
||||||
|
// connect/disconnect, which made the React context flip to null on every
|
||||||
|
// network blip and silently killed every `useRealtimeInvalidation`
|
||||||
|
// subscription session-wide.
|
||||||
|
setSocket(s);
|
||||||
|
|
||||||
|
s.on('connect', () => setIsConnected(true));
|
||||||
|
s.on('disconnect', () => setIsConnected(false));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
s.disconnect();
|
s.disconnect();
|
||||||
setSocket(null);
|
setSocket(null);
|
||||||
|
setIsConnected(false);
|
||||||
};
|
};
|
||||||
}, [session?.user, currentPortId]);
|
}, [session?.user, currentPortId]);
|
||||||
|
|
||||||
return <SocketContext.Provider value={socket}>{children}</SocketContext.Provider>;
|
return (
|
||||||
|
<SocketContext.Provider value={{ socket, isConnected }}>{children}</SocketContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSocket() {
|
/** Returns the Socket.IO client instance. The reference is stable for the
|
||||||
return useContext(SocketContext);
|
* duration of a session+port, even across transient disconnects. */
|
||||||
|
export function useSocket(): Socket | null {
|
||||||
|
return useContext(SocketContext).socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while the socket transport is connected. Flips false on disconnect,
|
||||||
|
* back to true on reconnect. Useful for surfacing an "offline" indicator. */
|
||||||
|
export function useIsSocketConnected(): boolean {
|
||||||
|
return useContext(SocketContext).isConnected;
|
||||||
}
|
}
|
||||||
|
|||||||
102
tests/integration/api/berth-reservations-list.test.ts
Normal file
102
tests/integration/api/berth-reservations-list.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Port-scoped global reservations list — locks in feat(marina): the new
|
||||||
|
* `GET /api/v1/berth-reservations` endpoint that powers the
|
||||||
|
* `[portSlug]/berth-reservations` page. The route is thin (parseQuery →
|
||||||
|
* listReservations); the test guarantees port scoping at the handler
|
||||||
|
* boundary so a future refactor of the service can't accidentally leak
|
||||||
|
* cross-port rows.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { listHandler } from '@/app/api/v1/berth-reservations/handlers';
|
||||||
|
import { createHandler as createReservationHandler } from '@/app/api/v1/berths/[id]/reservations/handlers';
|
||||||
|
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
||||||
|
import {
|
||||||
|
makeBerth,
|
||||||
|
makeClient,
|
||||||
|
makeFullPermissions,
|
||||||
|
makePort,
|
||||||
|
makeYacht,
|
||||||
|
} from '../../helpers/factories';
|
||||||
|
|
||||||
|
async function seedReservation(portId: string) {
|
||||||
|
const berth = await makeBerth({ portId });
|
||||||
|
const client = await makeClient({ portId });
|
||||||
|
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
|
||||||
|
const ctx = makeMockCtx({ portId, permissions: makeFullPermissions() });
|
||||||
|
const res = await createReservationHandler(
|
||||||
|
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
|
||||||
|
body: {
|
||||||
|
clientId: client.id,
|
||||||
|
yachtId: yacht.id,
|
||||||
|
startDate: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx,
|
||||||
|
{ id: berth.id },
|
||||||
|
);
|
||||||
|
return (await res.json()).data as { id: string; berthId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/v1/berth-reservations', () => {
|
||||||
|
it('returns all reservations for the requesting port', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const r1 = await seedReservation(port.id);
|
||||||
|
const r2 = await seedReservation(port.id);
|
||||||
|
|
||||||
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||||
|
const res = await listHandler(
|
||||||
|
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations'),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
const ids = (body.data as Array<{ id: string }>).map((r) => r.id).sort();
|
||||||
|
expect(ids).toEqual([r1.id, r2.id].sort());
|
||||||
|
expect(body.pagination).toMatchObject({ page: 1, total: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak reservations from a different port', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
const reservationInB = await seedReservation(portB.id);
|
||||||
|
|
||||||
|
// Caller is operating in portA; portB's reservation must not appear.
|
||||||
|
const ctx = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
|
||||||
|
const res = await listHandler(
|
||||||
|
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations'),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
const ids = (body.data as Array<{ id: string }>).map((r) => r.id);
|
||||||
|
expect(ids).not.toContain(reservationInB.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors pagination via query params', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
await seedReservation(port.id);
|
||||||
|
await seedReservation(port.id);
|
||||||
|
await seedReservation(port.id);
|
||||||
|
|
||||||
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||||
|
const res = await listHandler(
|
||||||
|
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations?page=1&limit=2'),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.data).toHaveLength(2);
|
||||||
|
expect(body.pagination).toMatchObject({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 2,
|
||||||
|
total: 3,
|
||||||
|
totalPages: 2,
|
||||||
|
hasNextPage: true,
|
||||||
|
hasPreviousPage: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
126
tests/integration/api/saved-views-ownership.test.ts
Normal file
126
tests/integration/api/saved-views-ownership.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Saved-views ownership enforcement — locks in the 403/404 split shipped
|
||||||
|
* in fix(auth). The route handlers preflight `assertViewOwner` BEFORE the
|
||||||
|
* service call, so even if the service's internal userId filter is later
|
||||||
|
* refactored, the route still rejects cross-user mutations.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||||
|
import { patchHandler, deleteHandler } from '@/app/api/v1/saved-views/[id]/handlers';
|
||||||
|
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
||||||
|
import { makePort } from '../../helpers/factories';
|
||||||
|
|
||||||
|
describe('saved-views ownership enforcement', () => {
|
||||||
|
let portId: string;
|
||||||
|
let viewId: string;
|
||||||
|
const ownerUserId = 'user-owner';
|
||||||
|
const otherUserId = 'user-other';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
portId = port.id;
|
||||||
|
const view = await savedViewsService.create(portId, ownerUserId, {
|
||||||
|
entityType: 'clients',
|
||||||
|
name: 'Hot leads',
|
||||||
|
filters: { stage: 'hot_lead' } as Record<string, unknown>,
|
||||||
|
isShared: false,
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
if (!view) throw new Error('seed view failed');
|
||||||
|
viewId = view.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH from owner: 200', async () => {
|
||||||
|
const ctx = makeMockCtx({ portId, userId: ownerUserId });
|
||||||
|
const res = await patchHandler(
|
||||||
|
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
|
||||||
|
body: { name: 'Renamed by owner' },
|
||||||
|
}),
|
||||||
|
ctx,
|
||||||
|
{ id: viewId },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.data.name).toBe('Renamed by owner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH from a different user: 403 (not 404)', async () => {
|
||||||
|
const ctx = makeMockCtx({ portId, userId: otherUserId });
|
||||||
|
const res = await patchHandler(
|
||||||
|
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
|
||||||
|
body: { name: 'Hostile rename' },
|
||||||
|
}),
|
||||||
|
ctx,
|
||||||
|
{ id: viewId },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
|
||||||
|
// Verify the row was not mutated.
|
||||||
|
const row = await db.query.savedViews.findFirst({
|
||||||
|
where: (sv, { eq }) => eq(sv.id, viewId),
|
||||||
|
});
|
||||||
|
expect(row?.name).toBe('Renamed by owner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE from a different user: 403 and view still exists', async () => {
|
||||||
|
const ctx = makeMockCtx({ portId, userId: otherUserId });
|
||||||
|
const res = await deleteHandler(
|
||||||
|
makeMockRequest('DELETE', `http://localhost/api/v1/saved-views/${viewId}`),
|
||||||
|
ctx,
|
||||||
|
{ id: viewId },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
|
||||||
|
const row = await db.query.savedViews.findFirst({
|
||||||
|
where: (sv, { eq }) => eq(sv.id, viewId),
|
||||||
|
});
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH on a non-existent id: 404', async () => {
|
||||||
|
const ctx = makeMockCtx({ portId, userId: ownerUserId });
|
||||||
|
const res = await patchHandler(
|
||||||
|
makeMockRequest(
|
||||||
|
'PATCH',
|
||||||
|
'http://localhost/api/v1/saved-views/00000000-0000-0000-0000-000000000000',
|
||||||
|
{ body: { name: 'no-op' } },
|
||||||
|
),
|
||||||
|
ctx,
|
||||||
|
{ id: '00000000-0000-0000-0000-000000000000' },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH on a view in a different port: 404 (cross-port enumeration is blocked)', async () => {
|
||||||
|
// The view exists in `portId` but the auth context says we're operating
|
||||||
|
// in a different port. The lookup is scoped to `(id, portId)` so the row
|
||||||
|
// is invisible — should 404, not 403.
|
||||||
|
const otherPort = await makePort();
|
||||||
|
const ctx = makeMockCtx({ portId: otherPort.id, userId: ownerUserId });
|
||||||
|
const res = await patchHandler(
|
||||||
|
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
|
||||||
|
body: { name: 'cross-port attempt' },
|
||||||
|
}),
|
||||||
|
ctx,
|
||||||
|
{ id: viewId },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE from owner: 200 and view is gone', async () => {
|
||||||
|
const ctx = makeMockCtx({ portId, userId: ownerUserId });
|
||||||
|
const res = await deleteHandler(
|
||||||
|
makeMockRequest('DELETE', `http://localhost/api/v1/saved-views/${viewId}`),
|
||||||
|
ctx,
|
||||||
|
{ id: viewId },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const row = await db.query.savedViews.findFirst({
|
||||||
|
where: (sv, { eq }) => eq(sv.id, viewId),
|
||||||
|
});
|
||||||
|
expect(row).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
183
tests/integration/dedup/client-merge.test.ts
Normal file
183
tests/integration/dedup/client-merge.test.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Client merge service — end-to-end integration test.
|
||||||
|
*
|
||||||
|
* Spins up two real clients in a real port via the factory helpers,
|
||||||
|
* attaches a few satellites (interest, contact, address, note),
|
||||||
|
* merges them, and asserts everything survived in the right place
|
||||||
|
* with the merge log written.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { clients, clientContacts, clientNotes, clientMergeLog } from '@/lib/db/schema/clients';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { mergeClients } from '@/lib/services/client-merge.service';
|
||||||
|
import { makeClient, makePort, makeBerth } from '../../helpers/factories';
|
||||||
|
|
||||||
|
describe('mergeClients', () => {
|
||||||
|
it('moves interests and contacts from loser to winner; archives loser; writes merge log', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const winner = await makeClient({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { fullName: 'Marcus Laurent' },
|
||||||
|
});
|
||||||
|
const loser = await makeClient({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { fullName: 'Marcus Laurent (dup)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach contact + interest to loser
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: loser.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'marcus@example.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await db.insert(clientNotes).values({
|
||||||
|
clientId: loser.id,
|
||||||
|
authorId: 'test-user',
|
||||||
|
content: 'Loser-side note',
|
||||||
|
});
|
||||||
|
const berth = await makeBerth({ portId: port.id });
|
||||||
|
await db.insert(interests).values({
|
||||||
|
portId: port.id,
|
||||||
|
clientId: loser.id,
|
||||||
|
berthId: berth.id,
|
||||||
|
pipelineStage: 'open',
|
||||||
|
leadCategory: 'general_interest',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Merge ─────────────────────────────────────────────────────────────
|
||||||
|
const result = await mergeClients({
|
||||||
|
winnerId: winner.id,
|
||||||
|
loserId: loser.id,
|
||||||
|
mergedBy: 'test-user',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.movedRows.interests).toBe(1);
|
||||||
|
expect(result.movedRows.contacts).toBe(1);
|
||||||
|
expect(result.movedRows.notes).toBe(1);
|
||||||
|
|
||||||
|
// ── Loser should be archived with mergedIntoClientId set ──────────────
|
||||||
|
const [archivedLoser] = await db.select().from(clients).where(eq(clients.id, loser.id));
|
||||||
|
expect(archivedLoser?.archivedAt).not.toBeNull();
|
||||||
|
expect(archivedLoser?.mergedIntoClientId).toBe(winner.id);
|
||||||
|
|
||||||
|
// ── All loser-side rows now point at the winner ───────────────────────
|
||||||
|
const winnerInterests = await db
|
||||||
|
.select()
|
||||||
|
.from(interests)
|
||||||
|
.where(eq(interests.clientId, winner.id));
|
||||||
|
expect(winnerInterests).toHaveLength(1);
|
||||||
|
|
||||||
|
const winnerContacts = await db
|
||||||
|
.select()
|
||||||
|
.from(clientContacts)
|
||||||
|
.where(eq(clientContacts.clientId, winner.id));
|
||||||
|
expect(winnerContacts.find((c) => c.value === 'marcus@example.com')).toBeDefined();
|
||||||
|
|
||||||
|
const winnerNotes = await db
|
||||||
|
.select()
|
||||||
|
.from(clientNotes)
|
||||||
|
.where(eq(clientNotes.clientId, winner.id));
|
||||||
|
expect(winnerNotes.find((n) => n.content === 'Loser-side note')).toBeDefined();
|
||||||
|
|
||||||
|
// ── Merge log row exists with snapshot ────────────────────────────────
|
||||||
|
const [log] = await db
|
||||||
|
.select()
|
||||||
|
.from(clientMergeLog)
|
||||||
|
.where(eq(clientMergeLog.id, result.mergeLogId));
|
||||||
|
expect(log?.survivingClientId).toBe(winner.id);
|
||||||
|
expect(log?.mergedClientId).toBe(loser.id);
|
||||||
|
expect(log?.mergedBy).toBe('test-user');
|
||||||
|
expect(log?.mergeDetails).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to merge a client into itself', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const c = await makeClient({ portId: port.id });
|
||||||
|
await expect(mergeClients({ winnerId: c.id, loserId: c.id, mergedBy: 'u' })).rejects.toThrow(
|
||||||
|
/itself/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to merge across different ports', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
const a = await makeClient({ portId: portA.id });
|
||||||
|
const b = await makeClient({ portId: portB.id });
|
||||||
|
await expect(mergeClients({ winnerId: a.id, loserId: b.id, mergedBy: 'u' })).rejects.toThrow(
|
||||||
|
/different ports/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to merge a client that has already been merged', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const winner = await makeClient({ portId: port.id });
|
||||||
|
const loser = await makeClient({ portId: port.id });
|
||||||
|
// First merge succeeds.
|
||||||
|
await mergeClients({ winnerId: winner.id, loserId: loser.id, mergedBy: 'u' });
|
||||||
|
// Second merge of the same loser should refuse.
|
||||||
|
const winner2 = await makeClient({ portId: port.id });
|
||||||
|
await expect(
|
||||||
|
mergeClients({ winnerId: winner2.id, loserId: loser.id, mergedBy: 'u' }),
|
||||||
|
).rejects.toThrow(/already merged/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops duplicate contact rows during reattach', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const winner = await makeClient({ portId: port.id });
|
||||||
|
const loser = await makeClient({ portId: port.id });
|
||||||
|
|
||||||
|
// Both have the same email contact.
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: winner.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'same@example.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: loser.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'same@example.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await mergeClients({
|
||||||
|
winnerId: winner.id,
|
||||||
|
loserId: loser.id,
|
||||||
|
mergedBy: 'u',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.movedRows.contacts).toBe(0); // duplicate dropped
|
||||||
|
const winnerEmails = await db
|
||||||
|
.select()
|
||||||
|
.from(clientContacts)
|
||||||
|
.where(eq(clientContacts.clientId, winner.id));
|
||||||
|
// Winner kept exactly one copy of the shared email.
|
||||||
|
expect(winnerEmails.filter((c) => c.value === 'same@example.com')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies fieldChoices to copy loser values onto the winner', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const winner = await makeClient({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { fullName: 'Marcus L.' },
|
||||||
|
});
|
||||||
|
const loser = await makeClient({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { fullName: 'Marcus Laurent' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await mergeClients({
|
||||||
|
winnerId: winner.id,
|
||||||
|
loserId: loser.id,
|
||||||
|
mergedBy: 'u',
|
||||||
|
fieldChoices: { fullName: 'loser' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updatedWinner] = await db.select().from(clients).where(eq(clients.id, winner.id));
|
||||||
|
expect(updatedWinner?.fullName).toBe('Marcus Laurent');
|
||||||
|
});
|
||||||
|
});
|
||||||
157
tests/integration/dedup/match-candidates-api.test.ts
Normal file
157
tests/integration/dedup/match-candidates-api.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Match-candidates API — integration test.
|
||||||
|
*
|
||||||
|
* Exercises the GET /api/v1/clients/match-candidates handler against a
|
||||||
|
* real port + clients pool. Verifies the dedup library's at-create
|
||||||
|
* suggestion path returns the right candidates and confidence tiers
|
||||||
|
* for the "use existing client?" form interruption.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { clientContacts } from '@/lib/db/schema/clients';
|
||||||
|
import { getMatchCandidatesHandler } from '@/app/api/v1/clients/match-candidates/handlers';
|
||||||
|
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
||||||
|
import { makeClient, makePort } from '../../helpers/factories';
|
||||||
|
|
||||||
|
interface MatchData {
|
||||||
|
clientId: string;
|
||||||
|
fullName: string;
|
||||||
|
score: number;
|
||||||
|
confidence: 'high' | 'medium' | 'low';
|
||||||
|
reasons: string[];
|
||||||
|
interestCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callHandler(
|
||||||
|
ctx: ReturnType<typeof makeMockCtx>,
|
||||||
|
query: Record<string, string>,
|
||||||
|
): Promise<MatchData[]> {
|
||||||
|
const url = new URL('http://localhost/api/v1/clients/match-candidates');
|
||||||
|
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
|
||||||
|
const req = makeMockRequest('GET', url.toString());
|
||||||
|
const res = await getMatchCandidatesHandler(req, ctx);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
return body.data as MatchData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/v1/clients/match-candidates', () => {
|
||||||
|
it('returns empty when nothing actionable was provided', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const ctx = makeMockCtx({ portId: port.id });
|
||||||
|
const data = await callHandler(ctx, {});
|
||||||
|
expect(data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds an existing client by exact email match (high confidence)', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const ctx = makeMockCtx({ portId: port.id });
|
||||||
|
const existing = await makeClient({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { fullName: 'Marcus Laurent' },
|
||||||
|
});
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: existing.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'marcus@example.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: existing.id,
|
||||||
|
channel: 'phone',
|
||||||
|
value: '+15551234567',
|
||||||
|
valueE164: '+15551234567',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await callHandler(ctx, {
|
||||||
|
email: 'Marcus@example.com',
|
||||||
|
phone: '+15551234567',
|
||||||
|
name: 'Marcus Laurent',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(data).toHaveLength(1);
|
||||||
|
expect(data[0]!.clientId).toBe(existing.id);
|
||||||
|
expect(data[0]!.confidence).toBe('high');
|
||||||
|
expect(data[0]!.reasons).toEqual(expect.arrayContaining(['email match', 'phone match']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not surface unrelated clients in the same port', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const ctx = makeMockCtx({ portId: port.id });
|
||||||
|
const target = await makeClient({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { fullName: 'Marcus Laurent' },
|
||||||
|
});
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: target.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'marcus@example.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
// An unrelated client.
|
||||||
|
const unrelated = await makeClient({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { fullName: 'Bob Smith' },
|
||||||
|
});
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: unrelated.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'bob@example.org',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await callHandler(ctx, { email: 'marcus@example.com' });
|
||||||
|
expect(data.map((d) => d.clientId)).toEqual([target.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns medium-confidence partial matches', async () => {
|
||||||
|
// Same name, different contact info — Pattern F territory.
|
||||||
|
const port = await makePort();
|
||||||
|
const ctx = makeMockCtx({ portId: port.id });
|
||||||
|
const existing = await makeClient({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { fullName: 'Etiennette Clamouze' },
|
||||||
|
});
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: existing.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'clamouze.etiennette@gmail.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await callHandler(ctx, {
|
||||||
|
// Different email + phone, same name.
|
||||||
|
email: 'etiennette@the-manoah.com',
|
||||||
|
name: 'Etiennette Clamouze',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Either no match (low confidence filtered out) or a medium one —
|
||||||
|
// either is fine. Critically, NOT high.
|
||||||
|
if (data.length > 0) {
|
||||||
|
expect(data[0]!.confidence).not.toBe('high');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak across ports', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
|
||||||
|
const ctxA = makeMockCtx({ portId: portA.id });
|
||||||
|
const inB = await makeClient({
|
||||||
|
portId: portB.id,
|
||||||
|
overrides: { fullName: 'In Port B' },
|
||||||
|
});
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: inB.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'b@example.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Caller is in port A, asking for an email that lives in port B.
|
||||||
|
const data = await callHandler(ctxA, { email: 'b@example.com' });
|
||||||
|
expect(data).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -268,6 +268,21 @@ describe('resolveTemplate — company-owned yacht', () => {
|
|||||||
portId: port.id,
|
portId: port.id,
|
||||||
overrides: { fullName: 'Bob Contact' },
|
overrides: { fullName: 'Bob Contact' },
|
||||||
});
|
});
|
||||||
|
// EOI gate now requires client primary email + address.
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: client.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'bob@example.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await db.insert(clientAddresses).values({
|
||||||
|
clientId: client.id,
|
||||||
|
portId: port.id,
|
||||||
|
streetAddress: '1 Marina Way',
|
||||||
|
city: 'Anguilla',
|
||||||
|
countryIso: 'AI',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
const yacht = await makeYacht({
|
const yacht = await makeYacht({
|
||||||
portId: port.id,
|
portId: port.id,
|
||||||
ownerType: 'company',
|
ownerType: 'company',
|
||||||
|
|||||||
92
tests/integration/documents-expired-webhook.test.ts
Normal file
92
tests/integration/documents-expired-webhook.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* DOCUMENT_EXPIRED webhook handling — locks in fix(documenso). The handler
|
||||||
|
* was previously defined but never wired to the route's event switch, so
|
||||||
|
* expired EOIs stayed in `sent` / `partially_signed` forever.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { handleDocumentExpired } from '@/lib/services/documents.service';
|
||||||
|
import { makeBerth, makeClient, makePort } from '../helpers/factories';
|
||||||
|
|
||||||
|
describe('handleDocumentExpired', () => {
|
||||||
|
it('flips a sent EOI to expired and writes a documentEvents row', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
|
||||||
|
const documensoId = `documenso-test-${Date.now()}`;
|
||||||
|
const [doc] = await db
|
||||||
|
.insert(documents)
|
||||||
|
.values({
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
documentType: 'eoi',
|
||||||
|
title: 'Expiring EOI',
|
||||||
|
status: 'sent',
|
||||||
|
documensoId,
|
||||||
|
createdBy: 'seed',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await handleDocumentExpired({ documentId: documensoId });
|
||||||
|
|
||||||
|
const after = await db.query.documents.findFirst({
|
||||||
|
where: eq(documents.id, doc!.id),
|
||||||
|
});
|
||||||
|
expect(after?.status).toBe('expired');
|
||||||
|
|
||||||
|
const events = await db
|
||||||
|
.select()
|
||||||
|
.from(documentEvents)
|
||||||
|
.where(eq(documentEvents.documentId, doc!.id));
|
||||||
|
expect(events.map((e) => e.eventType)).toContain('expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also flips the linked interest eoiStatus to expired', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
const berth = await makeBerth({ portId: port.id });
|
||||||
|
|
||||||
|
const [interest] = await db
|
||||||
|
.insert(interests)
|
||||||
|
.values({
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
berthId: berth.id,
|
||||||
|
pipelineStage: 'eoi_sent',
|
||||||
|
leadCategory: 'hot_lead',
|
||||||
|
eoiStatus: 'sent',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const documensoId = `documenso-test-${Date.now()}-i`;
|
||||||
|
await db.insert(documents).values({
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
interestId: interest!.id,
|
||||||
|
documentType: 'eoi',
|
||||||
|
title: 'Expiring EOI for interest',
|
||||||
|
status: 'sent',
|
||||||
|
documensoId,
|
||||||
|
createdBy: 'seed',
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleDocumentExpired({ documentId: documensoId });
|
||||||
|
|
||||||
|
const updatedInterest = await db.query.interests.findFirst({
|
||||||
|
where: eq(interests.id, interest!.id),
|
||||||
|
});
|
||||||
|
expect(updatedInterest?.eoiStatus).toBe('expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the documensoId does not match any document', async () => {
|
||||||
|
// Should NOT throw — the handler logs a warning and returns. Verify no
|
||||||
|
// exception propagates up to the webhook route.
|
||||||
|
await expect(
|
||||||
|
handleDocumentExpired({ documentId: 'definitely-not-a-real-doc' }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
84
tests/integration/portal-auth.test.ts
Normal file
84
tests/integration/portal-auth.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Portal JWT verification — locks in the audience/issuer hardening shipped
|
||||||
|
* in fix(auth): a token without `aud: 'portal'` + `iss: 'pn-crm'` claims
|
||||||
|
* must NOT verify, even if it's signed with the correct shared secret.
|
||||||
|
*
|
||||||
|
* Without these claims the CRM (better-auth) and portal sessions are
|
||||||
|
* structurally identical, so a portal token could be replayed against any
|
||||||
|
* `verifyPortalToken` consumer (and vice versa).
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SignJWT } from 'jose';
|
||||||
|
|
||||||
|
import { createPortalToken, verifyPortalToken } from '@/lib/portal/auth';
|
||||||
|
|
||||||
|
const SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET);
|
||||||
|
|
||||||
|
const SESSION = {
|
||||||
|
clientId: '11111111-1111-1111-1111-111111111111',
|
||||||
|
portId: '22222222-2222-2222-2222-222222222222',
|
||||||
|
email: 'client@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('portal JWT', () => {
|
||||||
|
it('round-trips a token signed with createPortalToken', async () => {
|
||||||
|
const token = await createPortalToken(SESSION);
|
||||||
|
const verified = await verifyPortalToken(token);
|
||||||
|
expect(verified).toMatchObject(SESSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token missing the `aud: portal` claim', async () => {
|
||||||
|
// Issuer present, audience absent — exactly the shape an old (pre-fix)
|
||||||
|
// portal session would have.
|
||||||
|
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuer('pn-crm')
|
||||||
|
.setExpirationTime('24h')
|
||||||
|
.setIssuedAt()
|
||||||
|
.sign(SECRET);
|
||||||
|
|
||||||
|
expect(await verifyPortalToken(token)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token missing the `iss: pn-crm` claim', async () => {
|
||||||
|
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setAudience('portal')
|
||||||
|
.setExpirationTime('24h')
|
||||||
|
.setIssuedAt()
|
||||||
|
.sign(SECRET);
|
||||||
|
|
||||||
|
expect(await verifyPortalToken(token)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token with the wrong audience (CRM session replay shape)', async () => {
|
||||||
|
// What a better-auth session token might roughly look like — same secret,
|
||||||
|
// different audience. Must not verify against the portal path.
|
||||||
|
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setAudience('crm')
|
||||||
|
.setIssuer('pn-crm')
|
||||||
|
.setExpirationTime('24h')
|
||||||
|
.setIssuedAt()
|
||||||
|
.sign(SECRET);
|
||||||
|
|
||||||
|
expect(await verifyPortalToken(token)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a token with the wrong issuer', async () => {
|
||||||
|
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setAudience('portal')
|
||||||
|
.setIssuer('attacker')
|
||||||
|
.setExpirationTime('24h')
|
||||||
|
.setIssuedAt()
|
||||||
|
.sign(SECRET);
|
||||||
|
|
||||||
|
expect(await verifyPortalToken(token)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects garbage', async () => {
|
||||||
|
expect(await verifyPortalToken('not.a.jwt')).toBeNull();
|
||||||
|
expect(await verifyPortalToken('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
379
tests/unit/dedup/find-matches.test.ts
Normal file
379
tests/unit/dedup/find-matches.test.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* Match-finding library — unit tests.
|
||||||
|
*
|
||||||
|
* Each duplicate cluster from the legacy NocoDB Interests audit (see
|
||||||
|
* docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §1.2)
|
||||||
|
* is encoded as a fixture here. The expected scoring tier (high / medium
|
||||||
|
* / low) is the design contract — if the algorithm starts returning
|
||||||
|
* "high" for a Pattern F case (Etiennette / Bruno+Bruce) it has lost
|
||||||
|
* the false-positive guard and we'll know immediately.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { findClientMatches, type MatchCandidate } from '@/lib/dedup/find-matches';
|
||||||
|
|
||||||
|
// Sensible defaults for tests — match the design's recommended thresholds.
|
||||||
|
const THRESHOLDS = {
|
||||||
|
highScore: 90,
|
||||||
|
mediumScore: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
function candidate(partial: Partial<MatchCandidate> & { id: string }): MatchCandidate {
|
||||||
|
return {
|
||||||
|
id: partial.id,
|
||||||
|
fullName: partial.fullName ?? null,
|
||||||
|
surnameToken: partial.surnameToken ?? null,
|
||||||
|
emails: partial.emails ?? [],
|
||||||
|
phonesE164: partial.phonesE164 ?? [],
|
||||||
|
countryIso: partial.countryIso ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('findClientMatches', () => {
|
||||||
|
describe('Pattern A — pure double-submit (high confidence)', () => {
|
||||||
|
it('flags identical email + phone as high', () => {
|
||||||
|
// From real data: Deepak Ramchandani #624/#625, identical fields.
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'b',
|
||||||
|
fullName: 'Deepak Ramchandani',
|
||||||
|
surnameToken: 'ramchandani',
|
||||||
|
emails: ['dannyrams8888@gmail.com'],
|
||||||
|
phonesE164: ['+17215868888'],
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
id: 'a',
|
||||||
|
fullName: 'Deepak Ramchandani',
|
||||||
|
surnameToken: 'ramchandani',
|
||||||
|
emails: ['dannyrams8888@gmail.com'],
|
||||||
|
phonesE164: ['+17215868888'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
expect(matches[0]!.candidate.id).toBe('a');
|
||||||
|
expect(matches[0]!.score).toBeGreaterThanOrEqual(90);
|
||||||
|
expect(matches[0]!.confidence).toBe('high');
|
||||||
|
expect(matches[0]!.reasons).toEqual(expect.arrayContaining(['email match', 'phone match']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pattern B — same email, different phone format (high)', () => {
|
||||||
|
it('high confidence when phones already normalize-equal', () => {
|
||||||
|
// From real data: Howard Wiarda #236/#536, "574-274-0548" vs "+15742740548".
|
||||||
|
// After normalization both phones are the same E.164, so the rule fires.
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'b',
|
||||||
|
fullName: 'Howard Wiarda',
|
||||||
|
surnameToken: 'wiarda',
|
||||||
|
emails: ['hwiarda@hotmail.com'],
|
||||||
|
phonesE164: ['+15742740548'],
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
id: 'a',
|
||||||
|
fullName: 'Howard Wiarda',
|
||||||
|
surnameToken: 'wiarda',
|
||||||
|
emails: ['hwiarda@hotmail.com'],
|
||||||
|
phonesE164: ['+15742740548'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
expect(matches[0]!.confidence).toBe('high');
|
||||||
|
expect(matches[0]!.score).toBeGreaterThanOrEqual(90);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pattern C — name capitalization variant (high)', () => {
|
||||||
|
it('treats lowercase + uppercase as the same person when surname-token + email + phone all match', () => {
|
||||||
|
// From real data: Nicolas Ruiz #681/#682/#683, email differs only by case.
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'b',
|
||||||
|
fullName: 'Nicolas Ruiz',
|
||||||
|
surnameToken: 'ruiz',
|
||||||
|
emails: ['ruiz.nicolas@ufl.edu'],
|
||||||
|
phonesE164: ['+17862006617'],
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
id: 'a',
|
||||||
|
fullName: 'Nicolas Ruiz',
|
||||||
|
surnameToken: 'ruiz',
|
||||||
|
emails: ['ruiz.nicolas@ufl.edu'],
|
||||||
|
phonesE164: ['+17862006617'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
expect(matches[0]!.confidence).toBe('high');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pattern D — name shortening (high)', () => {
|
||||||
|
it('Chris vs Christopher with same email + phone scores high', () => {
|
||||||
|
// From real data: Chris Allen #700 vs Christopher Allen #534.
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'b',
|
||||||
|
fullName: 'Chris Allen',
|
||||||
|
surnameToken: 'allen',
|
||||||
|
emails: ['chris@thundercatsports.com'],
|
||||||
|
phonesE164: ['+17814548950'],
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
id: 'a',
|
||||||
|
fullName: 'Christopher Allen',
|
||||||
|
surnameToken: 'allen',
|
||||||
|
emails: ['chris@thundercatsports.com'],
|
||||||
|
phonesE164: ['+17814548950'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
expect(matches[0]!.confidence).toBe('high');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pattern E — typo on resubmit', () => {
|
||||||
|
it('same email + nearly-identical phone (typo in last digits) scores high', () => {
|
||||||
|
// Christopher Camazou #649/#650 — phone differs in last 4 digits but
|
||||||
|
// everything else matches. Exact phone equality fails; email exact
|
||||||
|
// match alone (60) + name-token match (20) puts us in medium tier.
|
||||||
|
// The user can confirm the merge.
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'b',
|
||||||
|
fullName: 'Christopher Camazou',
|
||||||
|
surnameToken: 'camazou',
|
||||||
|
emails: ['camazou11@gmail.com'],
|
||||||
|
phonesE164: ['+33608334455'],
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
id: 'a',
|
||||||
|
fullName: 'Christopher Camazou',
|
||||||
|
surnameToken: 'camazou',
|
||||||
|
emails: ['camazou11@gmail.com'],
|
||||||
|
phonesE164: ['+33608336549'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
// Email + name match without phone match — strong but not certain.
|
||||||
|
expect(matches[0]!.confidence).toMatch(/^(high|medium)$/);
|
||||||
|
expect(matches[0]!.score).toBeGreaterThanOrEqual(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Constanzo / Costanzo surname typo with same email + phone scores high', () => {
|
||||||
|
// Gianfranco Di Constanzo #585 vs Di Costanzo #336 — same email + phone
|
||||||
|
// and only a 1-letter surname typo. This is a strong "same client,
|
||||||
|
// multiple yachts" signal — the design's signature win.
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'b',
|
||||||
|
fullName: 'Gianfranco Di Constanzo',
|
||||||
|
surnameToken: 'constanzo',
|
||||||
|
emails: ['gdc@nauticall.com'],
|
||||||
|
phonesE164: ['+17542628669'],
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
id: 'a',
|
||||||
|
fullName: 'Gianfranco Di Costanzo',
|
||||||
|
surnameToken: 'costanzo',
|
||||||
|
emails: ['gdc@nauticall.com'],
|
||||||
|
phonesE164: ['+17542628669'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
expect(matches[0]!.confidence).toBe('high');
|
||||||
|
expect(matches[0]!.score).toBeGreaterThanOrEqual(90);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pattern F — hard cases (must NOT auto-merge)', () => {
|
||||||
|
it('same name with different country phone + different email scores at most medium', () => {
|
||||||
|
// Etiennette Clamouze #188/#717 — same name but completely different
|
||||||
|
// email + phone (and the phones are in different country codes,
|
||||||
|
// suggesting either a relative, a coworker, or a name-collision).
|
||||||
|
// We must NOT classify this as "high" or it would force-merge two
|
||||||
|
// distinct people.
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'b',
|
||||||
|
fullName: 'Etiennette Clamouze',
|
||||||
|
surnameToken: 'clamouze',
|
||||||
|
emails: ['etiennette@the-manoah.com'],
|
||||||
|
phonesE164: ['+12645815607'],
|
||||||
|
countryIso: 'AI',
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
id: 'a',
|
||||||
|
fullName: 'Etiennette Clamouze',
|
||||||
|
surnameToken: 'clamouze',
|
||||||
|
emails: ['clamouze.etiennette@gmail.com'],
|
||||||
|
phonesE164: ['+33767780640'],
|
||||||
|
countryIso: 'FR',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
// Surname-token + name-exact match should score in medium tier so
|
||||||
|
// the pair lands in the review queue but doesn't auto-merge.
|
||||||
|
if (matches.length > 0) {
|
||||||
|
expect(matches[0]!.confidence).not.toBe('high');
|
||||||
|
expect(matches[0]!.score).toBeLessThan(90);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared email between two clearly different names is medium not high', () => {
|
||||||
|
// Bruno Joyerot #18 vs Bruce Hearn #19 — Bruno's row shows email
|
||||||
|
// belonging to "catherine elaine hearn" (Bruce's spouse). Same
|
||||||
|
// household phone area code. Name overlap is partial. Don't merge.
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'b',
|
||||||
|
fullName: 'Bruce Hearn',
|
||||||
|
surnameToken: 'hearn',
|
||||||
|
emails: ['bhearn1063@gmail.com'],
|
||||||
|
phonesE164: ['+12642358840'],
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
id: 'a',
|
||||||
|
fullName: 'Bruno Joyerot',
|
||||||
|
surnameToken: 'joyerot',
|
||||||
|
emails: ['catherineelainehearn@gmail.com'],
|
||||||
|
phonesE164: ['+12642352816'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
// Names don't match, emails don't match, phones differ — there's
|
||||||
|
// no reason for this to surface at all. Either no match or low.
|
||||||
|
if (matches.length > 0) {
|
||||||
|
expect(matches[0]!.confidence).toBe('low');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Negative evidence — same email but different country phone', () => {
|
||||||
|
it('reduces score when email matches but phone country differs', () => {
|
||||||
|
// Constructed: same email, but one phone is +33 (FR) and the other
|
||||||
|
// is +1 (US). Likely a shared-inbox spouse situation. We want
|
||||||
|
// medium tier so it lands in review, not high tier.
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'b',
|
||||||
|
fullName: 'Test User',
|
||||||
|
surnameToken: 'user',
|
||||||
|
emails: ['shared@example.com'],
|
||||||
|
phonesE164: ['+15551234567'],
|
||||||
|
countryIso: 'US',
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
id: 'a',
|
||||||
|
fullName: 'Test User',
|
||||||
|
surnameToken: 'user',
|
||||||
|
emails: ['shared@example.com'],
|
||||||
|
phonesE164: ['+33611111111'],
|
||||||
|
countryIso: 'FR',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
// Email match alone would be 60 + name token match 20 = 80 (medium).
|
||||||
|
// Negative evidence (different phone country) brings it down further.
|
||||||
|
expect(matches[0]!.confidence).toBe('medium');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Blocking — only relevant candidates are scored', () => {
|
||||||
|
it('does not score candidates with no shared emails / phones / surname token', () => {
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'newbie',
|
||||||
|
fullName: 'Alice Smith',
|
||||||
|
surnameToken: 'smith',
|
||||||
|
emails: ['alice@example.com'],
|
||||||
|
phonesE164: ['+15551234567'],
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
id: 'unrelated1',
|
||||||
|
fullName: 'Bob Jones',
|
||||||
|
surnameToken: 'jones',
|
||||||
|
emails: ['bob@example.org'],
|
||||||
|
phonesE164: ['+33611111111'],
|
||||||
|
}),
|
||||||
|
candidate({
|
||||||
|
id: 'unrelated2',
|
||||||
|
fullName: 'Carol White',
|
||||||
|
surnameToken: 'white',
|
||||||
|
emails: ['carol@example.net'],
|
||||||
|
phonesE164: ['+447700900111'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
expect(matches).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty pool', () => {
|
||||||
|
it('returns no matches when the pool is empty', () => {
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'a',
|
||||||
|
fullName: 'Alice',
|
||||||
|
emails: ['alice@example.com'],
|
||||||
|
});
|
||||||
|
expect(findClientMatches(incoming, [], THRESHOLDS)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sort order', () => {
|
||||||
|
it('returns matches sorted by score descending', () => {
|
||||||
|
const incoming = candidate({
|
||||||
|
id: 'incoming',
|
||||||
|
fullName: 'John Smith',
|
||||||
|
surnameToken: 'smith',
|
||||||
|
emails: ['john@example.com'],
|
||||||
|
phonesE164: ['+15551234567'],
|
||||||
|
});
|
||||||
|
const pool = [
|
||||||
|
candidate({
|
||||||
|
// High match — same email + phone
|
||||||
|
id: 'high-match',
|
||||||
|
fullName: 'John Smith',
|
||||||
|
surnameToken: 'smith',
|
||||||
|
emails: ['john@example.com'],
|
||||||
|
phonesE164: ['+15551234567'],
|
||||||
|
}),
|
||||||
|
candidate({
|
||||||
|
// Medium match — same email only
|
||||||
|
id: 'medium-match',
|
||||||
|
fullName: 'Different Person',
|
||||||
|
surnameToken: 'person',
|
||||||
|
emails: ['john@example.com'],
|
||||||
|
phonesE164: ['+33611111111'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const matches = findClientMatches(incoming, pool, THRESHOLDS);
|
||||||
|
|
||||||
|
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(matches[0]!.candidate.id).toBe('high-match');
|
||||||
|
expect(matches[0]!.score).toBeGreaterThan(matches[1]!.score);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
213
tests/unit/dedup/migration-transform.test.ts
Normal file
213
tests/unit/dedup/migration-transform.test.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Migration transform — fixture-based regression test.
|
||||||
|
*
|
||||||
|
* Feeds the transform a small frozen NocoDB snapshot containing one
|
||||||
|
* representative row from each duplicate pattern documented in
|
||||||
|
* docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §1.2,
|
||||||
|
* and asserts the resulting plan matches the algorithm's expected
|
||||||
|
* behavior. If any future change starts merging Pattern F (Etiennette
|
||||||
|
* Clamouze) or stops merging Pattern A (Deepak Ramchandani), this
|
||||||
|
* test fails immediately.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { transformSnapshot } from '@/lib/dedup/migration-transform';
|
||||||
|
import type { NocoDbRow, NocoDbSnapshot } from '@/lib/dedup/nocodb-source';
|
||||||
|
|
||||||
|
function row(fields: Partial<NocoDbRow> & { Id: number }): NocoDbRow {
|
||||||
|
return fields as NocoDbRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIXTURE: NocoDbSnapshot = {
|
||||||
|
fetchedAt: '2026-05-03T12:00:00.000Z',
|
||||||
|
berths: [],
|
||||||
|
residentialInterests: [],
|
||||||
|
websiteInterestSubmissions: [],
|
||||||
|
websiteContactFormSubmissions: [],
|
||||||
|
websiteBerthEoiSupplements: [],
|
||||||
|
interests: [
|
||||||
|
// Pattern A: pure double-submit (Deepak Ramchandani #624/#625)
|
||||||
|
row({
|
||||||
|
Id: 624,
|
||||||
|
'Full Name': 'Deepak Ramchandani',
|
||||||
|
'Email Address': 'dannyrams8888@gmail.com',
|
||||||
|
'Phone Number': '+17215868888',
|
||||||
|
'Sales Process Level': 'General Qualified Interest',
|
||||||
|
}),
|
||||||
|
row({
|
||||||
|
Id: 625,
|
||||||
|
'Full Name': 'Deepak Ramchandani',
|
||||||
|
'Email Address': 'dannyrams8888@gmail.com',
|
||||||
|
'Phone Number': '+17215868888',
|
||||||
|
'Sales Process Level': 'General Qualified Interest',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Pattern B: phone format variance (Howard Wiarda #236/#536)
|
||||||
|
row({
|
||||||
|
Id: 236,
|
||||||
|
'Full Name': 'Howard Wiarda',
|
||||||
|
'Email Address': 'hwiarda@hotmail.com',
|
||||||
|
'Phone Number': '574-274-0548',
|
||||||
|
'Place of Residence': 'USA',
|
||||||
|
'Sales Process Level': 'General Qualified Interest',
|
||||||
|
}),
|
||||||
|
row({
|
||||||
|
Id: 536,
|
||||||
|
'Full Name': 'Howard Wiarda',
|
||||||
|
'Email Address': 'hwiarda@hotmail.com',
|
||||||
|
'Phone Number': '+15742740548',
|
||||||
|
'Sales Process Level': 'General Qualified Interest',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Pattern C: name capitalization (Nicolas Ruiz #681/#682/#683 — three rows)
|
||||||
|
row({
|
||||||
|
Id: 681,
|
||||||
|
'Full Name': 'Nicolas Ruiz',
|
||||||
|
'Email Address': 'ruiz.nicolas@ufl.edu',
|
||||||
|
'Phone Number': '+17862006617',
|
||||||
|
'Sales Process Level': 'General Qualified Interest',
|
||||||
|
}),
|
||||||
|
row({
|
||||||
|
Id: 682,
|
||||||
|
'Full Name': 'Nicolas Ruiz',
|
||||||
|
'Email Address': 'ruiz.nicolas@ufl.edu',
|
||||||
|
'Phone Number': '+17862006617',
|
||||||
|
'Sales Process Level': 'Specific Qualified Interest',
|
||||||
|
}),
|
||||||
|
row({
|
||||||
|
Id: 683,
|
||||||
|
'Full Name': 'Nicolas Ruiz',
|
||||||
|
'Email Address': 'Ruiz.Nicolas@ufl.edu',
|
||||||
|
'Phone Number': '+17862006617',
|
||||||
|
'Sales Process Level': 'General Qualified Interest',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Pattern E: surname typo with same email + phone (Constanzo/Costanzo)
|
||||||
|
row({
|
||||||
|
Id: 336,
|
||||||
|
'Full Name': 'Gianfranco Di Costanzo',
|
||||||
|
'Email Address': 'gdc@nauticall.com',
|
||||||
|
'Phone Number': '+17542628669',
|
||||||
|
'Yacht Name': 'GEMINI',
|
||||||
|
'Sales Process Level': 'Contract Signed',
|
||||||
|
}),
|
||||||
|
row({
|
||||||
|
Id: 585,
|
||||||
|
'Full Name': 'Gianfranco Di Constanzo',
|
||||||
|
'Email Address': 'gdc@nauticall.com',
|
||||||
|
'Phone Number': '+17542628669',
|
||||||
|
'Yacht Name': 'CALYPSO',
|
||||||
|
'Sales Process Level': 'Signed EOI and NDA',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Pattern F: same name, different country phones (Etiennette Clamouze)
|
||||||
|
row({
|
||||||
|
Id: 188,
|
||||||
|
'Full Name': 'Etiennette Clamouze',
|
||||||
|
'Email Address': 'clamouze.etiennette@gmail.com',
|
||||||
|
'Phone Number': '+33767780640',
|
||||||
|
'Sales Process Level': 'General Qualified Interest',
|
||||||
|
}),
|
||||||
|
row({
|
||||||
|
Id: 717,
|
||||||
|
'Full Name': 'Etiennette Clamouze',
|
||||||
|
'Email Address': 'Etiennette@the-manoah.com',
|
||||||
|
'Phone Number': '+12645815607',
|
||||||
|
'Sales Process Level': 'General Qualified Interest',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Single isolated row to verify non-duplicates pass through
|
||||||
|
row({
|
||||||
|
Id: 999,
|
||||||
|
'Full Name': 'Lone Wolf',
|
||||||
|
'Email Address': 'lone@example.com',
|
||||||
|
'Phone Number': '+15551234567',
|
||||||
|
'Sales Process Level': 'General Qualified Interest',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('transformSnapshot — fixture regression', () => {
|
||||||
|
it('produces the expected number of clients + interests', () => {
|
||||||
|
const plan = transformSnapshot(FIXTURE);
|
||||||
|
|
||||||
|
// 12 input rows → 7 unique clients (Deepak: 1, Wiarda: 1, Ruiz: 1,
|
||||||
|
// Constanzo: 1, Etiennette x2: 2, Lone: 1). Etiennette stays as 2
|
||||||
|
// because Pattern F is correctly NOT auto-merged.
|
||||||
|
expect(plan.stats.outputClients).toBe(7);
|
||||||
|
expect(plan.stats.outputInterests).toBe(12); // one per source row
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-links every Pattern A–E cluster', () => {
|
||||||
|
const plan = transformSnapshot(FIXTURE);
|
||||||
|
const linkedSourceIds = new Set<number>();
|
||||||
|
for (const link of plan.autoLinks) {
|
||||||
|
linkedSourceIds.add(link.leadSourceId);
|
||||||
|
for (const merged of link.mergedSourceIds) {
|
||||||
|
linkedSourceIds.add(merged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern A: 624 + 625
|
||||||
|
expect(linkedSourceIds.has(624) && linkedSourceIds.has(625)).toBe(true);
|
||||||
|
// Pattern B: 236 + 536
|
||||||
|
expect(linkedSourceIds.has(236) && linkedSourceIds.has(536)).toBe(true);
|
||||||
|
// Pattern C: 681 + 682 + 683 (three-way)
|
||||||
|
expect(linkedSourceIds.has(681) && linkedSourceIds.has(682) && linkedSourceIds.has(683)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// Pattern E: 336 + 585
|
||||||
|
expect(linkedSourceIds.has(336) && linkedSourceIds.has(585)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT auto-link Pattern F (Etiennette Clamouze, different country)', () => {
|
||||||
|
const plan = transformSnapshot(FIXTURE);
|
||||||
|
const linkedSourceIds = new Set<number>();
|
||||||
|
for (const link of plan.autoLinks) {
|
||||||
|
linkedSourceIds.add(link.leadSourceId);
|
||||||
|
for (const merged of link.mergedSourceIds) {
|
||||||
|
linkedSourceIds.add(merged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both Etiennette rows must remain as separate clients.
|
||||||
|
expect(linkedSourceIds.has(188)).toBe(false);
|
||||||
|
expect(linkedSourceIds.has(717)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves every interest as its own row even when clients merge', () => {
|
||||||
|
const plan = transformSnapshot(FIXTURE);
|
||||||
|
const sourceIds = plan.interests.map((i) => i.sourceId).sort((a, b) => a - b);
|
||||||
|
expect(sourceIds).toEqual([188, 236, 336, 536, 585, 624, 625, 681, 682, 683, 717, 999]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the legacy 8-stage enum to new pipeline stages', () => {
|
||||||
|
const plan = transformSnapshot(FIXTURE);
|
||||||
|
const stagesById = new Map(plan.interests.map((i) => [i.sourceId, i.pipelineStage]));
|
||||||
|
expect(stagesById.get(681)).toBe('open'); // General Qualified Interest
|
||||||
|
expect(stagesById.get(682)).toBe('details_sent'); // Specific Qualified Interest
|
||||||
|
expect(stagesById.get(336)).toBe('contract_signed'); // Contract Signed
|
||||||
|
expect(stagesById.get(585)).toBe('eoi_signed'); // Signed EOI and NDA
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches different yachts to one merged Constanzo client', () => {
|
||||||
|
const plan = transformSnapshot(FIXTURE);
|
||||||
|
const constanzoClient = plan.clients.find(
|
||||||
|
(c) => c.sourceIds.includes(336) && c.sourceIds.includes(585),
|
||||||
|
);
|
||||||
|
expect(constanzoClient).toBeDefined();
|
||||||
|
const yachtsForConstanzo = plan.interests
|
||||||
|
.filter((i) => i.clientTempId === constanzoClient!.tempId)
|
||||||
|
.map((i) => i.yachtName)
|
||||||
|
.sort();
|
||||||
|
expect(yachtsForConstanzo).toEqual(['CALYPSO', 'GEMINI']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces deterministic output (same input → same plan)', () => {
|
||||||
|
// The transform is pure — running it twice should yield bit-identical
|
||||||
|
// results. Catches order-dependent bugs in the dedup clustering.
|
||||||
|
const a = transformSnapshot(FIXTURE);
|
||||||
|
const b = transformSnapshot(FIXTURE);
|
||||||
|
expect(JSON.stringify(a.stats)).toBe(JSON.stringify(b.stats));
|
||||||
|
expect(a.autoLinks.length).toBe(b.autoLinks.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
270
tests/unit/dedup/normalize.test.ts
Normal file
270
tests/unit/dedup/normalize.test.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Normalization library — unit tests.
|
||||||
|
*
|
||||||
|
* Every fixture here comes from real dirty values observed in the legacy
|
||||||
|
* NocoDB Interests table during the 2026-05-03 audit (see
|
||||||
|
* docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §1.3).
|
||||||
|
* The point is regression-prevention: if any of these patterns ever
|
||||||
|
* stops normalizing the way it should, dedup quality silently drops.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeName,
|
||||||
|
normalizeEmail,
|
||||||
|
normalizePhone,
|
||||||
|
resolveCountry,
|
||||||
|
} from '@/lib/dedup/normalize';
|
||||||
|
|
||||||
|
describe('normalizeName', () => {
|
||||||
|
it('returns null fields for empty / null input', () => {
|
||||||
|
expect(normalizeName('')).toEqual({ display: '', normalized: '', surnameToken: undefined });
|
||||||
|
expect(normalizeName(' ')).toEqual({
|
||||||
|
display: '',
|
||||||
|
normalized: '',
|
||||||
|
surnameToken: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims leading/trailing whitespace', () => {
|
||||||
|
expect(normalizeName(' Marcus Laurent ')).toMatchObject({
|
||||||
|
display: 'Marcus Laurent',
|
||||||
|
normalized: 'marcus laurent',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses repeated internal whitespace to a single space', () => {
|
||||||
|
// From real data: "Arthur Matthews" (#183), "Corinne Roche" (#208).
|
||||||
|
expect(normalizeName('Arthur Matthews').display).toBe('Arthur Matthews');
|
||||||
|
expect(normalizeName('Corinne Roche').display).toBe('Corinne Roche');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces embedded carriage returns and newlines with single spaces', () => {
|
||||||
|
// From real data: "Andrei \nVAGNANOV" (#178), "Daniel\r PRZEDBORSKI" (#175).
|
||||||
|
expect(normalizeName('Andrei \nVAGNANOV').display).toBe('Andrei Vagnanov');
|
||||||
|
expect(normalizeName('Daniel\r PRZEDBORSKI').display).toBe('Daniel Przedborski');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title-cases ALL-CAPS surnames while keeping given name title-cased', () => {
|
||||||
|
// From real data: "Jona ANDERSEN" (#232), "Duane SALTSGAVER" (#227),
|
||||||
|
// "Marcos DALLA PRIA" (#165).
|
||||||
|
expect(normalizeName('Jona ANDERSEN').display).toBe('Jona Andersen');
|
||||||
|
expect(normalizeName('Duane SALTSGAVER').display).toBe('Duane Saltsgaver');
|
||||||
|
// Particle 'dalla' stays lowercase mid-name.
|
||||||
|
expect(normalizeName('Marcos DALLA PRIA').display).toBe('Marcos dalla Pria');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title-cases lowercased entries', () => {
|
||||||
|
// From real data: "antony amaral" (#665), "david rosenbloom" (#239),
|
||||||
|
// "john Tickner" (#247).
|
||||||
|
expect(normalizeName('antony amaral').display).toBe('Antony Amaral');
|
||||||
|
expect(normalizeName('david rosenbloom').display).toBe('David Rosenbloom');
|
||||||
|
expect(normalizeName('john Tickner').display).toBe('John Tickner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Romance and Germanic particles lowercase mid-name', () => {
|
||||||
|
// From real data: "Olav van Velsen" (#526), "Bruno Joyerot" (#18),
|
||||||
|
// "OLIVIER DAIN" (#677). Also synthetic "Carla de la Cruz".
|
||||||
|
expect(normalizeName('Olav van Velsen').display).toBe('Olav van Velsen');
|
||||||
|
expect(normalizeName('Carla de la Cruz').display).toBe('Carla de la Cruz');
|
||||||
|
expect(normalizeName('OLIVIER DAIN').display).toBe('Olivier Dain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves O‘-prefixed Irish surnames as title-case', () => {
|
||||||
|
expect(normalizeName("liam o'brien").display).toBe("Liam O'Brien");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the slash-with-company structure intact', () => {
|
||||||
|
// From real data: "Daniel Wainstein / 7 Knots, LLC" (#637),
|
||||||
|
// "Bruno Joyerot / SAS TIKI" (#18).
|
||||||
|
expect(normalizeName('Daniel Wainstein / 7 Knots, LLC').display).toBe(
|
||||||
|
'Daniel Wainstein / 7 Knots, LLC',
|
||||||
|
);
|
||||||
|
expect(normalizeName('Bruno Joyerot / SAS TIKI').display).toBe('Bruno Joyerot / SAS TIKI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the last non-particle token as surnameToken (lowercase) for blocking', () => {
|
||||||
|
expect(normalizeName('Marcus Laurent').surnameToken).toBe('laurent');
|
||||||
|
expect(normalizeName('Olav van Velsen').surnameToken).toBe('velsen');
|
||||||
|
expect(normalizeName('Carla de la Cruz').surnameToken).toBe('cruz');
|
||||||
|
expect(normalizeName("Liam O'Brien").surnameToken).toBe("o'brien");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single-token names — surnameToken is the only token', () => {
|
||||||
|
expect(normalizeName('Madonna').surnameToken).toBe('madonna');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a normalized form that is always lowercase', () => {
|
||||||
|
expect(normalizeName('Andrei VAGNANOV').normalized).toBe('andrei vagnanov');
|
||||||
|
expect(normalizeName('Daniel Wainstein / 7 Knots, LLC').normalized).toBe(
|
||||||
|
'daniel wainstein / 7 knots, llc',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeEmail', () => {
|
||||||
|
it('returns null for empty / null inputs', () => {
|
||||||
|
expect(normalizeEmail('')).toBeNull();
|
||||||
|
expect(normalizeEmail(' ')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowercases and trims', () => {
|
||||||
|
// From real data: "Arthur@laser-align.com" vs "arthur@laser-align.com" (#183/#686).
|
||||||
|
expect(normalizeEmail('Arthur@laser-align.com')).toBe('arthur@laser-align.com');
|
||||||
|
expect(normalizeEmail(' marcus@example.com ')).toBe('marcus@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowercases capitalized localparts', () => {
|
||||||
|
// From real data: "Bmalone850@gmail.com" (#489), "Hef355@yahoo.com" (#533),
|
||||||
|
// "Donclaytonmusic@gmail.com" (#679).
|
||||||
|
expect(normalizeEmail('Bmalone850@gmail.com')).toBe('bmalone850@gmail.com');
|
||||||
|
expect(normalizeEmail('Hef355@yahoo.com')).toBe('hef355@yahoo.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves plus-aliases — both legitimate and tricks', () => {
|
||||||
|
// Per design §3.2: "+aliases" are not stripped. Compare by full localpart.
|
||||||
|
expect(normalizeEmail('marcus+sales@example.com')).toBe('marcus+sales@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid email shapes', () => {
|
||||||
|
expect(normalizeEmail('not-an-email')).toBeNull();
|
||||||
|
expect(normalizeEmail('@example.com')).toBeNull();
|
||||||
|
expect(normalizeEmail('user@')).toBeNull();
|
||||||
|
expect(normalizeEmail('user@.com')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizePhone', () => {
|
||||||
|
it('returns null for empty / whitespace / null', () => {
|
||||||
|
expect(normalizePhone('', 'AI')).toBeNull();
|
||||||
|
expect(normalizePhone(' ', 'AI')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a plain E.164 number', () => {
|
||||||
|
expect(normalizePhone('+15742740548', 'US')).toMatchObject({
|
||||||
|
e164: '+15742740548',
|
||||||
|
country: 'US',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips embedded carriage returns and trailing whitespace', () => {
|
||||||
|
// From real data: "+1-264-235-8840\r" (#19), "+1-264-772-3272\r" (#20).
|
||||||
|
const out = normalizePhone('+1-264-235-8840\r', 'AI');
|
||||||
|
expect(out?.e164).toBe('+12642358840');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips dashes, dots, parens, single quotes, spaces in a single pass', () => {
|
||||||
|
// From real data: "'+1.214.603.4235" (#205), "574-274-0548" (#236),
|
||||||
|
// "+1-264-235-8840" (#19), "+1 (212) 555-0123" (synthetic).
|
||||||
|
expect(normalizePhone("'+1.214.603.4235", 'US')?.e164).toBe('+12146034235');
|
||||||
|
expect(normalizePhone('574-274-0548', 'US')?.e164).toBe('+15742740548');
|
||||||
|
expect(normalizePhone('+1 (212) 555-0123', 'US')?.e164).toBe('+12125550123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts a leading 00 prefix to + (international dialling)', () => {
|
||||||
|
// From real data: "00447956657022" (#216), "0033651381036" (#702).
|
||||||
|
expect(normalizePhone('00447956657022', 'GB')?.e164).toBe('+447956657022');
|
||||||
|
expect(normalizePhone('0033651381036', 'FR')?.e164).toBe('+33651381036');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses defaultCountry when input has no international prefix', () => {
|
||||||
|
// From real data: "0690699699" (#203, French local), "0651381036" (#701).
|
||||||
|
expect(normalizePhone('0690699699', 'FR')?.e164).toBe('+33690699699');
|
||||||
|
expect(normalizePhone('0651381036', 'FR')?.e164).toBe('+33651381036');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when there is no prefix AND no defaultCountry', () => {
|
||||||
|
// The migration script flags these for human review.
|
||||||
|
const out = normalizePhone('5742740548');
|
||||||
|
expect(out?.e164 ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags placeholder all-zeros numbers and returns null', () => {
|
||||||
|
// From real data: "+447000000000" (#641, "Milos Vitkovic" — clearly fake).
|
||||||
|
const out = normalizePhone('+447000000000', 'GB');
|
||||||
|
expect(out?.flagged).toBe('placeholder');
|
||||||
|
expect(out?.e164).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags multi-number fields and uses the first segment', () => {
|
||||||
|
// From real data: "0677580750/0690511494" (#209). Other separators: ; ,
|
||||||
|
const slash = normalizePhone('0677580750/0690511494', 'FR');
|
||||||
|
expect(slash?.flagged).toBe('multi_number');
|
||||||
|
expect(slash?.e164).toBe('+33677580750');
|
||||||
|
|
||||||
|
const semi = normalizePhone('+33611111111;+33622222222', 'FR');
|
||||||
|
expect(semi?.flagged).toBe('multi_number');
|
||||||
|
expect(semi?.e164).toBe('+33611111111');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags genuinely unparseable input as `unparseable`', () => {
|
||||||
|
const out = normalizePhone('xyz-not-a-phone', 'US');
|
||||||
|
expect(out?.flagged).toBe('unparseable');
|
||||||
|
expect(out?.e164).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips an apostrophe-prefix without breaking the parse', () => {
|
||||||
|
// From real data: leading "'" copy-pasted from spreadsheets escapes
|
||||||
|
// numeric-cell coercion. Should be invisible to dedup.
|
||||||
|
expect(normalizePhone("'0690699699", 'FR')?.e164).toBe('+33690699699');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the country alongside the E.164 form', () => {
|
||||||
|
expect(normalizePhone('+33690699699', 'FR')).toMatchObject({
|
||||||
|
e164: '+33690699699',
|
||||||
|
country: 'FR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveCountry', () => {
|
||||||
|
it('returns null for empty / nullish input', () => {
|
||||||
|
expect(resolveCountry('')).toEqual({ iso: null, confidence: null });
|
||||||
|
expect(resolveCountry(' ')).toEqual({ iso: null, confidence: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exact-matches a canonical English country name', () => {
|
||||||
|
expect(resolveCountry('Anguilla')).toEqual({ iso: 'AI', confidence: 'exact' });
|
||||||
|
expect(resolveCountry('United Kingdom')).toEqual({ iso: 'GB', confidence: 'exact' });
|
||||||
|
expect(resolveCountry('United States')).toEqual({ iso: 'US', confidence: 'exact' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches case-insensitively', () => {
|
||||||
|
expect(resolveCountry('anguilla').iso).toBe('AI');
|
||||||
|
expect(resolveCountry('UNITED KINGDOM').iso).toBe('GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches values with surrounding whitespace', () => {
|
||||||
|
expect(resolveCountry(' United States ').iso).toBe('US');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles diacritic variants of Saint-Barthélemy', () => {
|
||||||
|
// From real data: "Saint barthelemy" (#203), "St Barth" (#208), "Saint-Barthélemy".
|
||||||
|
expect(resolveCountry('Saint-Barthélemy').iso).toBe('BL');
|
||||||
|
expect(resolveCountry('Saint Barthelemy').iso).toBe('BL');
|
||||||
|
expect(resolveCountry('saint barthelemy').iso).toBe('BL');
|
||||||
|
expect(resolveCountry('St Barth').iso).toBe('BL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves common abbreviations', () => {
|
||||||
|
expect(resolveCountry('USA').iso).toBe('US');
|
||||||
|
expect(resolveCountry('UK').iso).toBe('GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a city → country mapping for high-frequency cities', () => {
|
||||||
|
// From real data: "Kansas City" (#198), "Sag Harbor Y" (#239).
|
||||||
|
expect(resolveCountry('Kansas City').iso).toBe('US');
|
||||||
|
expect(resolveCountry('Sag Harbor Y').iso).toBe('US');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the confidence tier appropriately', () => {
|
||||||
|
expect(resolveCountry('Anguilla').confidence).toBe('exact');
|
||||||
|
expect(resolveCountry('Kansas City').confidence).toBe('city');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null + null for unresolvable values', () => {
|
||||||
|
// Migration script flags these for human review rather than guessing.
|
||||||
|
expect(resolveCountry('asdfghjkl xyz')).toEqual({ iso: null, confidence: null });
|
||||||
|
expect(resolveCountry('Mars')).toEqual({ iso: null, confidence: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
158
tests/unit/hooks/realtime-invalidation.test.ts
Normal file
158
tests/unit/hooks/realtime-invalidation.test.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import {
|
||||||
|
subscribeRealtimeInvalidations,
|
||||||
|
type EventMap,
|
||||||
|
type SocketLike,
|
||||||
|
} from '@/hooks/realtime-invalidation-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-logic tests for the realtime-invalidation subscription helper. The
|
||||||
|
* React hook (`useRealtimeInvalidation`) is just a thin wrapper around this
|
||||||
|
* function — verifying the handler-registration / fire-time-lookup behavior
|
||||||
|
* here is sufficient to lock in the bug fixes:
|
||||||
|
* 1. Re-subscribe storm (caller passing inline literals)
|
||||||
|
* 2. Fresh queryKeys read at fire-time
|
||||||
|
*
|
||||||
|
* The `useSocket` provider fix (don't null-context on disconnect) is verified
|
||||||
|
* separately by manual smoke + the existing socket integration coverage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeStubSocket() {
|
||||||
|
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||||
|
const onCalls: Array<{ event: string }> = [];
|
||||||
|
const offCalls: Array<{ event: string }> = [];
|
||||||
|
|
||||||
|
const socket: SocketLike = {
|
||||||
|
on(event, handler) {
|
||||||
|
onCalls.push({ event });
|
||||||
|
const arr = listeners.get(event) ?? [];
|
||||||
|
arr.push(handler);
|
||||||
|
listeners.set(event, arr);
|
||||||
|
},
|
||||||
|
off(event, handler) {
|
||||||
|
offCalls.push({ event });
|
||||||
|
const arr = listeners.get(event) ?? [];
|
||||||
|
listeners.set(
|
||||||
|
event,
|
||||||
|
arr.filter((h) => h !== handler),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function emit(event: string, ...args: unknown[]) {
|
||||||
|
for (const h of listeners.get(event) ?? []) h(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { socket, emit, onCalls, offCalls, listeners };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStubQueryClient() {
|
||||||
|
const calls: QueryKey[] = [];
|
||||||
|
const queryClient = {
|
||||||
|
invalidateQueries: vi.fn(({ queryKey }: { queryKey: QueryKey }) => {
|
||||||
|
calls.push(queryKey);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
} as unknown as QueryClient;
|
||||||
|
return { queryClient, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('subscribeRealtimeInvalidations', () => {
|
||||||
|
it('registers one .on() per event key', () => {
|
||||||
|
const { socket, onCalls } = makeStubSocket();
|
||||||
|
const { queryClient } = makeStubQueryClient();
|
||||||
|
const map: EventMap = {
|
||||||
|
'client:created': [['clients']],
|
||||||
|
'client:updated': [['clients'], ['clients', 'abc']],
|
||||||
|
};
|
||||||
|
|
||||||
|
subscribeRealtimeInvalidations(socket, Object.keys(map), queryClient, () => map);
|
||||||
|
|
||||||
|
expect(onCalls.map((c) => c.event).sort()).toEqual(['client:created', 'client:updated']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates each queryKey for the matching event', () => {
|
||||||
|
const { socket, emit } = makeStubSocket();
|
||||||
|
const { queryClient, calls } = makeStubQueryClient();
|
||||||
|
const map: EventMap = {
|
||||||
|
'client:updated': [['clients'], ['clients', 'abc']],
|
||||||
|
};
|
||||||
|
|
||||||
|
subscribeRealtimeInvalidations(socket, Object.keys(map), queryClient, () => map);
|
||||||
|
emit('client:updated');
|
||||||
|
|
||||||
|
expect(calls).toEqual([['clients'], ['clients', 'abc']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the LATEST eventMap at fire time, not at subscription time', () => {
|
||||||
|
// This is the core of the re-subscribe-storm fix: callers can swap in a
|
||||||
|
// new eventMap object without re-subscribing, and the handler still sees
|
||||||
|
// the fresh queryKey list.
|
||||||
|
const { socket, emit } = makeStubSocket();
|
||||||
|
const { queryClient, calls } = makeStubQueryClient();
|
||||||
|
|
||||||
|
let currentMap: EventMap = {
|
||||||
|
'client:updated': [['clients']],
|
||||||
|
};
|
||||||
|
subscribeRealtimeInvalidations(socket, ['client:updated'], queryClient, () => currentMap);
|
||||||
|
|
||||||
|
// First fire: see the original map
|
||||||
|
emit('client:updated');
|
||||||
|
expect(calls).toEqual([['clients']]);
|
||||||
|
|
||||||
|
// Caller re-renders with a fresh literal that includes more queryKeys
|
||||||
|
currentMap = {
|
||||||
|
'client:updated': [['clients'], ['clients', 'abc']],
|
||||||
|
};
|
||||||
|
emit('client:updated');
|
||||||
|
|
||||||
|
expect(calls).toEqual([['clients'], ['clients'], ['clients', 'abc']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup deregisters every handler it registered', () => {
|
||||||
|
const { socket, emit, offCalls, listeners } = makeStubSocket();
|
||||||
|
const { queryClient, calls } = makeStubQueryClient();
|
||||||
|
const map: EventMap = {
|
||||||
|
'a:event': [['a']],
|
||||||
|
'b:event': [['b']],
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = subscribeRealtimeInvalidations(
|
||||||
|
socket,
|
||||||
|
Object.keys(map),
|
||||||
|
queryClient,
|
||||||
|
() => map,
|
||||||
|
);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
expect(offCalls.map((c) => c.event).sort()).toEqual(['a:event', 'b:event']);
|
||||||
|
// All listeners removed — emitting after cleanup invalidates nothing.
|
||||||
|
emit('a:event');
|
||||||
|
emit('b:event');
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
// Defensive: the listener list should be empty after cleanup.
|
||||||
|
expect(listeners.get('a:event')?.length ?? 0).toBe(0);
|
||||||
|
expect(listeners.get('b:event')?.length ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silently ignores events that have no entry in the current map', () => {
|
||||||
|
// If the caller swaps an event OUT mid-session, the registered handler
|
||||||
|
// still fires (we don't re-subscribe) but should be a no-op rather than
|
||||||
|
// throw.
|
||||||
|
const { socket, emit } = makeStubSocket();
|
||||||
|
const { queryClient, calls } = makeStubQueryClient();
|
||||||
|
|
||||||
|
let currentMap: EventMap = {
|
||||||
|
'client:updated': [['clients']],
|
||||||
|
};
|
||||||
|
subscribeRealtimeInvalidations(socket, ['client:updated'], queryClient, () => currentMap);
|
||||||
|
|
||||||
|
// Wipe the entry — handler will fire but find nothing to invalidate.
|
||||||
|
currentMap = {};
|
||||||
|
expect(() => emit('client:updated')).not.toThrow();
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,8 +91,9 @@ describe('buildDocumensoPayload', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('defaults missing yacht dimensions to empty strings', () => {
|
it('defaults missing yacht dimensions to empty strings', () => {
|
||||||
|
const baseYacht = makeContext().yacht!;
|
||||||
const ctx = makeContext({
|
const ctx = makeContext({
|
||||||
yacht: { ...makeContext().yacht, lengthFt: null, widthFt: null, draftFt: null },
|
yacht: { ...baseYacht, lengthFt: null, widthFt: null, draftFt: null },
|
||||||
});
|
});
|
||||||
const payload = buildDocumensoPayload(ctx, OPTIONS);
|
const payload = buildDocumensoPayload(ctx, OPTIONS);
|
||||||
expect(payload.formValues.Length).toBe('');
|
expect(payload.formValues.Length).toBe('');
|
||||||
@@ -100,6 +101,16 @@ describe('buildDocumensoPayload', () => {
|
|||||||
expect(payload.formValues.Draft).toBe('');
|
expect(payload.formValues.Draft).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders empty Section 3 when yacht and berth are not linked', () => {
|
||||||
|
const ctx = makeContext({ yacht: null, berth: null });
|
||||||
|
const payload = buildDocumensoPayload(ctx, OPTIONS);
|
||||||
|
expect(payload.formValues['Yacht Name']).toBe('');
|
||||||
|
expect(payload.formValues.Length).toBe('');
|
||||||
|
expect(payload.formValues.Width).toBe('');
|
||||||
|
expect(payload.formValues.Draft).toBe('');
|
||||||
|
expect(payload.formValues['Berth Number']).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
it('formats empty address when client has no address', () => {
|
it('formats empty address when client has no address', () => {
|
||||||
const ctx = makeContext({ client: { ...makeContext().client, address: null } });
|
const ctx = makeContext({ client: { ...makeContext().client, address: null } });
|
||||||
const payload = buildDocumensoPayload(ctx, OPTIONS);
|
const payload = buildDocumensoPayload(ctx, OPTIONS);
|
||||||
|
|||||||
@@ -28,6 +28,33 @@ async function insertInterest(args: {
|
|||||||
return row!;
|
return row!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the EOI-required client details (primary email + primary address) so
|
||||||
|
* `buildEoiContext` clears its hard gate. Tests that exercise non-EOI-gating
|
||||||
|
* behavior should call this once per client they create.
|
||||||
|
*/
|
||||||
|
async function seedClientEoiPrereqs(args: {
|
||||||
|
clientId: string;
|
||||||
|
portId: string;
|
||||||
|
email?: string;
|
||||||
|
street?: string;
|
||||||
|
}) {
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: args.clientId,
|
||||||
|
channel: 'email',
|
||||||
|
value: args.email ?? `client-${args.clientId.slice(0, 8)}@example.com`,
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await db.insert(clientAddresses).values({
|
||||||
|
clientId: args.clientId,
|
||||||
|
portId: args.portId,
|
||||||
|
streetAddress: args.street ?? '1 Harbour Way',
|
||||||
|
city: 'Anguilla',
|
||||||
|
countryIso: 'AI',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('buildEoiContext', () => {
|
describe('buildEoiContext', () => {
|
||||||
@@ -107,13 +134,13 @@ describe('buildEoiContext', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Yacht assertions.
|
// Yacht assertions.
|
||||||
expect(ctx.yacht.name).toBe('Sea Breeze');
|
expect(ctx.yacht?.name).toBe('Sea Breeze');
|
||||||
expect(ctx.yacht.hullNumber).toBe('HN-1');
|
expect(ctx.yacht?.hullNumber).toBe('HN-1');
|
||||||
expect(ctx.yacht.yearBuilt).toBe(2020);
|
expect(ctx.yacht?.yearBuilt).toBe(2020);
|
||||||
|
|
||||||
// Berth assertions.
|
// Berth assertions.
|
||||||
expect(ctx.berth.mooringNumber).toBe('M-42');
|
expect(ctx.berth?.mooringNumber).toBe('M-42');
|
||||||
expect(ctx.berth.area).toBe('North');
|
expect(ctx.berth?.area).toBe('North');
|
||||||
|
|
||||||
// Interest assertions.
|
// Interest assertions.
|
||||||
expect(ctx.interest.stage).toBe('in_communication');
|
expect(ctx.interest.stage).toBe('in_communication');
|
||||||
@@ -144,6 +171,7 @@ describe('buildEoiContext', () => {
|
|||||||
portId: port.id,
|
portId: port.id,
|
||||||
overrides: { fullName: 'Bob Contact' },
|
overrides: { fullName: 'Bob Contact' },
|
||||||
});
|
});
|
||||||
|
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
|
||||||
|
|
||||||
const yacht = await makeYacht({
|
const yacht = await makeYacht({
|
||||||
portId: port.id,
|
portId: port.id,
|
||||||
@@ -187,6 +215,7 @@ describe('buildEoiContext', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const client = await makeClient({ portId: port.id });
|
const client = await makeClient({ portId: port.id });
|
||||||
|
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
|
||||||
const yacht = await makeYacht({
|
const yacht = await makeYacht({
|
||||||
portId: port.id,
|
portId: port.id,
|
||||||
ownerType: 'company',
|
ownerType: 'company',
|
||||||
@@ -211,9 +240,10 @@ describe('buildEoiContext', () => {
|
|||||||
expect(ctx.company!.billingAddress).toContain('Anguilla');
|
expect(ctx.company!.billingAddress).toContain('Anguilla');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws ValidationError when interest has no yacht', async () => {
|
it('builds a valid context when yacht is missing (Section 3 left blank)', async () => {
|
||||||
const port = await makePort();
|
const port = await makePort();
|
||||||
const client = await makeClient({ portId: port.id });
|
const client = await makeClient({ portId: port.id });
|
||||||
|
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
|
||||||
const berth = await makeBerth({ portId: port.id });
|
const berth = await makeBerth({ portId: port.id });
|
||||||
|
|
||||||
const interest = await insertInterest({
|
const interest = await insertInterest({
|
||||||
@@ -223,13 +253,18 @@ describe('buildEoiContext', () => {
|
|||||||
berthId: berth.id,
|
berthId: berth.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
|
const ctx = await buildEoiContext(interest.id, port.id);
|
||||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no yacht/i);
|
expect(ctx.yacht).toBeNull();
|
||||||
|
expect(ctx.berth?.mooringNumber).toBe(berth.mooringNumber);
|
||||||
|
// Owner falls back to the interest's client when no yacht is linked.
|
||||||
|
expect(ctx.owner.type).toBe('client');
|
||||||
|
expect(ctx.owner.name).toBe(client.fullName);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws ValidationError when interest has no berth', async () => {
|
it('builds a valid context when berth is missing (Section 3 left blank)', async () => {
|
||||||
const port = await makePort();
|
const port = await makePort();
|
||||||
const client = await makeClient({ portId: port.id });
|
const client = await makeClient({ portId: port.id });
|
||||||
|
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
|
||||||
const yacht = await makeYacht({
|
const yacht = await makeYacht({
|
||||||
portId: port.id,
|
portId: port.id,
|
||||||
ownerType: 'client',
|
ownerType: 'client',
|
||||||
@@ -243,8 +278,45 @@ describe('buildEoiContext', () => {
|
|||||||
berthId: null,
|
berthId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ctx = await buildEoiContext(interest.id, port.id);
|
||||||
|
expect(ctx.berth).toBeNull();
|
||||||
|
expect(ctx.yacht?.name).toBe(yacht.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationError when client has no email', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
// Address only, no email — gate should fail.
|
||||||
|
await db.insert(clientAddresses).values({
|
||||||
|
clientId: client.id,
|
||||||
|
portId: port.id,
|
||||||
|
streetAddress: '1 Harbour Way',
|
||||||
|
city: 'Anguilla',
|
||||||
|
countryIso: 'AI',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const interest = await insertInterest({ portId: port.id, clientId: client.id });
|
||||||
|
|
||||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
|
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
|
||||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no berth/i);
|
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client email/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ValidationError when client has no primary address', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
// Email only, no address — gate should fail.
|
||||||
|
await db.insert(clientContacts).values({
|
||||||
|
clientId: client.id,
|
||||||
|
channel: 'email',
|
||||||
|
value: 'test@example.com',
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const interest = await insertInterest({ portId: port.id, clientId: client.id });
|
||||||
|
|
||||||
|
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
|
||||||
|
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client address/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError for non-existent interest', async () => {
|
it('throws NotFoundError for non-existent interest', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user