Files
pn-new-crm/src/lib/services/client-archive-dossier.service.ts
Matt ccc775dc66 feat(tenancies-p2): rename berth_reservations → berth_tenancies (schema + perms + UI)
73-file atomic rename per docs/tenancies-design.md:

- Migration 0085: rename table + indexes + FK constraints; rename
  documents.reservation_id → tenancy_id; migrate jsonb permission maps
  (reservations resource → tenancies; collapse create+activate → manage);
  rewrite historical audit_logs.entity_type='berth_reservation' →
  'berth_tenancy'. FK renames wrapped in DO blocks so dev DBs that pre-date
  the FK additions don't abort.
- Schema: berthReservations → berthTenancies; BerthReservation type →
  BerthTenancy; indexes idx_br_* / idx_brr_* → idx_bt_*.
- RolePermissions: resource { view, create, activate, cancel } collapses to
  { view, manage, cancel }; all 8 default seed bundles + role-form + matrix
  updated.
- Service: berth-reservations.service.ts → berth-tenancies.service.ts;
  endReservation → endTenancy; listReservations → listTenancies.
- API: /api/v1/berth-reservations → /api/v1/tenancies (+ nested [id]);
  /api/v1/berths/[id]/reservations → /api/v1/berths/[id]/tenancies.
- Validators: reservations.ts → tenancies.ts; RESERVATION_STATUSES →
  TENANCY_STATUSES; endReservationSchema → endTenancySchema.
- Routes: /{portSlug}/berth-reservations → /{portSlug}/tenancies;
  /portal/my-reservations → /portal/my-tenancies.
- Components: src/components/reservations/* → src/components/tenancies/*;
  BerthReservationsTab → BerthTenanciesTab; ClientReservationsTab →
  ClientTenanciesTab; ReservationList → TenancyList.
- Socket events: berth_reservation:* → berth_tenancy:*; payload
  reservationId → tenancyId.
- Webhook events: berth_reservation.* → berth_tenancy.*.
- Portal: getPortalUserReservations → getPortalUserTenancies;
  PortalReservation → PortalTenancy; PortalDashboard.counts.activeReservations
  → activeTenancies; PortalNav label "Reservations" → "Tenancies".
- Dossier: DossierReservation → DossierTenancy; reservationDecisions →
  tenancyDecisions across smart-archive-dialog + bulk-archive routes.
- Documents schema: documents.reservationId → documents.tenancyId
  (TS + DB column + index + FK constraint).
- Activity feed label berth_reservation → berth_tenancy (matched against
  migrated historical audit rows).

KEPT (separate concepts):
- Reservation Agreement document type (the contract sent to clients).
- "Reservation" pipeline stage name.
- {{reservation.*}} merge tokens in template authoring.
- interest.reservationStatus / reservationDocStatus / dateReservationSent
  fields (track agreement signing on the deal).
- reservation-agreement-context.ts service (builds merge context for the
  Reservation Agreement doc; only its DB imports were renamed).

Verified: tsc clean, 1480/1480 vitest passing, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:09:35 +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 { berthTenancies } from '@/lib/db/schema/tenancies';
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 DossierTenancy {
tenancyId: 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[];
tenancies: DossierTenancy[];
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 tenancy
* 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 tenancies ────────────────────────────────────────────────────
const activeTenancies = await db
.select({
id: berthTenancies.id,
berthId: berthTenancies.berthId,
mooringNumber: berths.mooringNumber,
status: berthTenancies.status,
startDate: berthTenancies.startDate,
berthStatus: berths.status,
})
.from(berthTenancies)
.innerJoin(berths, eq(berthTenancies.berthId, berths.id))
.where(
and(
eq(berthTenancies.clientId, clientId),
eq(berthTenancies.portId, portId),
eq(berthTenancies.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 tenancy on a SOLD berth - we
// can't auto-handle this without crossing into refund territory. Force
// the operator to handle it via the existing tenancy UI first.
const blockers: string[] = [];
for (const r of activeTenancies) {
if (r.berthStatus === 'sold') {
blockers.push(
`Active tenancy on sold berth ${r.mooringNumber} (#${r.id.slice(0, 8)}). Process the refund or transfer the tenancy 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,
})),
tenancies: activeTenancies.map((r) => ({
tenancyId: 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;
}
}