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), ], }; }