mirror of
https://github.com/Sea-Haven-Industries/payments-dashboard.git
synced 2026-09-30 04:13:12 +00:00
Add VPC/NAT for static IP, dedup payments by check number, SSM token
- VPC with NAT Gateway for static outbound IP (52.86.95.107) for BoA API - Payments stored as individual DynamoDB items keyed by check number - Slack bot token fetched from SSM at runtime instead of CF parameter - Slack Lambda scans payment items instead of reading single blob
This commit is contained in:
parent
f1c2063f52
commit
7150028bd3
5 changed files with 2069 additions and 39 deletions
1871
package-lock.json
generated
Normal file
1871
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-dynamodb": "^3.600.0",
|
||||
"@aws-sdk/client-s3": "^3.600.0",
|
||||
"@aws-sdk/client-ssm": "^3.600.0",
|
||||
"@aws-sdk/lib-dynamodb": "^3.600.0",
|
||||
"csv-parse": "^5.5.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
|
||||
import { DynamoDBDocumentClient, BatchWriteCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
|
||||
import { parse } from "csv-parse/sync";
|
||||
|
||||
const s3 = new S3Client();
|
||||
|
|
@ -31,15 +31,16 @@ export const handler = async (event) => {
|
|||
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) => ({
|
||||
pk: `payment#${(row["Check Number"] || "").trim()}`,
|
||||
method: (row["Method"] || "").trim(),
|
||||
payee: (row["Payee"] || "").trim(),
|
||||
check_number: (row["Check Number"] || "").trim(),
|
||||
invoice_numbers: (row["Invoice Numbers"] || "").trim(),
|
||||
send_payment_on: (row["Send Payment On"] || "").trim(),
|
||||
amount_usd: parseAmount(row["Amount in USD"]),
|
||||
|
|
@ -47,28 +48,36 @@ export const handler = async (event) => {
|
|||
company_subsidiary: (row["Company/Subsidiary"] || "").trim(),
|
||||
}));
|
||||
|
||||
const grand_total = payments.reduce((sum, p) => sum + p.amount_usd, 0);
|
||||
// Write payments in batches of 25 (DynamoDB BatchWrite limit)
|
||||
const batches = [];
|
||||
for (let i = 0; i < payments.length; i += 25) {
|
||||
const batch = payments.slice(i, i + 25).map((item) => ({
|
||||
PutRequest: { Item: item },
|
||||
}));
|
||||
batches.push(batch);
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
for (const batch of batches) {
|
||||
await ddb.send(
|
||||
new BatchWriteCommand({
|
||||
RequestItems: { [TABLE_NAME]: batch },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Save to DynamoDB
|
||||
// Update metadata record
|
||||
await ddb.send(
|
||||
new PutCommand({
|
||||
TableName: TABLE_NAME,
|
||||
Item: {
|
||||
pk: "current_dashboard",
|
||||
...dashboard,
|
||||
pk: "metadata",
|
||||
file_name: key.split("/").pop(),
|
||||
last_updated: new Date().toISOString(),
|
||||
last_file_count: payments.length,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`Processed ${payments.length} payments from ${key}`);
|
||||
return { statusCode: 200, body: `Processed ${payments.length} payments` };
|
||||
console.log(`Upserted ${payments.length} payments from ${key}`);
|
||||
return { statusCode: 200, body: `Upserted ${payments.length} payments` };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,23 @@
|
|||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-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;
|
||||
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN;
|
||||
|
||||
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 || "{}");
|
||||
|
|
@ -20,25 +34,39 @@ export const handler = async (event) => {
|
|||
|
||||
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" },
|
||||
})
|
||||
// Get metadata
|
||||
const { Item: metadata } = await ddb.send(
|
||||
new GetCommand({ TableName: TABLE_NAME, Key: { pk: "metadata" } })
|
||||
);
|
||||
|
||||
if (!dashboard) {
|
||||
return { statusCode: 200, body: JSON.stringify({ error: "No dashboard data" }) };
|
||||
// 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(dashboard);
|
||||
const view = buildHomeView(payments, metadata);
|
||||
const slackToken = await getSlackToken();
|
||||
|
||||
const res = await fetch("https://slack.com/api/views.publish", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${SLACK_BOT_TOKEN}`,
|
||||
Authorization: `Bearer ${slackToken}`,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId, view }),
|
||||
|
|
@ -54,8 +82,7 @@ export const handler = async (event) => {
|
|||
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
|
||||
};
|
||||
|
||||
function buildHomeView(dashboard) {
|
||||
const payments = Array.isArray(dashboard.payments) ? dashboard.payments : [];
|
||||
function buildHomeView(payments, metadata) {
|
||||
|
||||
const formatCurrency = (value) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(
|
||||
|
|
@ -171,8 +198,8 @@ function buildHomeView(dashboard) {
|
|||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{ type: "mrkdwn", text: `*Last updated:* ${dashboard.last_updated || "N/A"}` },
|
||||
{ type: "mrkdwn", text: `*Source:* ${dashboard.file_name || "N/A"}` },
|
||||
{ type: "mrkdwn", text: `*Last updated:* ${metadata?.last_updated || "N/A"}` },
|
||||
{ type: "mrkdwn", text: `*Source:* ${metadata?.file_name || "N/A"}` },
|
||||
],
|
||||
},
|
||||
{ type: "divider" },
|
||||
|
|
|
|||
136
template.yaml
136
template.yaml
|
|
@ -2,12 +2,6 @@ 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
|
||||
|
|
@ -18,6 +12,103 @@ Globals:
|
|||
TABLE_NAME: !Ref DashboardTable
|
||||
|
||||
Resources:
|
||||
# VPC with private subnet + NAT Gateway for static outbound IP
|
||||
Vpc:
|
||||
Type: AWS::EC2::VPC
|
||||
Properties:
|
||||
CidrBlock: 10.20.0.0/16
|
||||
EnableDnsSupport: true
|
||||
EnableDnsHostnames: true
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: payments-dashboard-vpc
|
||||
|
||||
PrivateSubnet:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
CidrBlock: 10.20.1.0/24
|
||||
AvailabilityZone: !Select [0, !GetAZs ""]
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: payments-dashboard-private
|
||||
|
||||
PublicSubnet:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
CidrBlock: 10.20.2.0/24
|
||||
AvailabilityZone: !Select [0, !GetAZs ""]
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: payments-dashboard-public
|
||||
|
||||
InternetGateway:
|
||||
Type: AWS::EC2::InternetGateway
|
||||
|
||||
VpcGatewayAttachment:
|
||||
Type: AWS::EC2::VPCGatewayAttachment
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
InternetGatewayId: !Ref InternetGateway
|
||||
|
||||
NatEip:
|
||||
Type: AWS::EC2::EIP
|
||||
Properties:
|
||||
Domain: vpc
|
||||
|
||||
NatGateway:
|
||||
Type: AWS::EC2::NatGateway
|
||||
Properties:
|
||||
AllocationId: !GetAtt NatEip.AllocationId
|
||||
SubnetId: !Ref PublicSubnet
|
||||
|
||||
PublicRouteTable:
|
||||
Type: AWS::EC2::RouteTable
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
|
||||
PublicRoute:
|
||||
Type: AWS::EC2::Route
|
||||
DependsOn: VpcGatewayAttachment
|
||||
Properties:
|
||||
RouteTableId: !Ref PublicRouteTable
|
||||
DestinationCidrBlock: 0.0.0.0/0
|
||||
GatewayId: !Ref InternetGateway
|
||||
|
||||
PublicSubnetRouteTableAssociation:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties:
|
||||
SubnetId: !Ref PublicSubnet
|
||||
RouteTableId: !Ref PublicRouteTable
|
||||
|
||||
PrivateRouteTable:
|
||||
Type: AWS::EC2::RouteTable
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
|
||||
PrivateRoute:
|
||||
Type: AWS::EC2::Route
|
||||
Properties:
|
||||
RouteTableId: !Ref PrivateRouteTable
|
||||
DestinationCidrBlock: 0.0.0.0/0
|
||||
NatGatewayId: !Ref NatGateway
|
||||
|
||||
PrivateSubnetRouteTableAssociation:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties:
|
||||
SubnetId: !Ref PrivateSubnet
|
||||
RouteTableId: !Ref PrivateRouteTable
|
||||
|
||||
LambdaSecurityGroup:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Payments Dashboard Lambda outbound access
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupEgress:
|
||||
- IpProtocol: "-1"
|
||||
CidrIp: 0.0.0.0/0
|
||||
|
||||
PaymentsCsvBucket:
|
||||
Type: AWS::S3::Bucket
|
||||
Properties:
|
||||
|
|
@ -40,6 +131,11 @@ Resources:
|
|||
Properties:
|
||||
FunctionName: payments-processPaymentCsv
|
||||
Handler: src/processPaymentCsv.handler
|
||||
VpcConfig:
|
||||
SubnetIds:
|
||||
- !Ref PrivateSubnet
|
||||
SecurityGroupIds:
|
||||
- !Ref LambdaSecurityGroup
|
||||
Events:
|
||||
CsvUpload:
|
||||
Type: S3
|
||||
|
|
@ -56,15 +152,28 @@ Resources:
|
|||
BucketName: !Sub seahaven-payments-csv-${AWS::AccountId}
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref DashboardTable
|
||||
- Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- ec2:CreateNetworkInterface
|
||||
- ec2:DescribeNetworkInterfaces
|
||||
- ec2:DeleteNetworkInterface
|
||||
Resource: "*"
|
||||
|
||||
SlackAppHomeFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
FunctionName: payments-slackAppHome
|
||||
Handler: src/slackAppHome.handler
|
||||
VpcConfig:
|
||||
SubnetIds:
|
||||
- !Ref PrivateSubnet
|
||||
SecurityGroupIds:
|
||||
- !Ref LambdaSecurityGroup
|
||||
Environment:
|
||||
Variables:
|
||||
SLACK_BOT_TOKEN: !Ref SlackBotToken
|
||||
SLACK_BOT_TOKEN_PARAM: /payments-dashboard/slack-bot-token
|
||||
Events:
|
||||
SlackEvent:
|
||||
Type: HttpApi
|
||||
|
|
@ -74,6 +183,16 @@ Resources:
|
|||
Policies:
|
||||
- DynamoDBReadPolicy:
|
||||
TableName: !Ref DashboardTable
|
||||
- SSMParameterReadPolicy:
|
||||
ParameterName: payments-dashboard/slack-bot-token
|
||||
- Version: "2012-10-17"
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- ec2:CreateNetworkInterface
|
||||
- ec2:DescribeNetworkInterfaces
|
||||
- ec2:DeleteNetworkInterface
|
||||
Resource: "*"
|
||||
|
||||
Outputs:
|
||||
SlackEventUrl:
|
||||
|
|
@ -82,3 +201,6 @@ Outputs:
|
|||
CsvBucket:
|
||||
Description: S3 bucket for CSV uploads
|
||||
Value: !Ref PaymentsCsvBucket
|
||||
StaticOutboundIp:
|
||||
Description: Static IP for BoA API whitelist
|
||||
Value: !Ref NatEip
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue