feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view

Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.

PR4  Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
     date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5  Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
     right-rail, three-tab page (active/dismissed/resolved), socket-driven
     invalidation. Bell lazy-loads list on popover open to keep cold pages
     fast in non-dashboard routes.
PR6  EOI queue tab on documents hub — filters to in-flight EOIs, count
     surfaces in tab label.
PR7  Interests-by-berth tab on berth detail — replaces the stub.
PR8  Expense duplicate detection — BullMQ job runs scan on create, yellow
     banner on detail w/ Merge / Not-a-duplicate, transactional merge
     consolidates receipts and archives the source.
PR9  Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
     its own (scanner) group with no dashboard chrome, dynamic per-port
     manifest, OpenAI + Claude provider abstraction, admin OCR settings
     page (port-level + super-admin global default w/ opt-in fallback),
     test-connection endpoint, manual-entry fallback when no key is
     configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
     existing GIN index, cursor pagination, filters for entity/action/user
     /date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
     real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
     socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
     cleanly without their gate envs so CI stays green.

Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 17:21:55 +02:00
parent 2fa70f4582
commit f52d21df83
63 changed files with 4459 additions and 206 deletions

View File

@@ -0,0 +1,5 @@
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
export default function OcrSettingsPage() {
return <OcrSettingsForm />;
}

View File

