Lambda-based serverless backend with Google SSO, purchase orders, invoices, and X12 810 EDI generation for Amazon Payee Central. Includes bill-to/ship-to address support from Coupa purchase-orders table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
7 KiB
JavaScript
223 lines
7 KiB
JavaScript
// @ledgerflow/shared/index.js
|
|
// Shared utilities for all Lambda functions
|
|
|
|
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
|
|
const { DynamoDBDocumentClient } = require("@aws-sdk/lib-dynamodb");
|
|
|
|
// ─── HTTP Response Helpers ───────────────────────────────────────────────────
|
|
|
|
const ALLOWED_ORIGINS = [
|
|
process.env.ALLOWED_ORIGIN || "*",
|
|
"http://localhost:3000",
|
|
];
|
|
|
|
function getCorsHeaders(event) {
|
|
const origin = event?.headers?.origin || event?.headers?.Origin || "";
|
|
const matched = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
|
|
return {
|
|
"Access-Control-Allow-Origin": matched,
|
|
"Access-Control-Allow-Headers": "Content-Type,Authorization",
|
|
"Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
|
"Content-Type": "application/json",
|
|
};
|
|
}
|
|
|
|
// Static fallback for responses that don't have event context
|
|
const CORS_HEADERS = {
|
|
"Access-Control-Allow-Origin": process.env.ALLOWED_ORIGIN || "*",
|
|
"Access-Control-Allow-Headers": "Content-Type,Authorization",
|
|
"Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
|
|
"Content-Type": "application/json",
|
|
};
|
|
|
|
function ok(body, statusCode = 200) {
|
|
return {
|
|
statusCode,
|
|
headers: CORS_HEADERS,
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
function created(body) {
|
|
return ok(body, 201);
|
|
}
|
|
|
|
function noContent() {
|
|
return { statusCode: 204, headers: CORS_HEADERS, body: "" };
|
|
}
|
|
|
|
function badRequest(message, details = null) {
|
|
return {
|
|
statusCode: 400,
|
|
headers: CORS_HEADERS,
|
|
body: JSON.stringify({ error: "Bad Request", message, details }),
|
|
};
|
|
}
|
|
|
|
function unauthorized(message = "Unauthorized") {
|
|
return {
|
|
statusCode: 401,
|
|
headers: CORS_HEADERS,
|
|
body: JSON.stringify({ error: "Unauthorized", message }),
|
|
};
|
|
}
|
|
|
|
function forbidden(message = "Forbidden") {
|
|
return {
|
|
statusCode: 403,
|
|
headers: CORS_HEADERS,
|
|
body: JSON.stringify({ error: "Forbidden", message }),
|
|
};
|
|
}
|
|
|
|
function notFound(resource = "Resource") {
|
|
return {
|
|
statusCode: 404,
|
|
headers: CORS_HEADERS,
|
|
body: JSON.stringify({ error: "Not Found", message: `${resource} not found` }),
|
|
};
|
|
}
|
|
|
|
function conflict(message) {
|
|
return {
|
|
statusCode: 409,
|
|
headers: CORS_HEADERS,
|
|
body: JSON.stringify({ error: "Conflict", message }),
|
|
};
|
|
}
|
|
|
|
function serverError(message = "Internal server error", err = null) {
|
|
if (err) console.error("[ERROR]", err);
|
|
return {
|
|
statusCode: 500,
|
|
headers: CORS_HEADERS,
|
|
body: JSON.stringify({ error: "Internal Server Error", message }),
|
|
};
|
|
}
|
|
|
|
// ─── DynamoDB Client ─────────────────────────────────────────────────────────
|
|
|
|
let _docClient = null;
|
|
|
|
function getDocClient() {
|
|
if (!_docClient) {
|
|
const base = new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" });
|
|
_docClient = DynamoDBDocumentClient.from(base, {
|
|
marshallOptions: { removeUndefinedValues: true },
|
|
});
|
|
}
|
|
return _docClient;
|
|
}
|
|
|
|
// ─── Table Names ─────────────────────────────────────────────────────────────
|
|
|
|
const TABLES = {
|
|
POS: process.env.POS_TABLE || "ledgerflow-pos",
|
|
INVOICES: process.env.INVOICES_TABLE || "ledgerflow-invoices",
|
|
EDI_TX: process.env.EDI_TX_TABLE || "ledgerflow-edi-transactions",
|
|
SESSIONS: process.env.SESSIONS_TABLE || "ledgerflow-sessions",
|
|
SETTINGS: process.env.SETTINGS_TABLE || "ledgerflow-settings",
|
|
};
|
|
|
|
// ─── Pagination Helper ───────────────────────────────────────────────────────
|
|
|
|
function parsePagination(queryParams = {}) {
|
|
const limit = Math.min(parseInt(queryParams.limit || "50", 10), 200);
|
|
const cursor = queryParams.cursor || null;
|
|
return { limit, cursor };
|
|
}
|
|
|
|
function paginatedResponse(items, lastKey, total = null) {
|
|
return {
|
|
items,
|
|
cursor: lastKey ? Buffer.from(JSON.stringify(lastKey)).toString("base64") : null,
|
|
...(total !== null && { total }),
|
|
};
|
|
}
|
|
|
|
function decodeCursor(cursor) {
|
|
if (!cursor) return undefined;
|
|
try {
|
|
return JSON.parse(Buffer.from(cursor, "base64").toString("utf-8"));
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// ─── ID Generation ───────────────────────────────────────────────────────────
|
|
|
|
function genId(prefix = "") {
|
|
const ts = Date.now().toString(36);
|
|
const rand = Math.random().toString(36).slice(2, 8);
|
|
return `${prefix}${ts}${rand}`.toUpperCase();
|
|
}
|
|
|
|
// ─── Input Validation ────────────────────────────────────────────────────────
|
|
|
|
function parseBody(event) {
|
|
if (!event.body) return {};
|
|
try {
|
|
return JSON.parse(event.body);
|
|
} catch {
|
|
throw new ValidationError("Invalid JSON body");
|
|
}
|
|
}
|
|
|
|
class ValidationError extends Error {
|
|
constructor(message, details = null) {
|
|
super(message);
|
|
this.name = "ValidationError";
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
function require_fields(obj, fields) {
|
|
const missing = fields.filter((f) => !obj[f] && obj[f] !== 0);
|
|
if (missing.length) {
|
|
throw new ValidationError(`Missing required fields: ${missing.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
// ─── Lambda Handler Wrapper ──────────────────────────────────────────────────
|
|
// Catches errors, handles OPTIONS preflight, extracts auth context
|
|
|
|
function handler(fn) {
|
|
return async (event, context) => {
|
|
const corsHeaders = getCorsHeaders(event);
|
|
|
|
// CORS preflight
|
|
if (event.requestContext?.http?.method === "OPTIONS" || event.httpMethod === "OPTIONS") {
|
|
return { statusCode: 200, headers: corsHeaders, body: "" };
|
|
}
|
|
|
|
// Attach authenticated user from authorizer context
|
|
const user = event.requestContext?.authorizer?.lambda || null;
|
|
|
|
try {
|
|
const response = await fn(event, context, user);
|
|
// Override CORS origin with the matched request origin
|
|
if (response?.headers) response.headers["Access-Control-Allow-Origin"] = corsHeaders["Access-Control-Allow-Origin"];
|
|
return response;
|
|
} catch (err) {
|
|
if (err instanceof ValidationError) {
|
|
return badRequest(err.message, err.details);
|
|
}
|
|
console.error("[UNHANDLED]", err);
|
|
return serverError("An unexpected error occurred", err);
|
|
}
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
// Response
|
|
ok, created, noContent, badRequest, unauthorized, forbidden,
|
|
notFound, conflict, serverError,
|
|
CORS_HEADERS,
|
|
// DynamoDB
|
|
getDocClient, TABLES,
|
|
// Pagination
|
|
parsePagination, paginatedResponse, decodeCursor,
|
|
// Util
|
|
genId, parseBody, require_fields, handler,
|
|
ValidationError,
|
|
};
|