Files
pn-new-crm/src/lib/services/client-archive-dossier.service.ts
Matt 98211066a5
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m4s
Build & Push Docker Images / build-and-push (push) Has been cancelled
fix(legacy-stage): purge 9-stage enum keys from rank tables and stale copy
L-001 hunt landed these:

  - src/lib/services/clients.service.ts — stageRank used pre-refactor
    9-stage names exclusively (`contract_signed`, `deposit_10pct`, …).
    Every modern 7-stage interest fell to rank 0, making client-list
    "most-progressed deal" sort effectively random. Modern values now
    own the canonical ranks; legacy aliases map to their 7-stage
    equivalents so historical audit data still sorts.

  - src/lib/services/berth-recommender.service.ts — STAGE_ORDER had
    the same 9-stage shape. LATE_STAGE_THRESHOLD pointed at the (now
    nonexistent) `deposit_10pct` slot. Reworked to the 7-stage scale;
    threshold now at `deposit_paid` (5).

  - Stale comments referencing `deposit_10pct` in schema (clients,
    financial) and client-archive services updated to current copy.

  - Smart-archive dialog rendered `i.pipelineStage` as raw enum; now
    routes through `stageLabelFor` (the new helper added with A2).

Test fixture updates: berth-recommender.test.ts numeric inputs
re-mapped to the new 7-stage scale (eoi_signed=5 → eoi=3, etc.).
1373/1373 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:18:13 +02:00

451 lines
15 KiB
TypeScript

