mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
feat(api): add IAM-authenticated menu publication
Keep weekly menu writes behind Lambda so the GitHub runtime role cannot access the shared DynamoDB table directly.
This commit is contained in:
parent
d7c4d7a56d
commit
1ee291f8bf
4 changed files with 179 additions and 11 deletions
|
|
@ -24,6 +24,7 @@ from shared.db import (
|
||||||
get_settings,
|
get_settings,
|
||||||
get_summary,
|
get_summary,
|
||||||
list_weeks,
|
list_weeks,
|
||||||
|
put_menu,
|
||||||
put_order,
|
put_order,
|
||||||
)
|
)
|
||||||
from shared.secrets import get_parameter
|
from shared.secrets import get_parameter
|
||||||
|
|
@ -180,6 +181,12 @@ def lambda_handler(event, context):
|
||||||
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
|
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
|
||||||
path = event.get("rawPath", "")
|
path = event.get("rawPath", "")
|
||||||
|
|
||||||
|
if path == "/api/publish/settings" and method == "GET":
|
||||||
|
return handle_publish_settings()
|
||||||
|
|
||||||
|
if path == "/api/publish/menu" and method == "POST":
|
||||||
|
return handle_publish_menu(event)
|
||||||
|
|
||||||
if "/admin/orders" in path:
|
if "/admin/orders" in path:
|
||||||
if method == "DELETE":
|
if method == "DELETE":
|
||||||
return handle_admin_delete(event)
|
return handle_admin_delete(event)
|
||||||
|
|
@ -202,6 +209,61 @@ def lambda_handler(event, context):
|
||||||
return response(405, {"error": "Method not allowed"})
|
return response(405, {"error": "Method not allowed"})
|
||||||
|
|
||||||
|
|
||||||
|
def handle_publish_settings():
|
||||||
|
"""Return only the pricing fields needed by the weekly-menu workflow."""
|
||||||
|
settings = get_settings()
|
||||||
|
return response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"bulk_discount_percent": float(
|
||||||
|
settings.get("bulk_discount_percent", 0)
|
||||||
|
),
|
||||||
|
"company_subsidy_percent": float(
|
||||||
|
settings.get("company_subsidy_percent", 0)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_publish_menu(event):
|
||||||
|
"""Persist a scraped menu for the current Eastern-time week."""
|
||||||
|
try:
|
||||||
|
body = json.loads(event.get("body", "{}"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return response(400, {"error": "Invalid JSON"})
|
||||||
|
|
||||||
|
meals = body.get("meals")
|
||||||
|
if not isinstance(meals, list) or not meals:
|
||||||
|
return response(400, {"error": "At least one meal is required"})
|
||||||
|
|
||||||
|
for meal in meals:
|
||||||
|
price = meal.get("price") if isinstance(meal, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(meal, dict)
|
||||||
|
or not str(meal.get("name", "")).strip()
|
||||||
|
or isinstance(price, bool)
|
||||||
|
or not isinstance(price, (int, float))
|
||||||
|
or price < 0
|
||||||
|
):
|
||||||
|
return response(
|
||||||
|
400, {"error": "Each meal requires a name and non-negative price"}
|
||||||
|
)
|
||||||
|
|
||||||
|
week = current_week()
|
||||||
|
menu = {
|
||||||
|
"scraped_at": body.get("scraped_at"),
|
||||||
|
"menu_url": body.get("menu_url"),
|
||||||
|
"meal_count": len(meals),
|
||||||
|
"meals": meals,
|
||||||
|
}
|
||||||
|
put_menu(week, menu)
|
||||||
|
logger.info("Published %s meals for %s", len(meals), week)
|
||||||
|
return response(
|
||||||
|
200,
|
||||||
|
{"status": "published", "week": week, "meal_count": len(meals)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def handle_admin_orders(event):
|
def handle_admin_orders(event):
|
||||||
user, err = _verify_admin(event)
|
user, err = _verify_admin(event)
|
||||||
if err:
|
if err:
|
||||||
|
|
|
||||||
|
|
@ -34,16 +34,18 @@ def ttl_days(days: int) -> int:
|
||||||
|
|
||||||
def put_menu(week: str, menu_data: dict):
|
def put_menu(week: str, menu_data: dict):
|
||||||
_get_table().put_item(
|
_get_table().put_item(
|
||||||
Item={
|
Item=_to_decimal(
|
||||||
"PK": f"WEEK#{week}",
|
{
|
||||||
"SK": "MENU",
|
"PK": f"WEEK#{week}",
|
||||||
"form_status": "open",
|
"SK": "MENU",
|
||||||
"scraped_at": menu_data.get("scraped_at"),
|
"form_status": "open",
|
||||||
"menu_url": menu_data.get("menu_url"),
|
"scraped_at": menu_data.get("scraped_at"),
|
||||||
"meal_count": menu_data.get("meal_count"),
|
"menu_url": menu_data.get("menu_url"),
|
||||||
"meals": menu_data.get("meals"),
|
"meal_count": menu_data.get("meal_count"),
|
||||||
"ttl": ttl_days(90),
|
"meals": menu_data.get("meals"),
|
||||||
}
|
"ttl": ttl_days(90),
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -220,8 +220,9 @@ Resources:
|
||||||
# authorizer validates the same Google ID token (Authorization: Bearer)
|
# authorizer validates the same Google ID token (Authorization: Bearer)
|
||||||
# the admin panel already sends, so admin routes are no longer
|
# the admin panel already sends, so admin routes are no longer
|
||||||
# AuthorizationType NONE. Public routes (submit-order, form-status,
|
# AuthorizationType NONE. Public routes (submit-order, form-status,
|
||||||
# roster) stay open — they're explicitly set to NONE on their events.
|
# roster) stay open, while weekly-menu publication routes opt into IAM.
|
||||||
Auth:
|
Auth:
|
||||||
|
EnableIamAuthorizer: true
|
||||||
Authorizers:
|
Authorizers:
|
||||||
AdminGoogleAuthorizer:
|
AdminGoogleAuthorizer:
|
||||||
FunctionArn: !GetAtt AdminAuthorizerFunction.Arn
|
FunctionArn: !GetAtt AdminAuthorizerFunction.Arn
|
||||||
|
|
@ -248,6 +249,9 @@ Resources:
|
||||||
'POST /api/submit-order':
|
'POST /api/submit-order':
|
||||||
ThrottlingBurstLimit: 10
|
ThrottlingBurstLimit: 10
|
||||||
ThrottlingRateLimit: 5
|
ThrottlingRateLimit: 5
|
||||||
|
'POST /api/publish/menu':
|
||||||
|
ThrottlingBurstLimit: 2
|
||||||
|
ThrottlingRateLimit: 1
|
||||||
# CORS only allows the production domain. For local development, use the
|
# CORS only allows the production domain. For local development, use the
|
||||||
# Flask dev server (app.py) which proxies API requests and doesn't enforce CORS.
|
# Flask dev server (app.py) which proxies API requests and doesn't enforce CORS.
|
||||||
CorsConfiguration:
|
CorsConfiguration:
|
||||||
|
|
@ -317,6 +321,22 @@ Resources:
|
||||||
ApiId: !Ref OrderApi
|
ApiId: !Ref OrderApi
|
||||||
Path: /api/roster
|
Path: /api/roster
|
||||||
Method: GET
|
Method: GET
|
||||||
|
PublishSettings:
|
||||||
|
Type: HttpApi
|
||||||
|
Properties:
|
||||||
|
ApiId: !Ref OrderApi
|
||||||
|
Path: /api/publish/settings
|
||||||
|
Method: GET
|
||||||
|
Auth:
|
||||||
|
Authorizer: AWS_IAM
|
||||||
|
PublishMenu:
|
||||||
|
Type: HttpApi
|
||||||
|
Properties:
|
||||||
|
ApiId: !Ref OrderApi
|
||||||
|
Path: /api/publish/menu
|
||||||
|
Method: POST
|
||||||
|
Auth:
|
||||||
|
Authorizer: AWS_IAM
|
||||||
AdminOrders:
|
AdminOrders:
|
||||||
Type: HttpApi
|
Type: HttpApi
|
||||||
Properties:
|
Properties:
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,90 @@ def _menu_doc_from_retail_pairs(pairs):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# WEEKLY MENU PUBLICATION
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"submit_order_handler.get_settings",
|
||||||
|
return_value={
|
||||||
|
"bulk_discount_percent": 10,
|
||||||
|
"company_subsidy_percent": 50,
|
||||||
|
"admin_emails": ["admin@seahavenind.com"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_publish_settings_returns_only_pricing(mock_settings):
|
||||||
|
result = submit_order_handler.lambda_handler(
|
||||||
|
_make_event(method="GET", path="/api/publish/settings"), None
|
||||||
|
)
|
||||||
|
status, body = _parse_response(result)
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert body == {
|
||||||
|
"bulk_discount_percent": 10.0,
|
||||||
|
"company_subsidy_percent": 50.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("submit_order_handler.put_menu")
|
||||||
|
@patch("submit_order_handler.current_week", return_value="2026-W30")
|
||||||
|
def test_publish_menu_uses_current_week_and_server_meal_count(mock_week, mock_put):
|
||||||
|
menu = {
|
||||||
|
"scraped_at": "2026-07-27T07:31:00-04:00",
|
||||||
|
"menu_url": "https://example.com/menu",
|
||||||
|
"meal_count": 999,
|
||||||
|
"meals": [{"name": "Chicken Bowl", "price": 12.5}],
|
||||||
|
}
|
||||||
|
result = submit_order_handler.lambda_handler(
|
||||||
|
_make_event(method="POST", path="/api/publish/menu", body=menu), None
|
||||||
|
)
|
||||||
|
status, body = _parse_response(result)
|
||||||
|
|
||||||
|
assert status == 200
|
||||||
|
assert body == {"status": "published", "week": "2026-W30", "meal_count": 1}
|
||||||
|
mock_put.assert_called_once_with(
|
||||||
|
"2026-W30",
|
||||||
|
{
|
||||||
|
"scraped_at": menu["scraped_at"],
|
||||||
|
"menu_url": menu["menu_url"],
|
||||||
|
"meal_count": 1,
|
||||||
|
"meals": menu["meals"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"body",
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{"meals": []},
|
||||||
|
{"meals": [{"name": "", "price": 12.5}]},
|
||||||
|
{"meals": [{"name": "Chicken Bowl", "price": -1}]},
|
||||||
|
{"meals": [{"name": "Chicken Bowl", "price": "12.50"}]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("submit_order_handler.put_menu")
|
||||||
|
def test_publish_menu_rejects_invalid_payload(mock_put, body):
|
||||||
|
result = submit_order_handler.lambda_handler(
|
||||||
|
_make_event(method="POST", path="/api/publish/menu", body=body), None
|
||||||
|
)
|
||||||
|
status, _ = _parse_response(result)
|
||||||
|
|
||||||
|
assert status == 400
|
||||||
|
mock_put.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("submit_order_handler.put_menu")
|
||||||
|
def test_publish_menu_rejects_invalid_json(mock_put):
|
||||||
|
event = _make_event(method="POST", path="/api/publish/menu")
|
||||||
|
event["body"] = "{"
|
||||||
|
status, _ = _parse_response(submit_order_handler.lambda_handler(event, None))
|
||||||
|
|
||||||
|
assert status == 400
|
||||||
|
mock_put.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Module-level patches that must be active before the handler is imported
|
# Module-level patches that must be active before the handler is imported
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue