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:
@@ -1,9 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { activateAccount } from '@/lib/services/portal-auth.service';
|
|
||||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
|
import { activateAccount } from '@/lib/services/portal-auth.service';
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
@@ -15,22 +15,19 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
|
try {
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
throw new ValidationError('Invalid request body');
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = bodySchema.safeParse(body);
|
const parsed = bodySchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
|
||||||
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await activateAccount(parsed.data.token, parsed.data.password);
|
await activateAccount(parsed.data.token, parsed.data.password);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { requestPasswordReset } from '@/lib/services/portal-auth.service';
|
import { requestPasswordReset } from '@/lib/services/portal-auth.service';
|
||||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
|
||||||
|
|
||||||
const bodySchema = z.object({ email: z.string().email() });
|
const bodySchema = z.object({ email: z.string().email() });
|
||||||
|
|
||||||
@@ -14,17 +15,16 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
const limited = await enforcePublicRateLimit(req, 'portalForgot');
|
const limited = await enforcePublicRateLimit(req, 'portalForgot');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
|
try {
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
throw new ValidationError('Invalid request body');
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = bodySchema.safeParse(body);
|
const parsed = bodySchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) throw new ValidationError('Invalid email address');
|
||||||
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always return 200 to prevent account-enumeration. Errors are logged
|
// Always return 200 to prevent account-enumeration. Errors are logged
|
||||||
// server-side, never surfaced to the client.
|
// server-side, never surfaced to the client.
|
||||||
@@ -34,4 +34,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
logger.error({ err }, 'Portal forgot-password failed (swallowed)');
|
logger.error({ err }, 'Portal forgot-password failed (swallowed)');
|
||||||
}
|
}
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
|
||||||
import { resetPassword } from '@/lib/services/portal-auth.service';
|
|
||||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
|
import { resetPassword } from '@/lib/services/portal-auth.service';
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
@@ -15,22 +15,19 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||||
if (limited) return limited;
|
if (limited) return limited;
|
||||||
|
|
||||||
|
try {
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
throw new ValidationError('Invalid request body');
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = bodySchema.safeParse(body);
|
const parsed = bodySchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
|
||||||
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await resetPassword(parsed.data.token, parsed.data.password);
|
await resetPassword(parsed.data.token, parsed.data.password);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import { PORTAL_COOKIE } from '@/lib/portal/auth';
|
import { PORTAL_COOKIE } from '@/lib/portal/auth';
|
||||||
import { signIn } from '@/lib/services/portal-auth.service';
|
import { signIn } from '@/lib/services/portal-auth.service';
|
||||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -18,12 +18,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
|||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 });
|
return errorResponse(new ValidationError('Email format is invalid'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = bodySchema.safeParse(body);
|
const parsed = bodySchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 });
|
return errorResponse(new ValidationError('Email format is invalid'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
|
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
import { withPortalAuth } from '@/lib/portal/helpers';
|
import { withPortalAuth } from '@/lib/portal/helpers';
|
||||||
import { getPortalDashboard } from '@/lib/services/portal.service';
|
import { getPortalDashboard } from '@/lib/services/portal.service';
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
|
|
||||||
export const GET = withPortalAuth(async (_req, session) => {
|
export const GET = withPortalAuth(async (_req, session) => {
|
||||||
try {
|
try {
|
||||||
const dashboard = await getPortalDashboard(session.clientId, session.portId);
|
const dashboard = await getPortalDashboard(session.clientId, session.portId);
|
||||||
|
if (!dashboard) throw new NotFoundError('client');
|
||||||
if (!dashboard) {
|
|
||||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ data: dashboard });
|
return NextResponse.json({ data: dashboard });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Portal dashboard fetch failed');
|
logger.error({ err: error }, 'Portal dashboard fetch failed');
|
||||||
return NextResponse.json({ error: 'Failed to load dashboard' }, { status: 500 });
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
import { withPortalAuth } from '@/lib/portal/helpers';
|
import { withPortalAuth } from '@/lib/portal/helpers';
|
||||||
import { getDocumentDownloadUrl } from '@/lib/services/portal.service';
|
import { getDocumentDownloadUrl } from '@/lib/services/portal.service';
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
|
|
||||||
export const GET = withPortalAuth(async (_req, session, params) => {
|
export const GET = withPortalAuth(async (_req, session, params) => {
|
||||||
try {
|
try {
|
||||||
const documentId = params.documentId;
|
const documentId = params.documentId;
|
||||||
|
if (!documentId) throw new ValidationError('documentId is required');
|
||||||
if (!documentId) {
|
|
||||||
return NextResponse.json({ error: 'Document ID required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await getDocumentDownloadUrl(session.clientId, documentId, session.portId);
|
const url = await getDocumentDownloadUrl(session.clientId, documentId, session.portId);
|
||||||
|
if (!url) throw new NotFoundError('document');
|
||||||
if (!url) {
|
|
||||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ url });
|
return NextResponse.json({ url });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Portal document download URL fetch failed');
|
logger.error({ err: error }, 'Portal document download URL fetch failed');
|
||||||
return NextResponse.json({ error: 'Failed to generate download URL' }, { status: 500 });
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
import { withPortalAuth } from '@/lib/portal/helpers';
|
import { withPortalAuth } from '@/lib/portal/helpers';
|
||||||
import { getClientDocuments } from '@/lib/services/portal.service';
|
import { getClientDocuments } from '@/lib/services/portal.service';
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
|
|
||||||
export const GET = withPortalAuth(async (_req, session) => {
|
export const GET = withPortalAuth(async (_req, session) => {
|
||||||
try {
|
try {
|
||||||
const data = await getClientDocuments(session.clientId, session.portId);
|
const data = await getClientDocuments(session.clientId, session.portId);
|
||||||
return NextResponse.json({ data });
|
return NextResponse.json({ data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Portal documents fetch failed');
|
logger.error({ err: error }, 'Portal documents fetch failed');
|
||||||
return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
import { withPortalAuth } from '@/lib/portal/helpers';
|
import { withPortalAuth } from '@/lib/portal/helpers';
|
||||||
import { getClientInterests } from '@/lib/services/portal.service';
|
import { getClientInterests } from '@/lib/services/portal.service';
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
|
|
||||||
export const GET = withPortalAuth(async (_req, session) => {
|
export const GET = withPortalAuth(async (_req, session) => {
|
||||||
try {
|
try {
|
||||||
const data = await getClientInterests(session.clientId, session.portId);
|
const data = await getClientInterests(session.clientId, session.portId);
|
||||||
return NextResponse.json({ data });
|
return NextResponse.json({ data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Portal interests fetch failed');
|
logger.error({ err: error }, 'Portal interests fetch failed');
|
||||||
return NextResponse.json({ error: 'Failed to load interests' }, { status: 500 });
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
import { withPortalAuth } from '@/lib/portal/helpers';
|
import { withPortalAuth } from '@/lib/portal/helpers';
|
||||||
import { getClientInvoices } from '@/lib/services/portal.service';
|
import { getClientInvoices } from '@/lib/services/portal.service';
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
|
|
||||||
export const GET = withPortalAuth(async (_req, session) => {
|
export const GET = withPortalAuth(async (_req, session) => {
|
||||||
try {
|
try {
|
||||||
const data = await getClientInvoices(session.clientId, session.portId);
|
const data = await getClientInvoices(session.clientId, session.portId);
|
||||||
return NextResponse.json({ data });
|
return NextResponse.json({ data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Portal invoices fetch failed');
|
logger.error({ err: error }, 'Portal invoices fetch failed');
|
||||||
return NextResponse.json({ error: 'Failed to load invoices' }, { status: 500 });
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { db } from '@/lib/db';
|
|||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
||||||
import { interestBerths, interests } from '@/lib/db/schema/interests';
|
import { interestBerths, interests } from '@/lib/db/schema/interests';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { toPublicBerth } from '@/lib/services/public-berths';
|
import { toPublicBerth } from '@/lib/services/public-berths';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export async function GET(
|
|||||||
request: Request,
|
request: Request,
|
||||||
ctx: { params: Promise<{ mooringNumber: string }> },
|
ctx: { params: Promise<{ mooringNumber: string }> },
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
const { mooringNumber } = await ctx.params;
|
const { mooringNumber } = await ctx.params;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG;
|
const requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG;
|
||||||
@@ -101,8 +103,11 @@ export async function GET(
|
|||||||
|
|
||||||
if (out.Status !== 'Available' && out.Status !== 'Under Offer' && out.Status !== 'Sold') {
|
if (out.Status !== 'Available' && out.Status !== 'Under Offer' && out.Status !== 'Sold') {
|
||||||
logger.error({ berthId: berth.id, status: out.Status }, 'Public berth status out of range');
|
logger.error({ berthId: berth.id, status: out.Status }, 'Public berth status out of range');
|
||||||
return NextResponse.json({ error: 'internal' }, { status: 500 });
|
throw new Error('Public berth status out of range');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify(out), { headers: RESPONSE_HEADERS, status: 200 });
|
return new Response(JSON.stringify(out), { headers: RESPONSE_HEADERS, status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
return errorResponse(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { db } from '@/lib/db';
|
|||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
import { berths, berthMapData } from '@/lib/db/schema/berths';
|
||||||
import { interestBerths, interests } from '@/lib/db/schema/interests';
|
import { interestBerths, interests } from '@/lib/db/schema/interests';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { toPublicBerth, type PublicBerth } from '@/lib/services/public-berths';
|
import { toPublicBerth, type PublicBerth } from '@/lib/services/public-berths';
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ interface ListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: Request): Promise<Response> {
|
export async function GET(request: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG;
|
const requestedSlug = url.searchParams.get('portSlug') ?? DEFAULT_PUBLIC_PORT_SLUG;
|
||||||
if (!PUBLIC_PORT_SLUGS.has(requestedSlug)) {
|
if (!PUBLIC_PORT_SLUGS.has(requestedSlug)) {
|
||||||
@@ -123,10 +125,7 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
{ berthId: row.Id, mooringNumber: row['Mooring Number'], status: row.Status },
|
{ berthId: row.Id, mooringNumber: row['Mooring Number'], status: row.Status },
|
||||||
'Public berth status out of range',
|
'Public berth status out of range',
|
||||||
);
|
);
|
||||||
return NextResponse.json(
|
throw new Error('Public berth status out of range');
|
||||||
{ error: 'internal', detail: 'berth status enum drift' },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +139,9 @@ export async function GET(request: Request): Promise<Response> {
|
|||||||
isLastPage: true,
|
isLastPage: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return errorResponse(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function jsonResponse(body: ListResponse): Response {
|
function jsonResponse(body: ListResponse): Response {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ports } from '@/lib/db/schema/ports';
|
|||||||
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
||||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { errorResponse, RateLimitError } from '@/lib/errors';
|
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||||
import { publicInterestSchema } from '@/lib/validators/interests';
|
import { publicInterestSchema } from '@/lib/validators/interests';
|
||||||
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
|
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
|
||||||
@@ -49,9 +49,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// Resolve portId from query param or header (public endpoints need explicit port)
|
// Resolve portId from query param or header (public endpoints need explicit port)
|
||||||
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
|
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
|
||||||
if (!portId) {
|
if (!portId) throw new ValidationError('Port context required');
|
||||||
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server-side phone normalization for older website builds that post raw
|
// Server-side phone normalization for older website builds that post raw
|
||||||
// international/national strings. Newer builds may pre-fill phoneE164/Country.
|
// international/national strings. Newer builds may pre-fill phoneE164/Country.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { db } from '@/lib/db';
|
|||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||||
|
|
||||||
@@ -173,5 +174,5 @@ export async function POST(req: NextRequest) {
|
|||||||
{ submissionId: parsed.submission_id },
|
{ submissionId: parsed.submission_id },
|
||||||
'website-inquiry conflict but row not found on lookup',
|
'website-inquiry conflict but row not found on lookup',
|
||||||
);
|
);
|
||||||
return NextResponse.json({ error: 'Insert failed' }, { status: 500 });
|
return errorResponse(new Error('website-inquiry conflict but row not found on lookup'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Readable } from 'node:stream';
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { MAX_FILE_SIZE } from '@/lib/constants/file-validation';
|
import { MAX_FILE_SIZE } from '@/lib/constants/file-validation';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { redis } from '@/lib/redis';
|
import { redis } from '@/lib/redis';
|
||||||
import { FilesystemBackend, getStorageBackend } from '@/lib/storage';
|
import { FilesystemBackend, getStorageBackend } from '@/lib/storage';
|
||||||
@@ -229,7 +230,7 @@ export async function PUT(
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err, key: payload.k }, 'Storage proxy upload write failed');
|
logger.error({ err, key: payload.k }, 'Storage proxy upload write failed');
|
||||||
return NextResponse.json({ error: 'Upload write failed' }, { status: 500 });
|
return errorResponse(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, key: payload.k, sizeBytes: buffer.length }, { status: 200 });
|
return NextResponse.json({ ok: true, key: payload.k, sizeBytes: buffer.length }, { status: 200 });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
|
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) => {
|
export const POST = withAuth(async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.alerts.run-engine');
|
||||||
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
|
||||||
}
|
|
||||||
const summary = await runAlertEngineForPorts([ctx.portId]);
|
const summary = await runAlertEngineForPorts([ctx.portId]);
|
||||||
return NextResponse.json({ data: summary });
|
return NextResponse.json({ data: summary });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { getActiveConnections } from '@/lib/services/system-monitoring.service';
|
import { getActiveConnections } from '@/lib/services/system-monitoring.service';
|
||||||
|
|
||||||
export const GET = withAuth(async (_req, ctx) => {
|
export const GET = withAuth(async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.connections.read');
|
||||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const connections = await getActiveConnections();
|
const connections = await getActiveConnections();
|
||||||
return NextResponse.json({ data: connections });
|
return NextResponse.json({ data: connections });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, eq, inArray } from 'drizzle-orm';
|
|||||||
import type { AuthContext } from '@/lib/api/helpers';
|
import type { AuthContext } from '@/lib/api/helpers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { clients, clientMergeCandidates } from '@/lib/db/schema/clients';
|
import { clients, clientMergeCandidates } from '@/lib/db/schema/clients';
|
||||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import {
|
import {
|
||||||
listPendingMergeCandidates,
|
listPendingMergeCandidates,
|
||||||
mergeClients,
|
mergeClients,
|
||||||
@@ -81,7 +81,7 @@ export async function confirmMergeHandler(
|
|||||||
fieldChoices?: MergeFieldChoices;
|
fieldChoices?: MergeFieldChoices;
|
||||||
};
|
};
|
||||||
if (!body.winnerId) {
|
if (!body.winnerId) {
|
||||||
return NextResponse.json({ error: 'winnerId required' }, { status: 400 });
|
throw new ValidationError('winnerId is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [candidate] = await db
|
const [candidate] = await db
|
||||||
@@ -103,10 +103,7 @@ export async function confirmMergeHandler(
|
|||||||
? candidate.clientAId
|
? candidate.clientAId
|
||||||
: null;
|
: null;
|
||||||
if (!loserId) {
|
if (!loserId) {
|
||||||
return NextResponse.json(
|
throw new ValidationError('winnerId must match one of the candidate clients');
|
||||||
{ error: 'winnerId must match one of the candidate clients' },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await mergeClients({
|
const result = await mergeClients({
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { errorResponse } from '@/lib/errors';
|
||||||
import { getRecentErrors } from '@/lib/services/system-monitoring.service';
|
import { getRecentErrors } from '@/lib/services/system-monitoring.service';
|
||||||
|
|
||||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.errors.list');
|
||||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10)));
|
const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10)));
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { healthCheck } from '@/lib/services/system-monitoring.service';
|
import { healthCheck } from '@/lib/services/system-monitoring.service';
|
||||||
|
|
||||||
export const GET = withAuth(async (_req, ctx) => {
|
export const GET = withAuth(async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.health.read');
|
||||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await healthCheck();
|
const status = await healthCheck();
|
||||||
return NextResponse.json({ data: status });
|
return NextResponse.json({ data: status });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { resendCrmInvite } from '@/lib/services/crm-invite.service';
|
import { resendCrmInvite } from '@/lib/services/crm-invite.service';
|
||||||
|
|
||||||
// Resend mints a fresh token + new email on a global invite row;
|
// 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(
|
export const POST = withAuth(
|
||||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.invitations.resend');
|
||||||
throw new ForbiddenError('Resending CRM invites requires super-admin');
|
|
||||||
}
|
|
||||||
const id = params.id ?? '';
|
const id = params.id ?? '';
|
||||||
const result = await resendCrmInvite(id, {
|
const result = await resendCrmInvite(id, {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
|
import { revokeCrmInvite } from '@/lib/services/crm-invite.service';
|
||||||
|
|
||||||
// Invites are a global resource (no portId column). Revoking a foreign
|
// 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(
|
export const DELETE = withAuth(
|
||||||
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.invitations.revoke');
|
||||||
throw new ForbiddenError('Revoking CRM invites requires super-admin');
|
|
||||||
}
|
|
||||||
const id = params.id ?? '';
|
const id = params.id ?? '';
|
||||||
await revokeCrmInvite(id, {
|
await revokeCrmInvite(id, {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
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 { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||||
import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.service';
|
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
|
// port. Listing it cross-tenant would let a port-A director
|
||||||
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
// enumerate pending invitee emails, names, and isSuperAdmin flags
|
||||||
// for every other tenant. Restrict the listing to super-admins.
|
// for every other tenant. Restrict the listing to super-admins.
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.invitations.list');
|
||||||
throw new ForbiddenError('Listing CRM invites requires super-admin');
|
|
||||||
}
|
|
||||||
const data = await listCrmInvites();
|
const data = await listCrmInvites();
|
||||||
return NextResponse.json({ data });
|
return NextResponse.json({ data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
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 { 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';
|
import { getPublicOcrConfig, saveOcrConfig, OCR_MODELS } from '@/lib/services/ocr-config.service';
|
||||||
|
|
||||||
const saveSchema = z.object({
|
const saveSchema = z.object({
|
||||||
@@ -27,8 +27,8 @@ export const GET = withAuth(
|
|||||||
try {
|
try {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const scope = url.searchParams.get('scope') ?? 'port';
|
const scope = url.searchParams.get('scope') ?? 'port';
|
||||||
if (scope === 'global' && !ctx.isSuperAdmin) {
|
if (scope === 'global') {
|
||||||
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
requireSuperAdmin(ctx, 'admin.ocr-settings.read.global');
|
||||||
}
|
}
|
||||||
const config = await getPublicOcrConfig(scope === 'global' ? null : ctx.portId);
|
const config = await getPublicOcrConfig(scope === 'global' ? null : ctx.portId);
|
||||||
return NextResponse.json({ data: config, models: OCR_MODELS });
|
return NextResponse.json({ data: config, models: OCR_MODELS });
|
||||||
@@ -42,15 +42,12 @@ export const PUT = withAuth(
|
|||||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
const body = await parseBody(req, saveSchema);
|
const body = await parseBody(req, saveSchema);
|
||||||
if (body.scope === 'global' && !ctx.isSuperAdmin) {
|
if (body.scope === 'global') {
|
||||||
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
|
requireSuperAdmin(ctx, 'admin.ocr-settings.write.global');
|
||||||
}
|
}
|
||||||
const validModels = OCR_MODELS[body.provider];
|
const validModels = OCR_MODELS[body.provider];
|
||||||
if (!validModels.includes(body.model)) {
|
if (!validModels.includes(body.model)) {
|
||||||
return NextResponse.json(
|
throw new ValidationError(`Invalid model for provider ${body.provider}`);
|
||||||
{ error: `Invalid model for provider ${body.provider}` },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await saveOcrConfig(
|
await saveOcrConfig(
|
||||||
body.scope === 'global' ? null : ctx.portId,
|
body.scope === 'global' ? null : ctx.portId,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-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 { OCR_MODELS } from '@/lib/services/ocr-config.service';
|
||||||
import { testProvider } from '@/lib/services/ocr-providers';
|
import { testProvider } from '@/lib/services/ocr-providers';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export const POST = withAuth(
|
|||||||
try {
|
try {
|
||||||
const body = await parseBody(req, schema);
|
const body = await parseBody(req, schema);
|
||||||
if (!OCR_MODELS[body.provider].includes(body.model)) {
|
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);
|
const result = await testProvider(body.provider, body.apiKey, body.model);
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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 { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { listPorts, createPort } from '@/lib/services/ports.service';
|
import { listPorts, createPort } from '@/lib/services/ports.service';
|
||||||
import { createPortSchema } from '@/lib/validators/ports';
|
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:
|
// Listing every tenant and creating new tenants are super-admin operations:
|
||||||
// a port director must not be able to enumerate other ports (target
|
// 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(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.ports.list');
|
||||||
throw new ForbiddenError('Listing all ports requires super-admin');
|
|
||||||
}
|
|
||||||
const data = await listPorts();
|
const data = await listPorts();
|
||||||
return NextResponse.json({ data });
|
return NextResponse.json({ data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -27,9 +25,7 @@ export const GET = withAuth(
|
|||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.ports.create');
|
||||||
throw new ForbiddenError('Creating ports requires super-admin');
|
|
||||||
}
|
|
||||||
const body = await parseBody(req, createPortSchema);
|
const body = await parseBody(req, createPortSchema);
|
||||||
const data = await createPort(body, {
|
const data = await createPort(body, {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import { retryJob } from '@/lib/services/system-monitoring.service';
|
import { retryJob } from '@/lib/services/system-monitoring.service';
|
||||||
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
|
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
|
||||||
|
|
||||||
export const POST = withAuth(async (_req, ctx, params) => {
|
export const POST = withAuth(async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.queues.job.retry');
|
||||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueName = params['queueName'];
|
const queueName = params['queueName'];
|
||||||
const jobId = params['jobId'];
|
const jobId = params['jobId'];
|
||||||
if (!queueName || !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[];
|
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
|
||||||
if (!validQueues.includes(queueName 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);
|
await retryJob(queueName as QueueName, jobId, ctx.userId);
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import { deleteJob } from '@/lib/services/system-monitoring.service';
|
import { deleteJob } from '@/lib/services/system-monitoring.service';
|
||||||
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
|
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
|
||||||
|
|
||||||
export const DELETE = withAuth(async (_req, ctx, params) => {
|
export const DELETE = withAuth(async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.queues.job.delete');
|
||||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueName = params['queueName'];
|
const queueName = params['queueName'];
|
||||||
const jobId = params['jobId'];
|
const jobId = params['jobId'];
|
||||||
if (!queueName || !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[];
|
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
|
||||||
if (!validQueues.includes(queueName 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);
|
await deleteJob(queueName as QueueName, jobId, ctx.userId);
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import { getQueueJobs } from '@/lib/services/system-monitoring.service';
|
import { getQueueJobs } from '@/lib/services/system-monitoring.service';
|
||||||
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
|
import { QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
|
||||||
|
|
||||||
export const GET = withAuth(async (req: NextRequest, ctx, params) => {
|
export const GET = withAuth(async (req: NextRequest, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.queues.jobs.list');
|
||||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { queueName } = params;
|
const { queueName } = params;
|
||||||
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
|
const validQueues = Object.keys(QUEUE_CONFIGS) as QueueName[];
|
||||||
if (!validQueues.includes(queueName 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);
|
const url = new URL(req.url);
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { getQueueDashboard } from '@/lib/services/system-monitoring.service';
|
import { getQueueDashboard } from '@/lib/services/system-monitoring.service';
|
||||||
|
|
||||||
export const GET = withAuth(async (_req, ctx) => {
|
export const GET = withAuth(async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.queues.list');
|
||||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const queues = await getQueueDashboard();
|
const queues = await getQueueDashboard();
|
||||||
return NextResponse.json({ data: queues });
|
return NextResponse.json({ data: queues });
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
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 { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import { runMigration } from '@/lib/storage/migrate';
|
import { runMigration } from '@/lib/storage/migrate';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -25,12 +25,10 @@ export const runtime = 'nodejs';
|
|||||||
|
|
||||||
export const POST = withAuth(async (req, ctx) => {
|
export const POST = withAuth(async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.storage.migrate');
|
||||||
throw new ForbiddenError('Super admin only');
|
|
||||||
}
|
|
||||||
const body = await parseBody(req, schema);
|
const body = await parseBody(req, schema);
|
||||||
if (body.from === body.to) {
|
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 });
|
const result = await runMigration({ ...body, userId: ctx.userId });
|
||||||
return NextResponse.json({ data: result });
|
return NextResponse.json({ data: result });
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { TABLES_WITH_STORAGE_KEYS } from '@/lib/storage/migrate';
|
import { TABLES_WITH_STORAGE_KEYS } from '@/lib/storage/migrate';
|
||||||
import { getStorageBackend } from '@/lib/storage';
|
import { getStorageBackend } from '@/lib/storage';
|
||||||
import { S3Backend } from '@/lib/storage/s3';
|
import { S3Backend } from '@/lib/storage/s3';
|
||||||
@@ -19,9 +19,7 @@ export const runtime = 'nodejs';
|
|||||||
|
|
||||||
export const GET = withAuth(async (_req, ctx) => {
|
export const GET = withAuth(async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.storage.read');
|
||||||
throw new ForbiddenError('Super admin only');
|
|
||||||
}
|
|
||||||
const backend = await getStorageBackend();
|
const backend = await getStorageBackend();
|
||||||
|
|
||||||
// Aggregate row count + total bytes across every storage-bearing table.
|
// 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) => {
|
export const POST = withAuth(async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'admin.storage.test');
|
||||||
throw new ForbiddenError('Super admin only');
|
|
||||||
}
|
|
||||||
const backend = await getStorageBackend();
|
const backend = await getStorageBackend();
|
||||||
if (!(backend instanceof S3Backend)) {
|
if (!(backend instanceof S3Backend)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import { NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
import { getEmailDraftResult } from '@/lib/services/email-draft.service';
|
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) => {
|
export const GET = withAuth(async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const { jobId } = params;
|
const { jobId } = params;
|
||||||
if (!jobId) {
|
if (!jobId) throw new ValidationError('jobId is required');
|
||||||
return NextResponse.json({ error: 'jobId is required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getEmailDraftResult(jobId, {
|
const result = await getEmailDraftResult(jobId, {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -7,19 +7,18 @@ import { systemSettings } from '@/lib/db/schema/system';
|
|||||||
import { requestEmailDraft } from '@/lib/services/email-draft.service';
|
import { requestEmailDraft } from '@/lib/services/email-draft.service';
|
||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { requestDraftSchema } from '@/lib/validators/ai';
|
import { requestDraftSchema } from '@/lib/validators/ai';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { CodedError, errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
export const POST = withAuth(async (req, ctx) => {
|
export const POST = withAuth(async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
// Feature flag check
|
// Feature flag check
|
||||||
const flag = await db.query.systemSettings.findFirst({
|
const flag = await db.query.systemSettings.findFirst({
|
||||||
where: and(
|
where: and(eq(systemSettings.key, 'ai_email_drafts'), eq(systemSettings.portId, ctx.portId)),
|
||||||
eq(systemSettings.key, 'ai_email_drafts'),
|
|
||||||
eq(systemSettings.portId, ctx.portId),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
if (flag?.value !== true) {
|
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);
|
const body = await parseBody(req, requestDraftSchema);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { withAuth } from '@/lib/api/helpers';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { systemSettings } from '@/lib/db/schema/system';
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
import { calculateBulkScores } from '@/lib/services/interest-scoring.service';
|
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) => {
|
export const GET = withAuth(async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
@@ -17,7 +17,9 @@ export const GET = withAuth(async (_req, ctx) => {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (flag?.value !== true) {
|
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);
|
const scores = await calculateBulkScores(ctx.portId);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { systemSettings } from '@/lib/db/schema/system';
|
|||||||
import { calculateInterestScore } from '@/lib/services/interest-scoring.service';
|
import { calculateInterestScore } from '@/lib/services/interest-scoring.service';
|
||||||
import { parseQuery } from '@/lib/api/route-helpers';
|
import { parseQuery } from '@/lib/api/route-helpers';
|
||||||
import { requestScoreSchema } from '@/lib/validators/ai';
|
import { requestScoreSchema } from '@/lib/validators/ai';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { CodedError, errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
export const GET = withAuth(async (req, ctx) => {
|
export const GET = withAuth(async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
@@ -19,7 +19,9 @@ export const GET = withAuth(async (req, ctx) => {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (flag?.value !== true) {
|
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);
|
const { interestId } = parseQuery(req, requestScoreSchema);
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import { acknowledgeAlert } from '@/lib/services/alerts.service';
|
import { acknowledgeAlert } from '@/lib/services/alerts.service';
|
||||||
|
|
||||||
export const POST = withAuth(async (_req, ctx, params) => {
|
export const POST = withAuth(async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
if (!id) throw new ValidationError('id is required');
|
||||||
await acknowledgeAlert(id, ctx.portId, ctx.userId);
|
await acknowledgeAlert(id, ctx.portId, ctx.userId);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import { dismissAlert } from '@/lib/services/alerts.service';
|
import { dismissAlert } from '@/lib/services/alerts.service';
|
||||||
|
|
||||||
export const POST = withAuth(async (_req, ctx, params) => {
|
export const POST = withAuth(async (_req, ctx, params) => {
|
||||||
|
try {
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
if (!id) throw new ValidationError('id is required');
|
||||||
await dismissAlert(id, ctx.portId, ctx.userId);
|
await dismissAlert(id, ctx.portId, ctx.userId);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import {
|
import {
|
||||||
ALL_RANGES,
|
ALL_RANGES,
|
||||||
getLeadSourceAttribution,
|
getLeadSourceAttribution,
|
||||||
@@ -23,6 +24,7 @@ const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
|
|||||||
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||||
|
try {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const metric = url.searchParams.get('metric') as MetricBase | null;
|
const metric = url.searchParams.get('metric') as MetricBase | null;
|
||||||
const rawRange = url.searchParams.get('range') ?? '30d';
|
const rawRange = url.searchParams.get('range') ?? '30d';
|
||||||
@@ -30,25 +32,19 @@ export const GET = withAuth(
|
|||||||
const toParam = url.searchParams.get('to');
|
const toParam = url.searchParams.get('to');
|
||||||
|
|
||||||
if (!metric || !(metric in METRICS)) {
|
if (!metric || !(metric in METRICS)) {
|
||||||
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
|
throw new ValidationError('Invalid or missing metric');
|
||||||
}
|
}
|
||||||
|
|
||||||
let range: DateRange;
|
let range: DateRange;
|
||||||
if (rawRange === 'custom') {
|
if (rawRange === 'custom') {
|
||||||
if (!fromParam || !toParam) {
|
if (!fromParam || !toParam) {
|
||||||
return NextResponse.json(
|
throw new ValidationError('Custom range requires `from` and `to` (YYYY-MM-DD)');
|
||||||
{ error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
|
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
|
||||||
return NextResponse.json(
|
throw new ValidationError('`from`/`to` must be ISO date strings (YYYY-MM-DD)');
|
||||||
{ error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (fromParam > toParam) {
|
if (fromParam > toParam) {
|
||||||
return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 });
|
throw new ValidationError('`from` must be on or before `to`');
|
||||||
}
|
}
|
||||||
// Round-trip date check: regex passes "9999-13-99" or "2026-02-31"
|
// Round-trip date check: regex passes "9999-13-99" or "2026-02-31"
|
||||||
// (rolls over silently when handed to `new Date`). Re-serialize and
|
// (rolls over silently when handed to `new Date`). Re-serialize and
|
||||||
@@ -59,10 +55,7 @@ export const GET = withAuth(
|
|||||||
] as const) {
|
] as const) {
|
||||||
const d = new Date(`${raw}T00:00:00.000Z`);
|
const d = new Date(`${raw}T00:00:00.000Z`);
|
||||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
|
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
|
||||||
return NextResponse.json(
|
throw new ValidationError(`\`${label}\` is not a valid calendar date`);
|
||||||
{ error: `\`${label}\` is not a valid calendar date` },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Backstop against the occupancy-timeline N+1 query loop. Each day
|
// Backstop against the occupancy-timeline N+1 query loop. Each day
|
||||||
@@ -74,17 +67,20 @@ export const GET = withAuth(
|
|||||||
const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime();
|
const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime();
|
||||||
const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime();
|
const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime();
|
||||||
if ((toMs - fromMs) / 86_400_000 > 365) {
|
if ((toMs - fromMs) / 86_400_000 > 365) {
|
||||||
return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 });
|
throw new ValidationError('Custom range cannot exceed 365 days');
|
||||||
}
|
}
|
||||||
range = { kind: 'custom', from: fromParam, to: toParam };
|
range = { kind: 'custom', from: fromParam, to: toParam };
|
||||||
} else {
|
} else {
|
||||||
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
|
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
|
||||||
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
|
throw new ValidationError('Invalid range');
|
||||||
}
|
}
|
||||||
range = rawRange as PresetDateRange;
|
range = rawRange as PresetDateRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await METRICS[metric](ctx.portId, range);
|
const data = await METRICS[metric](ctx.portId, range);
|
||||||
return NextResponse.json({ metric, range, data });
|
return NextResponse.json({ metric, range, data });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-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 { createPortalUser, resendActivation } from '@/lib/services/portal-auth.service';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -36,9 +36,7 @@ export const POST = withAuth(
|
|||||||
const existing = await db.query.portalUsers.findFirst({
|
const existing = await db.query.portalUsers.findFirst({
|
||||||
where: eq(portalUsers.email, body.email.toLowerCase().trim()),
|
where: eq(portalUsers.email, body.email.toLowerCase().trim()),
|
||||||
});
|
});
|
||||||
if (!existing) {
|
if (!existing) throw new NotFoundError('portal user');
|
||||||
return NextResponse.json({ error: 'Portal user not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
await resendActivation(existing.id, ctx.portId);
|
await resendActivation(existing.id, ctx.portId);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { refreshRates } from '@/lib/services/currency';
|
import { refreshRates } from '@/lib/services/currency';
|
||||||
|
|
||||||
export const POST = withAuth(async (_req, ctx) => {
|
export const POST = withAuth(async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'currency.rates.refresh');
|
||||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshRates();
|
await refreshRates();
|
||||||
return NextResponse.json({ data: { success: true } });
|
return NextResponse.json({ data: { success: true } });
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
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';
|
import { clearDuplicate } from '@/lib/services/expense-dedup.service';
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('expenses', 'edit', async (_req, ctx, params) => {
|
withPermission('expenses', 'edit', async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const id = params.id;
|
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);
|
await clearDuplicate(id, ctx.portId);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { parseBody } from '@/lib/api/route-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';
|
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
|
||||||
|
|
||||||
const mergeSchema = z.object({
|
const mergeSchema = z.object({
|
||||||
@@ -15,9 +15,7 @@ export const POST = withAuth(
|
|||||||
withPermission('expenses', 'edit', async (req, ctx, params) => {
|
withPermission('expenses', 'edit', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const sourceId = params.id;
|
const sourceId = params.id;
|
||||||
if (!sourceId) {
|
if (!sourceId) throw new ValidationError('id is required');
|
||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
|
||||||
}
|
|
||||||
const body = await parseBody(req, mergeSchema);
|
const body = await parseBody(req, mergeSchema);
|
||||||
await mergeDuplicate(sourceId, body.targetId, ctx.portId);
|
await mergeDuplicate(sourceId, body.targetId, ctx.portId);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth } from '@/lib/api/helpers';
|
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { exportParentCompany } from '@/lib/services/expense-export';
|
import { exportParentCompany } from '@/lib/services/expense-export';
|
||||||
import { listExpensesSchema } from '@/lib/validators/expenses';
|
import { listExpensesSchema } from '@/lib/validators/expenses';
|
||||||
|
|
||||||
export const POST = withAuth(async (req, ctx) => {
|
export const POST = withAuth(async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
if (!ctx.isSuperAdmin) {
|
requireSuperAdmin(ctx, 'expenses.export.parent-company');
|
||||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const query = listExpensesSchema.parse(body);
|
const query = listExpensesSchema.parse(body);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
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 { logger } from '@/lib/logger';
|
||||||
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
||||||
import {
|
import {
|
||||||
@@ -29,9 +29,7 @@ export const POST = withAuth(
|
|||||||
try {
|
try {
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
const file = formData.get('file') as File | null;
|
const file = formData.get('file') as File | null;
|
||||||
if (!file) {
|
if (!file) throw new ValidationError('A file is required');
|
||||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
|
||||||
}
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const mimeType = file.type || 'image/jpeg';
|
const mimeType = file.type || 'image/jpeg';
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { withAuth, type AuthContext } from '@/lib/api/helpers';
|
|||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { userProfiles } from '@/lib/db/schema';
|
import { userProfiles } from '@/lib/db/schema';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const updateProfileSchema = z.object({
|
const updateProfileSchema = z.object({
|
||||||
@@ -42,9 +42,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
|||||||
const profile = await db.query.userProfiles.findFirst({
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
where: eq(userProfiles.userId, ctx.userId),
|
where: eq(userProfiles.userId, ctx.userId),
|
||||||
});
|
});
|
||||||
if (!profile) {
|
if (!profile) throw new NotFoundError('profile');
|
||||||
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
|||||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||||
import { listReminders, createReminder } from '@/lib/services/reminders.service';
|
import { listReminders, createReminder } from '@/lib/services/reminders.service';
|
||||||
import { reminderListQuerySchema, createReminderSchema } from '@/lib/validators/reminders';
|
import { reminderListQuerySchema, createReminderSchema } from '@/lib/validators/reminders';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||||
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('reminders', 'view_own', async (req, ctx) => {
|
withPermission('reminders', 'view_own', async (req, ctx) => {
|
||||||
@@ -25,14 +25,8 @@ export const POST = withAuth(
|
|||||||
|
|
||||||
// Check assign_others permission if assigning to someone else
|
// Check assign_others permission if assigning to someone else
|
||||||
if (body.assignedTo && body.assignedTo !== ctx.userId) {
|
if (body.assignedTo && body.assignedTo !== ctx.userId) {
|
||||||
if (!ctx.isSuperAdmin) {
|
if (!ctx.isSuperAdmin && !ctx.permissions?.reminders?.assign_others) {
|
||||||
const perms = ctx.permissions?.reminders;
|
throw new ForbiddenError('Cannot assign reminders to other users');
|
||||||
if (!perms?.assign_others) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Cannot assign reminders to other users' },
|
|
||||||
{ status: 403 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,33 +5,24 @@ import type { AuthContext } from '@/lib/api/helpers';
|
|||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { savedViews } from '@/lib/db/schema';
|
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 { savedViewsService } from '@/lib/services/saved-views.service';
|
||||||
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the view and enforces ownership before mutating.
|
* Resolves the view and enforces ownership before mutating.
|
||||||
*
|
*
|
||||||
* Returns a 404 when the view does not exist (or lives in a different port)
|
* Throws NotFoundError when the view does not exist (or lives in a different
|
||||||
* and a 403 when it belongs to a different user. The 404-before-403 split
|
* port) and ForbiddenError when it belongs to a different user. The 404-
|
||||||
* matches the rest of the API and avoids leaking the existence of another
|
* before-403 split matches the rest of the API and avoids leaking the
|
||||||
* user's saved view via timing or status code.
|
* existence of another user's saved view via timing or status code.
|
||||||
*/
|
*/
|
||||||
async function assertViewOwner(
|
async function assertViewOwner(id: string, portId: string, userId: string): Promise<void> {
|
||||||
id: string,
|
|
||||||
portId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<NextResponse | null> {
|
|
||||||
const view = await db.query.savedViews.findFirst({
|
const view = await db.query.savedViews.findFirst({
|
||||||
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
|
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
|
||||||
});
|
});
|
||||||
if (!view) {
|
if (!view) throw new NotFoundError('saved view');
|
||||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
if (view.userId !== userId) throw new ForbiddenError('That saved view belongs to someone else.');
|
||||||
}
|
|
||||||
if (view.userId !== userId) {
|
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function patchHandler(
|
export async function patchHandler(
|
||||||
@@ -41,8 +32,7 @@ export async function patchHandler(
|
|||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
try {
|
try {
|
||||||
const id = params.id ?? '';
|
const id = params.id ?? '';
|
||||||
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||||
if (denied) return denied;
|
|
||||||
const body = await parseBody(req as never, updateSavedViewSchema);
|
const body = await parseBody(req as never, updateSavedViewSchema);
|
||||||
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
||||||
return NextResponse.json({ data: view });
|
return NextResponse.json({ data: view });
|
||||||
@@ -58,8 +48,7 @@ export async function deleteHandler(
|
|||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
try {
|
try {
|
||||||
const id = params.id ?? '';
|
const id = params.id ?? '';
|
||||||
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||||
if (denied) return denied;
|
|
||||||
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
||||||
return NextResponse.json({ data: null }, { status: 200 });
|
return NextResponse.json({ data: null }, { status: 200 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import { and, eq } from 'drizzle-orm';
|
|||||||
import { withAuth } from '@/lib/api/helpers';
|
import { withAuth } from '@/lib/api/helpers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { systemSettings } from '@/lib/db/schema/system';
|
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) => {
|
export const GET = withAuth(async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
const key = req.nextUrl.searchParams.get('key');
|
const key = req.nextUrl.searchParams.get('key');
|
||||||
if (!key) {
|
if (!key) throw new ValidationError('key query parameter is required');
|
||||||
return NextResponse.json({ error: 'key query parameter is required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const setting = await db.query.systemSettings.findFirst({
|
const setting = await db.query.systemSettings.findFirst({
|
||||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, ctx.portId)),
|
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, ctx.portId)),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { withAuth } from '@/lib/api/helpers';
|
|||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { userProfiles, type UserPreferences } from '@/lib/db/schema/users';
|
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';
|
import { updateUserPreferencesSchema } from '@/lib/validators/user-preferences';
|
||||||
|
|
||||||
export const GET = withAuth(async (_req, ctx) => {
|
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({
|
const profile = await db.query.userProfiles.findFirst({
|
||||||
where: eq(userProfiles.userId, ctx.userId),
|
where: eq(userProfiles.userId, ctx.userId),
|
||||||
});
|
});
|
||||||
if (!profile) {
|
if (!profile) throw new NotFoundError('profile');
|
||||||
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const next: UserPreferences = {
|
const next: UserPreferences = {
|
||||||
...(profile.preferences ?? {}),
|
...(profile.preferences ?? {}),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||||
import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range';
|
import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range';
|
||||||
|
import { CodedError, errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import {
|
import {
|
||||||
getActiveVisitors,
|
getActiveVisitors,
|
||||||
getMetric,
|
getMetric,
|
||||||
@@ -68,21 +69,19 @@ function parseRange(req: NextRequest): DateRange | { error: string } {
|
|||||||
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||||
|
try {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const metric = url.searchParams.get('metric');
|
const metric = url.searchParams.get('metric');
|
||||||
if (!metric) {
|
if (!metric) throw new ValidationError('Missing metric');
|
||||||
return NextResponse.json({ error: 'Missing metric' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const rangeOrError = parseRange(req);
|
const rangeOrError = parseRange(req);
|
||||||
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
|
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
|
||||||
return NextResponse.json({ error: rangeOrError.error }, { status: 400 });
|
throw new ValidationError(rangeOrError.error);
|
||||||
}
|
}
|
||||||
const range = rangeOrError as DateRange;
|
const range = rangeOrError as DateRange;
|
||||||
|
|
||||||
try {
|
|
||||||
let data: unknown;
|
let data: unknown;
|
||||||
|
try {
|
||||||
if (metric === 'stats') {
|
if (metric === 'stats') {
|
||||||
data = await getStats(ctx.portId, range);
|
data = await getStats(ctx.portId, range);
|
||||||
} else if (metric === 'pageviews') {
|
} else if (metric === 'pageviews') {
|
||||||
@@ -94,20 +93,24 @@ export const GET = withAuth(
|
|||||||
const limit = Number(url.searchParams.get('limit') ?? 10);
|
const limit = Number(url.searchParams.get('limit') ?? 10);
|
||||||
data = await getMetric(ctx.portId, range, type, limit);
|
data = await getMetric(ctx.portId, range, type, limit);
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json({ error: `Unknown metric: ${metric}` }, { status: 400 });
|
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
|
// `data === null` from the service means Umami isn't configured for
|
||||||
// this port - surface that explicitly so the UI can render a
|
// this port - surface that explicitly so the UI can render a
|
||||||
// "configure your credentials" empty state instead of a chart.
|
// "configure your credentials" empty state instead of a chart.
|
||||||
if (data === null) {
|
if (data === null) throw new CodedError('UMAMI_NOT_CONFIGURED');
|
||||||
return NextResponse.json({ error: 'umami_not_configured', metric, range }, { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ metric, range, data });
|
return NextResponse.json({ metric, range, data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
return errorResponse(err);
|
||||||
return NextResponse.json({ error: message, metric, range }, { status: 502 });
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { formatErrorBanner } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, X } from 'lucide-react';
|
import { Plus, X } from 'lucide-react';
|
||||||
@@ -149,7 +150,7 @@ export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: Custom
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
const message = formatErrorBanner(err);
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -308,7 +309,7 @@ export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: Custom
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { formatErrorBanner } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -102,7 +103,7 @@ export function TemplateForm({ open, onOpenChange, template, onSuccess }: Templa
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
const message = formatErrorBanner(err);
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -186,7 +187,9 @@ export function TemplateForm({ open, onOpenChange, template, onSuccess }: Templa
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
|
<p className="whitespace-pre-line rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { formatErrorBanner } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -89,7 +90,7 @@ export function PortForm({ open, onOpenChange, port, onSuccess }: PortFormProps)
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
const message = formatErrorBanner(err);
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -187,7 +188,7 @@ export function PortForm({ open, onOpenChange, port, onSuccess }: PortFormProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { formatErrorBanner } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -202,7 +203,7 @@ export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps)
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
const message = formatErrorBanner(err);
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -283,7 +284,7 @@ export function RoleForm({ open, onOpenChange, role, onSuccess }: RoleFormProps)
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
{error && <p className="mt-2 whitespace-pre-line text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
<SheetFooter className="mt-4">
|
<SheetFooter className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { formatErrorBanner } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetFooter,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
interface TagFormProps {
|
interface TagFormProps {
|
||||||
@@ -21,9 +16,16 @@ interface TagFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
'#6B7280', '#EF4444', '#F97316', '#EAB308',
|
'#6B7280',
|
||||||
'#22C55E', '#14B8A6', '#3B82F6', '#8B5CF6',
|
'#EF4444',
|
||||||
'#EC4899', '#F43F5E',
|
'#F97316',
|
||||||
|
'#EAB308',
|
||||||
|
'#22C55E',
|
||||||
|
'#14B8A6',
|
||||||
|
'#3B82F6',
|
||||||
|
'#8B5CF6',
|
||||||
|
'#EC4899',
|
||||||
|
'#F43F5E',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TagForm({ open, onOpenChange, tag, onSuccess }: TagFormProps) {
|
export function TagForm({ open, onOpenChange, tag, onSuccess }: TagFormProps) {
|
||||||
@@ -54,7 +56,7 @@ export function TagForm({ open, onOpenChange, tag, onSuccess }: TagFormProps) {
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
const message = formatErrorBanner(err);
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -109,10 +111,7 @@ export function TagForm({ open, onOpenChange, tag, onSuccess }: TagFormProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<div
|
<div className="h-7 w-7 rounded-full border" style={{ backgroundColor: color }} />
|
||||||
className="h-7 w-7 rounded-full border"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
value={color}
|
value={color}
|
||||||
onChange={(e) => setColor(e.target.value)}
|
onChange={(e) => setColor(e.target.value)}
|
||||||
@@ -123,12 +122,15 @@ export function TagForm({ open, onOpenChange, tag, onSuccess }: TagFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading || !name.trim()}>
|
<Button type="submit" disabled={loading || !name.trim()}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { formatErrorBanner } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -115,7 +116,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
const message = formatErrorBanner(err);
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -221,7 +222,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { formatErrorBanner } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetFooter,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { WebhookEventSelector } from './webhook-event-selector';
|
import { WebhookEventSelector } from './webhook-event-selector';
|
||||||
import { WebhookSecretDisplay } from './webhook-secret-display';
|
import { WebhookSecretDisplay } from './webhook-secret-display';
|
||||||
@@ -64,7 +59,7 @@ export function WebhookForm({ open, onOpenChange, webhook, onSuccess }: WebhookF
|
|||||||
onSuccess();
|
onSuccess();
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
const message = formatErrorBanner(err);
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -82,7 +77,12 @@ export function WebhookForm({ open, onOpenChange, webhook, onSuccess }: WebhookF
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
|
<Sheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
|
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{isEdit ? 'Edit Webhook' : 'New Webhook'}</SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit Webhook' : 'New Webhook'}</SheetTitle>
|
||||||
@@ -92,7 +92,9 @@ export function WebhookForm({ open, onOpenChange, webhook, onSuccess }: WebhookF
|
|||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<p className="text-sm">Webhook created successfully.</p>
|
<p className="text-sm">Webhook created successfully.</p>
|
||||||
<WebhookSecretDisplay plaintext={createdSecret} masked="" />
|
<WebhookSecretDisplay plaintext={createdSecret} masked="" />
|
||||||
<Button onClick={handleClose} className="w-full">Done</Button>
|
<Button onClick={handleClose} className="w-full">
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
||||||
@@ -126,21 +128,20 @@ export function WebhookForm({ open, onOpenChange, webhook, onSuccess }: WebhookF
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Switch
|
<Switch id="webhook-active" checked={isActive} onCheckedChange={setIsActive} />
|
||||||
id="webhook-active"
|
|
||||||
checked={isActive}
|
|
||||||
onCheckedChange={setIsActive}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="webhook-active">Active</Label>
|
<Label htmlFor="webhook-active">Active</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading || !name.trim() || !url.trim() || events.length === 0}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !name.trim() || !url.trim() || events.length === 0}
|
||||||
|
>
|
||||||
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Webhook'}
|
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Webhook'}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
|
|||||||
@@ -4,6 +4,28 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
import { ApiError } from '@/lib/api/client';
|
import { ApiError } from '@/lib/api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a multi-line string suitable for an inline form banner — the
|
||||||
|
* primary message followed by `Error code:` / `Reference ID:` lines when
|
||||||
|
* the error is an ApiError. Use from admin forms that want to keep
|
||||||
|
* their inline error UX instead of switching to a toast.
|
||||||
|
*
|
||||||
|
* Example output:
|
||||||
|
* "Couldn't save the role.
|
||||||
|
* Error code: ROLES_DUPLICATE_NAME
|
||||||
|
* Reference ID: 1ab2c3d4-…"
|
||||||
|
*/
|
||||||
|
export function formatErrorBanner(err: unknown, fallback = 'Something went wrong.'): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
const parts = [err.message || fallback];
|
||||||
|
if (err.code) parts.push(`Error code: ${err.code}`);
|
||||||
|
if (err.requestId) parts.push(`Reference ID: ${err.requestId}`);
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message || fallback;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render an API error as a toast in the consistent platform format:
|
* Render an API error as a toast in the consistent platform format:
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user