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 { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { restoreClient } from '@/lib/services/clients.service';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
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(
|
||||
withPermission('clients', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
await restoreClient(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
const id = params.id;
|
||||
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,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
selections: body,
|
||||
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) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user