/** * Smart-restore service. * * Reads the persisted decision log from clients.archive_metadata and * classifies each decision as: * - autoReversible → safe to undo right now (system handles it * inside the restore tx). * - reversibleWithPrompt → can be undone but the world has moved on a * bit; surfaces in the wizard with a checkbox * so the operator opts in. * - locked → can't be undone (a different client now owns * the resource, the berth is sold to someone * else, etc). * * Mutating restore happens in `restoreClientWithSelections` once the UI * has the operator's selections. */ import { and, eq, isNull, ne, 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 { yachts } from '@/lib/db/schema/yachts'; import { portalUsers } from '@/lib/db/schema/portal'; import { documents } from '@/lib/db/schema/documents'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { ConflictError, NotFoundError } from '@/lib/errors'; import type { ArchiveMetadata } from '@/lib/services/client-archive.service'; // ─── Public types ─────────────────────────────────────────────────────────── export interface RestoreReversal { /** Stable id derived from the original decision so the UI can reference it. */ id: string; /** Mirror of the original decision kind for UI rendering. */ kind: ArchiveMetadata['decisions'][number]['kind']; /** Refers back to the entity that was changed (berth, yacht, etc). */ refId: string; /** Human-readable label for the wizard ("Berth A12", "Yacht Schaefer 44"). */ label: string; /** Why this is being shown the way it is (e.g. "berth still available"). */ reason: string; /** Carries the persisted decision detail through to applyReversal so we * can re-link berths to their original interest, restore yacht owners, * etc. without re-parsing meta.decisions. */ detail?: Record; } export interface RestoreDossier { client: { id: string; fullName: string; portId: string }; /** Always reversed automatically inside the restore transaction. */ autoReversible: RestoreReversal[]; /** Surfaces as opt-in checkboxes in the wizard. */ reversibleWithPrompt: RestoreReversal[]; /** Read-only list explaining what won't be restored. */ locked: Array; } export interface RestoreSelections { /** ids from RestoreDossier.reversibleWithPrompt the operator opted into. */ applyReversals: string[]; } export interface RestoreResult { clientId: string; autoReversed: number; promptedReversed: number; lockedSkipped: number; } // ─── Dossier ──────────────────────────────────────────────────────────────── export async function getRestoreDossier(clientId: string, portId: string): Promise { const [client] = await db .select({ id: clients.id, fullName: clients.fullName, portId: clients.portId, archivedAt: clients.archivedAt, archiveMetadata: clients.archiveMetadata, }) .from(clients) .where(and(eq(clients.id, clientId), eq(clients.portId, portId))) .limit(1); if (!client) throw new NotFoundError('client'); if (!client.archivedAt) throw new ConflictError('client is not archived'); const auto: RestoreReversal[] = []; const prompt: RestoreReversal[] = []; const locked: Array = []; const meta = (client.archiveMetadata ?? null) as ArchiveMetadata | null; if (!meta || !meta.decisions || meta.decisions.length === 0) { return { client: { id: client.id, fullName: client.fullName, portId: client.portId }, autoReversible: [], reversibleWithPrompt: [], locked: [], }; } for (const d of meta.decisions) { switch (d.kind) { case 'berth_released': { // Try to re-attach: only safe if the berth still exists and is // still 'available' (i.e. nobody has snapped it up since). const [b] = await db .select({ id: berths.id, mooringNumber: berths.mooringNumber, status: berths.status }) .from(berths) .where(eq(berths.id, d.refId)) .limit(1); if (!b) { locked.push({ id: `berth-${d.refId}`, kind: d.kind, refId: d.refId, label: `Berth ${(d.detail?.mooringNumber as string) ?? d.refId.slice(0, 8)}`, reason: 'released to available during archive', lockReason: 'berth no longer exists', }); break; } if (b.status === 'available') { auto.push({ id: `berth-${d.refId}`, kind: d.kind, refId: d.refId, label: `Berth ${b.mooringNumber}`, reason: 'still available — re-attaching to the restored client', detail: d.detail, }); } else if (b.status === 'sold') { locked.push({ id: `berth-${d.refId}`, kind: d.kind, refId: d.refId, label: `Berth ${b.mooringNumber}`, reason: 'released during archive', lockReason: 'berth has since been sold to another client', }); } else { // under_offer to a different interest now prompt.push({ id: `berth-${d.refId}`, kind: d.kind, refId: d.refId, label: `Berth ${b.mooringNumber}`, reason: 'currently under offer to another client — re-attach as a competing interest?', detail: d.detail, }); } break; } case 'yacht_transferred': { const [y] = await db .select({ id: yachts.id, name: yachts.name, currentOwnerType: yachts.currentOwnerType, currentOwnerId: yachts.currentOwnerId, }) .from(yachts) .where(eq(yachts.id, d.refId)) .limit(1); if (!y) { locked.push({ id: `yacht-${d.refId}`, kind: d.kind, refId: d.refId, label: 'Yacht', reason: 'transferred during archive', lockReason: 'yacht no longer exists', }); break; } // Look for active interests on the new owner that USE this yacht — // if any exist, the new owner's deal depends on the yacht and we // shouldn't yank ownership back without their consent. const [usage] = await db .select({ count: sql`count(*)::int` }) .from(interests) .where( and( eq(interests.yachtId, y.id), isNull(interests.archivedAt), isNull(interests.outcome), ne(interests.clientId, clientId), ), ); if ((usage?.count ?? 0) > 0) { locked.push({ id: `yacht-${d.refId}`, kind: d.kind, refId: d.refId, label: `Yacht ${y.name}`, reason: 'transferred during archive', lockReason: 'new owner has active interests using this yacht', }); } else { prompt.push({ id: `yacht-${d.refId}`, kind: d.kind, refId: d.refId, label: `Yacht ${y.name}`, reason: 'currently owned by another party with no active dependent interests — transfer back?', }); } break; } case 'yacht_marked_sold_away': case 'yacht_retained': { // Sold-away is a label change; restore can flip it back to active // automatically. Retained never moved, no action needed. if (d.kind === 'yacht_marked_sold_away') { auto.push({ id: `yacht-status-${d.refId}`, kind: d.kind, refId: d.refId, label: 'Yacht status', reason: 'was marked sold-away during archive — restoring to active', }); } break; } case 'portal_user_revoked': { const [pu] = await db .select({ id: portalUsers.id, isActive: portalUsers.isActive }) .from(portalUsers) .where(eq(portalUsers.clientId, clientId)) .limit(1); if (pu && !pu.isActive) { auto.push({ id: `portal-${pu.id}`, kind: d.kind, refId: pu.id, label: 'Portal user account', reason: 'was deactivated during archive — restoring access', }); } break; } case 'documenso_voided': { // Already void in Documenso; we can't un-void. Inform the operator. locked.push({ id: `doc-${d.refId}`, kind: d.kind, refId: d.refId, label: 'Documenso envelope', reason: 'voided during archive', lockReason: 'voided envelopes cannot be re-opened — regenerate the EOI if needed', }); break; } case 'invoice_voided': case 'invoice_written_off': locked.push({ id: `invoice-${d.refId}`, kind: d.kind, refId: d.refId, label: 'Invoice', reason: d.kind === 'invoice_voided' ? 'voided during archive' : 'written off during archive', lockReason: 'invoice status changes are not reversed by restore — un-cancel manually if needed', }); break; // Berth retained, yacht retained, document left, invoice left, // reservation_* — no action surfaced because nothing changed. default: break; } } return { client: { id: client.id, fullName: client.fullName, portId: client.portId }, autoReversible: auto, reversibleWithPrompt: prompt, locked, }; } // ─── Mutating restore ──────────────────────────────────────────────────────── export async function restoreClientWithSelections(args: { clientId: string; portId: string; selections: RestoreSelections; meta: AuditMeta; }): Promise { const dossier = await getRestoreDossier(args.clientId, args.portId); const opted = new Set(args.selections.applyReversals); let autoReversed = 0; let promptedReversed = 0; await db.transaction(async (tx) => { // Lock the client to prevent concurrent restore. const [locked] = await tx .select({ id: clients.id, archivedAt: clients.archivedAt }) .from(clients) .where(and(eq(clients.id, args.clientId), eq(clients.portId, args.portId))) .for('update'); if (!locked) throw new NotFoundError('client'); if (!locked.archivedAt) throw new ConflictError('client is not archived'); // Apply auto-reversals. for (const r of dossier.autoReversible) { await applyReversal(tx, r, args.clientId); autoReversed += 1; } // Apply opted-in prompts. for (const r of dossier.reversibleWithPrompt) { if (!opted.has(r.id)) continue; await applyReversal(tx, r, args.clientId); promptedReversed += 1; } // Restore the client itself. await tx .update(clients) .set({ archivedAt: null, archivedBy: null, archiveReason: null, archiveMetadata: null, updatedAt: new Date(), }) .where(eq(clients.id, args.clientId)); }); void createAuditLog({ portId: args.portId, userId: args.meta.userId, action: 'restore', entityType: 'client', entityId: args.clientId, metadata: { autoReversed, promptedReversed, lockedSkipped: dossier.locked.length, }, ipAddress: args.meta.ipAddress, userAgent: args.meta.userAgent, }); return { clientId: args.clientId, autoReversed, promptedReversed, lockedSkipped: dossier.locked.length, }; } async function applyReversal( // eslint-disable-next-line @typescript-eslint/no-explicit-any tx: any, r: RestoreReversal, clientId: string, ): Promise { switch (r.kind) { case 'berth_released': { // Re-link the berth to whichever interest originally owned it // (persisted in d.detail.interestId at archive time). We verify // the interest still belongs to the restored client and isn't // archived — defensive in case the operator deleted the interest // separately while the client was archived. const interestId = (r.detail?.interestId as string | undefined) ?? null; if (!interestId) break; const [iv] = await tx .select({ id: interests.id, archivedAt: interests.archivedAt }) .from(interests) .where(and(eq(interests.id, interestId), eq(interests.clientId, clientId))) .limit(1); if (!iv || iv.archivedAt) break; // Idempotent re-insert: the unique index on (interestId, berthId) // means a duplicate is a no-op via onConflictDoNothing. await tx .insert(interestBerths) .values({ interestId, berthId: r.refId, isPrimary: false, isSpecificInterest: true, isInEoiBundle: false, }) .onConflictDoNothing(); // Flip berth status back to under_offer so the public map reflects // the re-link. Only when berth is currently 'available' (sold // berths are immutable; under_offer to another client is handled // via the prompt branch which the operator may opt into). await tx .update(berths) .set({ status: 'under_offer' }) .where(and(eq(berths.id, r.refId), eq(berths.status, 'available'))); break; } case 'yacht_transferred': { // Transfer back to the restored client. await tx .update(yachts) .set({ currentOwnerType: 'client', currentOwnerId: clientId }) .where(eq(yachts.id, r.refId)); break; } case 'yacht_marked_sold_away': await tx.update(yachts).set({ status: 'active' }).where(eq(yachts.id, r.refId)); break; case 'portal_user_revoked': await tx .update(portalUsers) .set({ isActive: true, updatedAt: new Date() }) .where(eq(portalUsers.id, r.refId)); break; default: break; } } // Suppress lint for the test-helper imports used by future integration tests. void documents;