CM-4: remove Email/Call/WhatsApp deep-link pills from the client + interest detail headers; relocate GDPR export into the client-header action cluster as a compact icon. Keeps the interest "Log contact" quick action. CM-5: gate the interest assignment feature behind a per-port `assignment_enabled` setting (default OFF for single-rep ports). Hides the AssignedToChip + residential assigned-to row and skips tier-2/3 auto-assign on create; the column + data are preserved and reversible. Tests cover the auto-assign guard. CM-6: add a per-port `manualEntry` receipt mode (skip all parsing → empty form). Threaded through ocr-config.service, the admin OCR form, the scan-receipt route, and the scanner shell (skips Tesseract + the server call). Tests cover the save/resolve round-trip. Verified: tsc clean, lint 0 errors, 1631 vitest pass, prod build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
72 lines
2.5 KiB
TypeScript
72 lines
2.5 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { z } from 'zod';
|
|
|
|
import { requireSuperAdmin, withAuth, withPermission } from '@/lib/api/helpers';
|
|
import { parseBody } from '@/lib/api/route-helpers';
|
|
import { errorResponse, ValidationError } 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(),
|
|
aiEnabled: z.boolean().optional(),
|
|
manualEntry: z.boolean().optional(),
|
|
});
|
|
|
|
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
|
|
// may read or write the OCR config: the apiKey is stored encrypted but is
|
|
// passed straight into the receipt-scan handler, so a swapped key would
|
|
// exfiltrate every subsequent receipt image to whatever endpoint that key
|
|
// authenticates with.
|
|
export const GET = withAuth(
|
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
|
try {
|
|
const url = new URL(req.url);
|
|
const scope = url.searchParams.get('scope') ?? 'port';
|
|
if (scope === 'global') {
|
|
requireSuperAdmin(ctx, 'admin.ocr-settings.read.global');
|
|
}
|
|
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(
|
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
|
try {
|
|
const body = await parseBody(req, saveSchema);
|
|
if (body.scope === 'global') {
|
|
requireSuperAdmin(ctx, 'admin.ocr-settings.write.global');
|
|
}
|
|
const validModels = OCR_MODELS[body.provider];
|
|
if (!validModels.includes(body.model)) {
|
|
throw new ValidationError(`Invalid model for provider ${body.provider}`);
|
|
}
|
|
await saveOcrConfig(
|
|
body.scope === 'global' ? null : ctx.portId,
|
|
{
|
|
provider: body.provider,
|
|
model: body.model,
|
|
apiKey: body.apiKey,
|
|
clearApiKey: body.clearApiKey,
|
|
useGlobal: body.useGlobal,
|
|
aiEnabled: body.aiEnabled,
|
|
manualEntry: body.manualEntry,
|
|
},
|
|
ctx.userId,
|
|
);
|
|
return new NextResponse(null, { status: 204 });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}),
|
|
);
|