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({