wip(intake): router, enrich endpoint, Slack handlers, migration 0034 — UNTESTED checkpoint
ci / lint-test (push) Successful in 44s
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.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user