diff --git a/src/app/api/slack/interactive/route.ts b/src/app/api/slack/interactive/route.ts index c7158a5..8d78bc8 100644 --- a/src/app/api/slack/interactive/route.ts +++ b/src/app/api/slack/interactive/route.ts @@ -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> } }; +}) { + 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. * diff --git a/src/lib/slack-blocks.ts b/src/lib/slack-blocks.ts index 7578aa2..b01a16a 100644 --- a/src/lib/slack-blocks.ts +++ b/src/lib/slack-blocks.ts @@ -100,5 +100,123 @@ export function nudgeBlocks(s: NudgeState) { }, ], }, + { + type: "actions", + block_id: "details", + elements: [ + { + type: "button", + action_id: "open_details", + text: { type: "plain_text", text: "Add details" }, + value: `${s.transactionId}:details`, + }, + ], + }, ]; } + +/** + * The per-item verdicts and the free-text note, as a Slack modal. + * + * A message cannot collect free text and an actions block caps at 25 elements, + * so this is the only Slack-native way to ask "which dish, and why". It stays + * inside Slack — no browser, no app — which is the entire reason it exists + * rather than a link. + * + * The overall rating deliberately stays on the card: it is the thing you do + * every time and it should cost one tap. This is for the times something was + * notably good or bad. + * + * `private_metadata` carries the ids because a view_submission arrives as a + * fresh request with no reference to the message it came from. + */ +export function detailsModal( + transactionId: number, + participantId: number, + merchant: string, + items: string[], + existing: { note?: string | null; itemVerdicts?: { item: string; verdict: string }[] } +) { + const verdictOf = (item: string) => + existing.itemVerdicts?.find( + (v) => v.item.trim().toLowerCase() === item.trim().toLowerCase() + )?.verdict ?? null; + + const opt = (text: string, value: string) => ({ + text: { type: "plain_text", text }, + value, + }); + + const itemBlocks = items + // Slack allows 100 blocks per view; a grocery order can be long, and past + // ~20 rows nobody is scrolling a modal to rate a tin of tomatoes anyway. + .slice(0, 20) + .map((item, i) => { + const current = verdictOf(item); + const options = [opt("👍 Great", "loved"), opt("👎 Never again", "never")]; + return { + type: "input", + block_id: `item_${i}`, + optional: true, + // The label carries the item name; Slack truncates at 150 chars. + label: { type: "plain_text", text: item.slice(0, 150) }, + element: { + type: "radio_buttons", + action_id: "verdict", + options, + ...(current + ? { initial_option: options.find((o) => o.value === current) } + : {}), + }, + }; + }); + + return { + type: "modal", + callback_id: "order_details", + private_metadata: JSON.stringify({ + t: transactionId, + p: participantId, + // The item text is not recoverable from the submission — Slack returns + // block ids and values, not labels — so it travels with the view. + i: items.slice(0, 20), + }), + title: { type: "plain_text", text: "Order details" }, + submit: { type: "plain_text", text: "Save" }, + close: { type: "plain_text", text: "Cancel" }, + blocks: [ + { type: "section", text: { type: "mrkdwn", text: `*${merchant}*` } }, + { + type: "input", + block_id: "note", + optional: true, + label: { type: "plain_text", text: "Anything worth remembering?" }, + element: { + type: "plain_text_input", + action_id: "value", + multiline: true, + initial_value: existing.note ?? undefined, + placeholder: { + type: "plain_text", + text: "e.g. the biryani was decent, sides were cold", + }, + }, + }, + ...(itemBlocks.length + ? [{ type: "divider" }, ...itemBlocks] + : [ + { + type: "context", + elements: [ + { + type: "mrkdwn", + // Uber itemises groceries but not restaurant orders, so an + // empty list is the receipt, not a failure. + text: "_This receipt has no itemised list._", + }, + ], + }, + ]), + ], + }; +}