@@ -149,6 +149,12 @@ const SECTIONS: AdminSection[] = [
description: 'Initial-setup wizard for fresh ports.',
icon: LayoutDashboard,
},
{
href: 'ocr',
label: 'Receipt OCR',
description: 'Configure the AI provider used by the mobile receipt scanner.',
icon: ScrollText,
},
];
export default async function AdminLandingPage({

View File

@@ -0,0 +1,5 @@
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
export default function AlertsPage() {
return <AlertsPageShell />;
}

View File

@@ -0,0 +1,50 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { QueryProvider } from '@/providers/query-provider';
import { PortProvider } from '@/providers/port-provider';
import { eq } from 'drizzle-orm';
/**
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
* topbar — the scanner is its own contained surface. Adds the PWA
* manifest link + theme color so iOS/Android pick up "Add to Home
* Screen". Auth check matches the dashboard layout so unauthorized
* users still bounce to /login.
*/
export default async function ScannerLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ portSlug: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) redirect('/login');
const { portSlug } = await params;
const port = await db.query.ports.findFirst({
where: eq(portsTable.slug, portSlug),
});
if (!port) redirect('/login');
return (
<QueryProvider>
<PortProvider ports={port ? [port] : []} defaultPortId={port?.id ?? null}>
<head>
<link rel="manifest" href={`/${portSlug}/scan/manifest.webmanifest`} />
<meta name="theme-color" content="#3a7bc8" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="PN Scanner" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</head>
<div className="min-h-[100dvh] bg-background">{children}</div>
</PortProvider>
</QueryProvider>
);
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
/**
* Per-port PWA manifest. Scoped to `/<portSlug>/scan` so the install
* only covers the scanner page, not the rest of the CRM. Each port
* gets its own homescreen icon labeled with its name.
*/
export async function GET(_req: Request, { params }: { params: Promise<{ portSlug: string }> }) {
const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const portName = port?.name ?? 'Port Nimara';
const manifest = {
name: `${portName} — Scanner`,
short_name: 'Scanner',
description: `Capture and submit expense receipts for ${portName}.`,
start_url: `/${portSlug}/scan`,
scope: `/${portSlug}/scan`,
display: 'standalone',
orientation: 'portrait',
background_color: '#ffffff',
theme_color: '#3a7bc8',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
{
src: '/icon-512-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
};
return NextResponse.json(manifest, {
headers: {
'Content-Type': 'application/manifest+json',
'Cache-Control': 'public, max-age=300, must-revalidate',
},
});
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import { ScanShell } from '@/components/scan/scan-shell';
export const metadata: Metadata = {
title: 'Scan receipt — Port Nimara',
};
export default function ScanPage() {
return <ScanShell />;
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
/**
* Admin trigger for an immediate alert engine sweep over the caller's port.
* Useful for manual ops ("re-evaluate now after I fixed a rule") and
* exercised by the realapi socket fanout test.
*
* Requires super_admin or per-port admin permissions; the engine itself
* is idempotent — duplicate runs only re-evaluate, never duplicate rows.
*/
export const POST = withAuth(async (_req, ctx) => {
try {
if (!ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
}
const summary = await runAlertEngineForPorts([ctx.portId]);
return NextResponse.json({ data: summary });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -1,29 +1,76 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { inArray } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { listAuditLogs } from '@/lib/services/audit.service';
import { searchAuditLogs } from '@/lib/services/audit-search.service';
import { db } from '@/lib/db';
import { user } from '@/lib/db/schema/users';
import { errorResponse } from '@/lib/errors';
const auditQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
limit: z.coerce.number().int().min(1).max(200).default(50),
entityType: z.string().optional(),
action: z.string().optional(),
userId: z.string().optional(),
entityId: z.string().optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
/** Free-text query against the tsvector `search_text` column. */
search: z.string().optional(),
/** Cursor pair from the previous page's response. */
cursorAt: z.string().optional(),
cursorId: z.string().optional(),
});
export const GET = withAuth(
withPermission('admin', 'view_audit_log', async (req, ctx) => {
try {
const query = parseQuery(req, auditQuerySchema);
const result = await listAuditLogs(ctx.portId, query);
return NextResponse.json(result);
const cursor =
query.cursorAt && query.cursorId
? { createdAt: new Date(query.cursorAt), id: query.cursorId }
: undefined;
const { rows, nextCursor } = await searchAuditLogs({
portId: ctx.portId,
q: query.search,
userId: query.userId,
action: query.action,
entityType: query.entityType,
entityId: query.entityId,
from: query.dateFrom ? new Date(query.dateFrom) : undefined,
to: query.dateTo ? new Date(query.dateTo) : undefined,
cursor,
limit: query.limit,
});
// Resolve actor emails in one batched query so the table can show
// who did what without N+1 round trips.
const userIds = Array.from(
new Set(rows.map((r) => r.userId).filter((id): id is string => Boolean(id))),
);
const userRows = userIds.length
? await db
.select({ id: user.id, email: user.email, name: user.name })
.from(user)
.where(inArray(user.id, userIds))
: [];
const userMap = new Map(userRows.map((u) => [u.id, u]));
const data = rows.map((r) => ({
...r,
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
}));
return NextResponse.json({
data,
pagination: {
nextCursor: nextCursor
? { createdAt: nextCursor.createdAt.toISOString(), id: nextCursor.id }
: null,
},
});
} catch (error) {
return errorResponse(error);
}

View File

@@ -0,0 +1,61 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getPublicOcrConfig, saveOcrConfig, OCR_MODELS } from '@/lib/services/ocr-config.service';
const saveSchema = z.object({
/** When 'global', requires super_admin and stores at port_id=null. */
scope: z.enum(['port', 'global']),
provider: z.enum(['openai', 'claude']),
model: z.string().min(1),
apiKey: z.string().optional(),
clearApiKey: z.boolean().optional(),
useGlobal: z.boolean().optional(),
});
export const GET = withAuth(async (req, ctx) => {
try {
const url = new URL(req.url);
const scope = url.searchParams.get('scope') ?? 'port';
if (scope === 'global' && !ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
}
const config = await getPublicOcrConfig(scope === 'global' ? null : ctx.portId);
return NextResponse.json({ data: config, models: OCR_MODELS });
} catch (error) {
return errorResponse(error);
}
});
export const PUT = withAuth(async (req, ctx) => {
try {
const body = await parseBody(req, saveSchema);
if (body.scope === 'global' && !ctx.isSuperAdmin) {
return NextResponse.json({ error: 'Super admin only' }, { status: 403 });
}
const validModels = OCR_MODELS[body.provider];
if (!validModels.includes(body.model)) {
return NextResponse.json(
{ error: `Invalid model for provider ${body.provider}` },
{ status: 400 },
);
}
await saveOcrConfig(
body.scope === 'global' ? null : ctx.portId,
{
provider: body.provider,
model: body.model,
apiKey: body.apiKey,
clearApiKey: body.clearApiKey,
useGlobal: body.useGlobal,
},
ctx.userId,
);
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { OCR_MODELS } from '@/lib/services/ocr-config.service';
import { testProvider } from '@/lib/services/ocr-providers';
const schema = z.object({
provider: z.enum(['openai', 'claude']),
model: z.string().min(1),
apiKey: z.string().min(1),
});
export const POST = withAuth(async (req) => {
try {
const body = await parseBody(req, schema);
if (!OCR_MODELS[body.provider].includes(body.model)) {
return NextResponse.json({ error: 'Invalid model' }, { status: 400 });
}
const result = await testProvider(body.provider, body.apiKey, body.model);
return NextResponse.json(result);
} catch (error) {
return errorResponse(error);
}
});

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { withAuth } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { alerts } from '@/lib/db/schema/insights';
export const GET = withAuth(async (_req, ctx) => {
const rows = await db
.select({ severity: alerts.severity, count: sql<number>`count(*)::int` })
.from(alerts)
.where(
and(eq(alerts.portId, ctx.portId), isNull(alerts.resolvedAt), isNull(alerts.dismissedAt)),
)
.groupBy(alerts.severity);
const bySeverity = { info: 0, warning: 0, critical: 0 } as Record<string, number>;
let total = 0;
for (const r of rows) {
bySeverity[r.severity] = r.count;
total += r.count;
}
return NextResponse.json({ total, bySeverity });
});

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { listAlertsForPort } from '@/lib/services/alerts.service';
type AlertStatus = 'open' | 'dismissed' | 'resolved';
export const GET = withAuth(async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
const rows = await listAlertsForPort(ctx.portId, {
includeDismissed: status !== 'open',
includeResolved: status !== 'open',
});
// Filter to the requested status bucket so callers don't see overlap.
const filtered = rows.filter((a) => {
if (status === 'open') return !a.dismissedAt && !a.resolvedAt;
if (status === 'dismissed') return Boolean(a.dismissedAt) && !a.resolvedAt;
if (status === 'resolved') return Boolean(a.resolvedAt);
return true;
});
return NextResponse.json({ data: filtered });
});

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import {
ALL_RANGES,
getLeadSourceAttribution,
getOccupancyTimeline,
getPipelineFunnel,
getRevenueBreakdown,
type DateRange,
type MetricBase,
} from '@/lib/services/analytics.service';
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
pipeline_funnel: getPipelineFunnel,
occupancy_timeline: getOccupancyTimeline,
revenue_breakdown: getRevenueBreakdown,
lead_source_attribution: getLeadSourceAttribution,
};
export const GET = withAuth(async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const metric = url.searchParams.get('metric') as MetricBase | null;
const range = (url.searchParams.get('range') ?? '30d') as DateRange;
if (!metric || !(metric in METRICS)) {
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
}
if (!ALL_RANGES.includes(range)) {
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
}
const data = await METRICS[metric](ctx.portId, range);
return NextResponse.json({ metric, range, data });
});

View File

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

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
const mergeSchema = z.object({
/** Surviving expense id — typically the row's existing `duplicateOf` pointer. */
targetId: z.string().min(1),
});
export const POST = withAuth(
withPermission('expenses', 'edit', async (req, ctx, params) => {
try {
const sourceId = params.id;
if (!sourceId) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
const body = await parseBody(req, mergeSchema);
await mergeDuplicate(sourceId, body.targetId, ctx.portId);
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -2,24 +2,62 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { scanReceipt } from '@/lib/services/receipt-scanner';
import { logger } from '@/lib/logger';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
import { runOcr, type ParsedReceipt } from '@/lib/services/ocr-providers';
const EMPTY: ParsedReceipt = {
establishment: null,
date: null,
amount: null,
currency: null,
lineItems: [],
confidence: 0,
};
export const POST = withAuth(
withPermission('expenses', 'create', async (req, _ctx) => {
withPermission('expenses', 'create', async (req, ctx) => {
try {
const formData = await req.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/jpeg';
const result = await scanReceipt(buffer, mimeType);
const config = await getResolvedOcrConfig(ctx.portId);
if (!config.apiKey) {
// Manual-entry path — no OCR configured. Frontend will show the
// verify form with empty fields so the user can fill it in.
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'no-ocr-configured' },
});
}
return NextResponse.json({ data: result });
try {
const parsed = await runOcr({
provider: config.provider,
model: config.model,
apiKey: config.apiKey,
imageBuffer: buffer,
mimeType,
});
return NextResponse.json({
data: { parsed, source: 'ai', provider: config.provider, model: config.model },
});
} catch (err) {
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
// Provider hiccup — degrade to manual entry rather than 500-ing.
return NextResponse.json({
data: {
parsed: EMPTY,
source: 'manual',
reason: 'provider-error',
providerError: err instanceof Error ? err.message.slice(0, 200) : 'Unknown error',
},
});
}
} catch (error) {
return errorResponse(error);
}