From 941cd094fc257a2ac346455db66f246a5ca66508 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:08:06 -0400 Subject: [PATCH] Add drill-down modals: View buttons open payment detail for each row --- src/slackAppHome.js | 331 +++++++++++++++++++++++++++++++------------- 1 file changed, 232 insertions(+), 99 deletions(-) diff --git a/src/slackAppHome.js b/src/slackAppHome.js index 4551776..2a204cf 100644 --- a/src/slackAppHome.js +++ b/src/slackAppHome.js @@ -19,14 +19,106 @@ async function getSlackToken() { return cachedToken; } +// --- Shared helpers used by both home view and modals --- + +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 formatDisplayDate = (date) => + date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); + +const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"]; + +const ageBuckets = [ + { label: "0 – 15 days", min: 0, max: 15 }, + { label: "15 – 30 days", min: 15, max: 30 }, + { label: "30 – 45 days", min: 30, max: 45 }, + { label: "45 – 60 days", min: 45, max: 60 }, + { label: "60 – 90 days", min: 60, max: 90 }, + { label: "90+ days", min: 90, max: Infinity }, +]; + +const byDate = (a, b) => { + const da = parseMDYLocal(a.send_payment_on); + const db = parseMDYLocal(b.send_payment_on); + return (da || 0) - (db || 0); +}; + +// --- Categorize payments into buckets --- + +function categorizePayments(payments) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24)); + + const scheduledChecks = []; + const scheduledACH = []; + const outstandingChecks = []; + + for (const p of payments) { + if (p.method !== "ACH" && p.method !== "Check") continue; + const dt = parseMDYLocal(p.send_payment_on); + if (!dt) continue; + const status = (p.status || "").toLowerCase(); + if (skipStatuses.includes(status)) continue; + + if (dt >= today) { + if (p.method === "Check") scheduledChecks.push(p); + else scheduledACH.push(p); + } else if (p.method === "Check") { + outstandingChecks.push(p); + } + } + + scheduledChecks.sort(byDate); + scheduledACH.sort(byDate); + + const bucketedOutstanding = ageBuckets.map((bucket) => { + const items = outstandingChecks.filter((p) => { + const age = daysSince(parseMDYLocal(p.send_payment_on)); + return age >= bucket.min && age < bucket.max; + }); + items.sort(byDate); + const total = items.reduce((sum, p) => sum + p.amount_usd, 0); + return { ...bucket, items, total }; + }); + + return { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding }; +} + +// --- Main handler --- + export const handler = async (event) => { - const body = JSON.parse(event.body || "{}"); + // Slack sends block_actions as form-encoded: payload= + let body; + const rawBody = event.body || ""; + if (rawBody.startsWith("payload=")) { + body = JSON.parse(decodeURIComponent(rawBody.replace("payload=", ""))); + } else { + body = JSON.parse(rawBody || "{}"); + } // Handle Slack URL verification challenge if (body.type === "url_verification") { return { statusCode: 200, body: body.challenge }; } + // Handle button clicks → open modal + if (body.type === "block_actions") { + return handleBlockAction(body); + } + // Only process app_home_opened events if (body.event?.type !== "app_home_opened") { return { statusCode: 200, body: JSON.stringify({ ok: true }) }; @@ -34,32 +126,16 @@ export const handler = async (event) => { 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); + const payments = await scanPayments(); 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(); @@ -82,84 +158,136 @@ export const handler = async (event) => { 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) - ); +// --- Block action handler (button clicks) --- - 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); - }; +async function handleBlockAction(body) { + const action = body.actions?.[0]; + if (!action) return { statusCode: 200, body: JSON.stringify({ ok: true }) }; - const formatDisplayDate = (date) => - date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); + const triggerId = body.trigger_id; + const actionId = action.action_id; - const today = new Date(); - today.setHours(0, 0, 0, 0); + const payments = await scanPayments(); + const { today, daysSince, scheduledChecks, scheduledACH, bucketedOutstanding } = categorizePayments(payments); - const daysSince = (dt) => Math.floor((today - dt) / (1000 * 60 * 60 * 24)); + let modalTitle = ""; + let items = []; - // Categorize payments - const scheduledChecks = []; - const scheduledACH = []; - const outstandingChecks = []; + if (actionId.startsWith("view_scheduled_")) { + const dateKey = actionId.replace("view_scheduled_checks_", "").replace("view_scheduled_ach_", ""); + const method = actionId.includes("_checks_") ? "Check" : "ACH"; + const source = method === "Check" ? scheduledChecks : scheduledACH; - const skipStatuses = ["voided", "cancelled", "canceled", "marked as void", "cleared"]; + items = source.filter((p) => { + const dt = parseMDYLocal(p.send_payment_on); + return dt && dt.toISOString().split("T")[0] === dateKey; + }); - for (const p of payments) { - if (p.method !== "ACH" && p.method !== "Check") continue; - - const dt = parseMDYLocal(p.send_payment_on); - if (!dt) continue; - - const status = (p.status || "").toLowerCase(); - if (skipStatuses.includes(status)) continue; - - if (dt >= today) { - if (p.method === "Check") scheduledChecks.push(p); - else scheduledACH.push(p); - } else if (p.method === "Check") { - outstandingChecks.push(p); + const dt = items[0] ? parseMDYLocal(items[0].send_payment_on) : null; + modalTitle = dt ? formatDisplayDate(dt) : dateKey; + } else if (actionId.startsWith("view_outstanding_")) { + const bucketIdx = parseInt(actionId.replace("view_outstanding_", ""), 10); + const bucket = bucketedOutstanding[bucketIdx]; + if (bucket) { + items = bucket.items; + modalTitle = bucket.label; } } - // Sort by date - const byDate = (a, b) => { - const da = parseMDYLocal(a.send_payment_on); - const db = parseMDYLocal(b.send_payment_on); - return (da || 0) - (db || 0); - }; - scheduledChecks.sort(byDate); - scheduledACH.sort(byDate); + if (!items.length) { + return { statusCode: 200, body: JSON.stringify({ ok: true }) }; + } - // Age buckets for outstanding checks - const ageBuckets = [ - { label: "0 – 15 days", min: 0, max: 15 }, - { label: "15 – 30 days", min: 15, max: 30 }, - { label: "30 – 45 days", min: 30, max: 45 }, - { label: "45 – 60 days", min: 45, max: 60 }, - { label: "60 – 90 days", min: 60, max: 90 }, - { label: "90+ days", min: 90, max: Infinity }, - ]; + const modalBlocks = buildPaymentListBlocks(items); - const bucketedOutstanding = ageBuckets.map((bucket) => { - const items = outstandingChecks.filter((p) => { - const age = daysSince(parseMDYLocal(p.send_payment_on)); - return age >= bucket.min && age < bucket.max; - }); - const total = items.reduce((sum, p) => sum + p.amount_usd, 0); - return { ...bucket, items, total }; + const slackToken = await getSlackToken(); + const res = await fetch("https://slack.com/api/views.open", { + method: "POST", + headers: { + Authorization: `Bearer ${slackToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + trigger_id: triggerId, + view: { + type: "modal", + title: { type: "plain_text", text: modalTitle.slice(0, 24) }, + close: { type: "plain_text", text: "Close" }, + blocks: modalBlocks, + }, + }), }); - // --- Block builders --- + const data = await res.json(); + if (!data.ok) { + console.error("Slack views.open error:", JSON.stringify(data)); + } - const buildScheduledBlocks = (title, emoji, items) => { + return { statusCode: 200, body: JSON.stringify({ ok: true }) }; +} + +// --- Build modal payment list --- + +function buildPaymentListBlocks(items) { + const total = items.reduce((sum, p) => sum + p.amount_usd, 0); + + const blocks = [ + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `${items.length} payment${items.length !== 1 ? "s" : ""} · ${formatCurrency(total)} total`, + }, + ], + }, + { type: "divider" }, + ]; + + for (const p of items) { + const dt = parseMDYLocal(p.send_payment_on); + const dateStr = dt ? formatDisplayDate(dt) : "N/A"; + const payee = p.payee || "—"; + const checkNum = p.check_number || "—"; + + blocks.push({ + type: "section", + fields: [ + { type: "mrkdwn", text: `*${payee}*\nCheck #${checkNum}` }, + { type: "mrkdwn", text: `*${formatCurrency(p.amount_usd)}*\n${dateStr}` }, + ], + }); + } + + return blocks; +} + +// --- Scan all payments from DynamoDB --- + +async function scanPayments() { + 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); + return payments; +} + +// --- Build the App Home view --- + +function buildHomeView(payments, metadata) { + const { today, daysSince, scheduledChecks, scheduledACH, outstandingChecks, bucketedOutstanding } = categorizePayments(payments); + + const buildScheduledBlocks = (title, emoji, items, methodKey) => { const total = items.reduce((sum, p) => sum + p.amount_usd, 0); const blocks = [ @@ -205,13 +333,15 @@ function buildHomeView(payments, metadata) { blocks.push({ type: "section", - fields: [ - { type: "mrkdwn", text: `*${dateLabel}* ${daysTag}` }, - { - type: "mrkdwn", - text: `${group.payments.length} payment${group.payments.length !== 1 ? "s" : ""} · *${formatCurrency(dayTotal)}*`, - }, - ], + text: { + type: "mrkdwn", + text: `*${dateLabel}* ${daysTag}\n${group.payments.length} payment${group.payments.length !== 1 ? "s" : ""} · *${formatCurrency(dayTotal)}*`, + }, + accessory: { + type: "button", + text: { type: "plain_text", text: "View" }, + action_id: `view_scheduled_${methodKey}_${key}`, + }, }); } @@ -247,18 +377,21 @@ function buildHomeView(payments, metadata) { return blocks; } - for (const bucket of bucketedOutstanding) { + for (let i = 0; i < bucketedOutstanding.length; i++) { + const bucket = bucketedOutstanding[i]; if (!bucket.items.length) continue; blocks.push({ type: "section", - fields: [ - { type: "mrkdwn", text: `*${bucket.label}*` }, - { - type: "mrkdwn", - text: `${bucket.items.length} check${bucket.items.length !== 1 ? "s" : ""} · *${formatCurrency(bucket.total)}*`, - }, - ], + text: { + type: "mrkdwn", + text: `*${bucket.label}*\n${bucket.items.length} check${bucket.items.length !== 1 ? "s" : ""} · *${formatCurrency(bucket.total)}*`, + }, + accessory: { + type: "button", + text: { type: "plain_text", text: "View" }, + action_id: `view_outstanding_${i}`, + }, }); } @@ -266,7 +399,7 @@ function buildHomeView(payments, metadata) { return blocks; }; - // --- Summary bar --- + // Summary bar const totalScheduled = scheduledChecks.length + scheduledACH.length; const totalScheduledAmt = [...scheduledChecks, ...scheduledACH].reduce((sum, p) => sum + p.amount_usd, 0); const totalOutstandingAmt = outstandingChecks.reduce((sum, p) => sum + p.amount_usd, 0); @@ -300,8 +433,8 @@ function buildHomeView(payments, metadata) { ], }, { type: "divider" }, - ...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks), - ...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH), + ...buildScheduledBlocks("Scheduled Checks", ":ledger:", scheduledChecks, "checks"), + ...buildScheduledBlocks("Scheduled ACH", ":electric_plug:", scheduledACH, "ach"), ...buildOutstandingBlocks(), ], };