mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 06:33:11 +00:00
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
This commit is contained in:
commit
f1c2063f52
5 changed files with 356 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
.aws-sam/
|
||||
samconfig.toml
|
||||
12
package.json
Normal file
12
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
74
src/processPaymentCsv.js
Normal file
74
src/processPaymentCsv.js
Normal file
|
|
@ -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` };
|
||||
};
|
||||
183
src/slackAppHome.js
Normal file
183
src/slackAppHome.js
Normal file
|
|
@ -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),
|
||||
],
|
||||
};
|
||||
}
|
||||
84
template.yaml
Normal file
84
template.yaml
Normal file
|
|
@ -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<String>
|
||||
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
|
||||
Loading…
Add table
Reference in a new issue