Files
pn-new-crm/src/lib/services/ocr-config.service.ts
Matt f183f58b0c fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson
Address the CRITICAL + high-leverage HIGH items from the types-auditor:

**C1 — `tx: any` in client-restore.service**
Export a canonical `Tx` type from `lib/db/utils.ts` (derived from
Drizzle's `db.transaction` callback shape) and use it in
`applyReversal` so the 12+ downstream tx writes get full inference.

**C2 — berth-detail page stacked `useQuery<any>` escape hatches**
Export `BerthDetailData` from berth-detail-header and consume it
through useQuery + apiFetch. Removed three `any` escapes in the
highest-traffic detail page. Also collapsed the duplicate `BerthData`
in berth-tabs.tsx to import from berth-detail-header so the two
types can't drift.

**C3 — parseBody migration for portal/public routes**
Replace raw `await req.json() + schema.parse(body)` with the
project-standard `parseBody(req, schema)` helper across 7 routes:
- portal/auth/{change-password, activate, reset-password}
- auth/set-password
- public/{interests, residential-inquiries}
Skipped the three anti-enumeration routes (forgot-password, sign-in,
sign-in-by-identifier) where the manual validation gives opaque
errors on purpose. website-inquiries already wraps the parse in a
custom 400 — left as-is.

**HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)**
Introduce `toAuditJson<T extends object>(row: T): Record<string,
unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow`
that already exists for the same reason). Codemod 21 `<row> as unknown
as Record<string, unknown>` sites across:
- invoices.ts × 6
- expenses.ts × 6
- berths.service × 2
- documents.service × 2
- ocr-config.service × 2
- ai-budget.service × 2
- yachts.service, companies.service, company-memberships.service × 1 each

document-templates' `payload as unknown as Record<...>` is a different
shape (Documenso form-values widening, not an audit log) — kept the
manual cast there. Tests stay 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:27:08 +02:00

186 lines
5.8 KiB
TypeScript

/**
* 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 { toAuditJson } from '@/lib/audit';
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;
/**
* AI receipt parsing is opt-in per port. When false (the default),
* the scanner uses the in-browser Tesseract.js engine and the AI
* provider is never called even if a key is configured.
*/
aiEnabled: 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;
aiEnabled?: 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) {
// True upsert. The previous delete-then-insert pattern had a race
// window where two concurrent writes could both DELETE and both INSERT,
// accumulating duplicate rows (caught and dedupe'd by migration 0047).
// The (key, port_id) NULLS NOT DISTINCT unique index makes this
// upsert atomic.
await db
.insert(systemSettings)
.values({
key: KEY,
portId,
value: toAuditJson(value),
updatedBy: userId,
})
.onConflictDoUpdate({
target: [systemSettings.key, systemSettings.portId],
set: {
value: toAuditJson(value),
updatedBy: userId,
updatedAt: new Date(),
},
});
}
/**
* 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,
aiEnabled: false,
source: 'none',
};
}
// The aiEnabled flag is per-port: even if the port falls back to a global
// key, the port admin still has to flip the switch on this port.
const aiEnabled = portRow?.aiEnabled === true;
return {
provider: sourceRow.provider,
model: sourceRow.model,
apiKey: sourceRow.apiKeyEncrypted ? decrypt(sourceRow.apiKeyEncrypted) : null,
hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
useGlobal: portRow?.useGlobal === true,
aiEnabled,
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,
aiEnabled: false,
};
}
return {
provider: row.provider,
model: row.model,
hasApiKey: Boolean(row.apiKeyEncrypted),
useGlobal: row.useGlobal,
aiEnabled: row.aiEnabled === true,
};
}
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;
/** Per-port toggle: enable AI receipt parsing. Defaults to false. */
aiEnabled?: 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);
}
// AI is meaningful only at the port scope. Preserve the existing flag if the
// caller didn't pass one (so toggling provider/model doesn't re-disable AI).
const aiEnabled = portId === null ? false : (input.aiEnabled ?? existing?.aiEnabled ?? false);
await writeRow(
portId,
{
provider: input.provider,
model: input.model,
apiKeyEncrypted,
useGlobal: portId === null ? false : Boolean(input.useGlobal),
aiEnabled,
},
userId,
);
}