Files
pn-new-crm/src/lib/services/client-archive.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

404 lines
15 KiB
TypeScript

/**
* Smart-archive mutation service.
*
* Takes a fully-resolved set of decisions from the UI (built off the
* dossier) and applies them inside a single transaction. Records every
* decision into clients.archive_metadata so the restore wizard can
* later attempt reversal.
*
* External-system cleanup (Documenso envelope void/delete, mass email
* notifications to next-in-line interests) happens AFTER the local
* commit — best-effort, queued for retry, never blocks the archive.
*/
import { and, eq, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
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 { yachts } from '@/lib/db/schema/yachts';
import { companyMemberships } from '@/lib/db/schema/companies';
import { portalUsers } from '@/lib/db/schema/portal';
import { documents } from '@/lib/db/schema/documents';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import {
HIGH_STAKES_STAGES,
type ClientArchiveDossier,
} from '@/lib/services/client-archive-dossier.service';
import { activeInterestsWhere } from '@/lib/services/active-interest';
// ─── Decision payload (what the UI sends to the server) ─────────────────────
/** Per-berth choice. `interestId` is the interest in the archived client
* that owns the berth link (used to remove the right interestBerths row). */
export type BerthDecision = {
berthId: string;
interestId: string;
action: 'release' | 'retain';
};
export type YachtDecision = {
yachtId: string;
action: 'transfer' | 'mark_sold_away' | 'retain';
/** Required when action='transfer' — the new owner's client/company id. */
newOwnerType?: 'client' | 'company';
newOwnerId?: string;
};
export type ReservationDecision = {
reservationId: string;
action: 'cancel' | 'transfer';
/** Required when action='transfer' — the new client id. */
transferToClientId?: string;
};
export type InvoiceDecision = {
invoiceId: string;
action: 'void' | 'write_off' | 'leave';
};
export type DocumentDecision = {
documentId: string;
/** void = call Documenso API to void the envelope. leave = no action. */
action: 'void_documenso' | 'leave';
};
export interface ArchiveDecisions {
reason: string;
/** Required acknowledgment when the dossier surfaces signed legal docs. */
acknowledgedSignedDocuments: boolean;
berthDecisions: BerthDecision[];
yachtDecisions: YachtDecision[];
reservationDecisions: ReservationDecision[];
invoiceDecisions: InvoiceDecision[];
documentDecisions: DocumentDecision[];
}
// ─── Persisted decision log (lives in clients.archive_metadata jsonb) ───────
interface PersistedDecision {
kind:
| 'berth_released'
| 'berth_retained'
| 'yacht_transferred'
| 'yacht_marked_sold_away'
| 'yacht_retained'
| 'reservation_cancelled'
| 'reservation_transferred'
| 'invoice_voided'
| 'invoice_written_off'
| 'invoice_left'
| 'documenso_voided'
| 'document_left'
| 'portal_user_revoked';
refId: string;
detail?: Record<string, unknown>;
}
export interface ArchiveMetadata {
decisions: PersistedDecision[];
decidedAt: string;
decidedBy: string;
reason: string;
}
// ─── Result shape ───────────────────────────────────────────────────────────
export interface ArchiveResult {
clientId: string;
decisionsApplied: number;
externalCleanups: Array<{
kind: 'documenso_void';
documentId: string;
documensoId: string;
}>;
releasedBerths: Array<{
berthId: string;
mooringNumber: string;
/** Other interests that should be notified about this berth becoming
* available — drives the "next in line" notification fire. */
nextInLineInterestIds: string[];
}>;
}
// ─── Implementation ──────────────────────────────────────────────────────────
export async function archiveClientWithDecisions(args: {
dossier: ClientArchiveDossier;
decisions: ArchiveDecisions;
meta: AuditMeta;
}): Promise<ArchiveResult> {
const { dossier, decisions, meta } = args;
const clientId = dossier.client.id;
const portId = dossier.client.portId;
// ─── Pre-checks (echo dossier blockers; UI can't bypass) ────────────────
if (dossier.blockers.length > 0) {
throw new ConflictError(
`Cannot archive: ${dossier.blockers.length} unresolved blocker(s). ${dossier.blockers[0]}`,
);
}
if (dossier.stakeLevel === 'high' && !decisions.reason.trim()) {
throw new ValidationError(
'A reason is required when archiving a client at Deposit Paid or later.',
);
}
const hasSignedDocs = dossier.documents.some(
(d) => d.status === 'completed' || d.status === 'signed',
);
if (hasSignedDocs && !decisions.acknowledgedSignedDocuments) {
throw new ValidationError(
'You must acknowledge that signed documents remain binding before archiving.',
);
}
const persistedDecisions: PersistedDecision[] = [];
const externalCleanups: ArchiveResult['externalCleanups'] = [];
const releasedBerths: ArchiveResult['releasedBerths'] = [];
// ─── Atomic local apply ──────────────────────────────────────────────────
await db.transaction(async (tx) => {
// Lock the client row so a concurrent archive collides cleanly.
const [locked] = await tx
.select({ id: clients.id, archivedAt: clients.archivedAt })
.from(clients)
.where(and(eq(clients.id, clientId), eq(clients.portId, portId)))
.for('update');
if (!locked) throw new NotFoundError('client');
if (locked.archivedAt) throw new ConflictError('Client is already archived');
// ─── Berth decisions ─────────────────────────────────────────────────
for (const d of decisions.berthDecisions) {
const berth = dossier.berths.find((b) => b.berthId === d.berthId);
if (!berth) continue;
if (d.action === 'release') {
// Lock the berth row so a concurrent sale can't flip the status
// between our read of dossier.berths (outside the tx) and our
// write below. Without this lock, A archives client X while B
// sells berth A1 to client Y — A's pre-tx read says
// status='under_offer', B commits status='sold', A's update
// would flip it back to 'available'.
const [locked] = await tx
.select({ status: berths.status })
.from(berths)
.where(eq(berths.id, d.berthId))
.for('update');
const lockedStatus = locked?.status ?? berth.status;
// Drop the interest_berths link for this client's interest. Other
// interests on the berth survive (so the next-in-line notification
// can fire).
await tx
.delete(interestBerths)
.where(
and(eq(interestBerths.berthId, d.berthId), eq(interestBerths.interestId, d.interestId)),
);
// If no remaining interestBerths row marks this berth as
// is_specific_interest, set the berth status back to available.
// Sold berths are immutable from this flow — also re-checked
// against the freshly-locked row, not the pre-tx dossier read.
if (lockedStatus !== 'sold') {
const [stillUnderOffer] = await tx
.select({ count: sql<number>`count(*)::int` })
.from(interestBerths)
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
.where(
and(
activeInterestsWhere(portId),
eq(interestBerths.berthId, d.berthId),
eq(interestBerths.isSpecificInterest, true),
),
);
if ((stillUnderOffer?.count ?? 0) === 0) {
await tx.update(berths).set({ status: 'available' }).where(eq(berths.id, d.berthId));
}
}
persistedDecisions.push({
kind: 'berth_released',
refId: d.berthId,
detail: { interestId: d.interestId, mooringNumber: berth.mooringNumber },
});
releasedBerths.push({
berthId: d.berthId,
mooringNumber: berth.mooringNumber,
nextInLineInterestIds: berth.otherInterests.map((i) => i.interestId),
});
} else {
persistedDecisions.push({
kind: 'berth_retained',
refId: d.berthId,
detail: { interestId: d.interestId, mooringNumber: berth.mooringNumber },
});
}
}
// ─── Yacht decisions ─────────────────────────────────────────────────
for (const d of decisions.yachtDecisions) {
if (d.action === 'transfer') {
if (!d.newOwnerType || !d.newOwnerId) {
throw new ValidationError(
`Yacht ${d.yachtId}: transfer requires newOwnerType + newOwnerId`,
);
}
await tx
.update(yachts)
.set({ currentOwnerType: d.newOwnerType, currentOwnerId: d.newOwnerId })
.where(eq(yachts.id, d.yachtId));
persistedDecisions.push({
kind: 'yacht_transferred',
refId: d.yachtId,
detail: {
previousOwnerType: 'client',
previousOwnerId: clientId,
newOwnerType: d.newOwnerType,
newOwnerId: d.newOwnerId,
},
});
} else if (d.action === 'mark_sold_away') {
await tx.update(yachts).set({ status: 'sold_away' }).where(eq(yachts.id, d.yachtId));
persistedDecisions.push({ kind: 'yacht_marked_sold_away', refId: d.yachtId });
} else {
persistedDecisions.push({ kind: 'yacht_retained', refId: d.yachtId });
}
}
// ─── Reservation decisions ───────────────────────────────────────────
for (const d of decisions.reservationDecisions) {
if (d.action === 'cancel') {
await tx
.update(berthReservations)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(eq(berthReservations.id, d.reservationId));
persistedDecisions.push({ kind: 'reservation_cancelled', refId: d.reservationId });
} else if (d.action === 'transfer') {
if (!d.transferToClientId) {
throw new ValidationError(
`Reservation ${d.reservationId}: transfer requires transferToClientId`,
);
}
await tx
.update(berthReservations)
.set({ clientId: d.transferToClientId, updatedAt: new Date() })
.where(eq(berthReservations.id, d.reservationId));
persistedDecisions.push({
kind: 'reservation_transferred',
refId: d.reservationId,
detail: { previousClientId: clientId, newClientId: d.transferToClientId },
});
}
}
// ─── Invoice decisions ───────────────────────────────────────────────
for (const d of decisions.invoiceDecisions) {
if (d.action === 'void' || d.action === 'write_off') {
await tx
.update(invoices)
.set({
status: 'cancelled',
notes: sql`coalesce(${invoices.notes}, '') || ${'\n[archive ' + new Date().toISOString() + '] ' + (d.action === 'void' ? 'voided' : 'written off') + ' as part of client archive'}`,
updatedAt: new Date(),
})
.where(eq(invoices.id, d.invoiceId));
persistedDecisions.push({
kind: d.action === 'void' ? 'invoice_voided' : 'invoice_written_off',
refId: d.invoiceId,
});
} else {
persistedDecisions.push({ kind: 'invoice_left', refId: d.invoiceId });
}
}
// ─── Document (Documenso envelope) decisions ─────────────────────────
for (const d of decisions.documentDecisions) {
const doc = dossier.documents.find((x) => x.documentId === d.documentId);
if (!doc) continue;
if (d.action === 'void_documenso' && doc.documensoEnvelopeId) {
// Local marker — actual API call queued post-commit.
await tx
.update(documents)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(eq(documents.id, d.documentId));
externalCleanups.push({
kind: 'documenso_void',
documentId: d.documentId,
documensoId: doc.documensoEnvelopeId,
});
persistedDecisions.push({
kind: 'documenso_voided',
refId: d.documentId,
detail: { documensoEnvelopeId: doc.documensoEnvelopeId },
});
} else {
persistedDecisions.push({ kind: 'document_left', refId: d.documentId });
}
}
// ─── Auto-handled: portal user, company memberships ──────────────────
if (dossier.hasPortalUser) {
await tx
.update(portalUsers)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(portalUsers.clientId, clientId));
persistedDecisions.push({ kind: 'portal_user_revoked', refId: clientId });
}
// Auto-end company memberships (no decision needed — preserves history
// via end_date instead of deleting the membership row).
await tx
.update(companyMemberships)
.set({ endDate: sql`now()` })
.where(and(eq(companyMemberships.clientId, clientId), isNull(companyMemberships.endDate)));
// ─── Archive the client itself ────────────────────────────────────────
const archiveMetadata: ArchiveMetadata = {
decisions: persistedDecisions,
decidedAt: new Date().toISOString(),
decidedBy: meta.userId,
reason: decisions.reason,
};
await tx
.update(clients)
.set({
archivedAt: new Date(),
archivedBy: meta.userId,
archiveReason: decisions.reason || null,
archiveMetadata,
updatedAt: new Date(),
})
.where(eq(clients.id, clientId));
});
// ─── Audit log (one parent + one per non-trivial decision) ──────────────
void createAuditLog({
portId,
userId: meta.userId,
action: 'archive',
entityType: 'client',
entityId: clientId,
metadata: {
stakeLevel: dossier.stakeLevel,
highStakesStage: dossier.highStakesStage,
reason: decisions.reason,
decisionCount: persistedDecisions.length,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return {
clientId,
decisionsApplied: persistedDecisions.length,
externalCleanups,
releasedBerths,
};
}
/** Re-export for convenience. */
export { HIGH_STAKES_STAGES };