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:
@@ -17,18 +17,24 @@ export const POST = withAuth(
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const folderIdRaw = formData.get('folderId') as string | undefined;
|
||||
// A16: FormData.get returns null when the field is absent, not
|
||||
// undefined. Zod's .optional() accepts undefined but rejects null,
|
||||
// so the previous `as string | undefined` cast lied and uploads at
|
||||
// the hub root (no entity selected) 400'd. Coerce absent / empty
|
||||
// values to undefined before parse.
|
||||
const formStr = (key: string): string | undefined => {
|
||||
const v = formData.get(key);
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
||||
};
|
||||
const metadata = uploadFileSchema.parse({
|
||||
filename: (formData.get('filename') as string | null) ?? file.name,
|
||||
clientId: formData.get('clientId') as string | undefined,
|
||||
yachtId: formData.get('yachtId') as string | undefined,
|
||||
companyId: formData.get('companyId') as string | undefined,
|
||||
category: formData.get('category') as string | undefined,
|
||||
entityType: formData.get('entityType') as string | undefined,
|
||||
entityId: formData.get('entityId') as string | undefined,
|
||||
// Hub uploads pass the current folderId so the file lands inside
|
||||
// the user's currently-selected folder. Empty string ⇒ root (null).
|
||||
folderId: folderIdRaw && folderIdRaw.length > 0 ? folderIdRaw : undefined,
|
||||
filename: formStr('filename') ?? file.name,
|
||||
clientId: formStr('clientId'),
|
||||
yachtId: formStr('yachtId'),
|
||||
companyId: formStr('companyId'),
|
||||
category: formStr('category'),
|
||||
entityType: formStr('entityType'),
|
||||
entityId: formStr('entityId'),
|
||||
folderId: formStr('folderId'),
|
||||
});
|
||||
|
||||
const result = await uploadFile(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
import { changeInterestStage } from '@/lib/services/interests.service';
|
||||
import { changeInterestStage, STAGE_NOOP } from '@/lib/services/interests.service';
|
||||
import { changeStageSchema } from '@/lib/validators/interests';
|
||||
|
||||
export const PATCH = withAuth(
|
||||
@@ -26,6 +26,10 @@ export const PATCH = withAuth(
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
// A19 / F27: same-stage write returns the sentinel — emit 204.
|
||||
if (interest === STAGE_NOOP) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
return NextResponse.json({ data: interest });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
|
||||
38
src/app/api/v1/me/ports/route.ts
Normal file
38
src/app/api/v1/me/ports/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports as portsTable } from '@/lib/db/schema/ports';
|
||||
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// A17: bootstrap-friendly ports list for the calling user — sales-reps
|
||||
// and viewers can hit this without the super-admin gate that blocks
|
||||
// `/api/v1/admin/ports`. Returns only the ports the user actually has
|
||||
// access to (super-admin sees every active port).
|
||||
export const GET = withAuth(async (_req, ctx) => {
|
||||
try {
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, ctx.userId),
|
||||
});
|
||||
|
||||
if (profile?.isSuperAdmin) {
|
||||
const all = await db.query.ports.findMany({
|
||||
where: eq(portsTable.isActive, true),
|
||||
orderBy: portsTable.name,
|
||||
columns: { id: true, slug: true, name: true },
|
||||
});
|
||||
return NextResponse.json({ data: all });
|
||||
}
|
||||
|
||||
const memberships = await db.query.userPortRoles.findMany({
|
||||
where: eq(userPortRoles.userId, ctx.userId),
|
||||
with: { port: { columns: { id: true, slug: true, name: true } } },
|
||||
});
|
||||
const data = memberships.map((m) => m.port);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user