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>
404 lines
15 KiB
TypeScript
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 };
|