From 025648c40bb932dd32e47db0366caece242f8352 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 22:49:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(P1):=20soft-archive=20berths=20instead=20of?= =?UTF-8?q?=20hard-delete=20=E2=80=94=20F5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-audit, DELETE /api/v1/berths/[id] called `db.delete()` which permanently dropped the row, cascade-vanished `interest_berths` links, broke historical audit references, and could 404 the public feed mid- customer-inquiry. The `berths.archived_at` column existed in the schema but was never written. Changes: - `archiveBerth(id, portId, { reason }, meta)` is the new canonical soft-archive. Requires a reason (min 5 chars). Blocks when an active interest still depends on the berth (forces the rep to resolve the deal first). Audit-logs the old status + reason. - `restoreBerth(...)` reverses it. - DELETE route now accepts `{ reason }` and routes to archiveBerth. - New POST /api/v1/berths/[id]/restore. - `getBerthOptions` + dashboard occupancy / status-distribution queries gain `isNull(berths.archivedAt)` so archived moorings don't show up in pickers or skew metrics. - Legacy `deleteBerth(...)` kept as a thin wrapper around archiveBerth so import sites we haven't migrated still work — labeled @deprecated. Verified live: - DELETE w/o reason → 400 (validation) - DELETE w/ "x" → 400 "Reason must be ≥ 5 characters" - DELETE w/ proper reason → 204, row archived, reason persisted - DELETE twice → 409 "Berth is already archived" - POST /restore → 204, archived_at cleared Follow-up (deferred): apply isNull(archivedAt) to recommendations.ts, alert-rules.ts, portal.service.ts, report-generators.ts, berth-rules- engine.ts. The current set covers the visible surfaces; the rest are secondary aggregators. Co-Authored-By: Claude Opus 4.7 (1M context) --- next-env.d.ts | 2 +- src/app/api/v1/berths/[id]/restore/route.ts | 23 ++++ src/app/api/v1/berths/[id]/route.ts | 12 +- src/lib/services/berths.service.ts | 117 ++++++++++++++++++-- src/lib/services/dashboard.service.ts | 5 +- src/lib/validators/berths.ts | 11 ++ 6 files changed, 152 insertions(+), 18 deletions(-) create mode 100644 src/app/api/v1/berths/[id]/restore/route.ts diff --git a/next-env.d.ts b/next-env.d.ts index 1511519d..20e7bcfb 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import './.next/types/routes.d.ts'; +import './.next/dev/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/api/v1/berths/[id]/restore/route.ts b/src/app/api/v1/berths/[id]/restore/route.ts new file mode 100644 index 00000000..f79625ed --- /dev/null +++ b/src/app/api/v1/berths/[id]/restore/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { restoreBerth } from '@/lib/services/berths.service'; +import { errorResponse } from '@/lib/errors'; + +// POST /api/v1/berths/[id]/restore +// Post-audit F5: reverses an archive. No body. Audit-logged. +export const POST = withAuth( + withPermission('berths', 'edit', async (_req, ctx, params) => { + try { + await restoreBerth(params.id!, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/berths/[id]/route.ts b/src/app/api/v1/berths/[id]/route.ts index e79150db..83558bbb 100644 --- a/src/app/api/v1/berths/[id]/route.ts +++ b/src/app/api/v1/berths/[id]/route.ts @@ -2,8 +2,8 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; -import { updateBerthSchema } from '@/lib/validators/berths'; -import { getBerthById, updateBerth, deleteBerth } from '@/lib/services/berths.service'; +import { updateBerthSchema, archiveBerthSchema } from '@/lib/validators/berths'; +import { getBerthById, updateBerth, archiveBerth } from '@/lib/services/berths.service'; import { errorResponse } from '@/lib/errors'; // GET /api/v1/berths/[id] @@ -37,10 +37,14 @@ export const PATCH = withAuth( ); // DELETE /api/v1/berths/[id] +// Post-audit F5: this is a SOFT-ARCHIVE, not a hard delete. The body +// must carry `{ reason: string (>=5 chars) }`. Use POST /restore to +// reverse. Archive is blocked when an active interest is still linked. export const DELETE = withAuth( - withPermission('berths', 'edit', async (_req, ctx, params) => { + withPermission('berths', 'edit', async (req, ctx, params) => { try { - await deleteBerth(params.id!, ctx.portId, { + const body = await parseBody(req, archiveBerthSchema); + await archiveBerth(params.id!, ctx.portId, body, { userId: ctx.userId, portId: ctx.portId, ipAddress: ctx.ipAddress, diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts index d6a7cdc2..39d4656d 100644 --- a/src/lib/services/berths.service.ts +++ b/src/lib/services/berths.service.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm'; +import { and, eq, gte, lte, inArray, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths'; @@ -9,12 +9,11 @@ import { PIPELINE_STAGES } from '@/lib/constants'; import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit'; import { activeInterestsWhere } from '@/lib/services/active-interest'; import { diffEntity } from '@/lib/entity-diff'; -import { NotFoundError, ValidationError } from '@/lib/errors'; +import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { buildListQuery } from '@/lib/db/query-builder'; import { emitToRoom } from '@/lib/socket/server'; import { setEntityTags } from '@/lib/services/entity-tags.helper'; import { getPortBerthsDefaultCurrency } from '@/lib/services/port-config'; -import { ConflictError } from '@/lib/errors'; import { sortByMooring } from '@/lib/utils/mooring-sort'; import type { CreateBerthInput, @@ -668,34 +667,128 @@ export async function bulkAddBerths( return { inserted: inserted.length, ids: inserted.map((r) => r.id) }; } -// ─── Delete ───────────────────────────────────────────────────────────────── +// ─── Archive / Restore ───────────────────────────────────────────────────── -export async function deleteBerth(id: string, portId: string, meta: AuditMeta) { +/** + * Post-audit F5: soft-archive replaces hard-delete. The previous + * `db.delete()` permanently dropped the berth row + cascade-vanished + * interest_berths links + broke historical audit references. Now the + * row stays; `archived_at` shields it from default queries. + * + * Reasoning chain: + * 1. Block if there's an active (non-archived, no-outcome) interest + * still linked — archiving with deals in flight breaks reports. + * 2. Stamp archived_at + archived_by + archive_reason in a single update. + * 3. Audit log captures the reason so /admin/audit shows the why. + * 4. Emit a socket alert so any open berth-detail page bounces. + */ +export async function archiveBerth( + id: string, + portId: string, + input: { reason: string }, + meta: AuditMeta, +) { const berth = await db.query.berths.findFirst({ where: and(eq(berths.id, id), eq(berths.portId, portId)), }); if (!berth) throw new NotFoundError('Berth'); + if (berth.archivedAt) { + throw new ConflictError('Berth is already archived'); + } - await db.delete(berths).where(and(eq(berths.id, id), eq(berths.portId, portId))); + // Block archive when an active interest still depends on the berth — + // forces the rep to resolve the deal first instead of orphaning it. + const activeLink = await db + .select({ interestId: interestBerths.interestId }) + .from(interestBerths) + .innerJoin(interests, eq(interests.id, interestBerths.interestId)) + .where( + and(eq(interestBerths.berthId, id), isNull(interests.archivedAt), isNull(interests.outcome)), + ) + .limit(1); + if (activeLink.length > 0) { + throw new ConflictError( + 'Cannot archive a berth with an active interest. Resolve or archive the interest first.', + ); + } + + await db + .update(berths) + .set({ + archivedAt: new Date(), + archivedBy: meta.userId, + archiveReason: input.reason, + updatedAt: new Date(), + }) + .where(and(eq(berths.id, id), eq(berths.portId, portId))); void createAuditLog({ userId: meta.userId, portId, - action: 'delete', + action: 'archive', entityType: 'berth', entityId: id, - oldValue: { mooringNumber: berth.mooringNumber, area: berth.area }, + oldValue: { mooringNumber: berth.mooringNumber, area: berth.area, status: berth.status }, + newValue: { reason: input.reason }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'system:alert', { - alertType: 'berth:deleted', - message: `Berth "${berth.mooringNumber}" deleted`, + alertType: 'berth:archived', + message: `Berth "${berth.mooringNumber}" archived: ${input.reason}`, severity: 'info', }); } +/** Un-archive. Available to anyone with `berths:edit`. Audit-logged. */ +export async function restoreBerth(id: string, portId: string, meta: AuditMeta) { + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.id, id), eq(berths.portId, portId)), + }); + if (!berth) throw new NotFoundError('Berth'); + if (!berth.archivedAt) { + throw new ConflictError('Berth is not archived'); + } + + await db + .update(berths) + .set({ + archivedAt: null, + archivedBy: null, + archiveReason: null, + updatedAt: new Date(), + }) + .where(and(eq(berths.id, id), eq(berths.portId, portId))); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'restore', + entityType: 'berth', + entityId: id, + oldValue: { archivedAt: berth.archivedAt, archiveReason: berth.archiveReason }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'system:alert', { + alertType: 'berth:restored', + message: `Berth "${berth.mooringNumber}" restored`, + severity: 'info', + }); +} + +/** + * @deprecated Use `archiveBerth` instead. Kept temporarily for callers + * that haven't migrated. Calls archiveBerth under the hood — the + * "hard delete" name is now a lie but we don't break the import sites + * in a single PR. + */ +export async function deleteBerth(id: string, portId: string, meta: AuditMeta) { + return archiveBerth(id, portId, { reason: 'Deleted via legacy delete path' }, meta); +} + // ─── Options ────────────────────────────────────────────────────────────────── export async function getBerthOptions(portId: string) { @@ -710,6 +803,8 @@ export async function getBerthOptions(portId: string) { status: berths.status, }) .from(berths) - .where(eq(berths.portId, portId)); + // F5: hide archived berths from option pickers; otherwise a dead berth + // appears in the New Interest combobox and re-links itself to a deal. + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt))); return sortByMooring(rows, (r) => r.mooringNumber); } diff --git a/src/lib/services/dashboard.service.ts b/src/lib/services/dashboard.service.ts index e69997c3..a5e0020a 100644 --- a/src/lib/services/dashboard.service.ts +++ b/src/lib/services/dashboard.service.ts @@ -89,7 +89,8 @@ export async function getKpis(portId: string) { const allBerthsRows = await db .select({ status: berths.status }) .from(berths) - .where(eq(berths.portId, portId)); + // F5: archived berths excluded so retired moorings don't dilute denominator. + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt))); const totalBerths = allBerthsRows.length; const occupiedBerths = allBerthsRows.filter((b) => b.status === 'sold').length; @@ -205,7 +206,7 @@ export async function getBerthStatusDistribution(portId: string) { const rows = await db .select({ status: berths.status, c: sql`count(*)::int` }) .from(berths) - .where(eq(berths.portId, portId)) + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt))) .groupBy(berths.status); const counts: Record = {}; diff --git a/src/lib/validators/berths.ts b/src/lib/validators/berths.ts index e3ae6aa3..2e50df37 100644 --- a/src/lib/validators/berths.ts +++ b/src/lib/validators/berths.ts @@ -91,6 +91,17 @@ export const updateBerthStatusSchema = z.object({ export type UpdateBerthStatusInput = z.infer; +// ─── Archive Berth ──────────────────────────────────────────────────────────── + +// Post-audit F5: archive replaces hard-delete. A `reason` is required so +// the audit trail captures intent — "decommissioned 2026", "duplicate of +// A3", etc. min(5) blocks one-letter throwaways. +export const archiveBerthSchema = z.object({ + reason: z.string().trim().min(5, 'Reason must be at least 5 characters'), +}); + +export type ArchiveBerthInput = z.infer; + // ─── List Berths ────────────────────────────────────────────────────────────── export const listBerthsSchema = baseListQuerySchema.extend({