Files
finance-app/src/lib/intake-actions.ts
T
siddharthd 057595f4ac
ci / lint-test (push) Successful in 44s
wip(intake): router, enrich endpoint, Slack handlers, migration 0034 — UNTESTED checkpoint
Agent was terminated by the session usage limit before running lint/tests or
writing its report. Files are complete drafts against plan §11 (C1, C2, C5):
src/app/api/intake/, src/app/api/transactions/[id]/line-items/, src/lib/intake-*.ts,
src/lib/line-items.ts, prisma/migrations/0034_intake_batches, fixtures + tests.
Next: npm ci && npx prisma generate && npm run lint && npm test && npm run test:integration.
2026-08-21 12:03:48 +10:00

119 lines
4.7 KiB
TypeScript

import { loadFiles } from "@/lib/intake-files";
import type { PantryForward } from "@/lib/intake-pantry";
import type { IntakeSource } from "@/lib/intake-rules";
import {
createFromBatch,
loadBatch,
rememberKind,
resolvedCard,
routeFood,
routeGrocery,
type BatchRow,
type RouteContext,
} from "@/lib/intake-router";
import { replaceLineItems, TransactionNotFoundError } from "@/lib/line-items";
import { enrichedCard, errorCard, type Block } from "@/lib/intake-blocks";
import { queryRaw } from "@/lib/db";
/**
* The four intake taps (plan §11 C5), as one function the Slack route calls.
*
* Every tap acts on the persisted batch, never on the card's text. A tap on a
* batch that is already resolved returns its current card rather than acting
* twice — a pressed button that looked dead and got pressed again is how three
* splits were lost on the order nudge, and the same shape applies here.
*/
export const INTAKE_ACTIONS = ["intake_use_candidate", "intake_create", "intake_grocery", "intake_meal"] as const;
export type IntakeActionId = (typeof INTAKE_ACTIONS)[number];
export function isIntakeAction(id: unknown): id is IntakeActionId {
return typeof id === "string" && (INTAKE_ACTIONS as readonly string[]).includes(id);
}
export type IntakeActionValue = { batch_id?: string; transaction_id?: number };
export function parseActionValue(raw: unknown): IntakeActionValue {
if (typeof raw !== "string") return {};
try {
const v = JSON.parse(raw) as IntakeActionValue;
return v && typeof v === "object" ? v : {};
} catch {
return {};
}
}
const note = (text: string): { blocks: Block[]; text: string; replace: boolean } => ({
text,
blocks: [{ type: "section", text: { type: "mrkdwn", text } }],
replace: false,
});
const ctxFor = (batch: BatchRow): RouteContext | null =>
batch.header
? {
batchId: batch.id,
source: batch.source as IntakeSource,
sharedBy: batch.shared_by,
platform: batch.header.platform,
header: batch.header,
items: batch.items ?? [],
}
: null;
export async function handleIntakeAction(
actionId: IntakeActionId,
value: IntakeActionValue,
deps: { pantry: PantryForward }
): Promise<{ blocks: Block[]; text: string; replace: boolean }> {
if (!value.batch_id) return note("That button carried no batch id — I can't tell which upload it was.");
const batch = await loadBatch(value.batch_id);
if (!batch) return note("I can't find that upload any more.");
if (batch.status === "enriched" || batch.status === "created" || batch.status === "forwarded_to_pantry") {
return { ...resolvedCard(batch), replace: true };
}
const ctx = ctxFor(batch);
if (!ctx) return note("That upload never got a readable header — share it again with the top of the screen.");
try {
if (actionId === "intake_use_candidate") {
if (!Number.isInteger(value.transaction_id)) return note("That button carried no transaction id.");
const txnId = value.transaction_id as number;
await replaceLineItems(txnId, ctx.items, { source: "intake", platform: ctx.platform, batchId: batch.id });
await queryRaw(
`UPDATE intake_batches SET status = 'enriched', kind = COALESCE(kind, 'food'), transaction_id = $2, resolved_at = now() WHERE id = $1::uuid`,
[batch.id, txnId]
);
return {
...enrichedCard({ batchId: batch.id, merchant: ctx.header.merchant, total: ctx.header.total, orderDate: ctx.header.order_date, platform: ctx.platform, itemCount: ctx.items.length, transactionId: txnId }),
replace: true,
};
}
if (actionId === "intake_create") {
const kind = batch.kind === "grocery" ? "grocery" : "food";
const r = await createFromBatch(batch, kind);
return { blocks: r.slack_blocks ?? [], text: r.text, replace: true };
}
if (actionId === "intake_meal") {
await rememberKind(ctx.header.merchant, "food");
const r = await routeFood(ctx);
return { blocks: r.slack_blocks ?? [], text: r.text, replace: true };
}
// intake_grocery
await rememberKind(ctx.header.merchant, "grocery");
const files = await loadFiles(batch.file_sha256);
if (!files.length) {
return note("I remembered that as groceries, but the original files are gone — share it again and it will go straight to the pantry board.");
}
const r = await routeGrocery(ctx, files, deps.pantry);
return { blocks: r.slack_blocks ?? [], text: r.text, replace: r.status !== "error" };
} catch (e) {
if (e instanceof TransactionNotFoundError) return note("That transaction is no longer in the ledger.");
const detail = e instanceof Error ? e.message : String(e);
return { ...errorCard(detail), replace: false };
}
}