feat(slack): ask the other person too, and put rating before sharing
ci / lint-test (push) Successful in 43s
ci / lint-test (push) Successful in 43s
Two halves of the same requirement, one of which was quietly missing. Sharing split the money but never reached her: she is not in #smarthome, so the card whose caption said "both verdicts welcome" was one she could not see. Now a share DMs her a card of her own. 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. She was already in SLACK_USER_MAP, so her press files under participant 4. Only on the press that turns sharing ON, and only when someone else did the sharing. Re-notifying on every later rating press would turn one shared meal into a stream of DMs, which is how a nudge gets muted. Her card carries no share button: she is being told it was shared, not asked to decide, and two people toggling one split from separate copies of a card is a race with no upside. The app still holds no Slack bot token — it returns a notify instruction and n8n sends it, the same shape as the modal open. If SLACK_USER_MAP has no id for her the DM is skipped silently: the split is correct and complete either way, and failing the press over an unaddressable nudge would be the worse trade. Card reordered to rate -> details -> share. You judge the food, then decide who pays for it; asking "was this shared?" first inverts the order a person thinks in. The status caption moved under the share button it describes rather than sitting orphaned mid-card.
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
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 {
|
||||||
import { nudgeBlocks, detailsModal } from "@/lib/slack-blocks";
|
verifySlackSignature,
|
||||||
|
participantForSlackUser,
|
||||||
|
slackUserForParticipant,
|
||||||
|
} from "@/lib/slack-verify";
|
||||||
|
import { nudgeBlocks, detailsModal, partnerNudgeBlocks } from "@/lib/slack-blocks";
|
||||||
import {
|
import {
|
||||||
RATINGS,
|
RATINGS,
|
||||||
OWNER_PARTICIPANT_ID,
|
OWNER_PARTICIPANT_ID,
|
||||||
@@ -105,14 +109,50 @@ export async function POST(req: NextRequest) {
|
|||||||
const state = await nudgeState(transactionId);
|
const state = await nudgeState(transactionId);
|
||||||
if (!state) return NextResponse.json({ text: "That order is no longer in the ledger." });
|
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({
|
return NextResponse.json({
|
||||||
replace_original: true,
|
replace_original: true,
|
||||||
blocks: nudgeBlocks(state),
|
blocks: nudgeBlocks(state),
|
||||||
// Notification text for clients that cannot render blocks.
|
// Notification text for clients that cannot render blocks.
|
||||||
text: `${state.merchant} — ${state.currency} ${state.total.toFixed(2)}`,
|
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<ReturnType<typeof nudgeState>>,
|
||||||
|
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. */
|
/** The modal view, pre-filled with whatever this person already said. */
|
||||||
async function buildDetailsModal(transactionId: number, participantId: number) {
|
async function buildDetailsModal(transactionId: number, participantId: number) {
|
||||||
const row = await queryRow<{
|
const row = await queryRow<{
|
||||||
|
|||||||
+93
-30
@@ -50,25 +50,6 @@ export function nudgeBlocks(s: NudgeState) {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } },
|
{ 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
|
// 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
|
// 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
|
// 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",
|
type: "actions",
|
||||||
block_id: "details",
|
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`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ export function verifySlackSignature(
|
|||||||
* two-person household a wrong attribution is not a rounding error, it is the
|
* two-person household a wrong attribution is not a rounding error, it is the
|
||||||
* other person's opinion recorded under your name.
|
* 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 {
|
export function participantForSlackUser(slackUserId: string): number | null {
|
||||||
const map = process.env.SLACK_USER_MAP ?? "";
|
const map = process.env.SLACK_USER_MAP ?? "";
|
||||||
for (const pair of map.split(",")) {
|
for (const pair of map.split(",")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user