chore(cleanup): Phase 1 — gap closure across audit, alerts, soft-delete, perms
Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.
PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
and company.{incorporationCountryIso, incorporationSubdivisionIso}.
Server-side parsePhone() fallback for legacy raw phone strings.
PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
'audit.suspicious_login') were registered but evaluators returned [].
Both required schema/instrumentation that hadn't landed. Removed from
the registry; comments record the dependencies needed to revive them.
Effective rule count: 8 active.
PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
integration test files; webhook-delivery uses vi.hoisted for the
queue-add ref. Vitest no longer warns about non-top-level mocks.
Deflaked the 'short value' assertion in security-encryption.test.ts
by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.
PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
now filter by isNull(archivedAt). Berths use status (no archivedAt).
PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
walks every src/app/api/v1/**/route.ts and reports handlers without a
withPermission() wrapper. Initial run found 33 violations.
- Allow-listed 17 with explicit reasons (self-data, admin, alerts,
search, currency, ai, custom-fields — some marked TODO).
- Wrapped 7 routes with concrete permissions: clients/options
(clients:view), berths/options (berths:view), dashboard/*
(reports:view_dashboard), analytics (reports:view_analytics).
Audit report at docs/runbooks/permission-audit.md. Script exits
non-zero on any unallow-listed violation so it can become a CI gate.
Vitest: 741 -> 741 (no new tests; existing suite covers the changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, RateLimitError } from '@/lib/errors';
|
||||
import { publicInterestSchema } from '@/lib/validators/interests';
|
||||
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
|
||||
import { parsePhone } from '@/lib/i18n/phone';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
// ─── Simple in-memory rate limiter ───────────────────────────────────────────
|
||||
// Max 5 requests per hour per IP
|
||||
@@ -61,6 +63,16 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Server-side phone normalization for older website builds that post raw
|
||||
// international/national strings. Newer builds may pre-fill phoneE164/Country.
|
||||
let phoneE164 = data.phoneE164 ?? null;
|
||||
let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null;
|
||||
if (!phoneE164) {
|
||||
const parsed = parsePhone(data.phone, phoneCountry ?? undefined);
|
||||
phoneE164 = parsed.e164;
|
||||
phoneCountry = parsed.country ?? phoneCountry;
|
||||
}
|
||||
|
||||
const fullName =
|
||||
data.firstName && data.lastName
|
||||
? `${data.firstName} ${data.lastName}`
|
||||
@@ -96,17 +108,21 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
if (existingClient && existingClient.portId === portId) {
|
||||
clientId = existingClient.id;
|
||||
const updates: Partial<typeof clients.$inferInsert> = {};
|
||||
if (data.preferredContactMethod) {
|
||||
await tx
|
||||
.update(clients)
|
||||
.set({ preferredContactMethod: data.preferredContactMethod })
|
||||
.where(eq(clients.id, clientId));
|
||||
updates.preferredContactMethod = data.preferredContactMethod;
|
||||
}
|
||||
if (data.nationalityIso && !existingClient.nationalityIso) {
|
||||
updates.nationalityIso = data.nationalityIso;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await tx.update(clients).set(updates).where(eq(clients.id, clientId));
|
||||
}
|
||||
} else {
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
|
||||
}
|
||||
} else {
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
|
||||
}
|
||||
|
||||
// 2. Optional: upsert company + add membership
|
||||
@@ -129,6 +145,8 @@ export async function POST(req: NextRequest) {
|
||||
legalName: data.company.legalName ?? null,
|
||||
taxId: data.company.taxId ?? null,
|
||||
incorporationCountry: data.company.incorporationCountry ?? null,
|
||||
incorporationCountryIso: data.company.incorporationCountryIso ?? null,
|
||||
incorporationSubdivisionIso: data.company.incorporationSubdivisionIso ?? null,
|
||||
status: 'active',
|
||||
})
|
||||
.returning();
|
||||
@@ -199,8 +217,10 @@ export async function POST(req: NextRequest) {
|
||||
streetAddress: data.address.street ?? null,
|
||||
city: data.address.city ?? null,
|
||||
stateProvince: data.address.stateProvince ?? null,
|
||||
subdivisionIso: data.address.subdivisionIso ?? null,
|
||||
postalCode: data.address.postalCode ?? null,
|
||||
country: data.address.country ?? null,
|
||||
countryIso: data.address.countryIso ?? null,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
@@ -279,7 +299,9 @@ async function createClientInTx(
|
||||
tx: Tx,
|
||||
portId: string,
|
||||
fullName: string,
|
||||
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod'>,
|
||||
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod' | 'nationalityIso'>,
|
||||
phoneE164: string | null,
|
||||
phoneCountry: CountryCode | null,
|
||||
): Promise<string> {
|
||||
const [newClient] = await tx
|
||||
.insert(clients)
|
||||
@@ -287,6 +309,7 @@ async function createClientInTx(
|
||||
portId,
|
||||
fullName,
|
||||
preferredContactMethod: data.preferredContactMethod,
|
||||
nationalityIso: data.nationalityIso ?? null,
|
||||
source: 'website',
|
||||
})
|
||||
.returning();
|
||||
@@ -303,6 +326,8 @@ async function createClientInTx(
|
||||
clientId,
|
||||
channel: 'phone',
|
||||
value: data.phone,
|
||||
valueE164: phoneE164,
|
||||
valueCountry: phoneCountry,
|
||||
isPrimary: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import {
|
||||
ALL_RANGES,
|
||||
getLeadSourceAttribution,
|
||||
@@ -18,18 +18,20 @@ const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<
|
||||
lead_source_attribution: getLeadSourceAttribution,
|
||||
};
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const metric = url.searchParams.get('metric') as MetricBase | null;
|
||||
const range = (url.searchParams.get('range') ?? '30d') as DateRange;
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const metric = url.searchParams.get('metric') as MetricBase | null;
|
||||
const range = (url.searchParams.get('range') ?? '30d') as DateRange;
|
||||
|
||||
if (!metric || !(metric in METRICS)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
|
||||
}
|
||||
if (!ALL_RANGES.includes(range)) {
|
||||
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
|
||||
}
|
||||
if (!metric || !(metric in METRICS)) {
|
||||
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
|
||||
}
|
||||
if (!ALL_RANGES.includes(range)) {
|
||||
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = await METRICS[metric](ctx.portId, range);
|
||||
return NextResponse.json({ metric, range, data });
|
||||
});
|
||||
const data = await METRICS[metric](ctx.portId, range);
|
||||
return NextResponse.json({ metric, range, data });
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getBerthOptions } from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// GET /api/v1/berths/options — lightweight list for selects/comboboxes
|
||||
export const GET = withAuth(async (req, ctx) => {
|
||||
try {
|
||||
const options = await getBerthOptions(ctx.portId);
|
||||
return NextResponse.json({ data: options });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
export const GET = withAuth(
|
||||
withPermission('berths', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const options = await getBerthOptions(ctx.portId);
|
||||
return NextResponse.json({ data: options });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listClientOptions } from '@/lib/services/clients.service';
|
||||
|
||||
export const GET = withAuth(async (req, ctx) => {
|
||||
try {
|
||||
const search = req.nextUrl.searchParams.get('search') ?? undefined;
|
||||
const data = await listClientOptions(ctx.portId, search);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const search = req.nextUrl.searchParams.get('search') ?? undefined;
|
||||
const data = await listClientOptions(ctx.portId, search);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getRecentActivity } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
const result = await getRecentActivity(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getRecentActivity(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getRevenueForecast } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
const result = await getRevenueForecast(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getRevenueForecast(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getKpis } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
const result = await getKpis(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getKpis(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { getPipelineCounts } from '@/lib/services/dashboard.service';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
const result = await getPipelineCounts(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
export const GET = withAuth(
|
||||
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
|
||||
const result = await getPipelineCounts(ctx.portId);
|
||||
return NextResponse.json(result);
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user