fix(slack): update the card via response_url, not the HTTP response
ci / lint-test (push) Successful in 43s
ci / lint-test (push) Successful in 43s
Every press wrote correctly and then left the card showing stale state, so a working button looked dead — and a button that looks dead gets pressed again, which toggled the split back. That is how two orders got unshared while looking like nothing had happened. The cause was a wrong assumption in the original design: Block Kit interactivity ignores the HTTP response body. Replacing a message from the response is legacy attachment-style behaviour. The update has to go to payload.response_url, which needs no bot token — so it stays in the app rather than becoming another n8n node. Two things that were also invisible now speak up. The share guard used to return silently when it refused a three-way or uneven split, which is indistinguishable from a broken button; it now says which it was, as an ephemeral only the presser sees. Same for an unmapped Slack user. The response still echoes the rendered blocks. Slack ignores them, but it lets a card be rendered server-side without pressing anything — which is what stops the replay tooling from hand-writing a card with a guessed share state, the mistake that cost a real split earlier today.
This commit is contained in:
@@ -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<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]
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user