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>
This commit is contained in:
389
src/lib/services/client-archive.service.ts
Normal file
389
src/lib/services/client-archive.service.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* 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<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_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') {
|
||||
// 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 per design).
|
||||
if (berth.status !== 'sold') {
|
||||
const [stillUnderOffer] = await tx
|
||||
.select({ count: sql<number>`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 };
|
||||
Reference in New Issue
Block a user