fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners

Two final waves of error-surface hygiene closing the audit's MED §12 +
HIGH §15 + HIGH §17 findings:

* 50 route files swept (61 sites): manual NextResponse.json({error,
  status: 4xx|5xx}) early-returns replaced by typed throws +
  errorResponse(err) at the catch.
  - Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action)
    helper from src/lib/api/helpers.ts so denials hit the audit log.
  - Path-param + body validation 400s become ValidationError throws.
  - 404s become NotFoundError or CodedError('NOT_FOUND') for AI
    feature-flag paths.
  - 11 manual 5xx returns now re-throw so error_events captures the
    request-id (the admin error inspector becomes usable from real
    incidents).
  - website-analytics 200-with-error anti-pattern flipped to 409 +
    UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR.
  - 11 sites intentionally preserved: storage/[token] anti-enumeration
    token-failure paths, webhook-secret 401, "Unknown port" 400 in
    public intake.

* 7 admin forms (roles, users, ports, webhooks, custom-fields,
  document-templates, tags) gain a formatErrorBanner() helper from
  src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID"
  banner — the rep can copy the request id when reporting a failed
  save.  Banners get whitespace-pre-line so newlines render.

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1)
+ HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 20:36:59 +02:00
parent fc7595faf8
commit d3a6a9beef
58 changed files with 529 additions and 558 deletions

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
@@ -14,9 +14,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
*/
export const POST = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
}
requireSuperAdmin(ctx, 'admin.alerts.run-engine');
const summary = await runAlertEngineForPorts([ctx.portId]);
return NextResponse.json({ data: summary });
} catch (error) {

View File

@@ -1,14 +1,12 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getActiveConnections } from '@/lib/services/system-monitoring.service';
export const GET = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
requireSuperAdmin(ctx, 'admin.connections.read');
const connections = await getActiveConnections();
return NextResponse.json({ data: connections });

View File