/**
* Smart-archive dossier service.
*
* Returns a structured snapshot of "what's at stake" when archiving (or
* restoring) a client, so the UI can render the wizard with the right
* sections + populated decision points.
*
* The dossier is read-only and side-effect-free. Any mutation happens
* via the companion `client-archive.service.ts` which takes the user's
* decisions and applies them inside a single transaction.
*/
import { and, eq, isNull, ne, desc, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { yachts } from '@/lib/db/schema/yachts';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { berthReservations } from '@/lib/db/schema/reservations';
import { invoices } from '@/lib/db/schema/financial';
import { documents } from '@/lib/db/schema/documents';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { portalUsers } from '@/lib/db/schema/portal';
import { NotFoundError } from '@/lib/errors';
import type { PipelineStage } from '@/lib/constants';
// ─── Public types ───────────────────────────────────────────────────────────
/**
* Pipeline stages that count as "high-stakes" for the bulk wizard:
* Past these, money has changed hands or a contract is in motion. The
* bulk-archive UI prompts the operator to confirm individually + supply
* a reason for these clients.
*/
export const HIGH_STAKES_STAGES: ReadonlySet<PipelineStage> = new Set<PipelineStage>([
'deposit_paid',
'contract',
]);
export type ArchiveStakeLevel = 'low' | 'high';
/** A berth currently linked to one of the client's interests. */
export interface DossierBerth {
berthId: string;
mooringNumber: string;
status: string; // 'available' | 'under_offer' | 'sold'
/** Every interest of THIS client that links to the berth. The bulk
* wizard uses this to pick the right interestId per berth instead of
* guessing by primary-mooring (which fails when multiple interests
* share a primary or when none is primary). */
linkedInterestIds: string[];
/** Other interests still actively expressing interest in this berth
* (so the next-in-line notification can list them). */
otherInterests: Array<{
interestId: string;
clientId: string | null;
clientName: string | null;
pipelineStage: string;
daysSinceUpdate: number;
}>;
}
export interface DossierDocument {
documentId: string;
templateName: string | null;
status: string; // 'draft' | 'sent' | 'signed' | 'voided' | ...
documensoEnvelopeId: string | null;
/** True when there's a live envelope in Documenso awaiting signature. */
isInFlight: boolean;
}
export interface DossierYacht {
yachtId: string;
name: string;
hullNumber: string | null;
status: string;
}
export interface DossierCompany {
companyId: string;
name: string;
membershipRole: string | null;
}
export interface DossierReservation {
reservationId: string;
berthId: string;
mooringNumber: string;
status: string; // typically 'active'
startDate: string;
}
export interface DossierInvoice {
invoiceId: string;
invoiceNumber: string;
status: string;
total: string;
currency: string;
}
export interface DossierInterest {
interestId: string;
pipelineStage: PipelineStage;
primaryBerthMooring: string | null;
hasSignedEoi: boolean;
}
export interface ClientArchiveDossier {
client: {
id: string;
fullName: string;
portId: string;
archivedAt: string | null;
};
/** The headline classification — drives whether the bulk wizard
* treats this client as low-stakes (auto) or high-stakes (per-row
* confirmation + reason required). */
stakeLevel: ArchiveStakeLevel;
/** The interest stage that earned the high-stakes classification (so
* the UI can explain "this client is in Deposit Paid, please confirm").
* Null when low-stakes. */
highStakesStage: PipelineStage | null;
// Sections — empty arrays mean "nothing to handle in this category"
interests: DossierInterest[];
berths: DossierBerth[];
yachts: DossierYacht[];
companies: DossierCompany[];
reservations: DossierReservation[];
invoices: DossierInvoice[];
documents: DossierDocument[];
hasPortalUser: boolean;
/** Hard blockers — cannot proceed with archive at all until these are
* resolved manually. Currently the only one is "active reservation
* on a sold berth" (since you can't unsell a berth from this flow). */
blockers: string[];
}
// ─── Implementation ──────────────────────────────────────────────────────────
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Loads the full archive dossier for one client. Caller (route handler)
* is responsible for the tenant gate.
*/
export async function getClientArchiveDossier(
clientId: string,
portId: string,
): Promise<ClientArchiveDossier> {
const [client] = await db
.select({
id: clients.id,
fullName: clients.fullName,
portId: clients.portId,
archivedAt: clients.archivedAt,
})
.from(clients)
.where(and(eq(clients.id, clientId), eq(clients.portId, portId)))
.limit(1);
if (!client) throw new NotFoundError('client');
// ─── Interests + stake classification ────────────────────────────────────
const clientInterests = await db
.select({
id: interests.id,
pipelineStage: interests.pipelineStage,
})
.from(interests)
.where(and(eq(interests.clientId, clientId), isNull(interests.archivedAt)));
let stakeLevel: ArchiveStakeLevel = 'low';
let highStakesStage: PipelineStage | null = null;
for (const i of clientInterests) {
if (HIGH_STAKES_STAGES.has(i.pipelineStage as PipelineStage)) {
stakeLevel = 'high';
// Pick the highest-stage one to surface in the UI message.
if (
!highStakesStage ||
rankStage(i.pipelineStage as PipelineStage) > rankStage(highStakesStage)
) {
highStakesStage = i.pipelineStage as PipelineStage;
}
}
}
// ─── Documents (signed EOIs trigger the acknowledgment requirement) ─────
const clientDocuments = await db
.select({
id: documents.id,
title: documents.title,
documentType: documents.documentType,
status: documents.status,
documensoId: documents.documensoId,
})
.from(documents)
.where(and(eq(documents.clientId, clientId), eq(documents.portId, portId)));
const dossierDocs: DossierDocument[] = clientDocuments.map((d) => ({
documentId: d.id,
templateName: d.title,
status: d.status,
documensoEnvelopeId: d.documensoId,
isInFlight: !!d.documensoId && (d.status === 'sent' || d.status === 'partially_signed'),
}));
const interestsWithSignedEoi = new Set<string>(
clientDocuments.filter((d) => d.status === 'completed').map((d) => d.id),
);
// ─── Interests + berth links ─────────────────────────────────────────────
const interestIds = clientInterests.map((i) => i.id);
const interestBerthRows = interestIds.length
? await db
.select({
interestId: interestBerths.interestId,
berthId: interestBerths.berthId,
isPrimary: interestBerths.isPrimary,
mooringNumber: berths.mooringNumber,
berthStatus: berths.status,
})
.from(interestBerths)
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
.where(inArray(interestBerths.interestId, interestIds))
: [];
const dossierInterests: DossierInterest[] = clientInterests.map((i) => {
const primary = interestBerthRows.find((r) => r.interestId === i.id && r.isPrimary);
return {
interestId: i.id,
pipelineStage: i.pipelineStage as PipelineStage,
primaryBerthMooring: primary?.mooringNumber ?? null,
hasSignedEoi: interestsWithSignedEoi.has(i.id),
};
});
// ─── Berths section + next-in-line interests ────────────────────────────
const distinctBerthIds = Array.from(new Set(interestBerthRows.map((r) => r.berthId)));
const dossierBerths: DossierBerth[] = [];
for (const berthId of distinctBerthIds) {
const berth = interestBerthRows.find((r) => r.berthId === berthId);
if (!berth) continue;
// "Other interests" = interest_berths rows on this berth that DON'T
// belong to the client being archived AND whose interest is still
// active (no outcome set, not archived). Surfaces who the sales
// rep should reach out to next.
const others = await db
.select({
interestId: interests.id,
clientId: interests.clientId,
clientName: clients.fullName,
pipelineStage: interests.pipelineStage,
updatedAt: interests.updatedAt,
})
.from(interestBerths)
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
.leftJoin(clients, eq(interests.clientId, clients.id))
.where(
and(
activeInterestsWhere(portId),
eq(interestBerths.berthId, berthId),
ne(interests.clientId, clientId),
),
)
.orderBy(desc(interests.updatedAt))
.limit(10);
// Every linked interest belonging to THIS client (multiple
// interests can share a berth — primary flag is at most one per
// interest, not per berth).
const linkedInterestIds = Array.from(
new Set(interestBerthRows.filter((r) => r.berthId === berthId).map((r) => r.interestId)),
);
dossierBerths.push({
berthId,
mooringNumber: berth.mooringNumber,
status: berth.berthStatus,
linkedInterestIds,
otherInterests: others.map((o) => ({
interestId: o.interestId,
clientId: o.clientId,
clientName: o.clientName,
pipelineStage: o.pipelineStage,
daysSinceUpdate: Math.floor((Date.now() - new Date(o.updatedAt).getTime()) / DAY_MS),
})),
});
}
// ─── Yachts owned by client ──────────────────────────────────────────────
const ownedYachts = await db
.select({
id: yachts.id,
name: yachts.name,
hullNumber: yachts.hullNumber,
status: yachts.status,
})
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
eq(yachts.currentOwnerId, clientId),
isNull(yachts.archivedAt),
),
);
// ─── Company memberships (current — no end_date) ─────────────────────────
const memberRows = await db
.select({
companyId: companies.id,
name: companies.name,
role: companyMemberships.role,
})
.from(companyMemberships)
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
.where(
and(
eq(companyMemberships.clientId, clientId),
eq(companies.portId, portId),
isNull(companyMemberships.endDate),
),
);
// ─── Active reservations ─────────────────────────────────────────────────
const activeReservations = await db
.select({
id: berthReservations.id,
berthId: berthReservations.berthId,
mooringNumber: berths.mooringNumber,
status: berthReservations.status,
startDate: berthReservations.startDate,
berthStatus: berths.status,
})
.from(berthReservations)
.innerJoin(berths, eq(berthReservations.berthId, berths.id))
.where(
and(
eq(berthReservations.clientId, clientId),
eq(berthReservations.portId, portId),
eq(berthReservations.status, 'active'),
),
);
// ─── Outstanding invoices (anything not paid / not cancelled) ────────────
const outstandingInvoices = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
status: invoices.status,
total: invoices.total,
currency: invoices.currency,
})
.from(invoices)
.where(
and(
eq(invoices.portId, portId),
eq(invoices.billingEntityType, 'client'),
eq(invoices.billingEntityId, clientId),
isNull(invoices.archivedAt),
ne(invoices.status, 'paid'),
ne(invoices.status, 'cancelled'),
),
);
// ─── Portal user existence ───────────────────────────────────────────────
const [portalUser] = await db
.select({ id: portalUsers.id })
.from(portalUsers)
.where(and(eq(portalUsers.clientId, clientId), eq(portalUsers.portId, portId)))
.limit(1);
// ─── Hard blockers ───────────────────────────────────────────────────────
// The only true blocker is an active reservation on a SOLD berth — we
// can't auto-handle this without crossing into refund territory. Force
// the operator to handle it via the existing reservation UI first.
const blockers: string[] = [];
for (const r of activeReservations) {
if (r.berthStatus === 'sold') {
blockers.push(
`Active reservation on sold berth ${r.mooringNumber} (#${r.id.slice(0, 8)}). Process the refund or transfer the reservation before archiving.`,
);
}
}
return {
client: {
id: client.id,
fullName: client.fullName,
portId: client.portId,
archivedAt: client.archivedAt ? client.archivedAt.toISOString() : null,
},
stakeLevel,
highStakesStage,
interests: dossierInterests,
berths: dossierBerths,
yachts: ownedYachts.map((y) => ({
yachtId: y.id,
name: y.name,
hullNumber: y.hullNumber,
status: y.status,
})),
companies: memberRows.map((m) => ({
companyId: m.companyId,
name: m.name,
membershipRole: m.role,
})),
reservations: activeReservations.map((r) => ({
reservationId: r.id,
berthId: r.berthId,
mooringNumber: r.mooringNumber,
status: r.status,
startDate: r.startDate.toISOString(),
})),
invoices: outstandingInvoices.map((i) => ({
invoiceId: i.id,
invoiceNumber: i.invoiceNumber,
status: i.status,
total: i.total,
currency: i.currency,
})),
documents: dossierDocs,
hasPortalUser: !!portalUser,
blockers,
};
}
// Stage rank used to pick the "highest" high-stakes stage when surfacing
// the warning copy. Higher = more committed. Doc sub-status is folded back
// in via the caller (treating eoi+signed as past nurturing, contract+signed
// as the apex).
function rankStage(s: PipelineStage): number {
switch (s) {
case 'contract':
return 4;
case 'deposit_paid':
return 3;
case 'reservation':
return 2;
case 'eoi':
return 1;
default:
return 0;
}
}