workorder-ingest/lambdas/web_ui/handler.py
Adam Moussa df65d34eb0 Initial commit: work order email ingestion pipeline
Serverless AWS pipeline that receives Amazon APM work order emails via SES,
parses them with Claude AI, and stores structured data in DynamoDB. Includes
a web UI dashboard for viewing work orders and event history.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 16:37:04 -04:00

241 lines
8.8 KiB
Python

"""
Web UI Lambda.
Serves a simple HTML dashboard for viewing work orders and comments.
Accessed via Lambda Function URL.
"""
import json
import os
import urllib.parse
import boto3
dynamodb = boto3.resource("dynamodb")
WORK_ORDERS_TABLE = os.environ.get("WORK_ORDERS_TABLE", "WorkOrders")
COMMENTS_TABLE = os.environ.get("COMMENTS_TABLE", "WorkOrderComments")
def get_work_orders():
table = dynamodb.Table(WORK_ORDERS_TABLE)
response = table.scan()
items = response.get("Items", [])
# Sort by updated_at descending
items.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
return items
def get_comments(work_order_id):
table = dynamodb.Table(COMMENTS_TABLE)
response = table.query(
KeyConditionExpression="work_order_id = :woid",
ExpressionAttributeValues={":woid": work_order_id},
)
items = response.get("Items", [])
items.sort(key=lambda x: x.get("created_at", ""), reverse=True)
return items
def render_badge(value, color_map):
color = color_map.get(value, "#9ca3af")
label = value.replace("_", " ").title()
return f'<span style="background:{color};color:#fff;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:500;">{label}</span>'
STATUS_COLORS = {
"new": "#3b82f6",
"assigned": "#8b5cf6",
"in_progress": "#f59e0b",
"on_hold": "#6b7280",
"completed": "#10b981",
"cancelled": "#ef4444",
"unknown": "#9ca3af",
}
RECORD_TYPE_COLORS = {
"new_work_order": "#3b82f6",
"comment": "#8b5cf6",
"update": "#f59e0b",
"cancellation": "#ef4444",
"unknown": "#9ca3af",
}
def render_work_order_detail(wo, events):
events_html = ""
if events:
for e in events:
record_type = e.get("record_type", "unknown")
commenter = e.get("commenter", "")
created = e.get("created_at", "")
text = e.get("text", "")
badge = render_badge(record_type, RECORD_TYPE_COLORS)
commenter_str = f"<strong>{commenter}</strong> &mdash; " if commenter else ""
border_colors = {
"new_work_order": "#3b82f6",
"comment": "#8b5cf6",
"update": "#f59e0b",
"cancellation": "#ef4444",
}
border = border_colors.get(record_type, "#94a3b8")
events_html += f"""
<div style="border-left:3px solid {border};padding:8px 12px;margin-bottom:10px;background:#f8fafc;border-radius:0 6px 6px 0;">
<div style="font-size:12px;color:#64748b;margin-bottom:4px;">
{badge} {commenter_str}{created}
</div>
{"<div style='font-size:14px;color:#1e293b;margin-top:6px;'>" + text + "</div>" if text else ""}
</div>"""
else:
events_html = '<p style="color:#94a3b8;font-style:italic;">No events yet.</p>'
wo_id = wo.get("work_order_id", "")
fields = [
("Description", wo.get("description")),
("Status", render_badge(wo.get("wo_status", "unknown"), STATUS_COLORS)),
("Record Type", render_badge(wo.get("record_type", "unknown"), RECORD_TYPE_COLORS)),
("Site Code", wo.get("site_code")),
("Building", wo.get("building")),
("Address", wo.get("address")),
("Severity", wo.get("severity")),
("Priority", wo.get("priority")),
("Due Date", wo.get("due_date")),
("Date Reported", wo.get("date_reported")),
("Scheduled Start", wo.get("scheduled_start")),
("Assigned To", wo.get("assigned_to")),
("Created", wo.get("created_at")),
("Last Updated", wo.get("updated_at")),
]
details_html = ""
for label, value in fields:
if value:
details_html += f"""
<div style="display:flex;padding:8px 0;border-bottom:1px solid #f1f5f9;">
<div style="width:140px;font-size:13px;color:#64748b;font-weight:500;">{label}</div>
<div style="flex:1;font-size:14px;color:#1e293b;">{value}</div>
</div>"""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WO {wo_id} - Sea Haven</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
</style>
</head>
<body>
<div style="max-width:800px;margin:0 auto;padding:20px;">
<div style="margin-bottom:20px;">
<a href="/" style="color:#3b82f6;text-decoration:none;font-size:14px;">&larr; All Work Orders</a>
</div>
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);margin-bottom:20px;">
<h1 style="font-size:20px;margin-bottom:16px;">Work Order {wo_id}</h1>
{details_html}
</div>
<div style="background:#fff;border-radius:10px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
<h2 style="font-size:16px;margin-bottom:16px;">Events ({len(events)})</h2>
{events_html}
</div>
</div>
</body>
</html>"""
def render_work_orders_list(work_orders):
rows = ""
for wo in work_orders:
wo_id = wo.get("work_order_id", "")
desc = wo.get("description", "")
site = wo.get("site_code", "")
status = wo.get("wo_status", "unknown")
record_type = wo.get("record_type", "unknown")
due = wo.get("due_date", "")
updated = wo.get("updated_at", "")[:16]
rows += f"""
<tr style="border-bottom:1px solid #f1f5f9;cursor:pointer;" onclick="window.location='/wo?id={wo_id}'">
<td style="padding:12px;font-weight:500;color:#3b82f6;">{wo_id}</td>
<td style="padding:12px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{desc}</td>
<td style="padding:12px;">{site}</td>
<td style="padding:12px;">{render_badge(record_type, RECORD_TYPE_COLORS)}</td>
<td style="padding:12px;">{render_badge(status, STATUS_COLORS)}</td>
<td style="padding:12px;">{due}</td>
<td style="padding:12px;color:#64748b;font-size:13px;">{updated}</td>
</tr>"""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Work Orders - Sea Haven</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; }}
table {{ width: 100%; border-collapse: collapse; }}
tr:hover {{ background: #f8fafc; }}
th {{ text-align: left; padding: 12px; font-size: 12px; text-transform: uppercase; color: #64748b; border-bottom: 2px solid #e2e8f0; }}
</style>
</head>
<body>
<div style="max-width:1100px;margin:0 auto;padding:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h1 style="font-size:22px;">Work Orders</h1>
<span style="color:#64748b;font-size:14px;">{len(work_orders)} total</span>
</div>
<div style="background:#fff;border-radius:10px;box-shadow:0 1px 3px rgba(0,0,0,0.08);overflow:hidden;">
<table>
<thead>
<tr>
<th>WO #</th>
<th>Description</th>
<th>Site</th>
<th>Last Action</th>
<th>Status</th>
<th>Due Date</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{rows if rows else '<tr><td colspan="7" style="padding:40px;text-align:center;color:#94a3b8;">No work orders yet.</td></tr>'}
</tbody>
</table>
</div>
</div>
</body>
</html>"""
def handler(event, context):
path = event.get("rawPath", "/")
qs = event.get("queryStringParameters") or {}
if path == "/wo" and "id" in qs:
wo_id = qs["id"]
# Get work order
table = dynamodb.Table(WORK_ORDERS_TABLE)
result = table.get_item(Key={"work_order_id": wo_id})
wo = result.get("Item")
if not wo:
return {
"statusCode": 404,
"headers": {"Content-Type": "text/html"},
"body": "<h1>Work order not found</h1>",
}
events = get_comments(wo_id)
html = render_work_order_detail(wo, events)
else:
work_orders = get_work_orders()
html = render_work_orders_list(work_orders)
return {
"statusCode": 200,
"headers": {"Content-Type": "text/html"},
"body": html,
}