diff --git a/src/app/api/slack/interactive/route.ts b/src/app/api/slack/interactive/route.ts index 00691a6..e6bcddf 100644 --- a/src/app/api/slack/interactive/route.ts +++ b/src/app/api/slack/interactive/route.ts @@ -36,9 +36,12 @@ import { * 2. Slack's v0 request signature over the forwarded raw body, proving the * payload really came from Slack and is not a replay. * - * Returning a message body directly replaces the original message. That is why - * there is no call to `response_url`: the reply IS the update, and it keeps the - * whole interaction inside Slack's 3-second budget. + * 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) { @@ -84,11 +87,12 @@ export async function POST(req: NextRequest) { if (!participantId) { // Ephemeral: only the presser sees it, so an unmapped colleague does not // rewrite the shared message for everyone. - return NextResponse.json({ + 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 @@ -100,8 +104,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ action: "open_modal", trigger_id: payload.trigger_id, view }); } + let refused: string | null = null; if (verb === "share") { - await toggleShare(transactionId); + refused = await toggleShare(transactionId); } else if (verb === "rate" && RATINGS.includes(arg as Rating)) { await setRating(transactionId, participantId, arg as Rating); } @@ -118,13 +123,61 @@ export async function POST(req: NextRequest) { ? buildPartnerNotify(state, payload.user?.name) : null; - return NextResponse.json({ + // 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)}`, - ...(notify ? { notify } : {}), }); + + // 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 */ + } } /** @@ -240,7 +293,7 @@ async function handleModalSubmit(payload: { * 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) { +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] @@ -256,13 +309,19 @@ async function toggleShare(transactionId: number) { const uneven = existing.some( (e) => e.participant_id === SECOND_CONSUMER_ID && Number(e.share_percent) !== 50 ); - if (foreign.length || uneven) return; + 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; + return null; } await queryRaw( `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) @@ -271,6 +330,7 @@ async function toggleShare(transactionId: number) { DO UPDATE SET share_percent = 50`, [transactionId, SECOND_CONSUMER_ID] ); + return null; } async function setRating(transactionId: number, participantId: number, rating: Rating) {