Skip to content

Commit eae7d46

Browse files
whummerclaude
andcommitted
Add Products: DynamoDB table, pre-seeded swag, API endpoint, UI page + dropdown
- terraform: products table with 6 pre-seeded LocalStack swag items - order_handler: GET /products endpoint - index.html: Products nav section with table; order form uses product dropdown - index.html: fulfilled orders show S3 receipt download link in detail row Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0caec3d commit eae7d46

3 files changed

Lines changed: 159 additions & 9 deletions

File tree

01-serverless-app/lambdas/order_handler/handler.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
dynamodb = boto3.resource("dynamodb")
99
sqs = boto3.client("sqs")
1010

11-
TABLE_NAME = os.environ["ORDERS_TABLE"]
12-
QUEUE_URL = os.environ["ORDERS_QUEUE_URL"]
13-
DLQ_URL = os.environ.get("ORDERS_DLQ_URL", "")
11+
TABLE_NAME = os.environ["ORDERS_TABLE"]
12+
PRODUCTS_TABLE = os.environ["PRODUCTS_TABLE"]
13+
QUEUE_URL = os.environ["ORDERS_QUEUE_URL"]
14+
DLQ_URL = os.environ.get("ORDERS_DLQ_URL", "")
1415

1516
class DecimalEncoder(json.JSONEncoder):
1617
def default(self, o):
@@ -34,6 +35,9 @@ def handler(event, context):
3435
if method == "POST" and path.endswith("/replay"):
3536
return replay_dlq()
3637

38+
if method == "GET" and "/products" in path:
39+
return list_products()
40+
3741
if method == "GET":
3842
return list_orders()
3943

@@ -56,6 +60,16 @@ def replay_dlq():
5660
}
5761

5862

63+
def list_products():
64+
table = dynamodb.Table(PRODUCTS_TABLE)
65+
items = sorted(table.scan().get("Items", []), key=lambda x: x.get("name", ""))
66+
return {
67+
"statusCode": 200,
68+
"headers": {**CORS_HEADERS, "Content-Type": "application/json"},
69+
"body": json.dumps(items, cls=DecimalEncoder),
70+
}
71+
72+
5973
def list_orders():
6074
table = dynamodb.Table(TABLE_NAME)
6175
result = table.scan()

