440 lines
15 KiB
TypeScript
440 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 { 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([
|
||
|
|
'deposit_10pct',
|
||
|
|
'contract_sent',
|
||
|
|
'contract_signed',
|
||
|
|
'completed',
|
||
|
|
]);
|
||
|
|
|
||
|
|
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'
|
||
|
|
/** 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_10pct, 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(
|
||
|
|
eq(interestBerths.berthId, berthId),
|
||
|
|
ne(interests.clientId, clientId),
|
||
|
|
isNull(interests.archivedAt),
|
||
|
|
isNull(interests.outcome),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
.orderBy(desc(interests.updatedAt))
|
||
|
|
.limit(10);
|
||
|
|
|
||
|
|
dossierBerths.push({
|
||
|
|
berthId,
|
||
|
|
mooringNumber: berth.mooringNumber,
|
||
|
|
status: berth.berthStatus,
|
||
|
|
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.
|
||
|
|
function rankStage(s: PipelineStage): number {
|
||
|
|
switch (s) {
|
||
|
|
case 'completed':
|
||
|
|
return 5;
|
||
|
|
case 'contract_signed':
|
||
|
|
return 4;
|
||
|
|
case 'contract_sent':
|
||
|
|
return 3;
|
||
|
|
case 'deposit_10pct':
|
||
|
|
return 2;
|
||
|
|
case 'eoi_signed':
|
||
|
|
return 1;
|
||
|
|
default:
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
}
|