diff --git a/.gitignore b/.gitignore index b23b8eb..e73e16e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ node_modules/ cdk.out/ +.aws-sam/ .env +env.json +env.local.json *.js.map .DS_Store diff --git a/infra/lib/ledgerflow-stack.js b/infra/lib/ledgerflow-stack.js index 7e0c839..1c027f6 100644 --- a/infra/lib/ledgerflow-stack.js +++ b/infra/lib/ledgerflow-stack.js @@ -106,6 +106,7 @@ class LedgerFlowStack extends cdk.Stack { EDI_TX_TABLE: ediTxTable.tableName, SESSIONS_TABLE: sessionsTable.tableName, SETTINGS_TABLE: settingsTable.tableName, + PURCHASE_ORDERS_TABLE: "purchase-orders", EDI_INPUT_BUCKET: ediInputBucket.bucketName, EDI_OUTPUT_BUCKET: ediOutputBucket.bucketName, // Set these via SSM Parameter Store or Secrets Manager in production: diff --git a/lambdas/pos/index.js b/lambdas/pos/index.js index 2c2f9d0..72a88c6 100644 --- a/lambdas/pos/index.js +++ b/lambdas/pos/index.js @@ -1,19 +1,18 @@ // lambdas/pos/index.js // 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 // GET /pos/:id — get one PO // PUT /pos/:id — full update // PATCH /pos/:id — partial update (e.g. status change) // DELETE /pos/:id — delete -// POST /pos/import — bulk import (from DynamoDB scan proxy or pasted JSON) -// GET /pos/dynamo-scan — live scan of the customer's own DynamoDB PO table +// POST /pos/import — bulk import (from pasted JSON) const { ScanCommand, GetCommand, PutCommand, UpdateCommand, DeleteCommand, } = require("@aws-sdk/lib-dynamodb"); const { - ok, created, noContent, badRequest, notFound, conflict, serverError, + ok, created, noContent, badRequest, notFound, conflict, getDocClient, TABLES, genId, parseBody, require_fields, handler, parsePagination, paginatedResponse, decodeCursor, } = require("@ledgerflow/shared"); @@ -32,8 +31,6 @@ exports.handler = handler(async (event, _ctx, user) => { // POST /pos/import 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 (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" }) }; }); +// ─── 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 ───────────────────────────────────────────────────────────────────── async function listPOs(qs, user) { const { limit, cursor } = parsePagination(qs); 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 = { TableName: TABLE, Limit: limit, @@ -238,48 +317,3 @@ async function bulkImport(event, user) { 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); - } -} diff --git a/package.json b/package.json index ef04aae..edc473d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "deploy": "cd infra && npx aws-cdk deploy --all", "deploy:dev": "cd infra && npx aws-cdk deploy --all --context env=dev", "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": { "@types/aws-lambda": "^8.10.136", diff --git a/shared/index.js b/shared/index.js index 4644529..4b1b7d3 100644 --- a/shared/index.js +++ b/shared/index.js @@ -112,11 +112,12 @@ function getDocClient() { // ─── 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", + POS: process.env.POS_TABLE || "ledgerflow-pos", + PURCHASE_ORDERS: process.env.PURCHASE_ORDERS_TABLE || "purchase-orders", + 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 ─────────────────────────────────────────────────────── diff --git a/template.yaml b/template.yaml new file mode 100644 index 0000000..d717d0f --- /dev/null +++ b/template.yaml @@ -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"