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-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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-05-14 15:19:38 +02:00
|
|
|
import { and, eq, ne, sql } from 'drizzle-orm';
|
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
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson
Address the CRITICAL + high-leverage HIGH items from the types-auditor:
**C1 — `tx: any` in client-restore.service**
Export a canonical `Tx` type from `lib/db/utils.ts` (derived from
Drizzle's `db.transaction` callback shape) and use it in
`applyReversal` so the 12+ downstream tx writes get full inference.
**C2 — berth-detail page stacked `useQuery<any>` escape hatches**
Export `BerthDetailData` from berth-detail-header and consume it
through useQuery + apiFetch. Removed three `any` escapes in the
highest-traffic detail page. Also collapsed the duplicate `BerthData`
in berth-tabs.tsx to import from berth-detail-header so the two
types can't drift.
**C3 — parseBody migration for portal/public routes**
Replace raw `await req.json() + schema.parse(body)` with the
project-standard `parseBody(req, schema)` helper across 7 routes:
- portal/auth/{change-password, activate, reset-password}
- auth/set-password
- public/{interests, residential-inquiries}
Skipped the three anti-enumeration routes (forgot-password, sign-in,
sign-in-by-identifier) where the manual validation gives opaque
errors on purpose. website-inquiries already wraps the parse in a
custom 400 — left as-is.
**HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)**
Introduce `toAuditJson<T extends object>(row: T): Record<string,
unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow`
that already exists for the same reason). Codemod 21 `<row> as unknown
as Record<string, unknown>` sites across:
- invoices.ts × 6
- expenses.ts × 6
- berths.service × 2
- documents.service × 2
- ocr-config.service × 2
- ai-budget.service × 2
- yachts.service, companies.service, company-memberships.service × 1 each
document-templates' `payload as unknown as Record<...>` is a different
shape (Documenso form-values widening, not an audit log) — kept the
manual cast there. Tests stay 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00
|
|
|
import type { Tx } from '@/lib/db/utils';
|
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
|
|
|
import { clients } from '@/lib/db/schema/clients';
|
2026-05-06 22:11:00 +02:00
|
|
|
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
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
|
|
|
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';
|
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
|
|
|
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;
|
2026-05-06 22:11:00 +02:00
|
|
|
/** 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<string, unknown>;
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<RestoreReversal & { lockReason: string }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<RestoreDossier> {
|
|
|
|
|
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<RestoreReversal & { lockReason: string }> = [];
|
|
|
|
|
|
|
|
|
|
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',
|
2026-05-06 22:11:00 +02:00
|
|
|
detail: d.detail,
|
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
|
|
|
});
|
|
|
|
|
} 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?',
|
2026-05-06 22:11:00 +02:00
|
|
|
detail: d.detail,
|
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
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
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<number>`count(*)::int` })
|
|
|
|
|
.from(interests)
|
|
|
|
|
.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(interests.yachtId, y.id),
|
|
|
|
|
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<RestoreResult> {
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson
Address the CRITICAL + high-leverage HIGH items from the types-auditor:
**C1 — `tx: any` in client-restore.service**
Export a canonical `Tx` type from `lib/db/utils.ts` (derived from
Drizzle's `db.transaction` callback shape) and use it in
`applyReversal` so the 12+ downstream tx writes get full inference.
**C2 — berth-detail page stacked `useQuery<any>` escape hatches**
Export `BerthDetailData` from berth-detail-header and consume it
through useQuery + apiFetch. Removed three `any` escapes in the
highest-traffic detail page. Also collapsed the duplicate `BerthData`
in berth-tabs.tsx to import from berth-detail-header so the two
types can't drift.
**C3 — parseBody migration for portal/public routes**
Replace raw `await req.json() + schema.parse(body)` with the
project-standard `parseBody(req, schema)` helper across 7 routes:
- portal/auth/{change-password, activate, reset-password}
- auth/set-password
- public/{interests, residential-inquiries}
Skipped the three anti-enumeration routes (forgot-password, sign-in,
sign-in-by-identifier) where the manual validation gives opaque
errors on purpose. website-inquiries already wraps the parse in a
custom 400 — left as-is.
**HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)**
Introduce `toAuditJson<T extends object>(row: T): Record<string,
unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow`
that already exists for the same reason). Codemod 21 `<row> as unknown
as Record<string, unknown>` sites across:
- invoices.ts × 6
- expenses.ts × 6
- berths.service × 2
- documents.service × 2
- ocr-config.service × 2
- ai-budget.service × 2
- yachts.service, companies.service, company-memberships.service × 1 each
document-templates' `payload as unknown as Record<...>` is a different
shape (Documenso form-values widening, not an audit log) — kept the
manual cast there. Tests stay 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00
|
|
|
async function applyReversal(tx: Tx, r: RestoreReversal, clientId: string): Promise<void> {
|
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
|
|
|
switch (r.kind) {
|
2026-05-06 22:11:00 +02:00
|
|
|
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')));
|
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
|
|
|
break;
|
2026-05-06 22:11:00 +02:00
|
|
|
}
|
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
|
|
|
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;
|