intake_use_candidate / intake_create / intake_grocery / intake_meal carry JSON
{batch_id, ...} rather than <id>:<verb>, so they branch before the order-nudge
parse. They act on the persisted batch: enrich the chosen transaction, create
ING-10-style (statement_id NULL, owner_id from shared_by, source/source_ref
make a second press a no-op), or remember the merchant's kind and route. A tap
on an already-resolved batch returns its current card and writes nothing. Card
updates go to response_url, as the existing handlers do.
417 lines
17 KiB
TypeScript
417 lines
17 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { queryRaw, queryRow } from "@/lib/db";
|
|
import {
|
|
verifySlackSignature,
|
|
participantForSlackUser,
|
|
slackUserForParticipant,
|
|
} from "@/lib/slack-verify";
|
|
import { nudgeBlocks, detailsModal, partnerNudgeBlocks } from "@/lib/slack-blocks";
|
|
import { handleIntakeAction, isIntakeAction, parseActionValue } from "@/lib/intake-actions";
|
|
import { pantryForwarder } from "@/lib/intake-pantry";
|
|
import { completeSplit } from "@/lib/splits";
|
|
import {
|
|
RATINGS,
|
|
OWNER_PARTICIPANT_ID,
|
|
SECOND_CONSUMER_ID,
|
|
merchantVerdict,
|
|
type Rating,
|
|
type ItemOpinion,
|
|
} from "@/lib/order-reviews";
|
|
|
|
/**
|
|
* Slack button presses on the order nudge.
|
|
*
|
|
* Pressing "Shared 50/50" splits the transaction here and now and edits the
|
|
* message in place. It deliberately does NOT link back to the app: being sent
|
|
* to a web app to answer a yes/no question is enough friction that the question
|
|
* stops getting answered (user, 2026-07-28).
|
|
*
|
|
* **Slack does not reach this route directly.** It posts to an n8n webhook,
|
|
* which forwards the raw body and Slack's signature headers here. That was the
|
|
* user's suggestion (2026-07-28) and it is the better shape: n8n already
|
|
* terminates public webhooks, so the app keeps its blanket OAuth chain and
|
|
* gains no internet-facing unauthenticated route. n8n cannot do the verifying
|
|
* itself — its Code node sandbox has no `require`, so no `crypto`.
|
|
*
|
|
* Two independent gates, both fail closed:
|
|
* 1. `x-ingest-token`, proving the call came from n8n over the internal
|
|
* network. Same shared secret as the order ingest route.
|
|
* 2. Slack's v0 request signature over the forwarded raw body, proving the
|
|
* payload really came from Slack and is not a replay.
|
|
*
|
|
* The card is updated by POSTing to `payload.response_url`, NOT by returning a
|
|
* message body. Block Kit interactivity ignores the HTTP response body — that
|
|
* replacement behaviour belongs to legacy attachment-style messages. Assuming
|
|
* otherwise meant every press wrote correctly and then left the card showing
|
|
* stale state, so a working button looked dead and got pressed twice, undoing
|
|
* itself. `response_url` needs no bot token, which is why this stays in the app.
|
|
*/
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const expected = process.env.ORDER_INGEST_TOKEN;
|
|
if (!expected || req.headers.get("x-ingest-token") !== expected) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
// Must be the RAW body Slack signed — re-serialising the parsed form changes
|
|
// the bytes and every signature check fails. n8n forwards it verbatim.
|
|
const raw = await req.text();
|
|
if (
|
|
!verifySlackSignature(
|
|
raw,
|
|
req.headers.get("x-slack-request-timestamp"),
|
|
req.headers.get("x-slack-signature")
|
|
)
|
|
) {
|
|
return NextResponse.json({ error: "bad signature" }, { status: 401 });
|
|
}
|
|
|
|
const payloadRaw = new URLSearchParams(raw).get("payload");
|
|
if (!payloadRaw) return NextResponse.json({ error: "no payload" }, { status: 400 });
|
|
|
|
const payload = JSON.parse(payloadRaw);
|
|
|
|
if (payload.type === "view_submission") return handleModalSubmit(payload);
|
|
if (payload.type !== "block_actions") return new NextResponse(null, { status: 200 });
|
|
|
|
const action = payload.actions?.[0];
|
|
|
|
// Intake taps (plan §11 C5) act on a persisted batch, not a transaction, so
|
|
// their `value` is JSON `{batch_id, ...}` rather than `<id>:<verb>`. They are
|
|
// dispatched on action_id before the order-nudge parse below, which would
|
|
// otherwise read the JSON as a non-numeric transaction id and give up.
|
|
if (isIntakeAction(action?.action_id)) {
|
|
const reply = await handleIntakeAction(action.action_id, parseActionValue(action?.value), { pantry: pantryForwarder() });
|
|
await updateMessage(
|
|
payload.response_url,
|
|
reply.replace
|
|
? { replace_original: true, blocks: reply.blocks, text: reply.text }
|
|
: { response_type: "ephemeral", replace_original: false, text: reply.text }
|
|
);
|
|
return NextResponse.json({ blocks: reply.blocks, text: reply.text });
|
|
}
|
|
|
|
// A button carries `value`; a select carries it on the chosen option. The
|
|
// ratings moved to a select because four buttons became four full-width rows
|
|
// on Slack mobile, so both shapes have to resolve or rating silently stops
|
|
// working while sharing still does.
|
|
const rawValue = action?.value ?? action?.selected_option?.value ?? "";
|
|
const [idStr, verb, arg] = String(rawValue).split(":");
|
|
const transactionId = Number(idStr);
|
|
if (!Number.isInteger(transactionId)) {
|
|
return NextResponse.json({ text: "Could not tell which order that was." });
|
|
}
|
|
|
|
const participantId = participantForSlackUser(payload.user?.id ?? "");
|
|
if (!participantId) {
|
|
// Ephemeral: only the presser sees it, so an unmapped colleague does not
|
|
// rewrite the shared message for everyone.
|
|
await updateMessage(payload.response_url, {
|
|
response_type: "ephemeral",
|
|
replace_original: false,
|
|
text: `I don't know which participant ${payload.user?.id} is — add them to SLACK_USER_MAP.`,
|
|
});
|
|
return NextResponse.json({});
|
|
}
|
|
|
|
// The modal needs `views.open` called with this trigger_id within ~3s. The
|
|
// app has no Slack bot token — n8n already holds the credential — so the
|
|
// view is returned and n8n makes the call. That keeps one copy of the token.
|
|
if (verb === "details") {
|
|
const view = await buildDetailsModal(transactionId, participantId);
|
|
if (!view) return NextResponse.json({ text: "That order is no longer in the ledger." });
|
|
return NextResponse.json({ action: "open_modal", trigger_id: payload.trigger_id, view });
|
|
}
|
|
|
|
let refused: string | null = null;
|
|
if (verb === "share") {
|
|
refused = await toggleShare(transactionId);
|
|
} else if (verb === "rate" && RATINGS.includes(arg as Rating)) {
|
|
await setRating(transactionId, participantId, arg as Rating);
|
|
}
|
|
|
|
const state = await nudgeState(transactionId);
|
|
if (!state) return NextResponse.json({ text: "That order is no longer in the ledger." });
|
|
|
|
// Ask the other person for their verdict, but only on the press that turned
|
|
// sharing ON — and only when it was someone else who shared it with them.
|
|
// Re-notifying on every subsequent rating press would make one shared meal
|
|
// a stream of DMs, which is how a useful nudge becomes muted.
|
|
const notify =
|
|
verb === "share" && state.shared && participantId !== SECOND_CONSUMER_ID
|
|
? buildPartnerNotify(state, payload.user?.name)
|
|
: null;
|
|
|
|
// Block Kit interactivity does NOT replace the message from the HTTP response
|
|
// body — that is legacy attachment-style behaviour, and assuming it meant the
|
|
// splits changed while the card kept showing stale state, so a working button
|
|
// looked dead and got pressed twice. The update has to go to `response_url`,
|
|
// which needs no token, so the app can post it directly.
|
|
if (refused) {
|
|
await updateMessage(payload.response_url, {
|
|
response_type: "ephemeral",
|
|
replace_original: false,
|
|
text: refused,
|
|
});
|
|
}
|
|
|
|
await updateMessage(payload.response_url, {
|
|
replace_original: true,
|
|
blocks: nudgeBlocks(state),
|
|
// Notification text for clients that cannot render blocks.
|
|
text: `${state.merchant} — ${state.currency} ${state.total.toFixed(2)}`,
|
|
});
|
|
|
|
// The blocks are echoed for callers that want the rendered card without
|
|
// pressing anything — the replay tooling posts them with chat.postMessage, so
|
|
// a card is never hand-written with a guessed `shared` state again. Slack
|
|
// ignores the body for block_actions, which is the whole reason the real
|
|
// update goes to response_url above.
|
|
return NextResponse.json({
|
|
...(notify ? { notify } : {}),
|
|
blocks: nudgeBlocks(state),
|
|
text: `${state.merchant} — ${state.currency} ${state.total.toFixed(2)}`,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replace the card in place.
|
|
*
|
|
* `response_url` is a signed, single-use-ish Slack URL carried in the
|
|
* interaction payload; it needs no bot token, which is why this can live in the
|
|
* app rather than being handed back to n8n. Valid for 30 minutes and 5 uses —
|
|
* ample for a button press, and not something to cache.
|
|
*
|
|
* Failures are swallowed deliberately. The write already succeeded; throwing
|
|
* here would turn a cosmetic staleness into a 500 that Slack shows the user as
|
|
* a failed action, implying nothing happened when in fact it did.
|
|
*/
|
|
async function updateMessage(responseUrl: string | undefined, body: unknown) {
|
|
if (!responseUrl) return;
|
|
try {
|
|
await fetch(responseUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch {
|
|
/* the state is correct even when the card is stale */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The DM payload for the other person, or null if we cannot address them.
|
|
*
|
|
* Returns an instruction rather than sending: the app holds no Slack bot token,
|
|
* so n8n — which already has the credential — makes the call. Same shape as the
|
|
* modal open.
|
|
*
|
|
* Null when SLACK_USER_MAP has no Slack id for the second consumer. Silent
|
|
* rather than an error: the split is still correct and complete, and failing
|
|
* the whole press because a DM could not be addressed would be worse than the
|
|
* missing nudge.
|
|
*/
|
|
function buildPartnerNotify(
|
|
state: Awaited<ReturnType<typeof nudgeState>>,
|
|
sharerName?: string
|
|
) {
|
|
if (!state) return null;
|
|
const slackUser = slackUserForParticipant(SECOND_CONSUMER_ID);
|
|
if (!slackUser) return null;
|
|
return {
|
|
user: slackUser,
|
|
text: `${state.merchant} — shared with you 50/50`,
|
|
blocks: partnerNudgeBlocks(state, sharerName || "It was"),
|
|
};
|
|
}
|
|
|
|
/** The modal view, pre-filled with whatever this person already said. */
|
|
async function buildDetailsModal(transactionId: number, participantId: number) {
|
|
const row = await queryRow<{
|
|
merchant: string | null;
|
|
line_items: { description?: string }[] | null;
|
|
}>(
|
|
`SELECT merchant_normalized AS merchant, line_items
|
|
FROM expense_metadata
|
|
WHERE transaction_id = $1 OR matched_transaction_id = $1
|
|
LIMIT 1`,
|
|
[transactionId]
|
|
);
|
|
if (!row) return null;
|
|
|
|
const existing = await queryRow<{ note: string | null; item_verdicts: ItemOpinion[] }>(
|
|
`SELECT note, item_verdicts FROM order_reviews
|
|
WHERE transaction_id = $1 AND participant_id = $2`,
|
|
[transactionId, participantId]
|
|
);
|
|
|
|
const items = (row.line_items ?? [])
|
|
.map((i) => (i?.description ?? "").trim())
|
|
.filter(Boolean);
|
|
|
|
return detailsModal(
|
|
transactionId,
|
|
participantId,
|
|
row.merchant ?? "Order",
|
|
items,
|
|
{ note: existing?.note ?? null, itemVerdicts: existing?.item_verdicts ?? [] }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The modal came back. Save the note and the per-item verdicts.
|
|
*
|
|
* `response_action: "clear"` closes it. Returning a plain 200 with no body
|
|
* leaves the modal open with a spinner, which reads as a hang.
|
|
*
|
|
* The rating is NOT touched here — it lives on the card, and a modal that
|
|
* silently reset it would undo a decision the user did not revisit.
|
|
*/
|
|
async function handleModalSubmit(payload: {
|
|
view: { private_metadata: string; state: { values: Record<string, Record<string, { value?: string; selected_option?: { value: string } }>> } };
|
|
}) {
|
|
const meta = JSON.parse(payload.view.private_metadata ?? "{}");
|
|
const transactionId = Number(meta.t);
|
|
const participantId = Number(meta.p);
|
|
const items: string[] = Array.isArray(meta.i) ? meta.i : [];
|
|
if (!Number.isInteger(transactionId) || !Number.isInteger(participantId)) {
|
|
return NextResponse.json({ response_action: "clear" });
|
|
}
|
|
|
|
const values = payload.view.state.values ?? {};
|
|
const note = values.note?.value?.value?.trim() || null;
|
|
|
|
// Slack returns block ids and values, never the labels, so the item text is
|
|
// recovered from private_metadata by index.
|
|
const itemVerdicts: ItemOpinion[] = [];
|
|
items.forEach((item, i) => {
|
|
const picked = values[`item_${i}`]?.verdict?.selected_option?.value;
|
|
if (picked === "loved" || picked === "never") {
|
|
itemVerdicts.push({ item, verdict: picked });
|
|
}
|
|
});
|
|
|
|
await queryRaw(
|
|
`INSERT INTO order_reviews (transaction_id, participant_id, note, item_verdicts)
|
|
VALUES ($1, $2, $3, $4::jsonb)
|
|
ON CONFLICT (transaction_id, participant_id) DO UPDATE
|
|
SET note = EXCLUDED.note,
|
|
item_verdicts = EXCLUDED.item_verdicts,
|
|
updated_at = now()`,
|
|
[transactionId, participantId, note, JSON.stringify(itemVerdicts)]
|
|
);
|
|
|
|
return NextResponse.json({ response_action: "clear" });
|
|
}
|
|
|
|
/**
|
|
* Share or unshare, as a real 50/50 split.
|
|
*
|
|
* The split IS the record that an order was shared, so there is no separate
|
|
* flag to keep in step. Safe to clear on an ingested order because such a row
|
|
* is post-cutover by construction — the DB CHECK forbids credits orders before
|
|
* 2026-01-09 — so there is no settled historical obligation to lose.
|
|
*/
|
|
async function toggleShare(transactionId: number): Promise<string | null> {
|
|
const existing = await queryRaw<{ participant_id: number; share_percent: string }>(
|
|
`SELECT participant_id, share_percent FROM transaction_splits WHERE transaction_id = $1`,
|
|
[transactionId]
|
|
);
|
|
|
|
// Refuse to touch an arrangement this button cannot express. Splits are made
|
|
// by hand here, so a third participant or an uneven share is deliberate — and
|
|
// a one-tap button that silently flattened it would destroy a decision made
|
|
// with more care than the tap that undid it.
|
|
const foreign = existing.filter(
|
|
(e) => e.participant_id !== SECOND_CONSUMER_ID && e.participant_id !== OWNER_PARTICIPANT_ID
|
|
);
|
|
const uneven = existing.some(
|
|
(e) => e.participant_id === SECOND_CONSUMER_ID && Number(e.share_percent) !== 50
|
|
);
|
|
if (foreign.length || uneven) {
|
|
// Say so. Returning silently left the card unchanged, which reads exactly
|
|
// like a broken button — and a button that looks broken gets pressed again.
|
|
return foreign.length
|
|
? "This one is split with someone else, so I left it alone. Change it in the app."
|
|
: "This one is not an even 50/50, so I left it alone. Change it in the app.";
|
|
}
|
|
|
|
if (existing.some((e) => e.participant_id === SECOND_CONSUMER_ID)) {
|
|
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [
|
|
transactionId,
|
|
]);
|
|
return null;
|
|
}
|
|
await queryRaw(
|
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
VALUES ($1, $2, 50)
|
|
ON CONFLICT (transaction_id, participant_id)
|
|
DO UPDATE SET share_percent = 50`,
|
|
[transactionId, SECOND_CONSUMER_ID]
|
|
);
|
|
// Both halves, not just theirs. The button means "50/50", and a lone row for
|
|
// the other person renders as a 50% share against a blank. The payer's half
|
|
// comes from completeSplit rather than a second hardcoded insert, so it lands
|
|
// on whoever actually owns the row instead of assuming that is me.
|
|
await completeSplit(transactionId);
|
|
return null;
|
|
}
|
|
|
|
async function setRating(transactionId: number, participantId: number, rating: Rating) {
|
|
await queryRaw(
|
|
`INSERT INTO order_reviews (transaction_id, participant_id, rating, order_again)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (transaction_id, participant_id) DO UPDATE
|
|
SET rating = EXCLUDED.rating,
|
|
order_again = EXCLUDED.order_again,
|
|
updated_at = now()`,
|
|
// "bad" and "never" both mean no; only "never" warns on a future order.
|
|
[transactionId, participantId, rating, rating !== "never" && rating !== "bad"]
|
|
);
|
|
}
|
|
|
|
/** Everything the refreshed message needs, read back after the write. */
|
|
async function nudgeState(transactionId: number) {
|
|
const row = await queryRow<{
|
|
merchant: string | null;
|
|
currency: string | null;
|
|
amount: string;
|
|
}>(
|
|
`SELECT em.merchant_normalized AS merchant,
|
|
COALESCE(em.currency, 'AUD') AS currency,
|
|
t.amount
|
|
FROM transactions t
|
|
LEFT JOIN expense_metadata em
|
|
ON em.transaction_id = t.id OR em.matched_transaction_id = t.id
|
|
WHERE t.id = $1`,
|
|
[transactionId]
|
|
);
|
|
if (!row) return null;
|
|
|
|
const [splits, ratings, verdict] = await Promise.all([
|
|
queryRaw<{ participant_id: number }>(
|
|
`SELECT participant_id FROM transaction_splits WHERE transaction_id = $1`,
|
|
[transactionId]
|
|
),
|
|
queryRaw<{ name: string; rating: Rating }>(
|
|
`SELECT p.name, r.rating FROM order_reviews r
|
|
JOIN participants p ON p.id = r.participant_id
|
|
WHERE r.transaction_id = $1 AND r.rating IS NOT NULL
|
|
ORDER BY r.participant_id`,
|
|
[transactionId]
|
|
),
|
|
merchantVerdict(row.merchant, transactionId),
|
|
]);
|
|
|
|
return {
|
|
transactionId,
|
|
merchant: row.merchant ?? "Unknown merchant",
|
|
currency: row.currency ?? "AUD",
|
|
total: Number(row.amount),
|
|
shared: splits.some((s) => s.participant_id === SECOND_CONSUMER_ID),
|
|
ratings,
|
|
warn: verdict?.warn ?? false,
|
|
warnNote: verdict?.history.find((h) => h.note)?.note ?? null,
|
|
};
|
|
}
|