payments-dashboard/src/slackAppHome.js

211 lines
5.8 KiB
JavaScript
Raw Normal View History

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand, ScanCommand } from "@aws-sdk/lib-dynamodb";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient());
const ssm = new SSMClient();
const TABLE_NAME = process.env.TABLE_NAME;
let cachedToken;
async function getSlackToken() {
if (cachedToken) return cachedToken;
const { Parameter } = await ssm.send(
new GetParameterCommand({
Name: process.env.SLACK_BOT_TOKEN_PARAM,
WithDecryption: true,
})
);
cachedToken = Parameter.Value;
return cachedToken;
}
export const handler = async (event) => {
const body = JSON.parse(event.body || "{}");
// Handle Slack URL verification challenge
if (body.type === "url_verification") {
return { statusCode: 200, body: body.challenge };
}
// Only process app_home_opened events
if (body.event?.type !== "app_home_opened") {
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
}
const userId = body.event.user;
// Get metadata
const { Item: metadata } = await ddb.send(
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } })
);
// Scan all payment items
const payments = [];
let lastKey;
do {
const result = await ddb.send(
new ScanCommand({
TableName: TABLE_NAME,
FilterExpression: "begins_with(pk, :prefix)",
ExpressionAttributeValues: { ":prefix": "payment#" },
ExclusiveStartKey: lastKey,
})
);
payments.push(...result.Items);
lastKey = result.LastEvaluatedKey;
} while (lastKey);
if (!payments.length) {
return { statusCode: 200, body: JSON.stringify({ error: "No payment data" }) };
}
// Build and publish the home view
const view = buildHomeView(payments, metadata);
const slackToken = await getSlackToken();
const res = await fetch("https://slack.com/api/views.publish", {
method: "POST",
headers: {
Authorization: `Bearer ${slackToken}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({ user_id: userId, view }),
});
const data = await res.json();
if (!data.ok) {
console.error("Slack API error:", JSON.stringify(data));
return { statusCode: 500, body: JSON.stringify(data) };
}
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
};
function buildHomeView(payments, metadata) {
const formatCurrency = (value) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
Number(value || 0)
);
const parseMDYLocal = (value) => {
if (!value) return null;
const parts = String(value).split("/");
if (parts.length !== 3) return null;
const [mm, dd, yyyy] = parts.map((p) => parseInt(p, 10));
if (!mm || !dd || !yyyy) return null;
return new Date(yyyy, mm - 1, dd);
};
const formatDateKey = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
const formatDisplayDate = (dateKey) => {
const [y, m, d] = dateKey.split("-").map(Number);
const dt = new Date(y, m - 1, d);
return dt.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
};
const today = new Date();
today.setHours(0, 0, 0, 0);
const futurePayments = payments.filter((p) => {
const dt = parseMDYLocal(p.send_payment_on);
return dt && dt >= today && (p.method === "ACH" || p.method === "Check");
});
const groupByMethodAndDay = (methodName) => {
const grouped = {};
for (const payment of futurePayments) {
if (payment.method !== methodName) continue;
const dt = parseMDYLocal(payment.send_payment_on);
if (!dt) continue;
const key = formatDateKey(dt);
if (!grouped[key]) grouped[key] = { count: 0, total: 0 };
grouped[key].count += 1;
grouped[key].total += payment.amount_usd;
}
return Object.keys(grouped)
.sort()
.map((dateKey) => ({
date_key: dateKey,
display_date: formatDisplayDate(dateKey),
count: grouped[dateKey].count,
total: Number(grouped[dateKey].total.toFixed(2)),
}));
};
const achDays = groupByMethodAndDay("ACH");
const checkDays = groupByMethodAndDay("Check");
const buildDayBlocks = (title, days) => {
const total = days.reduce((sum, d) => sum + d.total, 0);
const count = days.reduce((sum, d) => sum + d.count, 0);
const blocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${title}*\n${count} payments • ${formatCurrency(total)}`,
},
},
{ type: "divider" },
];
if (!days.length) {
blocks.push({
type: "section",
text: { type: "mrkdwn", text: "_No upcoming payments found._" },
});
blocks.push({ type: "divider" });
return blocks;
}
for (const day of days) {
blocks.push({
type: "section",
fields: [
{ type: "mrkdwn", text: `*${day.display_date}*` },
{ type: "mrkdwn", text: `${day.count} payments • ${formatCurrency(day.total)}` },
],
});
}
blocks.push({ type: "divider" });
return blocks;
};
return {
type: "home",
blocks: [
{
type: "header",
text: { type: "plain_text", text: "Payments Dashboard" },
},
{
type: "context",
elements: [
{ type: "mrkdwn", text: `*Last updated:* ${metadata?.last_updated || "N/A"}` },
{ type: "mrkdwn", text: `*Source:* ${metadata?.file_name || "N/A"}` },
],
},
{ type: "divider" },
...buildDayBlocks("ACH", achDays),
...buildDayBlocks("Check", checkDays),
],
};
}