Files
pn-new-crm/src/lib/services/client-archive-dossier.service.ts

453 lines
15 KiB
TypeScript
Raw Normal View History

feat(client-archive): smart-archive backend foundation (dossier + archive + restore) The first slice of the smart-archive project. Replaces the dumb DELETE client flow with a deliberate "look before you leap" pattern: - New columns on clients: archived_by, archive_reason, archive_metadata (jsonb capturing every decision made during archive, so restore can attempt reversal). Migration 0043. - client-archive-dossier.service builds a structured snapshot of "what's at stake" for a given client: pipeline interests, berths under offer (with next-in-line interests for the notification), yachts owned, active reservations, outstanding invoices, signed/in-flight Documenso envelopes, portal user, company memberships. Classifies the client as low-stakes or high-stakes based on pipeline stage (HIGH_STAKES_STAGES = deposit_10pct + later) so the bulk wizard knows which clients to prompt individually. - client-archive.service.archiveClientWithDecisions takes the operator's decisions and applies them in a single transaction. Persists the decision log into archive_metadata for restore. Auto-handles portal user revocation + company membership end-dating; everything else is caller-driven. Surfaces external cleanups (Documenso void) for the caller to queue. - client-restore.service.getRestoreDossier classifies each persisted decision as autoReversible / reversibleWithPrompt / locked based on the current state of the world (berth still available? new owner has active interests on the yacht? etc). restoreClientWithSelections applies reversals + un-archives the client. - 4 API routes wire the services to HTTP. The existing /restore endpoint is upgraded to use the smart restore but stays backwards-compatible: clients archived before this feature have no archive_metadata so the dossier returns empty, and a POST with no body just un-archives them — same as before. UI work + bulk variant + hard-delete + Documenso cleanup queueing land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:13:08 +02:00
/**
* 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<PipelineStage>([
feat(client-archive): smart-archive backend foundation (dossier + archive + restore) The first slice of the smart-archive project. Replaces the dumb DELETE client flow with a deliberate "look before you leap" pattern: - New columns on clients: archived_by, archive_reason, archive_metadata (jsonb capturing every decision made during archive, so restore can attempt reversal). Migration 0043. - client-archive-dossier.service builds a structured snapshot of "what's at stake" for a given client: pipeline interests, berths under offer (with next-in-line interests for the notification), yachts owned, active reservations, outstanding invoices, signed/in-flight Documenso envelopes, portal user, company memberships. Classifies the client as low-stakes or high-stakes based on pipeline stage (HIGH_STAKES_STAGES = deposit_10pct + later) so the bulk wizard knows which clients to prompt individually. - client-archive.service.archiveClientWithDecisions takes the operator's decisions and applies them in a single transaction. Persists the decision log into archive_metadata for restore. Auto-handles portal user revocation + company membership end-dating; everything else is caller-driven. Surfaces external cleanups (Documenso void) for the caller to queue. - client-restore.service.getRestoreDossier classifies each persisted decision as autoReversible / reversibleWithPrompt / locked based on the current state of the world (berth still available? new owner has active interests on the yacht? etc). restoreClientWithSelections applies reversals + un-archives the client. - 4 API routes wire the services to HTTP. The existing /restore endpoint is upgraded to use the smart restore but stays backwards-compatible: clients archived before this feature have no archive_metadata so the dossier returns empty, and a POST with no body just un-archives them — same as before. UI work + bulk variant + hard-delete + Documenso cleanup queueing land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:13:08 +02:00
'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'
fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
/** 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[];
feat(client-archive): smart-archive backend foundation (dossier + archive + restore) The first slice of the smart-archive project. Replaces the dumb DELETE client flow with a deliberate "look before you leap" pattern: - New columns on clients: archived_by, archive_reason, archive_metadata (jsonb capturing every decision made during archive, so restore can attempt reversal). Migration 0043. - client-archive-dossier.service builds a structured snapshot of "what's at stake" for a given client: pipeline interests, berths under offer (with next-in-line interests for the notification), yachts owned, active reservations, outstanding invoices, signed/in-flight Documenso envelopes, portal user, company memberships. Classifies the client as low-stakes or high-stakes based on pipeline stage (HIGH_STAKES_STAGES = deposit_10pct + later) so the bulk wizard knows which clients to prompt individually. - client-archive.service.archiveClientWithDecisions takes the operator's decisions and applies them in a single transaction. Persists the decision log into archive_metadata for restore. Auto-handles portal user revocation + company membership end-dating; everything else is caller-driven. Surfaces external cleanups (Documenso void) for the caller to queue. - client-restore.service.getRestoreDossier classifies each persisted decision as autoReversible / reversibleWithPrompt / locked based on the current state of the world (berth still available? new owner has active interests on the yacht? etc). restoreClientWithSelections applies reversals + un-archives the client. - 4 API routes wire the services to HTTP. The existing /restore endpoint is upgraded to use the smart restore but stays backwards-compatible: clients archived before this feature have no archive_metadata so the dossier returns empty, and a POST with no body just un-archives them — same as before. UI work + bulk variant + hard-delete + Documenso cleanup queueing land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:13:08 +02:00
/** 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);
fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
// 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)),
);
feat(client-archive): smart-archive backend foundation (dossier + archive + restore) The first slice of the smart-archive project. Replaces the dumb DELETE client flow with a deliberate "look before you leap" pattern: - New columns on clients: archived_by, archive_reason, archive_metadata (jsonb capturing every decision made during archive, so restore can attempt reversal). Migration 0043. - client-archive-dossier.service builds a structured snapshot of "what's at stake" for a given client: pipeline interests, berths under offer (with next-in-line interests for the notification), yachts owned, active reservations, outstanding invoices, signed/in-flight Documenso envelopes, portal user, company memberships. Classifies the client as low-stakes or high-stakes based on pipeline stage (HIGH_STAKES_STAGES = deposit_10pct + later) so the bulk wizard knows which clients to prompt individually. - client-archive.service.archiveClientWithDecisions takes the operator's decisions and applies them in a single transaction. Persists the decision log into archive_metadata for restore. Auto-handles portal user revocation + company membership end-dating; everything else is caller-driven. Surfaces external cleanups (Documenso void) for the caller to queue. - client-restore.service.getRestoreDossier classifies each persisted decision as autoReversible / reversibleWithPrompt / locked based on the current state of the world (berth still available? new owner has active interests on the yacht? etc). restoreClientWithSelections applies reversals + un-archives the client. - 4 API routes wire the services to HTTP. The existing /restore endpoint is upgraded to use the smart restore but stays backwards-compatible: clients archived before this feature have no archive_metadata so the dossier returns empty, and a POST with no body just un-archives them — same as before. UI work + bulk variant + hard-delete + Documenso cleanup queueing land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:13:08 +02:00
dossierBerths.push({
berthId,
mooringNumber: berth.mooringNumber,
status: berth.berthStatus,
fix(audit): reliability HIGHs — smart-restore re-link, TOCTOU lock, bulk wrong-interest, ext-EOI tx, bulk idempotency R2-H1: smart-restore's berth_released auto-reversal was a no-op while the wizard claimed success. Now uses the persisted interestId from the decision detail to re-insert the interest_berths link and flip the berth status back to under_offer. Verifies the interest still exists and isn't archived before re-linking. R2-H2: smart-archive berth status update had a TOCTOU race — read outside tx, write inside without a lock. Now selects-for-update the berths row inside the tx and re-checks status against the locked row before flipping to available, preventing concurrent archive+sale from un-selling a berth. R2-H3: bulk-archive's berth→interest lookup fell back to dossier.interests[0]?.interestId ?? '' which sent empty-string interestIds that silently matched zero rows. Dossier now exposes linkedInterestIds[] per berth (authoritative interest_berths join); bulk + single-client wizard both use it and skip berths with no linked interest. Affected: - src/lib/services/client-archive-dossier.service.ts (DossierBerth) - src/app/api/v1/clients/bulk/route.ts - src/components/clients/smart-archive-dialog.tsx R2-H4: external-EOI ran storage upload + 4 DB writes outside a transaction. Now wraps file/document/event/interest writes in a single tx; storage upload stays before the tx (S3 isn't transactional), orphan-object on tx failure is acceptable. R2-H5: bulk archive double-submit treated already-archived clients as per-row failures. Bulk callback now early-returns success when the dossier shows archivedAt is set, making the endpoint idempotent. 1175/1175 vitest passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:11:00 +02:00
linkedInterestIds,
feat(client-archive): smart-archive backend foundation (dossier + archive + restore) The first slice of the smart-archive project. Replaces the dumb DELETE client flow with a deliberate "look before you leap" pattern: - New columns on clients: archived_by, archive_reason, archive_metadata (jsonb capturing every decision made during archive, so restore can attempt reversal). Migration 0043. - client-archive-dossier.service builds a structured snapshot of "what's at stake" for a given client: pipeline interests, berths under offer (with next-in-line interests for the notification), yachts owned, active reservations, outstanding invoices, signed/in-flight Documenso envelopes, portal user, company memberships. Classifies the client as low-stakes or high-stakes based on pipeline stage (HIGH_STAKES_STAGES = deposit_10pct + later) so the bulk wizard knows which clients to prompt individually. - client-archive.service.archiveClientWithDecisions takes the operator's decisions and applies them in a single transaction. Persists the decision log into archive_metadata for restore. Auto-handles portal user revocation + company membership end-dating; everything else is caller-driven. Surfaces external cleanups (Documenso void) for the caller to queue. - client-restore.service.getRestoreDossier classifies each persisted decision as autoReversible / reversibleWithPrompt / locked based on the current state of the world (berth still available? new owner has active interests on the yacht? etc). restoreClientWithSelections applies reversals + un-archives the client. - 4 API routes wire the services to HTTP. The existing /restore endpoint is upgraded to use the smart restore but stays backwards-compatible: clients archived before this feature have no archive_metadata so the dossier returns empty, and a POST with no body just un-archives them — same as before. UI work + bulk variant + hard-delete + Documenso cleanup queueing land in follow-on commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:13:08 +02:00
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;
}
}