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:
23
src/app/api/v1/clients/[id]/archive-dossier/route.ts
Normal file
23
src/app/api/v1/clients/[id]/archive-dossier/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { getClientArchiveDossier } from '@/lib/services/client-archive-dossier.service';
|
||||||
|
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only preview of "what's at stake" when archiving a client. The UI
|
||||||
|
* fetches this to render the smart-archive modal with the right sections,
|
||||||
|
* decision points, and warnings.
|
||||||
|
*/
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('clients', 'delete', async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) throw new NotFoundError('client');
|
||||||
|
const dossier = await getClientArchiveDossier(id, ctx.portId);
|
||||||
|
return NextResponse.json({ data: dossier });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
94
src/app/api/v1/clients/[id]/archive/route.ts
Normal file
94
src/app/api/v1/clients/[id]/archive/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import {
|
||||||
|
archiveClientWithDecisions,
|
||||||
|
type ArchiveDecisions,
|
||||||
|
} from '@/lib/services/client-archive.service';
|
||||||
|
import { getClientArchiveDossier } from '@/lib/services/client-archive-dossier.service';
|
||||||
|
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
const decisionsSchema = z.object({
|
||||||
|
reason: z.string().max(2000).default(''),
|
||||||
|
acknowledgedSignedDocuments: z.boolean().default(false),
|
||||||
|
berthDecisions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
berthId: z.string().min(1),
|
||||||
|
interestId: z.string().min(1),
|
||||||
|
action: z.enum(['release', 'retain']),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
|
yachtDecisions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
yachtId: z.string().min(1),
|
||||||
|
action: z.enum(['transfer', 'mark_sold_away', 'retain']),
|
||||||
|
newOwnerType: z.enum(['client', 'company']).optional(),
|
||||||
|
newOwnerId: z.string().min(1).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
|
reservationDecisions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
reservationId: z.string().min(1),
|
||||||
|
action: z.enum(['cancel', 'transfer']),
|
||||||
|
transferToClientId: z.string().min(1).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
|
invoiceDecisions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
invoiceId: z.string().min(1),
|
||||||
|
action: z.enum(['void', 'write_off', 'leave']),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
|
documentDecisions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
documentId: z.string().min(1),
|
||||||
|
action: z.enum(['void_documenso', 'leave']),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission('clients', 'delete', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) throw new NotFoundError('client');
|
||||||
|
const body = await parseBody(req, decisionsSchema);
|
||||||
|
|
||||||
|
// Re-fetch the dossier inside the request so the service has the
|
||||||
|
// current state of the world (the UI's dossier might be stale).
|
||||||
|
const dossier = await getClientArchiveDossier(id, ctx.portId);
|
||||||
|
const result = await archiveClientWithDecisions({
|
||||||
|
dossier,
|
||||||
|
decisions: body satisfies ArchiveDecisions,
|
||||||
|
meta: {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// External cleanups (Documenso void) + next-in-line notifications
|
||||||
|
// are queued post-commit. v1 fires them best-effort inline; future
|
||||||
|
// iteration: enqueue to BullMQ for retry/dead-letter (see
|
||||||
|
// bulletproof-webhooks design).
|
||||||
|
// TODO(bulletproof-webhooks): move to queue.
|
||||||
|
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
18
src/app/api/v1/clients/[id]/restore-dossier/route.ts
Normal file
18
src/app/api/v1/clients/[id]/restore-dossier/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { getRestoreDossier } from '@/lib/services/client-restore.service';
|
||||||
|
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('clients', 'edit', async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const id = params.id;
|
||||||
|
if (!id) throw new NotFoundError('client');
|
||||||
|
const dossier = await getRestoreDossier(id, ctx.portId);
|
||||||
|
return NextResponse.json({ data: dossier });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -1,19 +1,52 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { restoreClient } from '@/lib/services/clients.service';
|
import { restoreClientWithSelections } from '@/lib/services/client-restore.service';
|
||||||
|
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart restore endpoint. Replaces the previous dumb-restore which just
|
||||||
|
* cleared archived_at. The new flow consults the archive_metadata
|
||||||
|
* persisted at archive time and applies the operator's reversal
|
||||||
|
* selections.
|
||||||
|
*
|
||||||
|
* Backwards-compat: clients archived before the smart-archive feature
|
||||||
|
* have no archive_metadata. The dossier returns empty arrays in that
|
||||||
|
* case, and a POST with no body simply un-archives them — same effect
|
||||||
|
* as the old endpoint.
|
||||||
|
*/
|
||||||
|
const restoreSchema = z.object({
|
||||||
|
applyReversals: z.array(z.string().min(1)).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('clients', 'edit', async (req, ctx, params) => {
|
withPermission('clients', 'edit', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
await restoreClient(params.id!, ctx.portId, {
|
const id = params.id;
|
||||||
userId: ctx.userId,
|
if (!id) throw new NotFoundError('client');
|
||||||
|
|
||||||
|
// Tolerate an empty body for the legacy "just unarchive" call site.
|
||||||
|
let body: { applyReversals: string[] } = { applyReversals: [] };
|
||||||
|
try {
|
||||||
|
body = await parseBody(req, restoreSchema);
|
||||||
|
} catch {
|
||||||
|
// Empty / non-JSON body — defaults are fine.
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await restoreClientWithSelections({
|
||||||
|
clientId: id,
|
||||||
portId: ctx.portId,
|
portId: ctx.portId,
|
||||||
ipAddress: ctx.ipAddress,
|
selections: body,
|
||||||
userAgent: ctx.userAgent,
|
meta: {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/lib/db/migrations/0043_client_archive_metadata.sql
Normal file
30
src/lib/db/migrations/0043_client_archive_metadata.sql
Normal 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;
|
||||||
@@ -31,6 +31,18 @@ export const clients = pgTable(
|
|||||||
source: text('source'), // website, manual, referral, broker
|
source: text('source'), // website, manual, referral, broker
|
||||||
sourceDetails: text('source_details'),
|
sourceDetails: text('source_details'),
|
||||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
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
|
/** When this client was merged into another (the "loser" of a dedup
|
||||||
* merge), this points at the surviving client. Used by the
|
* merge), this points at the surviving client. Used by the
|
||||||
* /admin/duplicates review queue to redirect any stragglers, and by
|
* /admin/duplicates review queue to redirect any stragglers, and by
|
||||||
|
|||||||
439
src/lib/services/client-archive-dossier.service.ts
Normal file
439
src/lib/services/client-archive-dossier.service.ts
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
/**
|
||||||
|
* Smart-archive dossier service.
|
||||||
|
*
|
||||||
|
* Returns a structured snapshot of "what's at stake" when archiving (or
|
||||||
|
* restoring) a client, so the UI can render the wizard with the right
|
||||||
|
* sections + populated decision points.
|
||||||
|
*
|
||||||
|
* The dossier is read-only and side-effect-free. Any mutation happens
|
||||||
|
* via the companion `client-archive.service.ts` which takes the user's
|
||||||
|
* decisions and applies them inside a single transaction.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq, isNull, ne, desc, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||||
|
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 { documents } from '@/lib/db/schema/documents';
|
||||||
|
import { portalUsers } from '@/lib/db/schema/portal';
|
||||||
|
import { NotFoundError } from '@/lib/errors';
|
||||||
|
import type { PipelineStage } from '@/lib/constants';
|
||||||
|
|
||||||
|
// ─── Public types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline stages that count as "high-stakes" for the bulk wizard:
|
||||||
|
* Past these, money has changed hands or a contract is in motion. The
|
||||||
|
* bulk-archive UI prompts the operator to confirm individually + supply
|
||||||
|
* a reason for these clients.
|
||||||
|
*/
|
||||||
|
export const HIGH_STAKES_STAGES: ReadonlySet<PipelineStage> = new Set([
|
||||||
|
'deposit_10pct',
|
||||||
|
'contract_sent',
|
||||||
|
'contract_signed',
|
||||||
|
'completed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ArchiveStakeLevel = 'low' | 'high';
|
||||||
|
|
||||||
|
/** A berth currently linked to one of the client's interests. */
|
||||||
|
export interface DossierBerth {
|
||||||
|
berthId: string;
|
||||||
|
mooringNumber: string;
|
||||||
|
status: string; // 'available' | 'under_offer' | 'sold'
|
||||||
|
/** Other interests still actively expressing interest in this berth
|
||||||
|
* (so the next-in-line notification can list them). */
|
||||||
|
otherInterests: Array<{
|
||||||
|
interestId: string;
|
||||||
|
clientId: string | null;
|
||||||
|
clientName: string | null;
|
||||||
|
pipelineStage: string;
|
||||||
|
daysSinceUpdate: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DossierDocument {
|
||||||
|
documentId: string;
|
||||||
|
templateName: string | null;
|
||||||
|
status: string; // 'draft' | 'sent' | 'signed' | 'voided' | ...
|
||||||
|
documensoEnvelopeId: string | null;
|
||||||
|
/** True when there's a live envelope in Documenso awaiting signature. */
|
||||||
|
isInFlight: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DossierYacht {
|
||||||
|
yachtId: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DossierCompany {
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
membershipRole: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DossierReservation {
|
||||||
|
reservationId: string;
|
||||||
|
berthId: string;
|
||||||
|
mooringNumber: string;
|
||||||
|
status: string; // typically 'active'
|
||||||
|
startDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DossierInvoice {
|
||||||
|
invoiceId: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
status: string;
|
||||||
|
total: string;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DossierInterest {
|
||||||
|
interestId: string;
|
||||||
|
pipelineStage: PipelineStage;
|
||||||
|
primaryBerthMooring: string | null;
|
||||||
|
hasSignedEoi: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientArchiveDossier {
|
||||||
|
client: {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
portId: string;
|
||||||
|
archivedAt: string | null;
|
||||||
|
};
|
||||||
|
/** The headline classification — drives whether the bulk wizard
|
||||||
|
* treats this client as low-stakes (auto) or high-stakes (per-row
|
||||||
|
* confirmation + reason required). */
|
||||||
|
stakeLevel: ArchiveStakeLevel;
|
||||||
|
/** The interest stage that earned the high-stakes classification (so
|
||||||
|
* the UI can explain "this client is in deposit_10pct, please confirm").
|
||||||
|
* Null when low-stakes. */
|
||||||
|
highStakesStage: PipelineStage | null;
|
||||||
|
|
||||||
|
// Sections — empty arrays mean "nothing to handle in this category"
|
||||||
|
interests: DossierInterest[];
|
||||||
|
berths: DossierBerth[];
|
||||||
|
yachts: DossierYacht[];
|
||||||
|
companies: DossierCompany[];
|
||||||
|
reservations: DossierReservation[];
|
||||||
|
invoices: DossierInvoice[];
|
||||||
|
documents: DossierDocument[];
|
||||||
|
hasPortalUser: boolean;
|
||||||
|
|
||||||
|
/** Hard blockers — cannot proceed with archive at all until these are
|
||||||
|
* resolved manually. Currently the only one is "active reservation
|
||||||
|
* on a sold berth" (since you can't unsell a berth from this flow). */
|
||||||
|
blockers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Implementation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the full archive dossier for one client. Caller (route handler)
|
||||||
|
* is responsible for the tenant gate.
|
||||||
|
*/
|
||||||
|
export async function getClientArchiveDossier(
|
||||||
|
clientId: string,
|
||||||
|
portId: string,
|
||||||
|
): Promise<ClientArchiveDossier> {
|
||||||
|
const [client] = await db
|
||||||
|
.select({
|
||||||
|
id: clients.id,
|
||||||
|
fullName: clients.fullName,
|
||||||
|
portId: clients.portId,
|
||||||
|
archivedAt: clients.archivedAt,
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(and(eq(clients.id, clientId), eq(clients.portId, portId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) throw new NotFoundError('client');
|
||||||
|
|
||||||
|
// ─── Interests + stake classification ────────────────────────────────────
|
||||||
|
const clientInterests = await db
|
||||||
|
.select({
|
||||||
|
id: interests.id,
|
||||||
|
pipelineStage: interests.pipelineStage,
|
||||||
|
})
|
||||||
|
.from(interests)
|
||||||
|
.where(and(eq(interests.clientId, clientId), isNull(interests.archivedAt)));
|
||||||
|
|
||||||
|
let stakeLevel: ArchiveStakeLevel = 'low';
|
||||||
|
let highStakesStage: PipelineStage | null = null;
|
||||||
|
for (const i of clientInterests) {
|
||||||
|
if (HIGH_STAKES_STAGES.has(i.pipelineStage as PipelineStage)) {
|
||||||
|
stakeLevel = 'high';
|
||||||
|
// Pick the highest-stage one to surface in the UI message.
|
||||||
|
if (
|
||||||
|
!highStakesStage ||
|
||||||
|
rankStage(i.pipelineStage as PipelineStage) > rankStage(highStakesStage)
|
||||||
|
) {
|
||||||
|
highStakesStage = i.pipelineStage as PipelineStage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Documents (signed EOIs trigger the acknowledgment requirement) ─────
|
||||||
|
const clientDocuments = await db
|
||||||
|
.select({
|
||||||
|
id: documents.id,
|
||||||
|
title: documents.title,
|
||||||
|
documentType: documents.documentType,
|
||||||
|
status: documents.status,
|
||||||
|
documensoId: documents.documensoId,
|
||||||
|
})
|
||||||
|
.from(documents)
|
||||||
|
.where(and(eq(documents.clientId, clientId), eq(documents.portId, portId)));
|
||||||
|
|
||||||
|
const dossierDocs: DossierDocument[] = clientDocuments.map((d) => ({
|
||||||
|
documentId: d.id,
|
||||||
|
templateName: d.title,
|
||||||
|
status: d.status,
|
||||||
|
documensoEnvelopeId: d.documensoId,
|
||||||
|
isInFlight: !!d.documensoId && (d.status === 'sent' || d.status === 'partially_signed'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const interestsWithSignedEoi = new Set<string>(
|
||||||
|
clientDocuments.filter((d) => d.status === 'completed').map((d) => d.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Interests + berth links ─────────────────────────────────────────────
|
||||||
|
const interestIds = clientInterests.map((i) => i.id);
|
||||||
|
const interestBerthRows = interestIds.length
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
interestId: interestBerths.interestId,
|
||||||
|
berthId: interestBerths.berthId,
|
||||||
|
isPrimary: interestBerths.isPrimary,
|
||||||
|
mooringNumber: berths.mooringNumber,
|
||||||
|
berthStatus: berths.status,
|
||||||
|
})
|
||||||
|
.from(interestBerths)
|
||||||
|
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
|
||||||
|
.where(inArray(interestBerths.interestId, interestIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const dossierInterests: DossierInterest[] = clientInterests.map((i) => {
|
||||||
|
const primary = interestBerthRows.find((r) => r.interestId === i.id && r.isPrimary);
|
||||||
|
return {
|
||||||
|
interestId: i.id,
|
||||||
|
pipelineStage: i.pipelineStage as PipelineStage,
|
||||||
|
primaryBerthMooring: primary?.mooringNumber ?? null,
|
||||||
|
hasSignedEoi: interestsWithSignedEoi.has(i.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Berths section + next-in-line interests ────────────────────────────
|
||||||
|
const distinctBerthIds = Array.from(new Set(interestBerthRows.map((r) => r.berthId)));
|
||||||
|
const dossierBerths: DossierBerth[] = [];
|
||||||
|
for (const berthId of distinctBerthIds) {
|
||||||
|
const berth = interestBerthRows.find((r) => r.berthId === berthId);
|
||||||
|
if (!berth) continue;
|
||||||
|
|
||||||
|
// "Other interests" = interest_berths rows on this berth that DON'T
|
||||||
|
// belong to the client being archived AND whose interest is still
|
||||||
|
// active (no outcome set, not archived). Surfaces who the sales
|
||||||
|
// rep should reach out to next.
|
||||||
|
const others = await db
|
||||||
|
.select({
|
||||||
|
interestId: interests.id,
|
||||||
|
clientId: interests.clientId,
|
||||||
|
clientName: clients.fullName,
|
||||||
|
pipelineStage: interests.pipelineStage,
|
||||||
|
updatedAt: interests.updatedAt,
|
||||||
|
})
|
||||||
|
.from(interestBerths)
|
||||||
|
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
|
||||||
|
.leftJoin(clients, eq(interests.clientId, clients.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(interestBerths.berthId, berthId),
|
||||||
|
ne(interests.clientId, clientId),
|
||||||
|
isNull(interests.archivedAt),
|
||||||
|
isNull(interests.outcome),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(interests.updatedAt))
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
dossierBerths.push({
|
||||||
|
berthId,
|
||||||
|
mooringNumber: berth.mooringNumber,
|
||||||
|
status: berth.berthStatus,
|
||||||
|
otherInterests: others.map((o) => ({
|
||||||
|
interestId: o.interestId,
|
||||||
|
clientId: o.clientId,
|
||||||
|
clientName: o.clientName,
|
||||||
|
pipelineStage: o.pipelineStage,
|
||||||
|
daysSinceUpdate: Math.floor((Date.now() - new Date(o.updatedAt).getTime()) / DAY_MS),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Yachts owned by client ──────────────────────────────────────────────
|
||||||
|
const ownedYachts = await db
|
||||||
|
.select({
|
||||||
|
id: yachts.id,
|
||||||
|
name: yachts.name,
|
||||||
|
hullNumber: yachts.hullNumber,
|
||||||
|
status: yachts.status,
|
||||||
|
})
|
||||||
|
.from(yachts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(yachts.portId, portId),
|
||||||
|
eq(yachts.currentOwnerType, 'client'),
|
||||||
|
eq(yachts.currentOwnerId, clientId),
|
||||||
|
isNull(yachts.archivedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Company memberships (current — no end_date) ─────────────────────────
|
||||||
|
const memberRows = await db
|
||||||
|
.select({
|
||||||
|
companyId: companies.id,
|
||||||
|
name: companies.name,
|
||||||
|
role: companyMemberships.role,
|
||||||
|
})
|
||||||
|
.from(companyMemberships)
|
||||||
|
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(companyMemberships.clientId, clientId),
|
||||||
|
eq(companies.portId, portId),
|
||||||
|
isNull(companyMemberships.endDate),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Active reservations ─────────────────────────────────────────────────
|
||||||
|
const activeReservations = await db
|
||||||
|
.select({
|
||||||
|
id: berthReservations.id,
|
||||||
|
berthId: berthReservations.berthId,
|
||||||
|
mooringNumber: berths.mooringNumber,
|
||||||
|
status: berthReservations.status,
|
||||||
|
startDate: berthReservations.startDate,
|
||||||
|
berthStatus: berths.status,
|
||||||
|
})
|
||||||
|
.from(berthReservations)
|
||||||
|
.innerJoin(berths, eq(berthReservations.berthId, berths.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(berthReservations.clientId, clientId),
|
||||||
|
eq(berthReservations.portId, portId),
|
||||||
|
eq(berthReservations.status, 'active'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Outstanding invoices (anything not paid / not cancelled) ────────────
|
||||||
|
const outstandingInvoices = await db
|
||||||
|
.select({
|
||||||
|
id: invoices.id,
|
||||||
|
invoiceNumber: invoices.invoiceNumber,
|
||||||
|
status: invoices.status,
|
||||||
|
total: invoices.total,
|
||||||
|
currency: invoices.currency,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invoices.portId, portId),
|
||||||
|
eq(invoices.billingEntityType, 'client'),
|
||||||
|
eq(invoices.billingEntityId, clientId),
|
||||||
|
isNull(invoices.archivedAt),
|
||||||
|
ne(invoices.status, 'paid'),
|
||||||
|
ne(invoices.status, 'cancelled'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Portal user existence ───────────────────────────────────────────────
|
||||||
|
const [portalUser] = await db
|
||||||
|
.select({ id: portalUsers.id })
|
||||||
|
.from(portalUsers)
|
||||||
|
.where(and(eq(portalUsers.clientId, clientId), eq(portalUsers.portId, portId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// ─── Hard blockers ───────────────────────────────────────────────────────
|
||||||
|
// The only true blocker is an active reservation on a SOLD berth — we
|
||||||
|
// can't auto-handle this without crossing into refund territory. Force
|
||||||
|
// the operator to handle it via the existing reservation UI first.
|
||||||
|
const blockers: string[] = [];
|
||||||
|
for (const r of activeReservations) {
|
||||||
|
if (r.berthStatus === 'sold') {
|
||||||
|
blockers.push(
|
||||||
|
`Active reservation on sold berth ${r.mooringNumber} (#${r.id.slice(0, 8)}). Process the refund or transfer the reservation before archiving.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
id: client.id,
|
||||||
|
fullName: client.fullName,
|
||||||
|
portId: client.portId,
|
||||||
|
archivedAt: client.archivedAt ? client.archivedAt.toISOString() : null,
|
||||||
|
},
|
||||||
|
stakeLevel,
|
||||||
|
highStakesStage,
|
||||||
|
interests: dossierInterests,
|
||||||
|
berths: dossierBerths,
|
||||||
|
yachts: ownedYachts.map((y) => ({
|
||||||
|
yachtId: y.id,
|
||||||
|
name: y.name,
|
||||||
|
hullNumber: y.hullNumber,
|
||||||
|
status: y.status,
|
||||||
|
})),
|
||||||
|
companies: memberRows.map((m) => ({
|
||||||
|
companyId: m.companyId,
|
||||||
|
name: m.name,
|
||||||
|
membershipRole: m.role,
|
||||||
|
})),
|
||||||
|
reservations: activeReservations.map((r) => ({
|
||||||
|
reservationId: r.id,
|
||||||
|
berthId: r.berthId,
|
||||||
|
mooringNumber: r.mooringNumber,
|
||||||
|
status: r.status,
|
||||||
|
startDate: r.startDate.toISOString(),
|
||||||
|
})),
|
||||||
|
invoices: outstandingInvoices.map((i) => ({
|
||||||
|
invoiceId: i.id,
|
||||||
|
invoiceNumber: i.invoiceNumber,
|
||||||
|
status: i.status,
|
||||||
|
total: i.total,
|
||||||
|
currency: i.currency,
|
||||||
|
})),
|
||||||
|
documents: dossierDocs,
|
||||||
|
hasPortalUser: !!portalUser,
|
||||||
|
blockers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage rank used to pick the "highest" high-stakes stage when surfacing
|
||||||
|
// the warning copy. Higher = more committed.
|
||||||
|
function rankStage(s: PipelineStage): number {
|
||||||
|
switch (s) {
|
||||||
|
case 'completed':
|
||||||
|
return 5;
|
||||||
|
case 'contract_signed':
|
||||||
|
return 4;
|
||||||
|
case 'contract_sent':
|
||||||
|
return 3;
|
||||||
|
case 'deposit_10pct':
|
||||||
|
return 2;
|
||||||
|
case 'eoi_signed':
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
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 };
|
||||||
396
src/lib/services/client-restore.service.ts
Normal file
396
src/lib/services/client-restore.service.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
/**
|
||||||
|
* 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 } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
} 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?',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
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<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyReversal(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
tx: any,
|
||||||
|
r: RestoreReversal,
|
||||||
|
clientId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
switch (r.kind) {
|
||||||
|
case 'berth_released':
|
||||||
|
// Re-attach to whichever interest of the restored client originally
|
||||||
|
// owned the link. We don't know that interest id from the reversal
|
||||||
|
// alone, so we pick the most recent active interest on the same
|
||||||
|
// berth from this client; if none exists we skip (the berth is
|
||||||
|
// now genuinely orphaned for this client).
|
||||||
|
// For v1, leave the berth available — operator can re-attach
|
||||||
|
// manually via the interest-berths UI. The restore wizard surfaces
|
||||||
|
// this case as auto-reversible only when the berth is still free,
|
||||||
|
// so the operator can immediately add it back.
|
||||||
|
// (The system MARKS the berth as eligible for re-link; full
|
||||||
|
// automation would require persisting the original interestId.)
|
||||||
|
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;
|
||||||
Reference in New Issue
Block a user