/** * `dench crm detections …` — what the ingestion jobs created, and the one * rollback for all of it (plan §4, §13 #38). * * Every counterparty that emails the workspace from a domain the CRM does * not know gets ONE verdict (`crmAccountDetections`): Jev classifies the * conversation against ORGANISATION.md and only potential / existing * customers become an account + contacts. The LinkedIn resolver files its * rows on the same table (`evidenceKind: linkedin`), and tasks from email * leave a decision per candidate (`crmActivityTasks`). This surface is the * audit trail ("why is Acme in my CRM?" / "why isn't it?") and the undo. * * list [--verdict created|skipped|pending|no_org_context|failed|rolled_back] * [--kind detections|linkedin|all] [--since 30d|2026-09-01] [--limit N] [--json] * undo --since <30d|2026-09-01|ms> [--kind detections|linkedin|email-tasks|all] * [--dry-run] [--limit N] [--json] (admin) * * `undo` deletes what the detector (`detections`: email + the sweep), the * LinkedIn resolver (`linkedin`) and tasks-from-email (`email-tasks`) * created since `--since` that no human has touched (no user/agent write in * the row's history, no comment), and marks the verdicts / decisions * `rolled_back` so nothing is re-created. Rows a human edited stay, with * their `created` verdict. `--kind` defaults to `all`. Bounded per call; * the output says when to run it again. */ import { makeFunctionReference } from "convex/server"; import { assertNoUnknownFlags, type CrmCliContext, CrmCliError, callMutation, callQuery, out, } from "./crm"; import { readSince } from "./crm-comments"; import { getFlag, hasFlag } from "./lib/cli-args"; const api = { list: makeFunctionReference<"query">("functions/crm/accountDetection:list"), undo: makeFunctionReference<"mutation">( "functions/crm/accountDetection:undo", ), }; export const DETECTION_VERDICTS = [ "pending", "created", "skipped", "no_org_context", "failed", "rolled_back", ] as const; export const DETECTION_LIST_KINDS = ["detections", "linkedin", "all"] as const; export const DETECTION_UNDO_KINDS = [ "detections", "linkedin", "email-tasks", "all", ] as const; type UndoKind = (typeof DETECTION_UNDO_KINDS)[number]; type UndoRowKind = Exclude; /** Convex object fields must be identifiers: `email-tasks` → `emailTasks`. */ type UndoKindCountsKey = "detections" | "linkedin" | "emailTasks"; function undoKindCountsKey(kind: UndoRowKind): UndoKindCountsKey { return kind === "email-tasks" ? "emailTasks" : kind; } type DetectionRow = { key: string; personal: boolean; verdict: (typeof DETECTION_VERDICTS)[number]; evidenceKind: string; counterpartyType?: string; confidence?: number; wantsToUseProduct?: number; buyingIntent?: number; decidedAt: number; createdCompanyEntryId?: string; createdPersonEntryIds?: string[]; error?: string; }; type UndoKindCounts = { scanned: number; deleted: number; kept: number; rolledBack: number; remaining: boolean; }; type UndoResult = { dryRun: boolean; kind: UndoKind; scanned: number; rolledBack: number; deletedEntryIds: string[]; keptEntryIds: string[]; remaining: boolean; kinds: Record; rows: Array<{ kind: UndoRowKind; key: string; verdict: string; deleted: string[]; kept: string[]; }>; }; function readLimit(args: string[], fallback: number, max: number): number { const raw = getFlag(args, "--limit"); if (raw === undefined) return fallback; const limit = Number.parseInt(raw, 10); if (!Number.isFinite(limit) || limit < 1 || limit > max) { throw new CrmCliError(`--limit must be an integer between 1 and ${max}`); } return limit; } function readKind( args: string[], allowed: T, ): T[number] | undefined { const raw = getFlag(args, "--kind"); if (raw === undefined) return undefined; if (!allowed.includes(raw)) { throw new CrmCliError(`--kind must be one of ${allowed.join(", ")}`); } return raw as T[number]; } function pct(value: number | undefined): string { return value === undefined ? "—" : `${Math.round(value * 100)}%`; } export function formatDetectionLine(row: DetectionRow): string { const when = new Date(row.decidedAt).toISOString().slice(0, 16); const type = row.counterpartyType ?? "—"; const createdParts = [ row.createdCompanyEntryId ? "account" : null, row.createdPersonEntryIds?.length ? `${row.createdPersonEntryIds.length} contact${ row.createdPersonEntryIds.length === 1 ? "" : "s" }` : null, ].filter(Boolean); const created = row.verdict === "created" ? ` → ${createdParts.length > 0 ? createdParts.join(" + ") : "matched existing"}` : ""; const error = row.error ? ` (${row.error})` : ""; return `${when} ${row.verdict.padEnd(14)} ${row.evidenceKind.padEnd(9)}${ row.key }${row.personal ? " (personal)" : ""} ${type} ${pct( row.confidence, )} wants ${pct(row.wantsToUseProduct)} intent ${ row.buyingIntent ?? "—" }/10${created}${error}`; } const UNDO_KIND_LABEL: Record = { detections: "detections (email + sweep)", linkedin: "linkedin", "email-tasks": "email-tasks", }; export function formatUndoLines(result: UndoResult): string[] { const head = result.dryRun ? `Dry run: ${result.deletedEntryIds.length} row(s) would be deleted, ${result.keptEntryIds.length} kept (human-touched), ${result.rolledBack} verdict(s) would be rolled back.` : `Deleted ${result.deletedEntryIds.length} row(s), kept ${result.keptEntryIds.length} (human-touched), rolled back ${result.rolledBack} verdict(s).`; const lines = [head]; for (const kind of Object.keys(UNDO_KIND_LABEL) as UndoRowKind[]) { const counts = result.kinds?.[undoKindCountsKey(kind)]; if (!counts || (result.kind !== "all" && result.kind !== kind)) continue; lines.push( ` ${UNDO_KIND_LABEL[kind]}: scanned ${counts.scanned}, ${ result.dryRun ? "would delete" : "deleted" } ${counts.deleted}, kept ${counts.kept}, rolled back ${ counts.rolledBack }${counts.remaining ? ", more remain" : ""}`, ); } for (const row of result.rows) { lines.push( ` [${row.kind}] ${row.key}: ${row.verdict}${ row.deleted.length > 0 ? ` deleted ${row.deleted.join(", ")}` : "" }${row.kept.length > 0 ? ` kept ${row.kept.join(", ")}` : ""}`, ); } if (result.remaining) { lines.push("More rows match; run the same command again to continue."); } return lines; } export async function runDetectionsCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { const verdict = getFlag(ctx.args, "--verdict"); if ( verdict !== undefined && !(DETECTION_VERDICTS as readonly string[]).includes(verdict) ) { throw new CrmCliError( `--verdict must be one of ${DETECTION_VERDICTS.join(", ")}`, ); } const kind = readKind(ctx.args, DETECTION_LIST_KINDS); const since = readSince(ctx.args); const limit = readLimit(ctx.args, 50, 200); assertNoUnknownFlags(ctx.args, "crm detections list"); const rows = (await callQuery(ctx, api.list, { ...(verdict ? { verdict } : {}), ...(kind ? { kind } : {}), ...(since !== undefined ? { since } : {}), limit, })) as DetectionRow[]; if (ctx.jsonOutput) { out(ctx, rows); return; } out( ctx, rows.length === 0 ? "No detection verdicts match." : rows.map(formatDetectionLine).join("\n"), ); return; } case "undo": { const since = readSince(ctx.args); if (since === undefined) { throw new CrmCliError( "undo needs --since (30d, 12h, 2026-09-01 or a ms timestamp)", ); } const kind = readKind(ctx.args, DETECTION_UNDO_KINDS); const dryRun = hasFlag(ctx.args, "--dry-run"); const limit = readLimit(ctx.args, 50, 50); assertNoUnknownFlags(ctx.args, "crm detections undo"); const result = (await callMutation(ctx, api.undo, { since, ...(kind ? { kind } : {}), ...(dryRun ? { dryRun: true } : {}), limit, })) as UndoResult; if (ctx.jsonOutput) { out(ctx, result); return; } out(ctx, formatUndoLines(result).join("\n")); return; } default: throw new CrmCliError( `Usage: dench crm detections list [--verdict V] [--kind detections|linkedin|all] [--since 30d] [--limit N] | undo --since <30d|date|ms> [--kind detections|linkedin|email-tasks|all] [--dry-run] [--limit N]`, ); } }