diff --git a/functions/submit_order/handler.py b/functions/submit_order/handler.py index 2704122..fe7bb75 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -24,6 +24,7 @@ from shared.db import ( get_settings, get_summary, list_weeks, + put_menu, put_order, ) from shared.secrets import get_parameter @@ -180,6 +181,12 @@ def lambda_handler(event, context): method = event.get("requestContext", {}).get("http", {}).get("method", "GET") 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 method == "DELETE": return handle_admin_delete(event) @@ -202,6 +209,61 @@ def lambda_handler(event, context): 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): user, err = _verify_admin(event) if err: diff --git a/src/shared/shared/db.py b/src/shared/shared/db.py index 27502a9..39cffde 100644 --- a/src/shared/shared/db.py +++ b/src/shared/shared/db.py @@ -34,16 +34,18 @@ def ttl_days(days: int) -> int: def put_menu(week: str, menu_data: dict): _get_table().put_item( - Item={ - "PK": f"WEEK#{week}", - "SK": "MENU", - "form_status": "open", - "scraped_at": menu_data.get("scraped_at"), - "menu_url": menu_data.get("menu_url"), - "meal_count": menu_data.get("meal_count"), - "meals": menu_data.get("meals"), - "ttl": ttl_days(90), - } + Item=_to_decimal( + { + "PK": f"WEEK#{week}", + "SK": "MENU", + "form_status": "open", + "scraped_at": menu_data.get("scraped_at"), + "menu_url": menu_data.get("menu_url"), + "meal_count": menu_data.get("meal_count"), + "meals": menu_data.get("meals"), + "ttl": ttl_days(90), + } + ) ) diff --git a/template.yaml b/template.yaml index d51f97b..b2f5737 100644 --- a/template.yaml +++ b/template.yaml @@ -220,8 +220,9 @@ Resources: # authorizer validates the same Google ID token (Authorization: Bearer) # the admin panel already sends, so admin routes are no longer # 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: + EnableIamAuthorizer: true Authorizers: AdminGoogleAuthorizer: FunctionArn: !GetAtt AdminAuthorizerFunction.Arn @@ -248,6 +249,9 @@ Resources: 'POST /api/submit-order': ThrottlingBurstLimit: 10 ThrottlingRateLimit: 5 + 'POST /api/publish/menu': + ThrottlingBurstLimit: 2 + ThrottlingRateLimit: 1 # 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. CorsConfiguration: @@ -317,6 +321,22 @@ Resources: ApiId: !Ref OrderApi Path: /api/roster 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: Type: HttpApi Properties: diff --git a/tests/test_submit_order.py b/tests/test_submit_order.py index 6fa79b9..b7c4d8f 100644 --- a/tests/test_submit_order.py +++ b/tests/test_submit_order.py @@ -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 # ---------------------------------------------------------------------------