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 ?? "")}`;
|
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 section = (text: string): Block => ({ type: "section", text: { type: "mrkdwn", text } });
|
||||||
const button = (action_id: string, text: string, value: unknown, style?: "primary" | "danger"): Block => ({
|
const button = (action_id: string, text: string, value: unknown, style?: "primary" | "danger"): Block => ({
|
||||||
type: "button",
|
type: "button",
|
||||||
@@ -40,10 +60,12 @@ export type CardInput = {
|
|||||||
orderDate: string | null;
|
orderDate: string | null;
|
||||||
platform: string | null;
|
platform: string | null;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
|
currency?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function enrichedCard(c: CardInput & { transactionId: number }): { blocks: Block[]; text: string } {
|
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)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
const text = `Added ${c.itemCount} item${c.itemCount === 1 ? "" : "s"} to your ${what} order ✓`;
|
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 } {
|
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 {
|
return {
|
||||||
text,
|
text,
|
||||||
blocks: [
|
blocks: [
|
||||||
@@ -74,27 +96,28 @@ export function createdCard(c: CardInput & { transactionId: number }): { blocks:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function candidatesCard(c: CardInput, candidates: Candidate[]): { blocks: Block[]; text: string } {
|
export function candidatesCard(c: CardInput, candidates: Candidate[]): { blocks: Block[]; text: string } {
|
||||||
const label = platformLabel(c.platform) || "";
|
const label = esc(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 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 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?" : ""}`)];
|
const blocks: Block[] = [section(`:mag: ${head}${candidates.length ? "\nIs it one of these?" : ""}`)];
|
||||||
for (const cand of candidates) {
|
for (const cand of candidates) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "section",
|
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 }),
|
accessory: button("intake_use_candidate", "Use this", { batch_id: c.batchId, transaction_id: cand.transaction_id }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "actions",
|
type: "actions",
|
||||||
block_id: "intake_create",
|
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 };
|
return { blocks, text };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function forwardedCard(c: CardInput & { captureId: number; enriching: boolean }): { blocks: Block[]; text: string } {
|
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}`)] };
|
return { text, blocks: [section(`:shopping_trolley: ${text}`)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,13 +126,13 @@ export function needsHeaderCard(): { blocks: Block[]; text: string } {
|
|||||||
return { text, blocks: [section(`:warning: ${text}`)] };
|
return { text, blocks: [section(`:warning: ${text}`)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function heldCard(reason: string): { blocks: Block[]; text: string } {
|
export function heldCard(reason: string, headline = "Items don't add up to the total — is a shot missing?"): { blocks: Block[]; text: string } {
|
||||||
const text = `Items don't add up to the total — is a shot missing? (${reason})`;
|
const text = `${headline} (${esc(reason)})`;
|
||||||
return { text, blocks: [section(`:warning: ${text}`)] };
|
return { text, blocks: [section(`:warning: ${text}`)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function askCard(c: CardInput): { blocks: Block[]; text: string } {
|
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 {
|
return {
|
||||||
text,
|
text,
|
||||||
blocks: [
|
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 } {
|
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 ${c.batchId}` : "an earlier upload";
|
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 — ${c.merchant ?? "this order"} ${c.total !== null ? money(c.total) : ""} is ${where}. Nothing was changed.`.replace(/\s+/g, " ");
|
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}`)] };
|
return { text, blocks: [section(`:repeat: ${text}`)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unsupportedCard(detail?: string): { blocks: Block[]; text: string } {
|
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}`)] };
|
return { text, blocks: [section(`:no_entry_sign: ${text}`)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorCard(detail: string): { blocks: Block[]; text: string } {
|
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}`)] };
|
return { text, blocks: [section(`:x: ${text}`)] };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user