feat(crm): client-meeting batch — contact-pill cleanup, assignment toggle, receipt manual mode
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 9m16s

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>
This commit is contained in:
2026-06-18 21:42:36 +02:00
parent 7f04c765f4
commit 4dc0bdd8c4
14 changed files with 339 additions and 190 deletions

View File

@@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
export const metadata: Metadata = {
title: 'Scan receipt',
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
// CM-6: manual-entry mode is resolved server-side so the client can skip
// on-device parsing entirely (no wasted Tesseract pass) and open an empty form.
const ocr = port ? await getResolvedOcrConfig(port.id).catch(() => null) : null;
return (
<ScanShell
logoUrl={branding?.logoUrl ?? null}
portName={port?.name ?? null}
manualEntry={ocr?.manualEntry ?? false}
/>
);
}

View File

@@ -15,6 +15,7 @@ const saveSchema = z.object({
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)
@@ -58,6 +59,7 @@ export const PUT = withAuth(
clearApiKey: body.clearApiKey,
useGlobal: body.useGlobal,
aiEnabled: body.aiEnabled,
manualEntry: body.manualEntry,
},
ctx.userId,
);

View File

@@ -48,6 +48,14 @@ export const POST = withAuth(
}
const config = await getResolvedOcrConfig(ctx.portId);
// CM-6: manual-entry mode short-circuits ALL parsing - the operator
// types the details by hand. The client should skip this route entirely
// in manual mode, but we guard server-side too.
if (config.manualEntry) {
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'manual-mode' },
});
}
// Tesseract.js (in-browser) is the default. The server only invokes
// an AI provider when (a) the port admin has flipped `aiEnabled` on
// and (b) a key resolves. Otherwise the client falls back to its

View File

