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 }; } }