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:
157
src/lib/services/ocr-config.service.ts
Normal file
157
src/lib/services/ocr-config.service.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* OCR provider config — stored in `system_settings` under the key
|
||||
* `ocr.config`. Each port can either have its own row (port_id = port.id)
|
||||
* or opt into the global row (port_id = null) by setting `useGlobal: true`.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
import { encrypt, decrypt } from '@/lib/utils/encryption';
|
||||
|
||||
export type OcrProvider = 'openai' | 'claude';
|
||||
|
||||
export const OCR_MODELS: Record<OcrProvider, string[]> = {
|
||||
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo'],
|
||||
claude: ['claude-haiku-4-5', 'claude-sonnet-4-6', 'claude-opus-4-7'],
|
||||
};
|
||||
|
||||
export const DEFAULT_MODEL: Record<OcrProvider, string> = {
|
||||
openai: 'gpt-4o-mini',
|
||||
claude: 'claude-haiku-4-5',
|
||||
};
|
||||
|
||||
/** Public shape that admin UIs read — never includes the raw key. */
|
||||
export interface OcrConfigPublic {
|
||||
provider: OcrProvider;
|
||||
model: string;
|
||||
/** True when an encrypted key is present. We never echo the key itself. */
|
||||
hasApiKey: boolean;
|
||||
/** Port-level rows can opt into the global config. */
|
||||
useGlobal: boolean;
|
||||
}
|
||||
|
||||
/** Internal shape including the decrypted key — server-side only. */
|
||||
export interface OcrConfigResolved extends OcrConfigPublic {
|
||||
apiKey: string | null;
|
||||
/** Source of the resolved row: 'port' | 'global' | 'none'. */
|
||||
source: 'port' | 'global' | 'none';
|
||||
}
|
||||
|
||||
interface StoredOcrConfig {
|
||||
provider: OcrProvider;
|
||||
model: string;
|
||||
apiKeyEncrypted: string | null;
|
||||
useGlobal: boolean;
|
||||
}
|
||||
|
||||
const KEY = 'ocr.config';
|
||||
|
||||
async function readRow(portId: string | null): Promise<StoredOcrConfig | null> {
|
||||
const where =
|
||||
portId === null
|
||||
? and(eq(systemSettings.key, KEY), isNull(systemSettings.portId))
|
||||
: and(eq(systemSettings.key, KEY), eq(systemSettings.portId, portId));
|
||||
const [row] = await db.select().from(systemSettings).where(where);
|
||||
if (!row) return null;
|
||||
return row.value as unknown as StoredOcrConfig;
|
||||
}
|
||||
|
||||
async function writeRow(portId: string | null, value: StoredOcrConfig, userId: string) {
|
||||
// upsert: delete + insert keeps logic simple given the (key, port_id) unique index.
|
||||
await db
|
||||
.delete(systemSettings)
|
||||
.where(
|
||||
portId === null
|
||||
? and(eq(systemSettings.key, KEY), isNull(systemSettings.portId))
|
||||
: and(eq(systemSettings.key, KEY), eq(systemSettings.portId, portId)),
|
||||
);
|
||||
await db.insert(systemSettings).values({
|
||||
key: KEY,
|
||||
portId,
|
||||
value: value as unknown as Record<string, unknown>,
|
||||
updatedBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active OCR config for a port: port row (unless `useGlobal`),
|
||||
* falling back to the global row, falling back to a default-empty config.
|
||||
*/
|
||||
export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigResolved> {
|
||||
const portRow = await readRow(portId);
|
||||
const useGlobal = portRow?.useGlobal === true || !portRow;
|
||||
const sourceRow = useGlobal ? await readRow(null) : portRow;
|
||||
if (!sourceRow) {
|
||||
return {
|
||||
provider: 'openai',
|
||||
model: DEFAULT_MODEL.openai,
|
||||
apiKey: null,
|
||||
hasApiKey: false,
|
||||
useGlobal: portRow?.useGlobal === true,
|
||||
source: 'none',
|
||||
};
|
||||
}
|
||||
return {
|
||||
provider: sourceRow.provider,
|
||||
model: sourceRow.model,
|
||||
apiKey: sourceRow.apiKeyEncrypted ? decrypt(sourceRow.apiKeyEncrypted) : null,
|
||||
hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
|
||||
useGlobal: portRow?.useGlobal === true,
|
||||
source: useGlobal ? 'global' : 'port',
|
||||
};
|
||||
}
|
||||
|
||||
/** Public-safe view for the admin UI — same shape but never the key. */
|
||||
export async function getPublicOcrConfig(portId: string | null): Promise<OcrConfigPublic> {
|
||||
const row = await readRow(portId);
|
||||
if (!row) {
|
||||
return {
|
||||
provider: 'openai',
|
||||
model: DEFAULT_MODEL.openai,
|
||||
hasApiKey: false,
|
||||
useGlobal: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
provider: row.provider,
|
||||
model: row.model,
|
||||
hasApiKey: Boolean(row.apiKeyEncrypted),
|
||||
useGlobal: row.useGlobal,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SaveOcrConfigInput {
|
||||
provider: OcrProvider;
|
||||
model: string;
|
||||
/** When provided, replaces any stored key. When undefined, the existing key is preserved. */
|
||||
apiKey?: string;
|
||||
/** When true, clears the stored key. */
|
||||
clearApiKey?: boolean;
|
||||
useGlobal?: boolean;
|
||||
}
|
||||
|
||||
export async function saveOcrConfig(
|
||||
portId: string | null,
|
||||
input: SaveOcrConfigInput,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const existing = await readRow(portId);
|
||||
let apiKeyEncrypted = existing?.apiKeyEncrypted ?? null;
|
||||
if (input.clearApiKey) {
|
||||
apiKeyEncrypted = null;
|
||||
} else if (input.apiKey !== undefined && input.apiKey.length > 0) {
|
||||
apiKeyEncrypted = encrypt(input.apiKey);
|
||||
}
|
||||
await writeRow(
|
||||
portId,
|
||||
{
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
apiKeyEncrypted,
|
||||
useGlobal: portId === null ? false : Boolean(input.useGlobal),
|
||||
},
|
||||
userId,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user