@@ -30,6 +30,7 @@ interface ConfigResp {
hasApiKey: boolean;
useGlobal: boolean;
aiEnabled: boolean;
manualEntry: boolean;
};
models: Record<Provider, string[]>;
}
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
// Key the body on the loaded payload so useState initializers seed
// from server values cleanly.
const sig = data?.data
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}`
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}:${data.data.manualEntry}`
: 'loading';
return (
<SettingsBlockBody
@@ -89,6 +90,7 @@ function SettingsBlockBody({
const [showKey, setShowKey] = useState(false);
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
const [manualEntry, setManualEntry] = useState(data?.data.manualEntry ?? false);
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
null,
);
@@ -105,6 +107,7 @@ function SettingsBlockBody({
clearApiKey: Boolean(clearApiKey),
useGlobal: scope === 'global' ? false : useGlobal,
aiEnabled: scope === 'global' ? false : aiEnabled,
manualEntry: scope === 'global' ? false : manualEntry,
},
}),
onSuccess: () => {
@@ -190,6 +193,25 @@ function SettingsBlockBody({
</div>
) : null}
{scope === 'port' ? (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id={`manualEntry-${scope}`}
checked={manualEntry}
onCheckedChange={(v) => setManualEntry(v === true)}
/>
<div className="space-y-0.5">
<Label htmlFor={`manualEntry-${scope}`} className="text-sm font-medium">
Manual entry only (skip receipt scanning)
</Label>
<p className="text-xs text-muted-foreground">
When on, staff just attach a receipt photo and type the details by hand - no
on-device or AI parsing runs. Takes precedence over AI parsing above.
</p>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`provider-${scope}`}>Provider</Label>

View File

@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean',
defaultValue: true,
},
{
key: 'assignment_enabled',
label: 'Interest Assignment',
description:
'Allow assigning interests to sales users (the "Assigned to" owner chip + auto-assign on create). Off by default - turn on only when more than one person works the pipeline. Disabling hides the assignment UI and stops auto-assigning new interests; existing assignment data is preserved and reappears if you re-enable.',
type: 'boolean',
defaultValue: false,
},
{
key: 'tenancies_module_enabled',
label: 'Tenancies Module',

View File

@@ -3,11 +3,9 @@
import { useParams, useRouter } from 'next/navigation';
import type { Route } from 'next';
import { useState } from 'react';
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { PermissionGate } from '@/components/shared/permission-gate';
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
client.contacts?.find((c) => c.channel === 'email')?.value;
const primaryPhoneContact =
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone');
const primaryPhone = primaryPhoneContact?.value;
// wa.me requires the E.164 number without the leading "+". Strip from the
// canonical E.164 form when available; otherwise strip non-digits from the
// display value as a best-effort fallback.
const whatsappNumber = primaryPhoneContact?.valueE164
? primaryPhoneContact.valueE164.replace(/^\+/, '')
: primaryPhoneContact?.value
? primaryPhoneContact.value.replace(/[^\d]/g, '')
: null;
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const addedLabel = client.createdAt
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</p>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{primaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
<Mail />
Email
</a>
</Button>
) : null}
{primaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message ${primaryPhone} on WhatsApp`}
>
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>
) : null}
{!isArchived && client.clientPortalEnabled === true ? (
{/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
request. GDPR export moved to the top-right action cluster.
Portal-invite stays as the one primary CTA here. */}
{!isArchived && client.clientPortalEnabled === true ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<div className="hidden sm:inline-flex">
<PortalInviteButton
clientId={client.id}
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
defaultEmail={primaryEmail}
/>
</div>
) : null}
<div className="hidden sm:inline-flex">
<GdprExportButton clientId={client.id} />
</div>
</div>
) : null}
{client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
right perm) permanently-delete. Destructive actions sit out
of the primary action flow. */}
<div className="flex items-start gap-1">
{/* CM-4: GDPR export relocated here as a compact icon trigger,
alongside reminder/archive/delete. Self-gates on permission. */}
<GdprExportButton clientId={client.id} variant="icon" />
{isArchived && (
<PermissionGate resource="admin" action="permanently_delete_clients">
<button

View File

@@ -48,7 +48,15 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
failed: 'destructive',
};
export function GdprExportButton({ clientId }: { clientId: string }) {
export function GdprExportButton({
clientId,
variant = 'button',
}: {
clientId: string;
/** `button` = standalone outline button (default). `icon` = compact icon-only
* trigger for the detail-header top-right action cluster (CM-4). */
variant?: 'button' | 'icon';
}) {
const { can, isSuperAdmin } = usePermissions();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
GDPR export
</Button>
{variant === 'icon' ? (
<button
type="button"
aria-label="GDPR export"
title="GDPR export"
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
>
<FileDown className="size-4" aria-hidden />
</button>
) : (
<Button variant="outline" size="sm" className="h-8">
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
GDPR export
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>

View File

@@ -11,14 +11,11 @@ import {
Trophy,
XCircle,
RefreshCcw,
Mail,
MessageSquarePlus,
Phone,
AlarmClock,
User,
} from 'lucide-react';
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
@@ -35,6 +32,7 @@ import { AssignedToChip } from '@/components/interests/assigned-to-chip';
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { formatOutcome } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { cn } from '@/lib/utils';
@@ -74,9 +72,9 @@ interface InterestDetailHeaderProps {
id: string;
clientId: string;
clientName: string | null;
/** Primary contact channels resolved from the linked client. The header
* uses these to render Email / Call / WhatsApp buttons so the rep
* doesn't have to navigate to the client page just to reach out. */
/** Primary contact channels resolved from the linked client. The
* Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload
* for downstream reuse (e.g. proxy comms routing, CM-9). */
clientPrimaryEmail?: string | null;
clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null;
@@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [logContactOpen, setLogContactOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false);
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
// CM-5: assignment UI is hidden when the per-port toggle is off (default).
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
const isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
const isClosed = !!interest.outcome;
// Contact deep-links - resolved from the linked client's primary channels.
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
// stripping non-digits from the display value when the canonical form is
// missing.
const whatsappNumber = interest.clientPrimaryPhoneE164
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
: interest.clientPrimaryPhone
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
: null;
const reopenMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
@@ -285,13 +275,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
{interest.activeReminderCount}
</span>
) : null}
<PermissionGate resource="interests" action="edit">
<AssignedToChip
interestId={interest.id}
currentAssignedTo={interest.assignedTo ?? null}
currentAssignedToName={interest.assignedToName ?? null}
/>
</PermissionGate>
{assignmentEnabled ? (
<PermissionGate resource="interests" action="edit">
<AssignedToChip
interestId={interest.id}
currentAssignedTo={interest.assignedTo ?? null}
currentAssignedToName={interest.assignedToName ?? null}
/>
</PermissionGate>
) : null}
<MultiEoiChip interestId={interest.id} />
<DealPulseChip
interest={{
@@ -340,94 +332,38 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</div>
)}
{/* Contact deep-links - let the rep email / call / WhatsApp the
client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
{interest.clientPrimaryEmail ||
interest.clientPrimaryPhone ||
whatsappNumber ||
interest.clientId ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button>
) : null}
{interest.clientPrimaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`mailto:${interest.clientPrimaryEmail}`}
aria-label={`Email ${interest.clientPrimaryEmail}`}
>
<Mail />
Email
</a>
</Button>
) : null}
{interest.clientPrimaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`tel:${interest.clientPrimaryPhone}`}
aria-label={`Call ${interest.clientPrimaryPhone}`}
>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message on WhatsApp`}
>
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>
) : null}
{/* CM-4: Email/Call/WhatsApp deep-links removed at client request.
Client-page link + Log-contact action stay - the rep can still
jump to the client and record outreach without leaving here. */}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
onClick={() => setLogContactOpen(true)}
aria-label="Log a contact for this interest"
>
<MessageSquarePlus />
Log contact
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button>
</div>
) : null}
) : null}
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
onClick={() => setLogContactOpen(true)}
aria-label="Log a contact for this interest"
>
<MessageSquarePlus />
Log contact
</Button>
</div>
</div>
{/* Top-right actions. Won/Lost are sales-critical and read as text

View File

@@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { SOURCES } from '@/lib/constants';
interface ResidentialInterest {
@@ -95,6 +96,8 @@ function OverviewTab({
stageOptions: Array<{ value: string; label: string }>;
}) {
const update = useInterestPatch(interestId);
// CM-5: residential assignment row hidden when the per-port toggle is off.
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
const save = (field: string) => async (next: string | null) => {
await update.mutateAsync({ [field]: next });
};
@@ -105,6 +108,7 @@ function OverviewTab({
}>({
queryKey: ['residential-assignable-users'],
queryFn: () => apiFetch('/api/v1/residential/assignable-users'),
enabled: assignmentEnabled,
});
const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({
value: u.id,
@@ -132,15 +136,17 @@ function OverviewTab({
onSave={save('source')}
/>
</Row>
<Row label="Assigned to">
<InlineEditableField
variant="select"
options={assigneeOptions}
value={interest.assignedTo}
onSave={save('assignedTo')}
placeholder="Unassigned"
/>
</Row>
{assignmentEnabled ? (
<Row label="Assigned to">
<InlineEditableField
variant="select"
options={assigneeOptions}
value={interest.assignedTo}
onSave={save('assignedTo')}
placeholder="Unassigned"
/>
</Row>
) : null}
</div>
<div className="space-y-1">

View File

@@ -320,9 +320,11 @@ interface ScanShellProps {
* imagery. */
logoUrl?: string | null;
portName?: string | null;
/** CM-6: when true, skip ALL parsing - open an empty form for manual entry. */
manualEntry?: boolean;
}
export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
export function ScanShell({ logoUrl, portName, manualEntry = false }: ScanShellProps = {}) {
const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug);
const fileRef = useRef<HTMLInputElement>(null);
@@ -351,6 +353,26 @@ export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(URL.createObjectURL(file));
setCurrentFile(file);
// CM-6: manual-entry mode - the port admin disabled scanning. Skip
// Tesseract AND the server call entirely; go straight to an empty form.
if (manualEntry) {
setState({
kind: 'verify',
parsed: {
establishment: null,
date: null,
amount: null,
currency: null,
lineItems: [],
confidence: 0,
},
source: 'manual',
reason: 'manual-mode',
});
return;
}
setState({ kind: 'processing', engine: 'tesseract' });
// Always run Tesseract first - it's free, on-device, and gives us a

