payments-dashboard/src/expenseProcessor.js
Adam Moussa 5330c3f88a
Some checks failed
Deploy / deploy (push) Has been cancelled
Merge expense-approval-bot into payments-dashboard (#28)
* Merge expense-approval-bot into payments-dashboard

Port the Slack reaction-driven expense routing workflow (receiver +
processor) from expense-approval-bot into this stack as JavaScript ESM.
Secrets copied to payments-dashboard/ prefix in Secrets Manager.

* Fix review findings from PR #28

- Add length check before timingSafeEqual to prevent RangeError on
  malformed signatures (returns 401 instead of 500)
- Check event.type === reaction_added to prevent reaction_removed
  from advancing expenses
- Move getPermalink call behind isOrigin check to skip unnecessary
  API call on non-origin stage transitions
2026-05-12 13:08:42 -04:00

120 lines
3.5 KiB
JavaScript

import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
const secrets = new SecretsManagerClient();
const EXPENSE_BOT_TOKEN_SECRET_NAME = process.env.EXPENSE_BOT_TOKEN_SECRET_NAME;
let cachedToken;
async function getBotToken() {
if (cachedToken) return cachedToken;
const { SecretString } = await secrets.send(
new GetSecretValueCommand({ SecretId: EXPENSE_BOT_TOKEN_SECRET_NAME })
);
cachedToken = SecretString;
return cachedToken;
}
const SUBMITTED_CHANNEL = "C0AQ2AWLNEN";
const STAGES = {
[SUBMITTED_CHANNEL]: { next: "C0APLSGABAB", label: "Processed" },
C0APLSGABAB: { next: "C0AQ09CDJH4", label: "Authorized" },
C0AQ09CDJH4: { next: "C0APYUM1JFP", label: "Matched" },
};
const REACT_HINT_RE = /_React_ :white_check_mark: _to advance to \w+_/;
async function slackGet(method, token, params) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(`https://slack.com/api/${method}?${qs}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (!data.ok) throw new Error(`${method} failed: ${data.error}`);
return data;
}
async function slackPost(method, token, body) {
const res = await fetch(`https://slack.com/api/${method}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.ok) throw new Error(`${method} failed: ${data.error}`);
return data;
}
export const handler = async (event) => {
if (event.type !== "reaction_added" || event.reaction !== "white_check_mark") {
console.log(`Skipping event type=${event.type} reaction=${event.reaction}`);
return;
}
const fromChannel = event.item.channel;
const messageTs = event.item.ts;
const route = STAGES[fromChannel];
if (!route) {
console.log(`Channel ${fromChannel} not in STAGES, skipping`);
return;
}
const toChannel = route.next;
const label = route.label;
const isOrigin = fromChannel === SUBMITTED_CHANNEL;
const token = await getBotToken();
const history = await slackGet("conversations.history", token, {
channel: fromChannel,
latest: messageTs,
limit: "1",
inclusive: "true",
});
const originalText = history.messages[0].text;
const text = originalText.replace(REACT_HINT_RE, "").trim();
const nextStage = STAGES[toChannel];
const nextLabel = nextStage ? nextStage.label : null;
const reactLine = nextLabel
? `\n\n_React_ :white_check_mark: _to advance to ${nextLabel}_`
: "";
let permalinkLine = "";
if (isOrigin) {
const permalinkResp = await slackGet("chat.getPermalink", token, {
channel: fromChannel,
message_ts: messageTs,
});
permalinkLine = `\n\n:paperclip: *Original Submission:* <${permalinkResp.permalink}|View Original Message>`;
}
const fullText = `${text}${permalinkLine}${reactLine}`;
await slackPost("chat.postMessage", token, {
channel: toChannel,
text: fullText,
mrkdwn: true,
unfurl_links: false,
unfurl_media: false,
});
if (isOrigin) {
await slackPost("chat.postMessage", token, {
channel: fromChannel,
thread_ts: messageTs,
text: `➡️ Advanced to ${label}`,
});
console.log(`Advanced user submission to ${label} (origin preserved)`);
} else {
await slackPost("chat.delete", token, {
channel: fromChannel,
ts: messageTs,
});
console.log(`Advanced to ${label} and deleted previous copy`);
}
};