/** * 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'; // ─── 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; } 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 { 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_10pct 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`count(*)::int` }) .from(interestBerths) .innerJoin(interests, eq(interestBerths.interestId, interests.id)) .where( and( eq(interestBerths.berthId, d.berthId), eq(interestBerths.isSpecificInterest, true), isNull(interests.archivedAt), isNull(interests.outcome), ), ); 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 };