@@ -4,7 +4,7 @@ import { and, eq, inArray } from 'drizzle-orm';
import type { AuthContext } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { clients, clientMergeCandidates } from '@/lib/db/schema/clients';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import {
listPendingMergeCandidates,
mergeClients,
@@ -81,7 +81,7 @@ export async function confirmMergeHandler(
fieldChoices?: MergeFieldChoices;
};
if (!body.winnerId) {
return NextResponse.json({ error: 'winnerId required' }, { status: 400 });
throw new ValidationError('winnerId is required');
}
const [candidate] = await db
@@ -103,10 +103,7 @@ export async function confirmMergeHandler(
? candidate.clientAId
: null;
if (!loserId) {
return NextResponse.json(
{ error: 'winnerId must match one of the candidate clients' },
{ status: 400 },
);
throw new ValidationError('winnerId must match one of the candidate clients');
}
const result = await mergeClients({

View File

@@ -1,14 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getRecentErrors } from '@/lib/services/system-monitoring.service';
export const GET = withAuth(async (req: NextRequest, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
requireSuperAdmin(ctx, 'admin.errors.list');
const url = new URL(req.url);
const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10)));

View File

@@ -1,14 +1,12 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { healthCheck } from '@/lib/services/system-monitoring.service';
export const GET = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
requireSuperAdmin(ctx, 'admin.health.read');
const status = await healthCheck();
return NextResponse.json({ data: status });

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { resendCrmInvite } from '@/lib/services/crm-invite.service';
// Resend mints a fresh token + new email on a global invite row;
@@ -10,9 +10,7 @@ import { resendCrmInvite } from '@/lib/services/crm-invite.service';
export const POST = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Resending CRM invites requires super-admin');
}
requireSuperAdmin(ctx, 'admin.invitations.resend');
const id = params.id ?? '';
const result = await resendCrmInvite(id, {
userId: ctx.userId,

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
// Invites are a global resource (no portId column). Revoking a foreign
@@ -10,9 +10,7 @@ import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
export const DELETE = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Revoking CRM invites requires super-admin');
}
requireSuperAdmin(ctx, 'admin.invitations.revoke');
const id = params.id ?? '';
await revokeCrmInvite(id, {
userId: ctx.userId,

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service';
@@ -14,9 +14,7 @@ export const GET = withAuth(
// port. Listing it cross-tenant would let a port-A director
// enumerate pending invitee emails, names, and isSuperAdmin flags
// for every other tenant. Restrict the listing to super-admins.
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Listing CRM invites requires super-admin');
}
requireSuperAdmin(ctx, 'admin.invitations.list');
const data = await listCrmInvites();
return NextResponse.json({ data });
} catch (error) {

View File

@@ -1,9 +1,9 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import { getPublicOcrConfig, saveOcrConfig, OCR_MODELS } from '@/lib/services/ocr-config.service';
const saveSchema = z.object({
@@ -27,8 +27,8 @@ export const GET = withAuth(
try {
const url = new URL(req.url);
const scope = url.searchParams.get('scope') ?? 'port';
if (scope === 'global' && !ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
if (scope === 'global') {
requireSuperAdmin(ctx, 'admin.ocr-settings.read.global');
}
const config = await getPublicOcrConfig(scope === 'global' ? null : ctx.portId);
return NextResponse.json({ data: config, models: OCR_MODELS });
@@ -42,15 +42,12 @@ export const PUT = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, saveSchema);
if (body.scope === 'global' && !ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
if (body.scope === 'global') {
requireSuperAdmin(ctx, 'admin.ocr-settings.write.global');
}
const validModels = OCR_MODELS[body.provider];
if (!validModels.includes(body.model)) {
return NextResponse.json(
{ error: `Invalid model for provider ${body.provider}` },
{ status: 400 },
);
throw new ValidationError(`Invalid model for provider ${body.provider}`);
}
await saveOcrConfig(
body.scope === 'global' ? null : ctx.portId,

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import { OCR_MODELS } from '@/lib/services/ocr-config.service';
import { testProvider } from '@/lib/services/ocr-providers';
@@ -20,7 +20,7 @@ export const POST = withAuth(
try {
const body = await parseBody(req, schema);
if (!OCR_MODELS[body.provider].includes(body.model)) {
return NextResponse.json({ error: 'Invalid model' }, { status: 400 });
throw new ValidationError('Invalid model');
}
const result = await testProvider(body.provider, body.apiKey, body.model);
return NextResponse.json(result);

View File

@@ -1,10 +1,10 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { listPorts, createPort } from '@/lib/services/ports.service';
import { createPortSchema } from '@/lib/validators/ports';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { errorResponse } from '@/lib/errors';
// Listing every tenant and creating new tenants are super-admin operations:
// a port director must not be able to enumerate other ports (target
@@ -13,9 +13,7 @@ import { errorResponse, ForbiddenError } from '@/lib/errors';
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Listing all ports requires super-admin');
}
requireSuperAdmin(ctx, 'admin.ports.list');
const data = await listPorts();
return NextResponse.json({ data });
} catch (error) {
@@ -27,9 +25,7 @@ export const GET = withAuth(
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Creating ports requires super-admin');
}
requireSuperAdmin(ctx, 'admin.ports.create');
const body = await parseBody(req, createPortSchema);
const data = await createPort(body, {
userId: ctx.userId,

View File

@@ -1,24 +1,22 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { retryJob } from '@/lib/services/system-monitoring.service';
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
export const POST = withAuth(async (_req, ctx, params) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
requireSuperAdmin(ctx, 'admin.queues.job.retry');
const queueName = params['queueName'];
const jobId = params['jobId'];
if (!queueName || !jobId) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
throw new ValidationError('queueName and jobId are required');
}
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
if (!validQueues.includes(queueName as QueueName)) {
return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 });
throw new ValidationError('Invalid queue name');
}
await retryJob(queueName as QueueName, jobId, ctx.userId);

View File

@@ -1,24 +1,22 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { deleteJob } from '@/lib/services/system-monitoring.service';
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
export const DELETE = withAuth(async (_req, ctx, params) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
requireSuperAdmin(ctx, 'admin.queues.job.delete');
const queueName = params['queueName'];
const jobId = params['jobId'];
if (!queueName || !jobId) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
throw new ValidationError('queueName and jobId are required');
}
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
if (!validQueues.includes(queueName as QueueName)) {
return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 });
throw new ValidationError('Invalid queue name');
}
await deleteJob(queueName as QueueName, jobId, ctx.userId);

View File

@@ -1,20 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { getQueueJobs } from '@/lib/services/system-monitoring.service';
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
export const GET = withAuth(async (req: NextRequest, ctx, params) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
requireSuperAdmin(ctx, 'admin.queues.jobs.list');
const { queueName } = params;
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
if (!validQueues.includes(queueName as QueueName)) {
return NextResponse.json({ error: 'Invalid queue name' }, { status: 400 });
throw new ValidationError('Invalid queue name');
}
const url = new URL(req.url);

View File

@@ -1,14 +1,12 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getQueueDashboard } from '@/lib/services/system-monitoring.service';
export const GET = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
requireSuperAdmin(ctx, 'admin.queues.list');
const queues = await getQueueDashboard();
return NextResponse.json({ data: queues });

View File

@@ -10,9 +10,9 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import { runMigration } from '@/lib/storage/migrate';
const schema = z.object({
@@ -25,12 +25,10 @@ export const runtime = 'nodejs';
export const POST = withAuth(async (req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Super admin only');
}
requireSuperAdmin(ctx, 'admin.storage.migrate');
const body = await parseBody(req, schema);
if (body.from === body.to) {
return NextResponse.json({ error: 'from and to must differ' }, { status: 400 });
throw new ValidationError('from and to must differ');
}
const result = await runMigration({ ...body, userId: ctx.userId });
return NextResponse.json({ data: result });

View File

@@ -7,8 +7,8 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { TABLES_WITH_STORAGE_KEYS } from '@/lib/storage/migrate';
import { getStorageBackend } from '@/lib/storage';
import { S3Backend } from '@/lib/storage/s3';
@@ -19,9 +19,7 @@ export const runtime = 'nodejs';
export const GET = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Super admin only');
}
requireSuperAdmin(ctx, 'admin.storage.read');
const backend = await getStorageBackend();
// Aggregate row count + total bytes across every storage-bearing table.
@@ -54,9 +52,7 @@ export const GET = withAuth(async (_req, ctx) => {
export const POST = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
throw new ForbiddenError('Super admin only');
}
requireSuperAdmin(ctx, 'admin.storage.test');
const backend = await getStorageBackend();
if (!(backend instanceof S3Backend)) {
return NextResponse.json(

View File

@@ -2,14 +2,12 @@ import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { getEmailDraftResult } from '@/lib/services/email-draft.service';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
export const GET = withAuth(async (_req, ctx, params) => {
try {
const { jobId } = params;
if (!jobId) {
return NextResponse.json({ error: 'jobId is required' }, { status: 400 });
}
if (!jobId) throw new ValidationError('jobId is required');
const result = await getEmailDraftResult(jobId, {
userId: ctx.userId,

View File

@@ -7,19 +7,18 @@ import { systemSettings } from '@/lib/db/schema/system';
import { requestEmailDraft } from '@/lib/services/email-draft.service';
import { parseBody } from '@/lib/api/route-helpers';
import { requestDraftSchema } from '@/lib/validators/ai';
import { errorResponse } from '@/lib/errors';
import { CodedError, errorResponse } from '@/lib/errors';
export const POST = withAuth(async (req, ctx) => {
try {
// Feature flag check
const flag = await db.query.systemSettings.findFirst({
where: and(
eq(systemSettings.key, 'ai_email_drafts'),
eq(systemSettings.portId, ctx.portId),
),
where: and(eq(systemSettings.key, 'ai_email_drafts'), eq(systemSettings.portId, ctx.portId)),
});
if (flag?.value !== true) {
return NextResponse.json({ error: 'Feature not available' }, { status: 404 });
throw new CodedError('NOT_FOUND', {
internalMessage: 'AI email-draft feature flag disabled for this port',
});
}
const body = await parseBody(req, requestDraftSchema);

View File

@@ -5,7 +5,7 @@ import { withAuth } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { calculateBulkScores } from '@/lib/services/interest-scoring.service';
import { errorResponse } from '@/lib/errors';
import { CodedError, errorResponse } from '@/lib/errors';
export const GET = withAuth(async (_req, ctx) => {
try {
@@ -17,7 +17,9 @@ export const GET = withAuth(async (_req, ctx) => {
),
});
if (flag?.value !== true) {
return NextResponse.json({ error: 'Feature not available' }, { status: 404 });
throw new CodedError('NOT_FOUND', {
internalMessage: 'AI bulk interest-score feature flag disabled for this port',
});
}
const scores = await calculateBulkScores(ctx.portId);

View File

@@ -7,7 +7,7 @@ import { systemSettings } from '@/lib/db/schema/system';
import { calculateInterestScore } from '@/lib/services/interest-scoring.service';
import { parseQuery } from '@/lib/api/route-helpers';
import { requestScoreSchema } from '@/lib/validators/ai';
import { errorResponse } from '@/lib/errors';
import { CodedError, errorResponse } from '@/lib/errors';
export const GET = withAuth(async (req, ctx) => {
try {
@@ -19,7 +19,9 @@ export const GET = withAuth(async (req, ctx) => {
),
});
if (flag?.value !== true) {
return NextResponse.json({ error: 'Feature not available' }, { status: 404 });
throw new CodedError('NOT_FOUND', {
internalMessage: 'AI interest-score feature flag disabled for this port',
});
}
const { interestId } = parseQuery(req, requestScoreSchema);

View File

@@ -1,11 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { acknowledgeAlert } from '@/lib/services/alerts.service';
export const POST = withAuth(async (_req, ctx, params) => {
const id = params.id;
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
await acknowledgeAlert(id, ctx.portId, ctx.userId);
return NextResponse.json({ ok: true });
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
await acknowledgeAlert(id, ctx.portId, ctx.userId);
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -1,11 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { dismissAlert } from '@/lib/services/alerts.service';
export const POST = withAuth(async (_req, ctx, params) => {
const id = params.id;
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
await dismissAlert(id, ctx.portId, ctx.userId);
return NextResponse.json({ ok: true });
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
await dismissAlert(id, ctx.portId, ctx.userId);
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import {
ALL_RANGES,
getLeadSourceAttribution,
@@ -23,68 +24,63 @@ const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
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 rawRange = url.searchParams.get('range') ?? '30d';
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
try {
const url = new URL(req.url);
const metric = url.searchParams.get('metric') as MetricBase | null;
const rawRange = url.searchParams.get('range') ?? '30d';
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
if (!metric || !(metric in METRICS)) {
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
}
if (!metric || !(metric in METRICS)) {
throw new ValidationError('Invalid or missing metric');
}
let range: DateRange;
if (rawRange === 'custom') {
if (!fromParam || !toParam) {
return NextResponse.json(
{ error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' },
{ status: 400 },
);
}
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
return NextResponse.json(
{ error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' },
{ status: 400 },
);
}
if (fromParam > toParam) {
return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 });
}
// Round-trip date check: regex passes "9999-13-99" or "2026-02-31"
// (rolls over silently when handed to `new Date`). Re-serialize and
// confirm it matches the input to catch invalid calendar values.
for (const [label, raw] of [
['from', fromParam],
['to', toParam],
] as const) {
const d = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
return NextResponse.json(
{ error: `\`${label}\` is not a valid calendar date` },
{ status: 400 },
);
let range: DateRange;
if (rawRange === 'custom') {
if (!fromParam || !toParam) {
throw new ValidationError('Custom range requires `from` and `to` (YYYY-MM-DD)');
}
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
throw new ValidationError('`from`/`to` must be ISO date strings (YYYY-MM-DD)');
}
if (fromParam > toParam) {
throw new ValidationError('`from` must be on or before `to`');
}
// Round-trip date check: regex passes "9999-13-99" or "2026-02-31"
// (rolls over silently when handed to `new Date`). Re-serialize and
// confirm it matches the input to catch invalid calendar values.
for (const [label, raw] of [
['from', fromParam],
['to', toParam],
] as const) {
const d = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
throw new ValidationError(`\`${label}\` is not a valid calendar date`);
}
}
// Backstop against the occupancy-timeline N+1 query loop. Each day
// in the range issues its own DB query, so a multi-year custom
// range would saturate the connection pool. 365 days is a generous
// ceiling for analytical queries; if a longer span is needed, the
// service should be restructured to use `generate_series` instead
// of a JS loop.
const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime();
const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime();
if ((toMs - fromMs) / 86_400_000 > 365) {
throw new ValidationError('Custom range cannot exceed 365 days');
}
range = { kind: 'custom', from: fromParam, to: toParam };
} else {
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
throw new ValidationError('Invalid range');
}
range = rawRange as PresetDateRange;
}
// Backstop against the occupancy-timeline N+1 query loop. Each day
// in the range issues its own DB query, so a multi-year custom
// range would saturate the connection pool. 365 days is a generous
// ceiling for analytical queries; if a longer span is needed, the
// service should be restructured to use `generate_series` instead
// of a JS loop.
const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime();
const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime();
if ((toMs - fromMs) / 86_400_000 > 365) {
return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 });
}
range = { kind: 'custom', from: fromParam, to: toParam };
} else {
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
}
range = rawRange as PresetDateRange;
}
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 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { createPortalUser, resendActivation } from '@/lib/services/portal-auth.service';
import { db } from '@/lib/db';
import { eq } from 'drizzle-orm';
@@ -36,9 +36,7 @@ export const POST = withAuth(
const existing = await db.query.portalUsers.findFirst({
where: eq(portalUsers.email, body.email.toLowerCase().trim()),
});
if (!existing) {
return NextResponse.json({ error: 'Portal user not found' }, { status: 404 });
}
if (!existing) throw new NotFoundError('portal user');
await resendActivation(existing.id, ctx.portId);
return NextResponse.json({ success: true });
}

View File

@@ -1,14 +1,12 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { refreshRates } from '@/lib/services/currency';
export const POST = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
requireSuperAdmin(ctx, 'currency.rates.refresh');
await refreshRates();
return NextResponse.json({ data: { success: true } });

View File

@@ -1,14 +1,14 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import { clearDuplicate } from '@/lib/services/expense-dedup.service';
export const POST = withAuth(
withPermission('expenses', 'edit', async (_req, ctx, params) => {
try {
const id = params.id;
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
if (!id) throw new ValidationError('id is required');
await clearDuplicate(id, ctx.portId);
return NextResponse.json({ ok: true });
} catch (error) {

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
const mergeSchema = z.object({
@@ -15,9 +15,7 @@ export const POST = withAuth(
withPermission('expenses', 'edit', async (req, ctx, params) => {
try {
const sourceId = params.id;
if (!sourceId) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
if (!sourceId) throw new ValidationError('id is required');
const body = await parseBody(req, mergeSchema);
await mergeDuplicate(sourceId, body.targetId, ctx.portId);
return NextResponse.json({ ok: true });

View File

@@ -1,15 +1,13 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { exportParentCompany } from '@/lib/services/expense-export';
import { listExpensesSchema } from '@/lib/validators/expenses';
export const POST = withAuth(async (req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
}
requireSuperAdmin(ctx, 'expenses.export.parent-company');
const body = await req.json().catch(() => ({}));
const query = listExpensesSchema.parse(body);

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
import {
@@ -29,9 +29,7 @@ export const POST = withAuth(
try {
const formData = await req.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (!file) throw new ValidationError('A file is required');
const buffer = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/jpeg';

View File

@@ -5,7 +5,7 @@ import { withAuth, type AuthContext } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { userProfiles } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { z } from 'zod';
const updateProfileSchema = z.object({
@@ -42,9 +42,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, ctx.userId),
});
if (!profile) {
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
}
if (!profile) throw new NotFoundError('profile');
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (body.displayName !== undefined) updates.displayName = body.displayName;

View File

@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { listReminders, createReminder } from '@/lib/services/reminders.service';
import { reminderListQuerySchema, createReminderSchema } from '@/lib/validators/reminders';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ForbiddenError } from '@/lib/errors';
export const GET = withAuth(
withPermission('reminders', 'view_own', async (req, ctx) => {
@@ -25,14 +25,8 @@ export const POST = withAuth(
// Check assign_others permission if assigning to someone else
if (body.assignedTo && body.assignedTo !== ctx.userId) {
if (!ctx.isSuperAdmin) {
const perms = ctx.permissions?.reminders;
if (!perms?.assign_others) {
return NextResponse.json(
{ error: 'Cannot assign reminders to other users' },
{ status: 403 },
);
}
if (!ctx.isSuperAdmin && !ctx.permissions?.reminders?.assign_others) {
throw new ForbiddenError('Cannot assign reminders to other users');
}
}

View File

@@ -5,33 +5,24 @@ import type { AuthContext } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { savedViews } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ForbiddenError, NotFoundError } from '@/lib/errors';
import { savedViewsService } from '@/lib/services/saved-views.service';
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
/**
* Resolves the view and enforces ownership before mutating.
*
* Returns a 404 when the view does not exist (or lives in a different port)
* and a 403 when it belongs to a different user. The 404-before-403 split
* matches the rest of the API and avoids leaking the existence of another
* user's saved view via timing or status code.
* Throws NotFoundError when the view does not exist (or lives in a different
* port) and ForbiddenError when it belongs to a different user. The 404-
* before-403 split matches the rest of the API and avoids leaking the
* existence of another user's saved view via timing or status code.
*/
async function assertViewOwner(
id: string,
portId: string,
userId: string,
): Promise<NextResponse | null> {
async function assertViewOwner(id: string, portId: string, userId: string): Promise<void> {
const view = await db.query.savedViews.findFirst({
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
});
if (!view) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (view.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return null;
if (!view) throw new NotFoundError('saved view');
if (view.userId !== userId) throw new ForbiddenError('That saved view belongs to someone else.');
}
export async function patchHandler(
@@ -41,8 +32,7 @@ export async function patchHandler(
): Promise<NextResponse> {
try {
const id = params.id ?? '';
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
if (denied) return denied;
await assertViewOwner(id, ctx.portId, ctx.userId);
const body = await parseBody(req as never, updateSavedViewSchema);
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
return NextResponse.json({ data: view });
@@ -58,8 +48,7 @@ export async function deleteHandler(
): Promise<NextResponse> {
try {
const id = params.id ?? '';
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
if (denied) return denied;
await assertViewOwner(id, ctx.portId, ctx.userId);
await savedViewsService.delete(ctx.portId, ctx.userId, id);
return NextResponse.json({ data: null }, { status: 200 });
} catch (error) {

View File

@@ -4,14 +4,12 @@ import { and, eq } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
export const GET = withAuth(async (req, ctx) => {
try {
const key = req.nextUrl.searchParams.get('key');
if (!key) {
return NextResponse.json({ error: 'key query parameter is required' }, { status: 400 });
}
if (!key) throw new ValidationError('key query parameter is required');
const setting = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, ctx.portId)),

View File

@@ -5,7 +5,7 @@ import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { userProfiles, type UserPreferences } from '@/lib/db/schema/users';
import { errorResponse } from '@/lib/errors';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { updateUserPreferencesSchema } from '@/lib/validators/user-preferences';
export const GET = withAuth(async (_req, ctx) => {
@@ -26,9 +26,7 @@ export const PATCH = withAuth(async (req, ctx) => {
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, ctx.userId),
});
if (!profile) {
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
}
if (!profile) throw new NotFoundError('profile');
const next: UserPreferences = {
...(profile.preferences ?? {}),

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range';
import { CodedError, errorResponse, ValidationError } from '@/lib/errors';
import {
getActiveVisitors,
getMetric,
@@ -68,46 +69,48 @@ function parseRange(req: NextRequest): DateRange | { error: string } {
export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const metric = url.searchParams.get('metric');
if (!metric) {
return NextResponse.json({ error: 'Missing metric' }, { status: 400 });
}
const rangeOrError = parseRange(req);
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
return NextResponse.json({ error: rangeOrError.error }, { status: 400 });
}
const range = rangeOrError as DateRange;
try {
let data: unknown;
const url = new URL(req.url);
const metric = url.searchParams.get('metric');
if (!metric) throw new ValidationError('Missing metric');
if (metric === 'stats') {
data = await getStats(ctx.portId, range);
} else if (metric === 'pageviews') {
data = await getPageviewsSeries(ctx.portId, range);
} else if (metric === 'active') {
data = await getActiveVisitors(ctx.portId);
} else if (TOP_METRIC_RX.test(metric)) {
const type = metric.replace(/^top-/, '') as UmamiMetricType;
const limit = Number(url.searchParams.get('limit') ?? 10);
data = await getMetric(ctx.portId, range, type, limit);
} else {
return NextResponse.json({ error: `Unknown metric: ${metric}` }, { status: 400 });
const rangeOrError = parseRange(req);
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
throw new ValidationError(rangeOrError.error);
}
const range = rangeOrError as DateRange;
let data: unknown;
try {
if (metric === 'stats') {
data = await getStats(ctx.portId, range);
} else if (metric === 'pageviews') {
data = await getPageviewsSeries(ctx.portId, range);
} else if (metric === 'active') {
data = await getActiveVisitors(ctx.portId);
} else if (TOP_METRIC_RX.test(metric)) {
const type = metric.replace(/^top-/, '') as UmamiMetricType;
const limit = Number(url.searchParams.get('limit') ?? 10);
data = await getMetric(ctx.portId, range, type, limit);
} else {
throw new ValidationError(`Unknown metric: ${metric}`);
}
} catch (err) {
if (err instanceof ValidationError) throw err;
// Upstream Umami failure - re-throw as a typed code so the user gets
// a friendly message and the request id is captured to error_events.
const internalMessage = err instanceof Error ? err.message : 'Unknown error';
throw new CodedError('UMAMI_UPSTREAM_ERROR', { internalMessage });
}
// `data === null` from the service means Umami isn't configured for
// this port - surface that explicitly so the UI can render a
// "configure your credentials" empty state instead of a chart.
if (data === null) {
return NextResponse.json({ error: 'umami_not_configured', metric, range }, { status: 200 });
}
if (data === null) throw new CodedError('UMAMI_NOT_CONFIGURED');
return NextResponse.json({ metric, range, data });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message, metric, range }, { status: 502 });
return errorResponse(err);
}
}),
);