intake: escape mrkdwn, and stop printing every total as dollars
Merchant names, transaction descriptions and model output are none of ours: Slack reads &, < and > as markup, so "Ben & Jerry's" and a description carrying <http://…|text> rendered as something the card never meant to show. esc() on every interpolated value in mrkdwn — never on our own literal *bold*, and never on a button's plain_text label, where entities would print verbatim. money() also hard-coded "$" while the order nudge already prints "USD 15.00". The cards now carry the batch's currency.
This commit is contained in:
+39
-16
@@ -23,7 +23,27 @@ export function transactionLink(merchant: string | null): string {
|
||||
return `${financePublicUrl()}/transactions?q=${encodeURIComponent(merchant ?? "")}`;
|
||||
}
|
||||
|
||||
const money = (n: number | string) => `$${Number(n).toFixed(2)}`;
|
||||
/**
|
||||
* Slack renders `&`, `<` and `>` as markup in mrkdwn (and in the `text`
|
||||
* fallback). Merchant names, transaction descriptions and model output are
|
||||
* none of ours — `Ben & Jerry's` must not arrive as `Ben & Jerry's`, and a
|
||||
* description containing `<http://…|click>` must not become a link the card
|
||||
* never meant to show. Applied to interpolated values only; our own literal
|
||||
* `*bold*` and `:emoji:` stay markup.
|
||||
*/
|
||||
export function esc(value: string | null | undefined): string {
|
||||
return (value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* `$` is only right for AUD. An intake batch carries the currency the receipt
|
||||
* printed, and the order nudge already renders `USD 15.00` rather than
|
||||
* pretending a foreign order was dollars.
|
||||
*/
|
||||
const money = (n: number | string, currency?: string | null) => {
|
||||
const amount = Number(n).toFixed(2);
|
||||
return !currency || currency === "AUD" ? `$${amount}` : `${currency} ${amount}`;
|
||||
};
|
||||
const section = (text: string): Block => ({ type: "section", text: { type: "mrkdwn", text } });
|
||||
const button = (action_id: string, text: string, value: unknown, style?: "primary" | "danger"): Block => ({
|
||||
type: "button",
|
||||
@@ -40,10 +60,12 @@ export type CardInput = {
|
||||
orderDate: string | null;
|
||||
platform: string | null;
|
||||
itemCount: number;
|
||||
currency?: string | null;
|
||||
};
|
||||
|
||||
export function enrichedCard(c: CardInput & { transactionId: number }): { blocks: Block[]; text: string } {
|
||||
const what = [c.total !== null ? money(c.total) : null, platformLabel(c.platform) || c.merchant, c.platform && c.merchant ? `(${c.merchant})` : null]
|
||||
const merchant = esc(c.merchant);
|
||||
const what = [c.total !== null ? money(c.total, c.currency) : null, esc(platformLabel(c.platform)) || merchant, c.platform && merchant ? `(${merchant})` : null]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const text = `Added ${c.itemCount} item${c.itemCount === 1 ? "" : "s"} to your ${what} order ✓`;
|
||||
@@ -60,7 +82,7 @@ export function enrichedCard(c: CardInput & { transactionId: number }): { blocks
|
||||
}
|
||||
|
||||
export function createdCard(c: CardInput & { transactionId: number }): { blocks: Block[]; text: string } {
|
||||
const text = `Created a ${c.total !== null ? money(c.total) + " " : ""}${c.merchant ?? "new"} transaction with ${c.itemCount} item${c.itemCount === 1 ? "" : "s"} ✓`;
|
||||
const text = `Created a ${c.total !== null ? money(c.total, c.currency) + " " : ""}${esc(c.merchant) || "new"} transaction with ${c.itemCount} item${c.itemCount === 1 ? "" : "s"} ✓`;
|
||||
return {
|
||||
text,
|
||||
blocks: [
|
||||
@@ -74,27 +96,28 @@ export function createdCard(c: CardInput & { transactionId: number }): { blocks:
|
||||
}
|
||||
|
||||
export function candidatesCard(c: CardInput, candidates: Candidate[]): { blocks: Block[]; text: string } {
|
||||
const label = platformLabel(c.platform) || "";
|
||||
const head = `No matching ${label ? label + " " : ""}order for *${c.merchant ?? "this merchant"}* ${c.total !== null ? money(c.total) : ""} on ${c.orderDate ?? "?"}.`;
|
||||
const label = esc(platformLabel(c.platform));
|
||||
const head = `No matching ${label ? label + " " : ""}order for *${esc(c.merchant) || "this merchant"}* ${c.total !== null ? money(c.total, c.currency) : ""} on ${esc(c.orderDate) || "?"}.`;
|
||||
const text = `${head.replace(/\*/g, "")} ${candidates.length ? "Pick one below or create it." : "Create it?"}`;
|
||||
const blocks: Block[] = [section(`:mag: ${head}${candidates.length ? "\nIs it one of these?" : ""}`)];
|
||||
for (const cand of candidates) {
|
||||
blocks.push({
|
||||
type: "section",
|
||||
text: { type: "mrkdwn", text: `${cand.transaction_date} · ${cand.description ?? "—"} · ${money(cand.amount)}` },
|
||||
text: { type: "mrkdwn", text: `${esc(cand.transaction_date)} · ${esc(cand.description) || "—"} · ${money(cand.amount)}` },
|
||||
accessory: button("intake_use_candidate", "Use this", { batch_id: c.batchId, transaction_id: cand.transaction_id }),
|
||||
});
|
||||
}
|
||||
blocks.push({
|
||||
type: "actions",
|
||||
block_id: "intake_create",
|
||||
elements: [button("intake_create", `Create new ${c.total !== null ? money(c.total) + " " : ""}${c.merchant ?? ""} transaction`.replace(/\s+/g, " ").trim(), { batch_id: c.batchId }, "primary")],
|
||||
// A button's `text` is plain_text, never mrkdwn — the raw merchant belongs here.
|
||||
elements: [button("intake_create", `Create new ${c.total !== null ? money(c.total, c.currency) + " " : ""}${c.merchant ?? ""} transaction`.replace(/\s+/g, " ").trim(), { batch_id: c.batchId }, "primary")],
|
||||
});
|
||||
return { blocks, text };
|
||||
}
|
||||
|
||||
export function forwardedCard(c: CardInput & { captureId: number; enriching: boolean }): { blocks: Block[]; text: string } {
|
||||
const text = `${c.merchant ?? "Grocery receipt"} ${c.total !== null ? money(c.total) : ""} → pantry board (${c.itemCount} item${c.itemCount === 1 ? "" : "s"}) — finance ${c.enriching ? "updates" : "books it"} on confirm`.replace(/\s+/g, " ");
|
||||
const text = `${esc(c.merchant) || "Grocery receipt"} ${c.total !== null ? money(c.total, c.currency) : ""} → pantry board (${c.itemCount} item${c.itemCount === 1 ? "" : "s"}) — finance ${c.enriching ? "updates" : "books it"} on confirm`.replace(/\s+/g, " ");
|
||||
return { text, blocks: [section(`:shopping_trolley: ${text}`)] };
|
||||
}
|
||||
|
||||
@@ -103,13 +126,13 @@ export function needsHeaderCard(): { blocks: Block[]; text: string } {
|
||||
return { text, blocks: [section(`:warning: ${text}`)] };
|
||||
}
|
||||
|
||||
export function heldCard(reason: string): { blocks: Block[]; text: string } {
|
||||
const text = `Items don't add up to the total — is a shot missing? (${reason})`;
|
||||
export function heldCard(reason: string, headline = "Items don't add up to the total — is a shot missing?"): { blocks: Block[]; text: string } {
|
||||
const text = `${headline} (${esc(reason)})`;
|
||||
return { text, blocks: [section(`:warning: ${text}`)] };
|
||||
}
|
||||
|
||||
export function askCard(c: CardInput): { blocks: Block[]; text: string } {
|
||||
const text = `${c.merchant ?? "This upload"} ${c.total !== null ? money(c.total) : ""} — groceries or a meal?`.replace(/\s+/g, " ");
|
||||
const text = `${esc(c.merchant) || "This upload"} ${c.total !== null ? money(c.total, c.currency) : ""} — groceries or a meal?`.replace(/\s+/g, " ");
|
||||
return {
|
||||
text,
|
||||
blocks: [
|
||||
@@ -126,18 +149,18 @@ export function askCard(c: CardInput): { blocks: Block[]; text: string } {
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateCard(c: { merchant: string | null; total: number | null; transactionId: number | null; captureId: number | null; batchId: string | null }): { blocks: Block[]; text: string } {
|
||||
const where = c.transactionId ? `transaction ${c.transactionId}` : c.captureId ? `pantry capture ${c.captureId}` : c.batchId ? `batch ${c.batchId}` : "an earlier upload";
|
||||
const text = `Already handled — ${c.merchant ?? "this order"} ${c.total !== null ? money(c.total) : ""} is ${where}. Nothing was changed.`.replace(/\s+/g, " ");
|
||||
export function duplicateCard(c: { merchant: string | null; total: number | null; transactionId: number | null; captureId: number | null; batchId: string | null; currency?: string | null }): { blocks: Block[]; text: string } {
|
||||
const where = c.transactionId ? `transaction ${c.transactionId}` : c.captureId ? `pantry capture ${c.captureId}` : c.batchId ? `batch ${esc(c.batchId)}` : "an earlier upload";
|
||||
const text = `Already handled — ${esc(c.merchant) || "this order"} ${c.total !== null ? money(c.total, c.currency) : ""} is ${where}. Nothing was changed.`.replace(/\s+/g, " ");
|
||||
return { text, blocks: [section(`:repeat: ${text}`)] };
|
||||
}
|
||||
|
||||
export function unsupportedCard(detail?: string): { blocks: Block[]; text: string } {
|
||||
const text = `I can read receipts, statements and order screens — not this.${detail ? ` (${detail})` : ""}`;
|
||||
const text = `I can read receipts, statements and order screens — not this.${detail ? ` (${esc(detail)})` : ""}`;
|
||||
return { text, blocks: [section(`:no_entry_sign: ${text}`)] };
|
||||
}
|
||||
|
||||
export function errorCard(detail: string): { blocks: Block[]; text: string } {
|
||||
const text = `Couldn't process that upload: ${detail}`;
|
||||
const text = `Couldn't process that upload: ${esc(detail)}`;
|
||||
return { text, blocks: [section(`:x: ${text}`)] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user