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>
2026-05-06 17:13:08 +02:00
/ * *
* 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 / d e l e t e , m a s s e m a i l
* 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' ;
2026-05-14 15:19:38 +02:00
import { activeInterestsWhere } from '@/lib/services/active-interest' ;
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>
2026-05-06 17:13:08 +02:00
// ─── Decision payload (what the UI sends to the server) ─────────────────────
/ * * P e r - b e r t h c h o i c e . ` i n t e r e s t I d ` i s t h e i n t e r e s t i n t h e a r c h i v e d c l i e n t
* 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 ;
/ * * O t h e r i n t e r e s t s t h a t s h o u l d b e n o t i f i e d a b o u t t h i s b e r t h b e c o m i n g
* 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 (
2026-05-15 01:18:13 +02:00
'A reason is required when archiving a client at Deposit Paid or later.' ,
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>
2026-05-06 17:13:08 +02:00
) ;
}
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' ) {
2026-05-06 22:11:00 +02:00
// 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 ;
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>
2026-05-06 17:13:08 +02:00
// 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
2026-05-06 22:11:00 +02:00
// 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' ) {
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>
2026-05-06 17:13:08 +02:00
const [ stillUnderOffer ] = await tx
. select ( { count : sql < number > ` count(*)::int ` } )
. from ( interestBerths )
. innerJoin ( interests , eq ( interestBerths . interestId , interests . id ) )
. where (
and (
2026-05-14 15:19:38 +02:00
activeInterestsWhere ( portId ) ,
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>
2026-05-06 17:13:08 +02:00
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 } ;