diff --git a/src/app/api/slack/interactive/route.ts b/src/app/api/slack/interactive/route.ts index ad58f18..00691a6 100644 --- a/src/app/api/slack/interactive/route.ts +++ b/src/app/api/slack/interactive/route.ts @@ -1,7 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { queryRaw, queryRow } from "@/lib/db"; -import { verifySlackSignature, participantForSlackUser } from "@/lib/slack-verify"; -import { nudgeBlocks, detailsModal } from "@/lib/slack-blocks"; +import { + verifySlackSignature, + participantForSlackUser, + slackUserForParticipant, +} from "@/lib/slack-verify"; +import { nudgeBlocks, detailsModal, partnerNudgeBlocks } from "@/lib/slack-blocks"; import { RATINGS, OWNER_PARTICIPANT_ID, @@ -105,14 +109,50 @@ export async function POST(req: NextRequest) { const state = await nudgeState(transactionId); if (!state) return NextResponse.json({ text: "That order is no longer in the ledger." }); + // Ask the other person for their verdict, but only on the press that turned + // sharing ON — and only when it was someone else who shared it with them. + // Re-notifying on every subsequent rating press would make one shared meal + // a stream of DMs, which is how a useful nudge becomes muted. + const notify = + verb === "share" && state.shared && participantId !== SECOND_CONSUMER_ID + ? buildPartnerNotify(state, payload.user?.name) + : null; + return NextResponse.json({ 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 DM payload for the other person, or null if we cannot address them. + * + * Returns an instruction rather than sending: the app holds no Slack bot token, + * so n8n — which already has the credential — makes the call. Same shape as the + * modal open. + * + * Null when SLACK_USER_MAP has no Slack id for the second consumer. Silent + * rather than an error: the split is still correct and complete, and failing + * the whole press because a DM could not be addressed would be worse than the + * missing nudge. + */ +function buildPartnerNotify( + state: Awaited>, + sharerName?: string +) { + if (!state) return null; + const slackUser = slackUserForParticipant(SECOND_CONSUMER_ID); + if (!slackUser) return null; + return { + user: slackUser, + text: `${state.merchant} — shared with you 50/50`, + blocks: partnerNudgeBlocks(state, sharerName || "It was"), + }; +} + /** The modal view, pre-filled with whatever this person already said. */ async function buildDetailsModal(transactionId: number, participantId: number) { const row = await queryRow<{ diff --git a/src/lib/slack-blocks.ts b/src/lib/slack-blocks.ts index b01a16a..5c9602f 100644 --- a/src/lib/slack-blocks.ts +++ b/src/lib/slack-blocks.ts @@ -50,25 +50,6 @@ export function nudgeBlocks(s: NudgeState) { return [ { type: "section", text: { type: "mrkdwn", text: lines.join("\n") } }, - { - type: "actions", - block_id: "share", - elements: [ - { - type: "button", - action_id: "share_toggle", - // The label states what pressing it will DO, not the current state. - // A button labelled with its own state reads as already-pressed and - // gets tapped to "fix" it, toggling the thing it was reporting. - text: { - type: "plain_text", - text: s.shared ? "Make it just me" : "Shared 50/50", - }, - style: s.shared ? undefined : "primary", - value: `${s.transactionId}:share`, - }, - ], - }, // A select, not four buttons. Slack's mobile client gives every button in // an actions block its own full-width row, so four ratings became four // stacked bars and the card filled the screen. One select is one row and @@ -89,17 +70,6 @@ export function nudgeBlocks(s: NudgeState) { }, ], }, - { - type: "context", - elements: [ - { - type: "mrkdwn", - text: s.shared - ? ":busts_in_silhouette: Split 50/50 — both verdicts welcome" - : ":bust_in_silhouette: Not shared", - }, - ], - }, { type: "actions", block_id: "details", @@ -112,6 +82,40 @@ export function nudgeBlocks(s: NudgeState) { }, ], }, + // Sharing comes LAST. You judge the food, then decide who pays for it — + // asking "was this shared?" before "was it any good?" inverts the order a + // person actually thinks in (user, 2026-07-28). The controls are + // independent and each writes immediately, so this is presentation only. + { + type: "actions", + block_id: "share", + elements: [ + { + type: "button", + action_id: "share_toggle", + // The label states what pressing it will DO, not the current state. + // A button labelled with its own state reads as already-pressed and + // gets tapped to "fix" it, toggling the thing it was reporting. + text: { + type: "plain_text", + text: s.shared ? "Make it just me" : "Shared 50/50", + }, + style: s.shared ? undefined : "primary", + value: `${s.transactionId}:share`, + }, + ], + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: s.shared + ? ":busts_in_silhouette: Split 50/50 — both verdicts welcome" + : ":bust_in_silhouette: Not shared", + }, + ], + }, ]; } @@ -220,3 +224,62 @@ export function detailsModal( ], }; } + +/** + * The card DMed to the other person when an order is shared with them. + * + * Sharing splits the money; this is the other half of the same requirement — + * "if shared then get feedback from other user as well" (user, 2026-07-28). + * Without it that half was aspirational: she is not in #smarthome, so the + * channel card she was invited to answer was one she could not see. + * + * A DM rather than adding her to the channel, so her surface stays "orders that + * concern me" instead of the whole house's ops feed. + * + * No share button here on purpose. She is being told it was shared, not asked + * to decide — and a second person toggling the split from a stale copy of the + * card is a race with no upside. + */ +export function partnerNudgeBlocks(s: NudgeState, sharerName: string) { + const lines = [ + `:receipt: *${s.merchant}* — ${s.currency} ${s.total.toFixed(2)}`, + `${sharerName} shared this with you, 50/50. How was it?`, + ]; + if (s.warn) { + lines.push( + `:warning: This merchant was marked *never again* before` + + (s.warnNote ? ` — _${s.warnNote}_` : "") + ); + } + + return [ + { type: "section", text: { type: "mrkdwn", text: lines.join("\n") } }, + { + type: "actions", + block_id: "rate", + elements: [ + { + type: "static_select", + action_id: "rate", + placeholder: { type: "plain_text", text: "How was it?" }, + options: RATINGS.map((r) => ({ + text: { type: "plain_text", text: RATING_LABEL[r] }, + value: `${s.transactionId}:rate:${r}`, + })), + }, + ], + }, + { + type: "actions", + block_id: "details", + elements: [ + { + type: "button", + action_id: "open_details", + text: { type: "plain_text", text: "Add details" }, + value: `${s.transactionId}:details`, + }, + ], + }, + ]; +} diff --git a/src/lib/slack-verify.ts b/src/lib/slack-verify.ts index c6e1c14..c6943bc 100644 --- a/src/lib/slack-verify.ts +++ b/src/lib/slack-verify.ts @@ -47,6 +47,16 @@ export function verifySlackSignature( * two-person household a wrong attribution is not a rounding error, it is the * other person's opinion recorded under your name. */ +/** The reverse: which Slack user is this participant, for DMing them. */ +export function slackUserForParticipant(participantId: number): string | null { + const map = process.env.SLACK_USER_MAP ?? ""; + for (const pair of map.split(",")) { + const [slack, participant] = pair.split(":").map((s) => s.trim()); + if (slack && Number(participant) === participantId) return slack; + } + return null; +} + export function participantForSlackUser(slackUserId: string): number | null { const map = process.env.SLACK_USER_MAP ?? ""; for (const pair of map.split(",")) {