feat(slack): per-item verdicts and a note, in a Slack modal
ci / lint-test (push) Successful in 42s

The card can rate an order but cannot ask which dish or why: a message
cannot collect free text, and an actions block caps at 25 elements while
item counts vary per receipt. A modal is the only Slack-native answer,
and it stays inside Slack — no browser, no app, which is the whole reason
it exists rather than a link.

The overall rating deliberately stays on the card. That is the thing done
every time and it should cost one tap; this is for when something was
notably good or bad.

finance-app holds no Slack bot token by design, so it returns the view
and n8n — which already has the credential — calls views.open. One copy
of the token, no new secret, no compose change.

Item text travels in private_metadata because a submission returns block
ids and values, never labels, so there is otherwise no way back to which
dish a radio button referred to. Capped at 20 rows: a grocery order runs
long and nobody scrolls a modal to rate a tin of tomatoes.

The modal does NOT write the rating. A form that silently reset a
decision the user did not revisit is the same class of bug as the split
rewrite that dropped `settled`.
This commit is contained in:
2026-07-28 16:33:10 +10:00
parent 8fbcbc5f83
commit 14d6b40578
2 changed files with 210 additions and 1 deletions
+92 -1
View File
@@ -1,12 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw, queryRow } from "@/lib/db";
import { verifySlackSignature, participantForSlackUser } from "@/lib/slack-verify";
import { nudgeBlocks } from "@/lib/slack-blocks";
import { nudgeBlocks, detailsModal } from "@/lib/slack-blocks";
import {
RATINGS,
SECOND_CONSUMER_ID,
merchantVerdict,
type Rating,
type ItemOpinion,
} from "@/lib/order-reviews";
/**
@@ -58,6 +59,8 @@ export async function POST(req: NextRequest) {
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];
@@ -83,6 +86,15 @@ export async function POST(req: NextRequest) {
});
}
// 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 });
}
if (verb === "share") {
await toggleShare(transactionId);
} else if (verb === "rate" && RATINGS.includes(arg as Rating)) {
@@ -100,6 +112,85 @@ export async function POST(req: NextRequest) {
});
}
/** 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.
*