Add PO auto-sync from external purchase-orders table

POs now auto-sync from the external DynamoDB purchase-orders table on
every GET /pos request, replacing manual import. Adds cursor-based
pagination support and SAM template for local development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adam Moussa 2026-04-03 13:58:15 -04:00
parent 5efa6fc961
commit 3854750597
6 changed files with 376 additions and 57 deletions

3
.gitignore vendored
View file

@ -1,5 +1,8 @@
node_modules/ node_modules/
cdk.out/ cdk.out/
.aws-sam/
.env .env
env.json
env.local.json
*.js.map *.js.map
.DS_Store .DS_Store

View file

@ -106,6 +106,7 @@ class LedgerFlowStack extends cdk.Stack {
EDI_TX_TABLE: ediTxTable.tableName, EDI_TX_TABLE: ediTxTable.tableName,
SESSIONS_TABLE: sessionsTable.tableName, SESSIONS_TABLE: sessionsTable.tableName,
SETTINGS_TABLE: settingsTable.tableName, SETTINGS_TABLE: settingsTable.tableName,
PURCHASE_ORDERS_TABLE: "purchase-orders",
EDI_INPUT_BUCKET: ediInputBucket.bucketName, EDI_INPUT_BUCKET: ediInputBucket.bucketName,
EDI_OUTPUT_BUCKET: ediOutputBucket.bucketName, EDI_OUTPUT_BUCKET: ediOutputBucket.bucketName,
// Set these via SSM Parameter Store or Secrets Manager in production: // Set these via SSM Parameter Store or Secrets Manager in production:

View file

@ -1,19 +1,18 @@
// lambdas/pos/index.js // lambdas/pos/index.js
// Purchase Orders API // Purchase Orders API
// GET /pos — list all POs (paginated, filterable) // GET /pos — list all POs (auto-syncs from purchase-orders table)
// POST /pos — create a single PO manually // POST /pos — create a single PO manually
// GET /pos/:id — get one PO // GET /pos/:id — get one PO
// PUT /pos/:id — full update // PUT /pos/:id — full update
// PATCH /pos/:id — partial update (e.g. status change) // PATCH /pos/:id — partial update (e.g. status change)
// DELETE /pos/:id — delete // DELETE /pos/:id — delete
// POST /pos/import — bulk import (from DynamoDB scan proxy or pasted JSON) // POST /pos/import — bulk import (from pasted JSON)
// GET /pos/dynamo-scan — live scan of the customer's own DynamoDB PO table
const { const {
ScanCommand, GetCommand, PutCommand, UpdateCommand, DeleteCommand, ScanCommand, GetCommand, PutCommand, UpdateCommand, DeleteCommand,
} = require("@aws-sdk/lib-dynamodb"); } = require("@aws-sdk/lib-dynamodb");
const { const {
ok, created, noContent, badRequest, notFound, conflict, serverError, ok, created, noContent, badRequest, notFound, conflict,
getDocClient, TABLES, genId, parseBody, require_fields, handler, getDocClient, TABLES, genId, parseBody, require_fields, handler,
parsePagination, paginatedResponse, decodeCursor, parsePagination, paginatedResponse, decodeCursor,
} = require("@ledgerflow/shared"); } = require("@ledgerflow/shared");
@ -32,8 +31,6 @@ exports.handler = handler(async (event, _ctx, user) => {
// POST /pos/import // POST /pos/import
if (method === "POST" && id === "import") return bulkImport(event, user); if (method === "POST" && id === "import") return bulkImport(event, user);
// GET /pos/dynamo-scan
if (method === "GET" && id === "dynamo-scan") return dynamoScan(event, user);
if (!id) { if (!id) {
if (method === "GET") return listPOs(qs, user); if (method === "GET") return listPOs(qs, user);
@ -48,12 +45,94 @@ exports.handler = handler(async (event, _ctx, user) => {
return { statusCode: 405, body: JSON.stringify({ error: "Method Not Allowed" }) }; return { statusCode: 405, body: JSON.stringify({ error: "Method Not Allowed" }) };
}); });
// ─── Auto-Sync from External purchase-orders Table ──────────────────────────
// Scans the external purchase-orders table and upserts any new POs into
// the internal ledgerflow-pos table. Runs before every list to keep in sync.
async function syncFromPurchaseOrders(db, user) {
const EXTERNAL = TABLES.PURCHASE_ORDERS;
const now = new Date().toISOString();
// Scan all items from the external table (paginate through all pages)
let externalItems = [];
let lastKey;
do {
const params = { TableName: EXTERNAL, ...(lastKey && { ExclusiveStartKey: lastKey }) };
const result = await db.send(new ScanCommand(params));
externalItems = externalItems.concat(result.Items || []);
lastKey = result.LastEvaluatedKey;
} while (lastKey);
if (externalItems.length === 0) return;
// Get all existing PO numbers to avoid duplicates
let existingNumbers = new Set();
let lek;
do {
const scan = await db.send(new ScanCommand({
TableName: TABLE,
ProjectionExpression: "poNumber",
...(lek && { ExclusiveStartKey: lek }),
}));
(scan.Items || []).forEach(i => existingNumbers.add(i.poNumber));
lek = scan.LastEvaluatedKey;
} while (lek);
// Insert any POs that don't already exist
const newItems = externalItems.filter(item => {
const poNumber = (item.po_number || item.poNumber || "").toString().trim();
return poNumber && !existingNumbers.has(poNumber);
});
const batchSize = 25;
for (let i = 0; i < newItems.length; i += batchSize) {
const batch = newItems.slice(i, i + batchSize);
await Promise.all(batch.map(async (raw) => {
try {
const poNumber = (raw.po_number || raw.poNumber || "").toString().trim();
const vendor = (raw.supplier?.name || raw.vendor_name || raw.vendor || "").toString().trim();
const amount = parseFloat(raw.total_amount || raw.amount || 0);
if (!poNumber || !vendor) return;
const firstLine = Array.isArray(raw.line_items) ? raw.line_items[0] : null;
const item = {
id: genId("PO"),
poNumber,
vendor,
amount,
billed: 0,
status: (raw.po_status || raw.status || "open").toLowerCase(),
issueDate: raw.order_date || raw.issue_date || raw.issueDate || now.split("T")[0],
dueDate: firstLine?.need_by || raw.due_date || raw.dueDate || null,
notes: firstLine?.description || raw.notes || raw.description || "",
source: "auto-sync",
createdBy: user?.email || "system",
createdAt: now,
updatedAt: now,
};
await db.send(new PutCommand({ TableName: TABLE, Item: item }));
} catch (err) {
console.warn("[auto-sync] skipped item:", err.message);
}
}));
}
}
// ─── List ───────────────────────────────────────────────────────────────────── // ─── List ─────────────────────────────────────────────────────────────────────
async function listPOs(qs, user) { async function listPOs(qs, user) {
const { limit, cursor } = parsePagination(qs); const { limit, cursor } = parsePagination(qs);
const db = getDocClient(); const db = getDocClient();
// Auto-sync new POs from external purchase-orders table
try {
await syncFromPurchaseOrders(db, user);
} catch (err) {
console.warn("[auto-sync] failed, returning cached POs:", err.message);
}
const params = { const params = {
TableName: TABLE, TableName: TABLE,
Limit: limit, Limit: limit,
@ -238,48 +317,3 @@ async function bulkImport(event, user) {
return ok({ message: `Import complete`, ...results }); return ok({ message: `Import complete`, ...results });
} }
// ─── Live DynamoDB Scan (Customer's Own Table) ───────────────────────────────
// Proxies a scan against the customer's configured DynamoDB table.
// The customer's table name/field config is stored in their settings.
async function dynamoScan(event, user) {
const qs = event.queryStringParameters || {};
const filterStatus = qs.status;
const limit = Math.min(parseInt(qs.limit || "50"), 200);
// Retrieve customer's DynamoDB config from the settings table
const db = getDocClient();
const settingsResult = await db.send(new GetCommand({ TableName: TABLES.SETTINGS, Key: { userId: user.userId } }));
const dynamoCfg = settingsResult.Item?.config?.dynamo;
if (!dynamoCfg?.table) return badRequest("No DynamoDB table configured — update your settings first");
const params = { TableName: dynamoCfg.table, Limit: limit };
if (filterStatus) {
params.FilterExpression = "#s = :s";
params.ExpressionAttributeNames = { "#s": "status" };
params.ExpressionAttributeValues = { ":s": filterStatus };
}
try {
const result = await db.send(new ScanCommand(params));
// Map customer's field names to LedgerFlow's expected shape
const fields = dynamoCfg.fields || {};
const items = (result.Items || []).map(item => ({
poNumber: item[fields.poNumber || "po_number"] || "",
vendor: item[fields.vendor || "vendor_name"] || "",
amount: parseFloat(item[fields.amount || "total_amount"]) || 0,
status: item[fields.status || "status"] || "open",
issueDate: item[fields.issueDate || "issue_date"] || "",
dueDate: item[fields.dueDate || "due_date"] || "",
_raw: item,
}));
return ok({ items, count: result.Count });
} catch (err) {
if (err.name === "ResourceNotFoundException") return notFound(`Table ${dynamoCfg.table}`);
return serverError(`DynamoDB scan failed: ${err.message}`, err);
}
}

View file

@ -12,7 +12,10 @@
"deploy": "cd infra && npx aws-cdk deploy --all", "deploy": "cd infra && npx aws-cdk deploy --all",
"deploy:dev": "cd infra && npx aws-cdk deploy --all --context env=dev", "deploy:dev": "cd infra && npx aws-cdk deploy --all --context env=dev",
"destroy": "cd infra && npx aws-cdk destroy --all", "destroy": "cd infra && npx aws-cdk destroy --all",
"test": "jest --passWithNoTests" "test": "jest --passWithNoTests",
"local": "sam local start-api --env-vars env.local.json --warm-containers EAGER --port 3001",
"local:build": "sam build",
"local:invoke": "sam local invoke --env-vars env.local.json"
}, },
"devDependencies": { "devDependencies": {
"@types/aws-lambda": "^8.10.136", "@types/aws-lambda": "^8.10.136",

View file

@ -112,11 +112,12 @@ function getDocClient() {
// ─── Table Names ───────────────────────────────────────────────────────────── // ─── Table Names ─────────────────────────────────────────────────────────────
const TABLES = { const TABLES = {
POS: process.env.POS_TABLE || "ledgerflow-pos", POS: process.env.POS_TABLE || "ledgerflow-pos",
INVOICES: process.env.INVOICES_TABLE || "ledgerflow-invoices", PURCHASE_ORDERS: process.env.PURCHASE_ORDERS_TABLE || "purchase-orders",
EDI_TX: process.env.EDI_TX_TABLE || "ledgerflow-edi-transactions", INVOICES: process.env.INVOICES_TABLE || "ledgerflow-invoices",
SESSIONS: process.env.SESSIONS_TABLE || "ledgerflow-sessions", EDI_TX: process.env.EDI_TX_TABLE || "ledgerflow-edi-transactions",
SETTINGS: process.env.SETTINGS_TABLE || "ledgerflow-settings", SESSIONS: process.env.SESSIONS_TABLE || "ledgerflow-sessions",
SETTINGS: process.env.SETTINGS_TABLE || "ledgerflow-settings",
}; };
// ─── Pagination Helper ─────────────────────────────────────────────────────── // ─── Pagination Helper ───────────────────────────────────────────────────────

277
template.yaml Normal file
View file

@ -0,0 +1,277 @@
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: LedgerFlow B2B Accounting — local SAM development
Globals:
Function:
Runtime: nodejs20.x
Architectures: [arm64]
MemorySize: 512
Timeout: 30
Environment:
Variables:
NODE_ENV: dev
AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1"
POS_TABLE: ledgerflow-pos
INVOICES_TABLE: ledgerflow-invoices
EDI_TX_TABLE: ledgerflow-edi-transactions
SESSIONS_TABLE: ledgerflow-sessions
SETTINGS_TABLE: ledgerflow-settings
PURCHASE_ORDERS_TABLE: purchase-orders
EDI_INPUT_BUCKET: !Sub "ledgerflow-edi-input-${AWS::AccountId}"
EDI_OUTPUT_BUCKET: !Sub "ledgerflow-edi-output-${AWS::AccountId}"
GOOGLE_CLIENT_ID: ""
ALLOWED_ORIGIN: "*"
ALLOWED_DOMAINS: ""
EDI_PARTNERSHIP_ID: ""
EDI_TRANSFORMER_ID: ""
EDI_SENDER_ID: ""
EDI_RECEIVER_ID: ""
Resources:
# ── API Gateway ──────────────────────────────────────────────────────────────
LedgerFlowAPI:
Type: AWS::Serverless::HttpApi
Properties:
StageName: $default
CorsConfiguration:
AllowOrigins:
- "*"
- "http://localhost:3000"
AllowMethods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
AllowHeaders:
- Content-Type
- Authorization
MaxAge: 86400
Auth:
DefaultAuthorizer: GoogleJWT
Authorizers:
GoogleJWT:
AuthorizationScopes: []
FunctionArn: !GetAtt AuthorizerFn.Arn
FunctionInvokeRole: !GetAtt AuthorizerInvokeRole.Arn
Identity:
Headers:
- Authorization
AuthorizerPayloadFormatVersion: "2.0"
EnableSimpleResponses: true
# IAM role allowing API Gateway to invoke the authorizer Lambda
AuthorizerInvokeRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: apigateway.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: InvokeAuthorizerFn
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt AuthorizerFn.Arn
# ── Lambda Functions ─────────────────────────────────────────────────────────
AuthorizerFn:
Type: AWS::Serverless::Function
Properties:
FunctionName: ledgerflow-authorizer
Handler: index.handler
CodeUri: lambdas/authorizer/
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: false
Sourcemap: true
EntryPoints: [index.js]
External: []
AuthFn:
Type: AWS::Serverless::Function
Properties:
FunctionName: ledgerflow-auth
Handler: index.handler
CodeUri: lambdas/auth/
Events:
AuthConfig:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /auth/config
Method: ANY
Auth:
Authorizer: NONE
AuthConfigProxy:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /auth/config/{proxy+}
Method: ANY
Auth:
Authorizer: NONE
AuthMe:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /auth/me
Method: ANY
Auth:
Authorizer: NONE
AuthMeProxy:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /auth/me/{proxy+}
Method: ANY
Auth:
Authorizer: NONE
AuthLogout:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /auth/logout
Method: ANY
Auth:
Authorizer: NONE
AuthLogoutProxy:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /auth/logout/{proxy+}
Method: ANY
Auth:
Authorizer: NONE
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: false
Sourcemap: true
EntryPoints: [index.js]
External: []
POsFn:
Type: AWS::Serverless::Function
Properties:
FunctionName: ledgerflow-pos
Handler: index.handler
CodeUri: lambdas/pos/
Events:
POs:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /pos
Method: ANY
POsProxy:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /pos/{proxy+}
Method: ANY
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: false
Sourcemap: true
EntryPoints: [index.js]
External: []
InvoicesFn:
Type: AWS::Serverless::Function
Properties:
FunctionName: ledgerflow-invoices
Handler: index.handler
CodeUri: lambdas/invoices/
Events:
Invoices:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /invoices
Method: ANY
InvoicesProxy:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /invoices/{proxy+}
Method: ANY
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: false
Sourcemap: true
EntryPoints: [index.js]
External: []
EDIFn:
Type: AWS::Serverless::Function
Properties:
FunctionName: ledgerflow-edi
Handler: index.handler
CodeUri: lambdas/edi/
Timeout: 60
Events:
EDI:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /edi
Method: ANY
EDIProxy:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /edi/{proxy+}
Method: ANY
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: false
Sourcemap: true
EntryPoints: [index.js]
External: []
SettingsFn:
Type: AWS::Serverless::Function
Properties:
FunctionName: ledgerflow-settings
Handler: index.handler
CodeUri: lambdas/settings/
Events:
Settings:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /settings
Method: ANY
SettingsProxy:
Type: HttpApi
Properties:
ApiId: !Ref LedgerFlowAPI
Path: /settings/{proxy+}
Method: ANY
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: false
Sourcemap: true
EntryPoints: [index.js]
External: []
Outputs:
ApiUrl:
Description: Local API Gateway endpoint
Value: !Sub "https://${LedgerFlowAPI}.execute-api.${AWS::Region}.amazonaws.com"