fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20 from 2026-05-15 sweep

Knocks out 10 of the 13 known issues from yesterday's Playwright audit.

A4 — Client form silently rejected submit when a contact row had an
empty value. The F19 filter ran in mutationFn after zod's
handleSubmit had already short-circuited on min(1). Now wraps the
onSubmit to prune empty rows BEFORE handleSubmit/zod sees them.

A16 — File upload to documents hub root 400'd because FormData.get
returns null for absent fields and zod's .optional() rejects null.
Route handler now coerces null/empty → undefined before parse.

A17 — Added /api/v1/me/ports endpoint that any authenticated user
can hit; client.ts now uses it as the bootstrap port-slug→port-id
resolver. Eliminates the wasteful 400s sales-reps and viewers were
firing on every page load against the super-admin-gated /admin/ports.

A1 — Filter permission_denied actions from the dashboard activity
feed. Still in the audit log; just not noise on the dashboard.

A2 — New LEGACY_STAGE_REMAP table + canonicalizeStage / stageLabelFor
helpers in lib/constants. Activity-feed maps legacy 9-stage enum
values (deposit_10pct, contract_sent, etc.) to their 7-stage labels
on the way out, so historical audit rows read as "Deposit Paid" not
"Deposit 10Pct".

A19 — Same-stage write now returns 204 No Content. Service returns
a STAGE_NOOP sentinel; the route handler translates it.

A9 — Catch-up wizard now derives stage from berth status (under_offer
→ EOI, sold → contract) with a stageOverride state for explicit
user picks. Avoids the set-state-in-effect rule violation.

A20 — OwnerPicker shows a "Client / Company" hint chip on the
trigger when no value is set, so users know the trigger opens a
two-tab picker instead of just a client list.

A8 — Migration 0066 normalizes legacy `statusOverrideMode = 'auto'`
to NULL so the column lives at strictly 3 states.

A6 — file-preview-dialog gets a screen-reader DialogDescription so
the Radix "Missing aria-describedby" warning stops firing on every
preview.

A18 closed as not-a-bug: /api/v1/users genuinely doesn't exist
(Next returns 404); /api/v1/admin/audit exists and 403s.

A5 (Socket.IO dev noise) + A3 (react-grab CSP) left for a separate
pass — both are dev-only cosmetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 01:12:20 +02:00
parent 3b3ac287e0
commit 0d9208a052
13 changed files with 212 additions and 35 deletions

View File

@@ -21,7 +21,10 @@ async function resolvePortIdFromSlug(slug: string): Promise<string | null> {
if (!inFlightPortsLookup) {
inFlightPortsLookup = (async () => {
try {
const res = await fetch('/api/v1/admin/ports', { credentials: 'include' });
// A17: use /me/ports — works for every authenticated user.
// The prior code hit /admin/ports which is super-admin-gated, so
// sales-reps/viewers fired a wasteful 400 on every page load.
const res = await fetch('/api/v1/me/ports', { credentials: 'include' });
if (!res.ok) return null;
const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> };
return body.data ?? null;

View File

@@ -38,6 +38,54 @@ export const STAGE_LABELS: Record<PipelineStage, string> = {
contract: 'Contract',
};
/**
* Map legacy 9-stage enum values to their 7-stage equivalents. Audit logs
* and any pre-migration data still carry the legacy values; this lets the
* activity feed, audit diffs, and reporting render the modern label
* without having to back-fill the underlying rows.
*
* Mirrors the migration applied in `seed-synthetic-data.ts` (and
* documented in the 9→7 pipeline refactor):
* details_sent → enquiry
* in_communication → qualified
* eoi_sent, eoi_signed → eoi (doc-status carries sent/signed sub-state)
* deposit_10pct → deposit_paid
* contract_sent, contract_signed → contract
* completed → contract (with outcome=won)
* open → enquiry (legacy alias for the initial stage)
*/
export const LEGACY_STAGE_REMAP: Record<string, PipelineStage> = {
open: 'enquiry',
details_sent: 'enquiry',
in_communication: 'qualified',
eoi_sent: 'eoi',
eoi_signed: 'eoi',
deposit_10pct: 'deposit_paid',
contract_sent: 'contract',
contract_signed: 'contract',
completed: 'contract',
};
/**
* Resolve any stage-like string to a canonical 7-stage value. Returns
* the modern stage as-is, maps legacy values via LEGACY_STAGE_REMAP,
* and falls back to 'enquiry' for genuinely unknown values.
*/
export function canonicalizeStage(value: string | null | undefined): PipelineStage {
if (!value) return 'enquiry';
if (PIPELINE_STAGES.includes(value as PipelineStage)) return value as PipelineStage;
return LEGACY_STAGE_REMAP[value] ?? 'enquiry';
}
/**
* Human-friendly label for any stage-like string — modern or legacy. Use
* this in any read surface (activity feed, audit diff, notification copy,
* reports) that might be handed pre-migration data.
*/
export function stageLabelFor(value: string | null | undefined): string {
return STAGE_LABELS[canonicalizeStage(value)];
}
// Compact labels for cramped contexts (mobile chart axes, dense tables).
export const STAGE_SHORT_LABELS: Record<PipelineStage, string> = {
enquiry: 'Enquiry',

View File

@@ -0,0 +1,11 @@
-- A8: normalize legacy `statusOverrideMode = 'auto'` values to NULL.
--
-- The NocoDB importer historically wrote 'auto' to indicate "no override
-- in effect" for legacy data. The post-refactor code uses NULL for that
-- sentinel and 'manual' / 'automated' for the new states. Mixed values
-- pollute the reconcile-queue predicate and the Manual chip — neither
-- path treats 'auto' specially today, but normalizing closes the gap
-- once and for all and keeps the column to a 3-state enum.
UPDATE berths
SET status_override_mode = NULL
WHERE status_override_mode = 'auto';

View File

@@ -818,6 +818,13 @@ export async function updateInterest(
// ─── Change Stage ─────────────────────────────────────────────────────────────
/**
* Sentinel returned by changeInterestStage when the requested target
* matches the current stage (no audit row, no socket emit, no DB update).
* The route handler translates this to a 204 No Content response.
*/
export const STAGE_NOOP = Symbol('stage-noop');
export async function changeInterestStage(
id: string,
portId: string,
@@ -832,12 +839,13 @@ export async function changeInterestStage(
throw new NotFoundError('Interest');
}
// F27: same-stage write is a no-op. Return the existing row without
// bumping updatedAt or emitting an audit log entry — pre-fix every
// re-submit (e.g. accidental double-click) wrote a "Same → Same"
// audit entry and triggered downstream invalidations.
// F27 / A19: same-stage write is a no-op. The service signals this via
// the sentinel `STAGE_NOOP` so the route handler can return 204 No
// Content instead of 200 + full body. Pre-fix every re-submit (e.g.
// accidental double-click) wrote a "Same → Same" audit entry and
// triggered downstream invalidations.
if (existing.pipelineStage === data.pipelineStage) {
return existing;
return STAGE_NOOP;
}
// Plan: yachtId required to leave the initial enquiry stage