fix(P1): soft-archive berths instead of hard-delete — F5

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:49:43 +02:00
parent 2d0a49e0d1
commit 025648c40b
6 changed files with 152 additions and 18 deletions

View File

@@ -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);
}

View File

@@ -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<number>`count(*)::int` })
.from(berths)
.where(eq(berths.portId, portId))
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)))
.groupBy(berths.status);
const counts: Record<string, number> = {};

View File

@@ -91,6 +91,17 @@ export const updateBerthStatusSchema = z.object({
export type UpdateBerthStatusInput = z.infer<typeof updateBerthStatusSchema>;
// ─── 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<typeof archiveBerthSchema>;
// ─── List Berths ──────────────────────────────────────────────────────────────
export const listBerthsSchema = baseListQuerySchema.extend({