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 `:`. 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>, 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> } }; }) { 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 { 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, }; }