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:
Matt Ciaccio
2026-05-06 17:13:08 +02:00
parent f10334683d
commit d07f1ed5e0
9 changed files with 1441 additions and 7 deletions

View File

@@ -0,0 +1,30 @@
-- Smart-archive feature — add columns that capture WHO archived a client,
-- WHY, and WHAT decisions they made along the way (for the restore
-- wizard to attempt reversal).
--
-- archived_at already exists (since the original schema). The three new
-- columns are all nullable and default-friendly so existing archived
-- rows backfill cleanly: their reason/metadata stays NULL, which the
-- service treats as "legacy archive (no smart-archive metadata)".
ALTER TABLE clients ADD COLUMN IF NOT EXISTS archived_by text;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS archive_reason text;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS archive_metadata jsonb;
-- archived_by FK is SET NULL on user delete so the audit trail outlives
-- staff turnover. Not VALID immediately to keep the lock window short;
-- VALIDATE after the column is in place.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'clients_archived_by_fk'
) THEN
ALTER TABLE clients
ADD CONSTRAINT clients_archived_by_fk
FOREIGN KEY (archived_by) REFERENCES "user"(id)
ON DELETE SET NULL
NOT VALID;
END IF;
END$$;
ALTER TABLE clients VALIDATE CONSTRAINT clients_archived_by_fk;

View File

@@ -31,6 +31,18 @@ export const clients = pgTable(
source: text('source'), // website, manual, referral, broker
sourceDetails: text('source_details'),
archivedAt: timestamp('archived_at', { withTimezone: true }),
/** Better-auth user id of the operator who archived this client. */
archivedBy: text('archived_by'),
/** Free-text reason captured at archive time. Required when archiving a
* client at deposit_10pct or later (compliance trail). Optional when
* archiving an early-stage lead. */
archiveReason: text('archive_reason'),
/** Per-decision metadata captured during smart-archive flow. Used by
* the restore wizard to attempt reversal. Shape:
* { decisions: Array<{ kind, refId, ...specifics }>, decidedAt, decidedBy }
* See src/lib/services/client-archive.service.ts for the canonical
* payload schema. */
archiveMetadata: jsonb('archive_metadata'),
/** When this client was merged into another (the "loser" of a dedup
* merge), this points at the surviving client. Used by the
* /admin/duplicates review queue to redirect any stragglers, and by