Files
finance-app/src/app/api/slack/interactive/route.ts
T
siddharthd c7ea516c34 slack: dispatch the four intake taps on action_id (plan §11 C5)
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.
2026-08-21 12:10:05 +10:00

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