From 5112c1345b7c4a2c4c1f6da4d9f879e978c19a93 Mon Sep 17 00:00:00 2001
From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
Date: Tue, 12 May 2026 15:21:06 -0400
Subject: [PATCH] Merge workorder-ingest into unified procurement repo (#22)
* Merge workorder-ingest pipeline into unified repo
Move PO lambdas under lambdas/po/, add WO pipeline under lambdas/wo/.
Two independent CloudFormation stacks in one CDK app. Fix WO stack
compliance: ARM64 architecture, 60-day log retention, aarch64 bundling,
RETAIN on Anthropic secret. Remove stale CodePipeline buildspec.
* Fix test_local.py import path and remove dead shared/models.py
test_local.py referenced the old lambdas/email_processor path. Updated
to lambdas/wo/email_processor. Removed shared/ directory entirely as
nothing imports from it.
* Escape HTML in both web UI dashboards to prevent XSS
Both Function URLs are public (auth_type=NONE) and render
email-derived content via f-strings. Attacker-crafted emails
could inject scripts. Added html.escape() on all interpolated
values in both PO and WO dashboards.
* Add pagination to WO web UI scan
get_work_orders() only fetched the first 1MB page from DynamoDB.
Loop on LastEvaluatedKey to match the PO web UI pattern.
* Fix esc(None) TypeError and javascript: scheme in PO web UI
Coerce supplier name through `or ""` before escaping to handle
nested None from DynamoDB. Add scheme allowlist on view_order_url
to block javascript:/data: hrefs from LLM-extracted URLs.
* Fix WO render_badge None guard, updated_at slice, and backfill path
Add null guard to WO render_badge matching the PO version. Use
`or ""` before slicing updated_at to handle explicit None values.
Fix backfill_sites.py sys.path to use new lambdas/po/site_extractor.
* Harden WO web UI and fix JS-context XSS in both dashboards
- Use json.dumps for onclick URLs to prevent JS string breakout
- Add .lower() to WO render_badge color lookup matching PO pattern
- Add pagination to get_comments query
- Cap get_work_orders to 500 results matching PO pattern
* Apply ruff formatting to web UI handlers
---
.github/dependabot.yml | 20 +-
.gitignore | 2 +-
README.md | 159 +++++-----
buildspec.yml | 15 -
cdk/app.py | 12 +-
cdk/{stack.py => po_stack.py} | 6 +-
cdk/wo_stack.py | 185 ++++++++++++
lambdas/{ => po}/email_processor/handler.py | 0
.../{ => po}/email_processor/requirements.txt | 0
lambdas/{ => po}/site_extractor/handler.py | 0
lambdas/{ => po}/web_ui/handler.py | 73 ++---
lambdas/wo/email_processor/__init__.py | 0
lambdas/wo/email_processor/handler.py | 271 ++++++++++++++++++
lambdas/wo/email_processor/requirements.txt | 2 +
lambdas/wo/web_ui/__init__.py | 0
lambdas/wo/web_ui/handler.py | 256 +++++++++++++++++
lambdas/wo/web_ui/requirements.txt | 1 +
scripts/backfill_sites.py | 2 +-
test_local.py | 47 +++
19 files changed, 925 insertions(+), 126 deletions(-)
delete mode 100644 buildspec.yml
rename cdk/{stack.py => po_stack.py} (97%)
create mode 100644 cdk/wo_stack.py
rename lambdas/{ => po}/email_processor/handler.py (100%)
rename lambdas/{ => po}/email_processor/requirements.txt (100%)
rename lambdas/{ => po}/site_extractor/handler.py (100%)
rename lambdas/{ => po}/web_ui/handler.py (79%)
create mode 100644 lambdas/wo/email_processor/__init__.py
create mode 100644 lambdas/wo/email_processor/handler.py
create mode 100644 lambdas/wo/email_processor/requirements.txt
create mode 100644 lambdas/wo/web_ui/__init__.py
create mode 100644 lambdas/wo/web_ui/handler.py
create mode 100644 lambdas/wo/web_ui/requirements.txt
create mode 100644 test_local.py
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index c571f0e..9717d1b 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -10,7 +10,25 @@ updates:
- "minor"
- "patch"
- package-ecosystem: "pip"
- directory: "/lambdas/email_processor"
+ directory: "/lambdas/po/email_processor"
+ schedule:
+ interval: "weekly"
+ groups:
+ minor-and-patch:
+ update-types:
+ - "minor"
+ - "patch"
+ - package-ecosystem: "pip"
+ directory: "/lambdas/wo/email_processor"
+ schedule:
+ interval: "weekly"
+ groups:
+ minor-and-patch:
+ update-types:
+ - "minor"
+ - "patch"
+ - package-ecosystem: "pip"
+ directory: "/lambdas/wo/web_ui"
schedule:
interval: "weekly"
groups:
diff --git a/.gitignore b/.gitignore
index 5d81fa3..1dafb99 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,4 @@ node_modules/
cdk.out/
.env
*.eml
-lambdas/*/package/
+lambdas/*/*/package/
diff --git a/README.md b/README.md
index ae75e9d..16f74f9 100644
--- a/README.md
+++ b/README.md
@@ -1,106 +1,127 @@
-# PO Ingest
+# Procurement Ingest
-Coupa purchase-order email ingestion pipeline. SES receives Amazon PO emails, Claude extracts structured data, and the result lands in the shared `purchase-orders` DynamoDB table.
+Unified email ingestion pipelines for Amazon procurement data. Two independent pipelines — purchase orders (Coupa) and work orders (APM/Hexagon EAM) — share a single repo and CDK app but deploy as separate CloudFormation stacks.
-## Flow
+## Pipelines
-1. Coupa sends a PO email to `amazon_po@int.seahaven.com`.
-2. SES (using the shared `INBOUND_MAIL` rule set) drops the raw MIME into `s3://po-ingest-emails-{AccountId}/inbound/`.
-3. S3 `ObjectCreated` fires the `po-email-processor` Lambda.
-4. The Lambda parses the email, sends it to Claude Haiku 4.5 for structured JSON extraction, and writes to DynamoDB.
- - `email_type: new_po` — conditional `PutItem` on `purchase-orders` (idempotent on `po_number`).
- - `email_type: revision` — unconditional `PutItem` overwriting the existing record with updated data.
- - `email_type: cancellation` — `UpdateItem` marking the existing row `Cancelled`.
-5. DynamoDB Streams (NEW_IMAGE) on `purchase-orders` feeds two downstream consumers:
- - **LedgerFlow** (`seahaven-slack-bot/po-sync`) — daily KB sync.
- - **Verified-sites pipeline** (`po-ingest-site-extractor`) — real-time site address extraction (see below).
+### Purchase Orders (`po-ingest` stack)
-The extraction prompt includes domain-specific rules for site code identification (with a skip list for false positives like RME, BBM, JLL), trade classification across 23 categories (Plumbing PM/Reactive, Electrical, HVAC, Dock Doors, etc.), fiscal year derivation, and ship-to address parsing with zip code zero-padding.
+Coupa PO emails are received at `amazon_po@int.seahaven.com`, parsed by Claude Haiku 4.5, and written to the `purchase-orders` DynamoDB table.
-A separate `po-web-ui` Lambda (Function URL, unauthenticated) renders a simple HTML dashboard scanning the table.
+**Flow:**
+1. Coupa sends a PO email (new, revision, or cancellation).
+2. SES (`INBOUND_MAIL` rule set) drops the raw MIME into `s3://po-ingest-emails-{AccountId}/inbound/`.
+3. S3 `ObjectCreated` triggers the `po-email-processor` Lambda.
+4. Claude extracts structured JSON (PO number, status, supplier, site code, trade classification, line items, fiscal year).
+5. Conditional write to DynamoDB:
+ - `new_po` — idempotent insert (no-op if PO exists)
+ - `revision` — unconditional overwrite
+ - `cancellation` — marks existing row `Cancelled`
+6. DynamoDB Streams feeds downstream consumers:
+ - **LedgerFlow** (`seahaven-slack-bot/po-sync`) — daily KB sync
+ - **Site extractor** (`po-ingest-site-extractor`) — real-time site address extraction into `verified-sites` table
-### PO record schema
+**Lambdas** (`lambdas/po/`):
+| Function | Trigger | Purpose |
+|---|---|---|
+| `po-email-processor` | S3 ObjectCreated | Claude extraction + DynamoDB write |
+| `po-ingest-site-extractor` | DynamoDB Streams | Site code/address extraction -> `verified-sites` |
+| `po-web-ui` | Function URL | HTML dashboard |
-Each record in `purchase-orders` includes:
-- **Core**: `po_number` (PK), `email_type` (new_po/revision/cancellation), `po_status`, `source_system`
-- **People/dates**: `submitted_by`, `on_behalf_of`, `order_date`, `revision_date`, `payment_terms`, `requisition_number`, `department`
-- **Site**: `site_code`, `state` (top-level), `ship_to` (structured), `ship_to_raw` (original text)
-- **Classification**: `trade`, `fiscal_year`, `coupa_category`
-- **Financials**: `total_amount`, `currency`, `line_items[]` (with `description`, `amount`, `quantity`, `unit`, `price`, `need_by`)
-- **Metadata**: `data_source` ("email" or "email+payee_scrape"), `email_subject`, `processed_at`, `raw_s3_key`
+**Tables:**
+- `purchase-orders` (PK: `po_number`, Streams: NEW_IMAGE) — shared with payments-dashboard and seahaven-slack-bot
+- `verified-sites` (PK: `siteCode`, GSI: `by-state`) — ~1,100 unique Amazon facility sites
+- `pending-site-review` (PK: `po_number`) — unresolvable POs for manual Payee Central verification
-### Verified-sites pipeline
+### Work Orders (`WorkorderIngestStack` stack)
-The `po-ingest-site-extractor` Lambda is triggered by the DynamoDB Stream on every PO INSERT/MODIFY. It:
+Amazon APM work order emails (from Hexagon EAM / HxGN SmartCloud) are received at `apm@int.seahaven.com`, parsed by Claude Haiku 4.5, and written to the `WorkOrders` DynamoDB table.
-1. Extracts an Amazon facility site code from `ship_to.name` using a regex cascade (parentheses, `LLC - CODE`, `Station CODE`, `DS - CODE`) with a fallback to the first `line_items` description.
-2. Parses `ship_to.address` into structured fields (street, city, state, zip).
-3. Upserts to the `verified-sites` DynamoDB table — atomically increments `poCount` and appends the PO number to `sourcePOs`.
+**Flow:**
+1. Hexagon EAM sends email notifications (new assignments, comments, updates, cancellations) to `amazon@seahavenind.com`.
+2. Gmail filter forwards APM emails to `apm@int.seahaven.com` (SES).
+3. SES drops the raw MIME into `s3://workorder-ingest-emails-{AccountId}/inbound/`.
+4. S3 triggers the `workorder-email-processor` Lambda.
+5. Claude extracts structured JSON (work order ID, site code, severity, priority, dates, assigned technician).
+6. Work order upserted to `WorkOrders`, event/comment appended to `WorkOrderComments`.
-POs with no extractable site code fall through to an address reverse-lookup against the verified-sites cache (normalized street + zip). If still unresolved, the PO is written to the `pending-site-review` table for manual verification against Payee Central.
+**Lambdas** (`lambdas/wo/`):
+| Function | Trigger | Purpose |
+|---|---|---|
+| `workorder-email-processor` | S3 ObjectCreated | Claude extraction + DynamoDB write |
+| `workorder-web-ui` | Function URL | HTML dashboard |
-Backfill stats (initial run): 14,825 POs scanned → 9,900 with extractable site codes → 1,100 unique sites.
+**Tables:**
+- `WorkOrders` (PK: `work_order_id`, GSIs: `site-code-index`, `status-index`)
+- `WorkOrderComments` (PK: `work_order_id`, SK: `comment_id`)
## Architecture
-- **IaC:** AWS CDK (Python), stack name `po-ingest`, region `us-east-1`.
-- **Lambdas** (all Python 3.12, arm64, 60-day log retention):
- - `po-email-processor` — S3-triggered, parses PO emails via Claude Haiku.
- - `po-web-ui` — Function URL, HTML dashboard.
- - `po-ingest-site-extractor` — DynamoDB Streams-triggered, extracts site addresses.
-- **Storage:**
- - S3 `po-ingest-emails-{AccountId}` — 90-day lifecycle expiry.
- - DynamoDB `purchase-orders` — owned by this stack, Streams enabled (NEW_AND_OLD_IMAGES).
- - DynamoDB `verified-sites` — PK `siteCode`, GSI `by-state` on `state`.
- - DynamoDB `pending-site-review` — PK `po_number`. POs with no extractable site code and no address match, awaiting manual Payee Central verification.
-- **Secrets:** Anthropic API key in Secrets Manager at `po-ingest/anthropic-api-key`.
-- **SES:** adds the `PoEmailRule` to the existing `INBOUND_MAIL` receipt rule set (shared with `workorder-ingest`).
-- **CI/CD:** CodePipeline V2 (`po-ingest-pipeline`) → CodeBuild (`po-ingest-build`). Pushes to `main` auto-deploy via `buildspec.yml`.
+**IaC:** AWS CDK (Python), two stacks in one app, region `us-east-1`.
+
+All Lambdas: Python 3.12, ARM64, 60-day log retention.
+
+**Secrets:**
+- `po-ingest/anthropic-api-key` — Anthropic API key for PO parsing
+- `workorder-ingest/anthropic-api-key` — Anthropic API key for WO parsing
+
+**SES:** Both stacks add rules to the shared `INBOUND_MAIL` receipt rule set on `int.seahaven.com`.
## CI/CD
-Merges to `main` trigger the `po-ingest-pipeline` (CodePipeline V2) which runs CodeBuild to `cdk deploy`. The pipeline uses the existing CodeStar connection to the Sea-Haven-Industries GitHub org.
+GitHub Actions with reusable workflows from `Sea-Haven-Industries/.github`:
+- **CI** (PR to `main`): linting + `cdk synth` via `ci-python-sam.yaml@main`
+- **CD** (push to `main`): `cdk deploy --all` via `cd-cdk.yaml@main` (OIDC auth)
-**Branch protection:** `main` requires a PR (no direct push), no deletion, no force push.
+Branch protection on `main` — all changes through PR.
## Setup
-1. Bootstrap CDK in the account if you haven't already: `cdk bootstrap aws://{AccountId}/us-east-1`.
-2. Store the Anthropic API key:
+1. Bootstrap CDK: `cdk bootstrap aws://{AccountId}/us-east-1`
+2. Store Anthropic API keys:
```bash
- aws secretsmanager create-secret \
- --name po-ingest/anthropic-api-key \
- --secret-string "sk-ant-..."
+ aws secretsmanager create-secret --name po-ingest/anthropic-api-key --secret-string "sk-ant-..."
+ aws secretsmanager create-secret --name workorder-ingest/anthropic-api-key --secret-string "sk-ant-..."
```
-3. Install Lambda dependencies into the deployable package directory (gitignored):
- ```bash
- pip install -r lambdas/email_processor/requirements.txt -t lambdas/email_processor/package/
- ```
-4. Deploy:
+3. Deploy both stacks:
```bash
cd cdk
pip install -r requirements.txt
- cdk deploy
+ cdk deploy --all
```
-5. The `WebUIUrl` CloudFormation output is the dashboard URL.
+4. CloudFormation outputs include `WebUIUrl` for each stack's dashboard.
-## Reprocessing
-
-To re-run the processor against every email still sitting in `inbound/` (useful after a parser change):
+## Scripts
+**Reprocess PO emails** (re-run parser against all emails still in S3):
```bash
-python scripts/reprocess.py # dry-run — lists keys
-python scripts/reprocess.py --execute # invokes po-email-processor for each
+python scripts/reprocess.py # dry-run
+python scripts/reprocess.py --execute # invoke po-email-processor for each
```
-Inserts are conditional on `po_number`, so re-processing existing POs is a no-op.
-
-## Backfilling verified sites
-
-The stream Lambda handles all future POs automatically. To backfill from historical PO data (one-time):
-
+**Backfill verified sites** (one-time scan of historical POs):
```bash
python scripts/backfill_sites.py
```
-Uses the same extraction logic as the Lambda. Idempotent — safe to re-run.
+## Directory Structure
+
+```
+cdk/
+ app.py # Two stacks: po-ingest + WorkorderIngestStack
+ po_stack.py # Purchase order pipeline resources
+ wo_stack.py # Work order pipeline resources
+lambdas/
+ po/ # PO pipeline Lambdas
+ email_processor/
+ site_extractor/
+ web_ui/
+ wo/ # WO pipeline Lambdas
+ email_processor/
+ web_ui/
+shared/
+ models.py # Work order dataclasses/enums
+scripts/
+ reprocess.py
+ backfill_sites.py
+```
diff --git a/buildspec.yml b/buildspec.yml
deleted file mode 100644
index da69dde..0000000
--- a/buildspec.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-version: 0.2
-
-phases:
- install:
- runtime-versions:
- python: 3.12
- nodejs: 22
- commands:
- - npm install -g aws-cdk
- - pip install -r cdk/requirements.txt
- - pip install -r lambdas/email_processor/requirements.txt -t lambdas/email_processor/package/
- - cp lambdas/email_processor/handler.py lambdas/email_processor/package/
- build:
- commands:
- - cd cdk && cdk deploy --require-approval never
diff --git a/cdk/app.py b/cdk/app.py
index 2fac78d..6f237be 100644
--- a/cdk/app.py
+++ b/cdk/app.py
@@ -1,12 +1,22 @@
#!/usr/bin/env python3
import aws_cdk as cdk
-from stack import PoIngestStack
+from po_stack import PoIngestStack
+from wo_stack import WorkorderIngestStack
app = cdk.App()
+
PoIngestStack(
app,
"po-ingest",
stack_name="po-ingest",
env=cdk.Environment(region="us-east-1"),
)
+
+WorkorderIngestStack(
+ app,
+ "workorder-ingest",
+ stack_name="WorkorderIngestStack",
+ env=cdk.Environment(region="us-east-1"),
+)
+
app.synth()
diff --git a/cdk/stack.py b/cdk/po_stack.py
similarity index 97%
rename from cdk/stack.py
rename to cdk/po_stack.py
index 6f4bc86..f9d0d6c 100644
--- a/cdk/stack.py
+++ b/cdk/po_stack.py
@@ -67,7 +67,7 @@ class PoIngestStack(Stack):
architecture=lambda_.Architecture.ARM_64,
handler="handler.handler",
code=lambda_.Code.from_asset(
- "../lambdas/email_processor",
+ "../lambdas/po/email_processor",
bundling=cdk.BundlingOptions(
image=lambda_.Runtime.PYTHON_3_12.bundling_image,
command=[
@@ -125,7 +125,7 @@ class PoIngestStack(Stack):
runtime=lambda_.Runtime.PYTHON_3_12,
architecture=lambda_.Architecture.ARM_64,
handler="handler.handler",
- code=lambda_.Code.from_asset("../lambdas/web_ui"),
+ code=lambda_.Code.from_asset("../lambdas/po/web_ui"),
timeout=Duration.seconds(60),
memory_size=256,
log_retention=logs.RetentionDays.TWO_MONTHS,
@@ -174,7 +174,7 @@ class PoIngestStack(Stack):
runtime=lambda_.Runtime.PYTHON_3_12,
architecture=lambda_.Architecture.ARM_64,
handler="handler.handler",
- code=lambda_.Code.from_asset("../lambdas/site_extractor"),
+ code=lambda_.Code.from_asset("../lambdas/po/site_extractor"),
timeout=Duration.seconds(60),
memory_size=256,
log_retention=logs.RetentionDays.TWO_MONTHS,
diff --git a/cdk/wo_stack.py b/cdk/wo_stack.py
new file mode 100644
index 0000000..dd29781
--- /dev/null
+++ b/cdk/wo_stack.py
@@ -0,0 +1,185 @@
+"""CDK stack for the work order email ingestion pipeline."""
+
+import aws_cdk as cdk
+from aws_cdk import (
+ Duration,
+ RemovalPolicy,
+ Stack,
+ aws_dynamodb as dynamodb,
+ aws_lambda as lambda_,
+ aws_logs as logs,
+ aws_s3 as s3,
+ aws_s3_notifications as s3n,
+ aws_ses as ses,
+ aws_ses_actions as ses_actions,
+ aws_secretsmanager as secretsmanager,
+)
+from constructs import Construct
+
+
+class WorkorderIngestStack(Stack):
+ def __init__(self, scope: Construct, construct_id: str, **kwargs):
+ super().__init__(scope, construct_id, **kwargs)
+
+ # --- S3 bucket for raw emails ---
+ email_bucket = s3.Bucket(
+ self,
+ "EmailBucket",
+ bucket_name=f"workorder-ingest-emails-{self.account}",
+ removal_policy=RemovalPolicy.RETAIN,
+ lifecycle_rules=[
+ s3.LifecycleRule(expiration=Duration.days(90)),
+ ],
+ )
+
+ # --- DynamoDB tables ---
+ work_orders_table = dynamodb.Table(
+ self,
+ "WorkOrdersTable",
+ table_name="WorkOrders",
+ partition_key=dynamodb.Attribute(
+ name="work_order_id",
+ type=dynamodb.AttributeType.STRING,
+ ),
+ billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
+ removal_policy=RemovalPolicy.RETAIN,
+ )
+ work_orders_table.add_global_secondary_index(
+ index_name="site-code-index",
+ partition_key=dynamodb.Attribute(
+ name="site_code",
+ type=dynamodb.AttributeType.STRING,
+ ),
+ sort_key=dynamodb.Attribute(
+ name="updated_at",
+ type=dynamodb.AttributeType.STRING,
+ ),
+ )
+ work_orders_table.add_global_secondary_index(
+ index_name="status-index",
+ partition_key=dynamodb.Attribute(
+ name="wo_status",
+ type=dynamodb.AttributeType.STRING,
+ ),
+ sort_key=dynamodb.Attribute(
+ name="updated_at",
+ type=dynamodb.AttributeType.STRING,
+ ),
+ )
+
+ comments_table = dynamodb.Table(
+ self,
+ "CommentsTable",
+ table_name="WorkOrderComments",
+ partition_key=dynamodb.Attribute(
+ name="work_order_id",
+ type=dynamodb.AttributeType.STRING,
+ ),
+ sort_key=dynamodb.Attribute(
+ name="comment_id",
+ type=dynamodb.AttributeType.STRING,
+ ),
+ billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
+ removal_policy=RemovalPolicy.RETAIN,
+ )
+
+ # --- Secrets Manager for Anthropic API key ---
+ anthropic_secret = secretsmanager.Secret(
+ self,
+ "AnthropicApiKey",
+ secret_name="workorder-ingest/anthropic-api-key",
+ description="Anthropic API key for work order email parsing",
+ removal_policy=RemovalPolicy.RETAIN,
+ )
+
+ # --- Lambda function ---
+ email_processor = lambda_.Function(
+ self,
+ "EmailProcessor",
+ function_name="workorder-email-processor",
+ runtime=lambda_.Runtime.PYTHON_3_12,
+ architecture=lambda_.Architecture.ARM_64,
+ handler="handler.handler",
+ code=lambda_.Code.from_asset(
+ "../lambdas/wo/email_processor",
+ bundling=cdk.BundlingOptions(
+ image=lambda_.Runtime.PYTHON_3_12.bundling_image,
+ command=[
+ "bash",
+ "-c",
+ "pip install --platform manylinux2014_aarch64 --only-binary=:all: "
+ "-r requirements.txt -t /asset-output && "
+ "cp -r . /asset-output/",
+ ],
+ ),
+ ),
+ timeout=Duration.seconds(60),
+ memory_size=256,
+ log_retention=logs.RetentionDays.TWO_MONTHS,
+ environment={
+ "WORK_ORDERS_TABLE": work_orders_table.table_name,
+ "COMMENTS_TABLE": comments_table.table_name,
+ "ANTHROPIC_API_KEY_SECRET_ARN": anthropic_secret.secret_arn,
+ },
+ )
+
+ # Grant permissions
+ email_bucket.grant_read(email_processor)
+ work_orders_table.grant_read_write_data(email_processor)
+ comments_table.grant_read_write_data(email_processor)
+ anthropic_secret.grant_read(email_processor)
+
+ # S3 event notification -> Lambda
+ email_bucket.add_event_notification(
+ s3.EventType.OBJECT_CREATED,
+ s3n.LambdaDestination(email_processor),
+ s3.NotificationKeyFilter(prefix="inbound/"),
+ )
+
+ # --- SES Receipt Rule ---
+ rule_set = ses.ReceiptRuleSet.from_receipt_rule_set_name(
+ self,
+ "ExistingRuleSet",
+ "INBOUND_MAIL",
+ )
+
+ rule_set.add_rule(
+ "WorkorderEmailRule",
+ recipients=["apm@int.seahaven.com"],
+ actions=[
+ ses_actions.S3(
+ bucket=email_bucket,
+ object_key_prefix="inbound/",
+ ),
+ ],
+ )
+
+ # --- Web UI Lambda ---
+ web_ui = lambda_.Function(
+ self,
+ "WebUI",
+ function_name="workorder-web-ui",
+ runtime=lambda_.Runtime.PYTHON_3_12,
+ architecture=lambda_.Architecture.ARM_64,
+ handler="handler.handler",
+ code=lambda_.Code.from_asset("../lambdas/wo/web_ui"),
+ timeout=Duration.seconds(15),
+ memory_size=128,
+ log_retention=logs.RetentionDays.TWO_MONTHS,
+ environment={
+ "WORK_ORDERS_TABLE": work_orders_table.table_name,
+ "COMMENTS_TABLE": comments_table.table_name,
+ },
+ )
+
+ work_orders_table.grant_read_data(web_ui)
+ comments_table.grant_read_data(web_ui)
+
+ # Function URL for direct access
+ web_url = web_ui.add_function_url(
+ auth_type=lambda_.FunctionUrlAuthType.NONE,
+ )
+
+ cdk.CfnOutput(
+ self, "WebUIUrl", value=web_url.url, description="Work Order Dashboard URL"
+ )
diff --git a/lambdas/email_processor/handler.py b/lambdas/po/email_processor/handler.py
similarity index 100%
rename from lambdas/email_processor/handler.py
rename to lambdas/po/email_processor/handler.py
diff --git a/lambdas/email_processor/requirements.txt b/lambdas/po/email_processor/requirements.txt
similarity index 100%
rename from lambdas/email_processor/requirements.txt
rename to lambdas/po/email_processor/requirements.txt
diff --git a/lambdas/site_extractor/handler.py b/lambdas/po/site_extractor/handler.py
similarity index 100%
rename from lambdas/site_extractor/handler.py
rename to lambdas/po/site_extractor/handler.py
diff --git a/lambdas/web_ui/handler.py b/lambdas/po/web_ui/handler.py
similarity index 79%
rename from lambdas/web_ui/handler.py
rename to lambdas/po/web_ui/handler.py
index 90ef3c6..beaa174 100644
--- a/lambdas/web_ui/handler.py
+++ b/lambdas/po/web_ui/handler.py
@@ -5,8 +5,10 @@ Serves a simple HTML dashboard for viewing purchase orders.
Accessed via Lambda Function URL.
"""
+import json
import os
from decimal import Decimal
+from html import escape as esc
import boto3
@@ -31,7 +33,7 @@ def render_badge(value, color_map):
if not value:
value = "unknown"
color = color_map.get(value.lower(), "#9ca3af")
- label = value.replace("_", " ").title()
+ label = esc(value.replace("_", " ").title())
return f'{label}'
@@ -62,50 +64,51 @@ def fmt_currency(val):
def render_po_detail(po):
- po_number = po.get("po_number", "")
+ po_number = esc(po.get("po_number", ""))
fields = [
("PO Number", po_number),
("Status", render_badge(po.get("po_status", ""), STATUS_COLORS)),
("Email Type", render_badge(po.get("email_type", ""), EMAIL_TYPE_COLORS)),
("Total Amount", fmt_currency(po.get("total_amount"))),
- ("Currency", po.get("currency")),
- ("Supplier", (po.get("supplier") or {}).get("name")),
- ("Site Code", po.get("site_code")),
- ("State", po.get("state")),
- ("Trade", po.get("trade")),
- ("Fiscal Year", po.get("fiscal_year")),
- ("Coupa Category", po.get("coupa_category")),
- ("Submitted By", po.get("submitted_by")),
- ("On Behalf Of", po.get("on_behalf_of")),
- ("Order Date", po.get("order_date")),
- ("Revision Date", po.get("revision_date")),
- ("Payment Terms", po.get("payment_terms")),
- ("Requisition #", po.get("requisition_number")),
- ("Department", po.get("department")),
- ("Data Source", po.get("data_source")),
- ("Processed At", po.get("processed_at")),
+ ("Currency", esc(po.get("currency", "")) or None),
+ ("Supplier", esc((po.get("supplier") or {}).get("name") or "") or None),
+ ("Site Code", esc(po.get("site_code", "")) or None),
+ ("State", esc(po.get("state", "")) or None),
+ ("Trade", esc(po.get("trade", "")) or None),
+ ("Fiscal Year", esc(po.get("fiscal_year", "")) or None),
+ ("Coupa Category", esc(po.get("coupa_category", "")) or None),
+ ("Submitted By", esc(po.get("submitted_by", "")) or None),
+ ("On Behalf Of", esc(po.get("on_behalf_of", "")) or None),
+ ("Order Date", esc(po.get("order_date", "")) or None),
+ ("Revision Date", esc(po.get("revision_date", "")) or None),
+ ("Payment Terms", esc(po.get("payment_terms", "")) or None),
+ ("Requisition #", esc(po.get("requisition_number", "")) or None),
+ ("Department", esc(po.get("department", "")) or None),
+ ("Data Source", esc(po.get("data_source", "")) or None),
+ ("Processed At", esc(po.get("processed_at", "")) or None),
]
ship_to = po.get("ship_to") or {}
if any(ship_to.values()):
ship_parts = []
if ship_to.get("name"):
- ship_parts.append(ship_to["name"])
+ ship_parts.append(esc(ship_to["name"]))
if ship_to.get("address"):
- ship_parts.append(ship_to["address"])
+ ship_parts.append(esc(ship_to["address"]))
if ship_to.get("location_code"):
- ship_parts.append(f"Location: {ship_to['location_code']}")
+ ship_parts.append(f"Location: {esc(ship_to['location_code'])}")
if ship_to.get("attn"):
- ship_parts.append(f"Attn: {ship_to['attn']}")
+ ship_parts.append(f"Attn: {esc(ship_to['attn'])}")
fields.append(("Ship To", "
".join(ship_parts)))
view_url = po.get("view_order_url")
- if view_url:
+ if view_url and view_url.startswith(("https://", "http://")):
+ escaped_url = esc(view_url, quote=True)
fields.append(
(
"Coupa Link",
- f'View in Coupa',
+ f'View in Coupa',
)
)
@@ -124,17 +127,17 @@ def render_po_detail(po):
if line_items:
rows = ""
for item in line_items:
- qty = item.get("quantity", "") or ""
- unit = item.get("unit", "") or ""
- price = item.get("price", "") or ""
+ qty = esc(str(item.get("quantity", "") or ""))
+ unit = esc(str(item.get("unit", "") or ""))
+ price = esc(str(item.get("price", "") or ""))
rows += f"""
No events yet.
' + + wo_id = esc(wo.get("work_order_id", "")) + fields = [ + ("Description", esc(wo.get("description", "")) or None), + ("Status", render_badge(wo.get("wo_status", "unknown"), STATUS_COLORS)), + ( + "Record Type", + render_badge(wo.get("record_type", "unknown"), RECORD_TYPE_COLORS), + ), + ("Site Code", esc(wo.get("site_code", "")) or None), + ("Building", esc(wo.get("building", "")) or None), + ("Address", esc(wo.get("address", "")) or None), + ("Severity", esc(wo.get("severity", "")) or None), + ("Priority", esc(wo.get("priority", "")) or None), + ("Due Date", esc(wo.get("due_date", "")) or None), + ("Date Reported", esc(wo.get("date_reported", "")) or None), + ("Scheduled Start", esc(wo.get("scheduled_start", "")) or None), + ("Assigned To", esc(wo.get("assigned_to", "")) or None), + ("Created", esc(wo.get("created_at", "")) or None), + ("Last Updated", esc(wo.get("updated_at", "")) or None), + ] + + details_html = "" + for label, value in fields: + if value: + details_html += f""" +