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:
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
|
||||
23
src/app/api/v1/berths/[id]/restore/route.ts
Normal file
23
src/app/api/v1/berths/[id]/restore/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user