/** * 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 = new Set([ '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 { 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( 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; } }