This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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,
|
||||
permission: { capability: 'data:import', 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<ApiEnv>();
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user