ci / lint-test (push) Successful in 44s
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.
119 lines
4.7 KiB
TypeScript
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 };
|
|
}
|
|
}
|