import { FACT_STATUSES } from '@pig/core'; import type { Fact, Database } from '@pig/db'; import { accounts, contacts, facts } from '@pig/db'; import { and, desc, eq } from 'drizzle-orm'; import { Hono } from 'hono'; import { z } from 'zod'; import { apiError, MutationError, mutation, type ApiEnv, type MutationDefinition, } from '../lib/mutation'; const factDecisionSchema = z .object({ status: z.enum(FACT_STATUSES).refine( (status): status is 'approved' | 'dismissed' => status === 'approved' || status === 'dismissed', 'A review may only approve or dismiss a proposed fact.', ), }) .strict(); export interface FactDecisionResult { fact: Fact; /** Approval records a judgement; applying arbitrary field names is a separate safe system. */ recordUpdated: false; } function carriesEvidence(fact: Fact): boolean { return Boolean( fact.sourceUrl?.trim() || (fact.evidence && Object.keys(fact.evidence).length > 0), ); } /** * Human approval accepts the evidence, not an untyped write to an arbitrary * CRM field. Keeping those operations separate prevents a plausible claim * such as `email` from silently overwriting a real person's record. */ export const factDecisionDefinition: MutationDefinition< typeof factDecisionSchema, FactDecisionResult > = { schema: factDecisionSchema, // `fact:review`, not `data:import`. Accepting an agent's claim about a named // person is a judgement about evidence; rewriting five thousand rows from a // spreadsheet is not. They shared a capability until an audit noticed that // granting either granted both. permission: { capability: 'fact:review', team: 'research' }, invalidMessage: 'Invalid fact review decision.', async mutate({ input, params, principal, tx, now }) { const id = params.id; if (!id) throw MutationError.notFound('Fact'); const [before] = await tx.select().from(facts).where(eq(facts.id, id)).limit(1); if (!before) throw MutationError.notFound('Fact'); if (before.status !== 'proposed') { throw new MutationError( 'fact_already_decided', 'Only a proposed fact can be reviewed.', 409, ); } if (input.status === 'approved' && !carriesEvidence(before)) { throw new MutationError( 'missing_evidence', 'A fact needs a source or evidence before it can be approved.', 409, ); } const [updated] = await tx .update(facts) .set({ status: input.status, decidedByUserId: principal.userId, decidedAt: now, }) .where(and(eq(facts.id, id), eq(facts.status, 'proposed'))) .returning(); if (!updated) { throw new MutationError( 'fact_already_decided', 'This fact was reviewed by someone else.', 409, ); } const approved = input.status === 'approved'; return { data: { fact: updated, recordUpdated: false }, activity: { type: 'agent_action', subject: `${approved ? 'Approved' : 'Dismissed'} proposed ${before.field}`, body: approved ? 'Evidence approved; the CRM record remains unchanged until field-aware application is available.' : 'Proposal dismissed; the CRM record was not changed.', accountId: before.accountId ?? undefined, contactId: before.contactId ?? undefined, meta: { factId: before.id, decision: input.status, field: before.field, recordUpdated: false, }, }, }; }, }; export function createFactsRoute(db: Database) { const route = new Hono(); route.get('/api/facts', async (c) => { const parsedStatus = z .enum(FACT_STATUSES) .safeParse(c.req.query('status') ?? 'proposed'); if (!parsedStatus.success) { return c.json( apiError('invalid_request', 'Unknown fact status.', parsedStatus.error.issues), 400, ); } const rows = await db .select({ fact: facts, accountName: accounts.name, contactName: contacts.fullName, }) .from(facts) .leftJoin(accounts, eq(facts.accountId, accounts.id)) .leftJoin(contacts, eq(facts.contactId, contacts.id)) .where(eq(facts.status, parsedStatus.data)) .orderBy(desc(facts.observedAt)) .limit(200); return c.json({ facts: rows }); }); route.patch('/api/facts/:id/decision', mutation(db, factDecisionDefinition)); return route; }