commit f1c2063f5244ad6c1b2ace26adbb7d82021031e5 Author: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu Apr 9 13:29:28 2026 -0400 Initial SAM stack: payments CSV to Slack App Home Two Lambda functions: - processPaymentCsv: S3 trigger, parses CSV, stores dashboard in DynamoDB - slackAppHome: API Gateway endpoint for Slack events, publishes Home tab view diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efc9465 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.aws-sam/ +samconfig.toml diff --git a/package.json b/package.json new file mode 100644 index 0000000..ecc5b67 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "payments-dashboard", + "version": "1.0.0", + "type": "module", + "description": "Payments CSV ingestion to Slack App Home dashboard", + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.600.0", + "@aws-sdk/client-s3": "^3.600.0", + "@aws-sdk/lib-dynamodb": "^3.600.0", + "csv-parse": "^5.5.0" + } +} diff --git a/src/processPaymentCsv.js b/src/processPaymentCsv.js new file mode 100644 index 0000000..28e1785 --- /dev/null +++ b/src/processPaymentCsv.js @@ -0,0 +1,74 @@ +import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { parse } from "csv-parse/sync"; + +const s3 = new S3Client(); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); +const TABLE_NAME = process.env.TABLE_NAME; + +export const handler = async (event) => { + const record = event.Records[0]; + const bucket = record.s3.bucket.name; + const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " ")); + + // Download CSV from S3 + const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + const csvText = await Body.transformToString("utf-8"); + + // Parse CSV + const rows = parse(csvText, { + columns: true, + skip_empty_lines: true, + trim: true, + }); + + const normalizedRows = rows.map((row) => { + const clean = {}; + for (const [k, v] of Object.entries(row)) { + clean[String(k).trim()] = typeof v === "string" ? v.trim() : v; + } + return clean; + }); + + // Calculate + const parseAmount = (value) => { + const num = parseFloat(String(value || "0").replace(/,/g, "").trim()); + return isNaN(num) ? 0 : num; + }; + + const payments = normalizedRows.map((row) => ({ + method: (row["Method"] || "").trim(), + payee: (row["Payee"] || "").trim(), + invoice_numbers: (row["Invoice Numbers"] || "").trim(), + send_payment_on: (row["Send Payment On"] || "").trim(), + amount_usd: parseAmount(row["Amount in USD"]), + status: (row["Status"] || "").trim(), + company_subsidiary: (row["Company/Subsidiary"] || "").trim(), + })); + + const grand_total = payments.reduce((sum, p) => sum + p.amount_usd, 0); + + const dashboard = { + file_name: key.split("/").pop(), + row_count: payments.length, + grand_count: payments.length, + grand_total: Number(grand_total.toFixed(2)), + payments, + last_updated: new Date().toISOString(), + }; + + // Save to DynamoDB + await ddb.send( + new PutCommand({ + TableName: TABLE_NAME, + Item: { + pk: "current_dashboard", + ...dashboard, + }, + }) + ); + + console.log(`Processed ${payments.length} payments from ${key}`); + return { statusCode: 200, body: `Processed ${payments.length} payments` }; +}; diff --git a/src/slackAppHome.js b/src/slackAppHome.js new file mode 100644 index 0000000..956b912 --- /dev/null +++ b/src/slackAppHome.js @@ -0,0 +1,183 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb"; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient()); +const TABLE_NAME = process.env.TABLE_NAME; +const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN; + +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 dashboard data from DynamoDB + const { Item: dashboard } = await ddb.send( + new GetCommand({ + TableName: TABLE_NAME, + Key: { pk: "current_dashboard" }, + }) + ); + + if (!dashboard) { + return { statusCode: 200, body: JSON.stringify({ error: "No dashboard data" }) }; + } + + // Build and publish the home view + const view = buildHomeView(dashboard); + + const res = await fetch("https://slack.com/api/views.publish", { + method: "POST", + headers: { + Authorization: `Bearer ${SLACK_BOT_TOKEN}`, + "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(dashboard) { + const payments = Array.isArray(dashboard.payments) ? dashboard.payments : []; + + 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:* ${dashboard.last_updated || "N/A"}` }, + { type: "mrkdwn", text: `*Source:* ${dashboard.file_name || "N/A"}` }, + ], + }, + { type: "divider" }, + ...buildDayBlocks("ACH", achDays), + ...buildDayBlocks("Check", checkDays), + ], + }; +} diff --git a/template.yaml b/template.yaml new file mode 100644 index 0000000..ba686ea --- /dev/null +++ b/template.yaml @@ -0,0 +1,84 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Payments Dashboard - S3 CSV ingestion to Slack App Home + +Parameters: + SlackBotToken: + Type: AWS::SSM::Parameter::Value + Default: /payments-dashboard/slack-bot-token + NoEcho: true + +Globals: + Function: + Runtime: nodejs20.x + Timeout: 30 + MemorySize: 256 + Environment: + Variables: + TABLE_NAME: !Ref DashboardTable + +Resources: + PaymentsCsvBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub seahaven-payments-csv-${AWS::AccountId} + + DashboardTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: PaymentsDashboard + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: pk + AttributeType: S + KeySchema: + - AttributeName: pk + KeyType: HASH + + ProcessPaymentCsvFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: payments-processPaymentCsv + Handler: src/processPaymentCsv.handler + Events: + CsvUpload: + Type: S3 + Properties: + Bucket: !Ref PaymentsCsvBucket + Events: s3:ObjectCreated:* + Filter: + S3Key: + Rules: + - Name: suffix + Value: .csv + Policies: + - S3ReadPolicy: + BucketName: !Sub seahaven-payments-csv-${AWS::AccountId} + - DynamoDBCrudPolicy: + TableName: !Ref DashboardTable + + SlackAppHomeFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: payments-slackAppHome + Handler: src/slackAppHome.handler + Environment: + Variables: + SLACK_BOT_TOKEN: !Ref SlackBotToken + Events: + SlackEvent: + Type: HttpApi + Properties: + Path: /slack/events + Method: POST + Policies: + - DynamoDBReadPolicy: + TableName: !Ref DashboardTable + +Outputs: + SlackEventUrl: + Description: URL to set as the Slack app Request URL + Value: !Sub https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/slack/events + CsvBucket: + Description: S3 bucket for CSV uploads + Value: !Ref PaymentsCsvBucket