01-serverless-app/terraform/main.tf

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,41 @@ provider "aws" {
1818

1919
# ── DynamoDB ──────────────────────────────────────────────────────────────────
2020

21+
resource "aws_dynamodb_table" "products" {
22+
name = "products"
23+
billing_mode = "PAY_PER_REQUEST"
24+
hash_key = "product_id"
25+
26+
attribute {
27+
name = "product_id"
28+
type = "S"
29+
}
30+
}
31+
32+
locals {
33+
products = [
34+
{ product_id = "ls-tshirt", name = "LocalStack T-Shirt", description = "Classic logo tee", price = "24.99" },
35+
{ product_id = "ls-hoodie", name = "LocalStack Hoodie", description = "Warm & cloud-native", price = "49.99" },
36+
{ product_id = "ls-cap", name = "LocalStack Cap", description = "Keep the sun off your stack", price = "19.99" },
37+
{ product_id = "ls-mug", name = "LocalStack Mug", description = "Fill it with local coffee", price = "14.99" },
38+
{ product_id = "ls-stickers", name = "Sticker Pack", description = "10 cloud-native stickers", price = "4.99" },
39+
{ product_id = "ls-socks", name = "LocalStack Socks", description = "Deploy faster on your feet", price = "9.99" },
40+
]
41+
}
42+
43+
resource "aws_dynamodb_table_item" "products" {
44+
for_each = { for p in local.products : p.product_id => p }
45+
table_name = aws_dynamodb_table.products.name
46+
hash_key = aws_dynamodb_table.products.hash_key
47+
48+
item = jsonencode({
49+
product_id = { S = each.value.product_id }
50+
name = { S = each.value.name }
51+
description = { S = each.value.description }
52+
price = { N = each.value.price }
53+
})
54+
}
55+
2156
resource "aws_dynamodb_table" "orders" {
2257
name = "orders"
2358
billing_mode = "PAY_PER_REQUEST"
@@ -74,7 +109,7 @@ resource "aws_iam_role_policy" "lambda_policy" {
74109
{
75110
Effect = "Allow"
76111
Action = ["dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:GetItem", "dynamodb:Scan"]
77-
Resource = aws_dynamodb_table.orders.arn
112+
Resource = [aws_dynamodb_table.orders.arn, aws_dynamodb_table.products.arn]
78113
},
79114
{
80115
Effect = "Allow"
@@ -140,6 +175,7 @@ resource "aws_lambda_function" "order_handler" {
140175
environment {
141176
variables = {
142177
ORDERS_TABLE = aws_dynamodb_table.orders.name
178+
PRODUCTS_TABLE = aws_dynamodb_table.products.name
143179
ORDERS_QUEUE_URL = aws_sqs_queue.orders.url
144180
ORDERS_DLQ_URL = aws_sqs_queue.orders_dlq.url
145181
}
@@ -307,6 +343,44 @@ resource "aws_api_gateway_integration" "options_order_handler" {
307343
uri = aws_lambda_function.order_handler.invoke_arn
308344
}
309345

346+
resource "aws_api_gateway_resource" "products" {
347+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
348+
parent_id = aws_api_gateway_rest_api.orders_api.root_resource_id
349+
path_part = "products"
350+
}
351+
352+
resource "aws_api_gateway_method" "get_products" {
353+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
354+
resource_id = aws_api_gateway_resource.products.id
355+
http_method = "GET"
356+
authorization = "NONE"
357+
}
358+
359+
resource "aws_api_gateway_method" "options_products" {
360+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
361+
resource_id = aws_api_gateway_resource.products.id
362+
http_method = "OPTIONS"
363+
authorization = "NONE"
364+
}
365+
366+
resource "aws_api_gateway_integration" "get_products_handler" {
367+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
368+
resource_id = aws_api_gateway_resource.products.id
369+
http_method = aws_api_gateway_method.get_products.http_method
370+
integration_http_method = "POST"
371+
type = "AWS_PROXY"
372+
uri = aws_lambda_function.order_handler.invoke_arn
373+
}
374+
375+
resource "aws_api_gateway_integration" "options_products_handler" {
376+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
377+
resource_id = aws_api_gateway_resource.products.id
378+
http_method = aws_api_gateway_method.options_products.http_method
379+
integration_http_method = "POST"
380+
type = "AWS_PROXY"
381+
uri = aws_lambda_function.order_handler.invoke_arn
382+
}
383+
310384
resource "aws_api_gateway_resource" "orders_replay" {
311385
rest_api_id = aws_api_gateway_rest_api.orders_api.id
312386
parent_id = aws_api_gateway_resource.orders.id
@@ -363,6 +437,8 @@ resource "aws_api_gateway_deployment" "orders_api" {
363437
aws_api_gateway_integration.options_order_handler.id,
364438
aws_api_gateway_integration.post_replay_handler.id,
365439
aws_api_gateway_integration.options_replay_handler.id,
440+
aws_api_gateway_integration.get_products_handler.id,
441+
aws_api_gateway_integration.options_products_handler.id,
366442
]))
367443
}
368444

@@ -372,6 +448,8 @@ resource "aws_api_gateway_deployment" "orders_api" {
372448
aws_api_gateway_integration.options_order_handler,
373449
aws_api_gateway_integration.post_replay_handler,
374450
aws_api_gateway_integration.options_replay_handler,
451+
aws_api_gateway_integration.get_products_handler,
452+
aws_api_gateway_integration.options_products_handler,
375453
]
376454
}
377455

01-serverless-app/website/index.html

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,9 @@ <h1>Order Processing Pipeline</h1>
255255
<div class="nav-item active" onclick="showSection('orders')" id="nav-orders">
256256
<span class="nav-icon">📦</span> Orders
257257
</div>
258+
<div class="nav-item" onclick="showSection('products')" id="nav-products">
259+
<span class="nav-icon">🛍️</span> Products
260+
</div>
258261
<div class="nav-item" onclick="showSection('about')" id="nav-about">
259262
<span class="nav-icon">ℹ️</span> About
260263
</div>
@@ -269,8 +272,10 @@ <h1>Order Processing Pipeline</h1>
269272
<h2>New Order</h2>
270273
<form id="order-form">
271274
<label>
272-
Item
273-
<input type="text" id="item" placeholder="e.g. LocalStack T-Shirt" required />
275+
Product
276+
<select id="item" required style="padding:0.5rem 0.75rem;border:1px solid #ddd;border-radius:6px;font-size:0.9rem;width:240px;background:#fff;">
277+
<option value="">Loading products…</option>
278+
</select>
274279
</label>
275280
<label>
276281
Quantity
@@ -308,6 +313,25 @@ <h2>Orders</h2>
308313
</div>
309314
</div>
310315

316+
<!-- Products section -->
317+
<div class="section" id="section-products">
318+
<div class="card">
319+
<h2>Products</h2>
320+
<table>
321+
<thead>
322+
<tr>
323+
<th>Name</th>
324+
<th>Description</th>
325+
<th>Price</th>
326+
</tr>
327+
</thead>
328+
<tbody id="products-body">
329+
<tr><td colspan="3" class="empty">Loading…</td></tr>
330+
</tbody>
331+
</table>
332+
</div>
333+
</div>
334+
311335
<!-- About section -->
312336
<div class="section" id="section-about">
313337
<div class="card">
@@ -471,6 +495,7 @@ <h2>API Endpoint</h2>
471495
document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active"));
472496
document.getElementById("section-" + name).classList.add("active");
473497
document.getElementById("nav-" + name).classList.add("active");
498+
if (name === "products") loadProducts();
474499
if (name === "about" && !mermaidRendered) {
475500
mermaid.run({ nodes: document.querySelectorAll(".mermaid") });
476501
mermaidRendered = true;
@@ -509,15 +534,15 @@ <h2>API Endpoint</h2>
509534
{ label: "Pending", ts: o.created_at, desc: "Order received, queued for processing" },
510535
{ label: "Validating", ts: o.validating_at, desc: "Checking order details" },
511536
{ label: "Payment", ts: o.payment_at, desc: "Processing payment" },
512-
{ label: "Fulfilled", ts: o.fulfilled_at, desc: "Receipt stored in S3" },
537+
{ label: "Fulfilled", ts: o.fulfilled_at, desc: o.fulfilled_at ? `Receipt stored in S3 — <a href="${window.location.origin}/order-receipts/receipts/${o.order_id}.json" target="_blank" style="color:#084298;">download</a>` : "Receipt stored in S3" },
513538
{ label: "Failed", ts: o.failed_at, desc: "Processing error — message routed to DLQ" },
514539
].filter(s => s.ts);
515540

516541
const stepHtml = steps.map(s => `
517542
<div class="detail-item">
518543
<span class="detail-label">${s.label}</span>
519544
<span class="detail-value ts">${fmt(s.ts)}</span>
520-
<span style="font-size:0.72rem;color:#888;">${s.desc}</span>
545+
<span style="font-size:0.72rem;color:#888;">${s.desc || ""}</span>
521546
</div>`).join("");
522547

523548
const meta = `
@@ -611,7 +636,6 @@ <h2>API Endpoint</h2>
611636
const data = await res.json();
612637
flash.className = "flash success";
613638
flash.textContent = `Order placed! ID: ${data.order_id}`;
614-
document.getElementById("item").value = "";
615639
document.getElementById("quantity").value = "1";
616640
loadOrders();
617641
} catch (err) {
@@ -661,11 +685,45 @@ <h2>API Endpoint</h2>
661685
}
662686
});
663687

688+
// ── Products ──────────────────────────────────────────────────────────────
689+
690+
let productsCache = [];
691+
692+
async function loadProducts() {
693+
try {
694+
const res = await fetch(`${API}/products`);
695+
productsCache = await res.json();
696+
697+
// Populate order form dropdown
698+
const sel = document.getElementById("item");
699+
sel.innerHTML = productsCache.map(p =>
700+
`<option value="${escHtml(p.name)}">${escHtml(p.name)} — $${Number(p.price).toFixed(2)}</option>`
701+
).join("");
702+
703+
// Populate products table
704+
const tbody = document.getElementById("products-body");
705+
if (productsCache.length) {
706+
tbody.innerHTML = productsCache.map(p => `
707+
<tr>
708+
<td><strong>${escHtml(p.name)}</strong></td>
709+
<td style="color:#666;font-size:0.85rem;">${escHtml(p.description)}</td>
710+
<td style="font-family:monospace;">$${Number(p.price).toFixed(2)}</td>
711+
</tr>`).join("");
712+
} else {
713+
tbody.innerHTML = '<tr><td colspan="3" class="empty">No products found.</td></tr>';
714+
}
715+
} catch (e) {
716+
document.getElementById("products-body").innerHTML =
717+
'<tr><td colspan="3" class="empty">Error loading products.</td></tr>';
718+
}
719+
}
720+
664721
// ── Init ──────────────────────────────────────────────────────────────────
665722

666723
checkChaos();
667724
setInterval(checkChaos, 5000);
668725
loadOrders();
726+
loadProducts();
669727
</script>
670728
</body>
671729
</html>

0 commit comments

Comments
 (0)