wip(intake): router, enrich endpoint, Slack handlers, migration 0034 — UNTESTED checkpoint
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:
2026-08-21 12:03:48 +10:00
parent b958d05287
commit 057595f4ac
20 changed files with 2471 additions and 2 deletions
@@ -0,0 +1,64 @@
-- Intake router (plans/intake-router-slice-plan.md §11 C1, smarthome repo).
--
-- One upload point for receipts — a screenshot, a PDF export, a pasted body —
-- that reads what it was given and does every step that applies: fill the
-- empty `line_items` on the order the email pipeline already booked, hand a
-- grocery receipt to pantry, or ask when it cannot tell.
--
-- Every batch is persisted BEFORE anything is extracted, for two reasons:
-- 1. The Slack card it produces carries buttons ("Use this", "Create",
-- "Groceries or a meal?") that are pressed minutes later in a fresh
-- request with nothing but the batch id to go on.
-- 2. A re-share of an already-handled order — a second crop, the PDF after
-- the screenshot — must be recognised, and the only durable evidence of
-- the first share is this row (`file_sha256` overlap, or the same
-- (platform, merchant, date, total) header already resolved).
--
-- `id` is the uuid n8n mints per webhook call, so a retried delivery is the
-- same batch rather than a second one. `header` holds the extracted
-- (merchant, order_date, total, platform, ...) and `items` the verbatim item
-- list, so a later tap can enrich or create without re-reading the files.
--
-- Idempotent: safe to re-run.
CREATE TABLE IF NOT EXISTS intake_batches (
id uuid PRIMARY KEY,
source text NOT NULL,
shared_by text,
file_sha256 text[] NOT NULL DEFAULT '{}',
header jsonb,
items jsonb,
kind text,
status text NOT NULL,
transaction_id integer,
capture_id integer,
created_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz
);
COMMENT ON TABLE intake_batches IS
'One row per upload batch through POST /api/intake. Persisted before '
'extraction so Slack taps can act on it later and re-shares are detected. '
'kind: grocery|food|ask|held|unsupported. status: received|enriched|created|'
'candidates|forwarded_to_pantry|needs_header|duplicate|unsupported|held|error.';
-- Overlap lookups for the re-share check (`file_sha256 && $1`).
CREATE INDEX IF NOT EXISTS intake_batches_file_sha256_gin
ON intake_batches USING gin (file_sha256);
CREATE INDEX IF NOT EXISTS intake_batches_transaction_id
ON intake_batches (transaction_id) WHERE transaction_id IS NOT NULL;
-- The "Groceries or a meal?" answer, remembered per merchant (plan §2, Codex
-- #6). A tiny answer table, not merchant master-data: the pattern list in code
-- decides the obvious cases and this catches the `Romeo's Food Hall`s. Keyed on
-- the normalised merchant name the router already computes for matching.
CREATE TABLE IF NOT EXISTS intake_merchant_kinds (
merchant_key text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('grocery', 'food')),
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE intake_merchant_kinds IS
'Per-merchant answer to "Groceries or a meal?" from the intake Slack card. '
'Consulted after the grocery pattern list; an unknown merchant asks once.';
+25
View File
@@ -268,3 +268,28 @@ model rule_apply_runs {
reverted_at DateTime?
snapshot Json @default("[]")
}
// 0034 — intake router. One row per upload batch, written before extraction so
// Slack taps can act on it later and a re-share is detected. Columns are the
// §11 C1 contract in plans/intake-router-slice-plan.md (smarthome repo).
model intake_batches {
id String @id @db.Uuid
source String
shared_by String?
file_sha256 String[] @default([])
header Json?
items Json?
kind String?
status String
transaction_id Int?
capture_id Int?
created_at DateTime @default(now()) @db.Timestamptz(6)
resolved_at DateTime? @db.Timestamptz(6)
}
// 0034 — the remembered answer to "Groceries or a meal?" per merchant.
model intake_merchant_kinds {
merchant_key String @id
kind String
created_at DateTime @default(now()) @db.Timestamptz(6)
}