View File

@@ -833,7 +833,12 @@ export async function createInterest(portId: string, data: CreateInterestInput,
// every new lead. Falls back to null (Unassigned) when none of
// the above resolve.
let resolvedAssignedTo = interestData.assignedTo ?? null;
if (resolvedAssignedTo === null && !('assignedTo' in interestData)) {
// CM-5: tiers 2 & 3 (port default-owner + auto-assign-to-creator) only run
// when the per-port assignment feature is enabled. Tier 1 (an explicit
// assignedTo from the caller) is always honored. Default is OFF.
const assignmentSetting = await getSetting('assignment_enabled', portId);
const assignmentEnabled = assignmentSetting?.value === true;
if (assignmentEnabled && resolvedAssignedTo === null && !('assignedTo' in interestData)) {
const defaultOwner = await getSetting('default_new_interest_owner', portId);
const v = defaultOwner?.value as { userId?: string } | null | undefined;
if (v?.userId) {

View File

@@ -37,6 +37,12 @@ export interface OcrConfigPublic {
* provider is never called even if a key is configured.
*/
aiEnabled: boolean;
/**
* CM-6: manual-entry mode. When true the scanner skips ALL parsing
* (Tesseract + AI) and presents an empty form for the operator to fill in
* by hand. Per-port; takes precedence over `aiEnabled`. Default false.
*/
manualEntry: boolean;
}
/** Internal shape including the decrypted key - server-side only. */
@@ -52,6 +58,7 @@ interface StoredOcrConfig {
apiKeyEncrypted: string | null;
useGlobal: boolean;
aiEnabled?: boolean;
manualEntry?: boolean;
}
const KEY = 'ocr.config';
@@ -106,12 +113,14 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
hasApiKey: false,
useGlobal: portRow?.useGlobal === true,
aiEnabled: false,
manualEntry: portRow?.manualEntry === true,
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.
// The aiEnabled / manualEntry flags are per-port: even if the port falls back
// to a global key, the port admin still has to flip these on this port.
const aiEnabled = portRow?.aiEnabled === true;
const manualEntry = portRow?.manualEntry === true;
return {
provider: sourceRow.provider,
model: sourceRow.model,
@@ -119,6 +128,7 @@ export async function getResolvedOcrConfig(portId: string): Promise<OcrConfigRes
hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
useGlobal: portRow?.useGlobal === true,
aiEnabled,
manualEntry,
source: useGlobal ? 'global' : 'port',
};
}
@@ -133,6 +143,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
hasApiKey: false,
useGlobal: false,
aiEnabled: false,
manualEntry: false,
};
}
return {
@@ -141,6 +152,7 @@ export async function getPublicOcrConfig(portId: string | null): Promise<OcrConf
hasApiKey: Boolean(row.apiKeyEncrypted),
useGlobal: row.useGlobal,
aiEnabled: row.aiEnabled === true,
manualEntry: row.manualEntry === true,
};
}
@@ -154,6 +166,8 @@ export interface SaveOcrConfigInput {
useGlobal?: boolean;
/** Per-port toggle: enable AI receipt parsing. Defaults to false. */
aiEnabled?: boolean;
/** Per-port toggle: manual entry (skip all parsing). Defaults to false. */
manualEntry?: boolean;
}
export async function saveOcrConfig(
@@ -171,6 +185,9 @@ export async function saveOcrConfig(
// 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);
// Manual entry is also port-only; preserve when the caller omits it.
const manualEntry =
portId === null ? false : (input.manualEntry ?? existing?.manualEntry ?? false);
await writeRow(
portId,
{
@@ -179,6 +196,7 @@ export async function saveOcrConfig(
apiKeyEncrypted,
useGlobal: portId === null ? false : Boolean(input.useGlobal),
aiEnabled,
manualEntry,
},
userId,
);

View File

@@ -0,0 +1,94 @@
/**
* CM-5: interest assignment is gated behind the per-port `assignment_enabled`
* setting. When off (the default), createInterest must NOT auto-assign an owner
* even when a `default_new_interest_owner` is configured. When on, the existing
* tier-2 (port default-owner) auto-assign fires. An explicit `assignedTo` from
* the caller (tier 1) is always honored regardless of the toggle.
*/
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
// interests.assigned_to FKs to user_profiles(user_id); the owner must exist.
const OWNER = 'cm5-default-owner';
describe('interests.service - assignment_enabled gate (CM-5)', () => {
let createInterest: typeof import('@/lib/services/interests.service').createInterest;
let makePort: typeof import('../helpers/factories').makePort;
let makeClient: typeof import('../helpers/factories').makeClient;
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
beforeAll(async () => {
const svc = await import('@/lib/services/interests.service');
createInterest = svc.createInterest;
const factories = await import('../helpers/factories');
makePort = factories.makePort;
makeClient = factories.makeClient;
makeAuditMeta = factories.makeAuditMeta;
// Idempotent owner profile - left in place (created interests reference it,
// so we never delete it in teardown).
await db
.insert(userProfiles)
.values({ userId: OWNER, displayName: 'CM5 Default Owner' })
.onConflictDoNothing();
});
afterEach(async () => {
await db.delete(systemSettings).where(eq(systemSettings.key, 'assignment_enabled'));
await db.delete(systemSettings).where(eq(systemSettings.key, 'default_new_interest_owner'));
});
async function setSetting(portId: string, key: string, value: unknown) {
await db.insert(systemSettings).values({ key, portId, value: value as never });
}
it('does NOT auto-assign the port default owner when assignment is disabled (default)', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// A default owner IS configured, but the feature is OFF - the guard must
// skip tier-2 entirely and leave the interest unassigned.
await setSetting(port.id, 'default_new_interest_owner', { userId: OWNER });
const interest = await createInterest(
port.id,
{ clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
makeAuditMeta({ portId: port.id }),
);
expect(interest.assignedTo).toBeNull();
});
it('auto-assigns the port default owner when assignment is enabled', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
await setSetting(port.id, 'assignment_enabled', true);
await setSetting(port.id, 'default_new_interest_owner', { userId: OWNER });
const interest = await createInterest(
port.id,
{ clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
makeAuditMeta({ portId: port.id }),
);
expect(interest.assignedTo).toBe(OWNER);
});
it('always honors an explicit assignedTo regardless of the toggle', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// Feature off, but the caller explicitly picked an owner - tier 1 wins.
const interest = await createInterest(
port.id,
{
clientId: client.id,
assignedTo: OWNER,
pipelineStage: 'enquiry',
tagIds: [],
reminderEnabled: false,
},
makeAuditMeta({ portId: port.id }),
);
expect(interest.assignedTo).toBe(OWNER);
});
});

View File

@@ -147,6 +147,60 @@ describe('OCR config', () => {
expect(resolved.aiEnabled).toBe(false);
});
// CM-6: manual-entry mode (skip all parsing) - mirrors the aiEnabled contract.
it('manualEntry defaults to false and round-trips when toggled', async () => {
const port = await makePort();
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'sk-y' },
'user-1',
);
let resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(false);
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', manualEntry: true },
'user-1',
);
resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(true);
expect(resolved.apiKey).toBe('sk-y'); // toggling the mode never wipes the key
});
it('manualEntry is preserved when other fields change', async () => {
const port = await makePort();
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'sk-z', manualEntry: true },
'user-1',
);
// Update the model only - manualEntry must survive (mirrors aiEnabled).
await saveOcrConfig(port.id, { provider: 'openai', model: 'gpt-4o' }, 'user-1');
const resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(true);
expect(resolved.model).toBe('gpt-4o');
});
it('manualEntry shows on the public view and is forced false at global scope', async () => {
await saveOcrConfig(
null,
{ provider: 'openai', model: 'gpt-4o-mini', apiKey: 'g', manualEntry: true },
'user-1',
);
const port = await makePort();
const resolved = await getResolvedOcrConfig(port.id);
expect(resolved.manualEntry).toBe(false); // per-port, never inherited from global
await saveOcrConfig(
port.id,
{ provider: 'openai', model: 'gpt-4o-mini', manualEntry: true },
'user-1',
);
const pub = await getPublicOcrConfig(port.id);
expect(pub.manualEntry).toBe(true);
});
it('global rows force useGlobal=false on save (not meaningful at global scope)', async () => {
await saveOcrConfig(
null,