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:
25
src/app/api/v1/admin/alerts/run-engine/route.ts
Normal file
25
src/app/api/v1/admin/alerts/run-engine/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
61
src/app/api/v1/admin/ocr-settings/route.ts
Normal file
61
src/app/api/v1/admin/ocr-settings/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
27
src/app/api/v1/admin/ocr-settings/test/route.ts
Normal file
27
src/app/api/v1/admin/ocr-settings/test/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user