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:
@@ -1,12 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { queryRaw, queryRow } from "@/lib/db";
|
import { queryRaw, queryRow } from "@/lib/db";
|
||||||
import { verifySlackSignature, participantForSlackUser } from "@/lib/slack-verify";
|
import { verifySlackSignature, participantForSlackUser } from "@/lib/slack-verify";
|
||||||
import { nudgeBlocks } from "@/lib/slack-blocks";
|
import { nudgeBlocks, detailsModal } from "@/lib/slack-blocks";
|
||||||
import {
|
import {
|
||||||
RATINGS,
|
RATINGS,
|
||||||
SECOND_CONSUMER_ID,
|
SECOND_CONSUMER_ID,
|
||||||
merchantVerdict,
|
merchantVerdict,
|
||||||
type Rating,
|
type Rating,
|
||||||
|
type ItemOpinion,
|
||||||
} from "@/lib/order-reviews";
|
} from "@/lib/order-reviews";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +59,8 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!payloadRaw) return NextResponse.json({ error: "no payload" }, { status: 400 });
|
if (!payloadRaw) return NextResponse.json({ error: "no payload" }, { status: 400 });
|
||||||
|
|
||||||
const payload = JSON.parse(payloadRaw);
|
const payload = JSON.parse(payloadRaw);
|
||||||
|
|
||||||
|
if (payload.type === "view_submission") return handleModalSubmit(payload);
|
||||||
if (payload.type !== "block_actions") return new NextResponse(null, { status: 200 });
|
if (payload.type !== "block_actions") return new NextResponse(null, { status: 200 });
|
||||||
|
|
||||||
const action = payload.actions?.[0];
|
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") {
|
if (verb === "share") {
|
||||||
await toggleShare(transactionId);
|
await toggleShare(transactionId);
|
||||||
} else if (verb === "rate" && RATINGS.includes(arg as Rating)) {
|
} 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.
|
* Share or unshare, as a real 50/50 split.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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._",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user