From a752c24e0f6a221724f2d348f51567dd1702d689 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 13 May 2026 18:00:21 -0400 Subject: [PATCH] Add discount pricing, Google auth, and order hardening (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add discount settings and two-tier pricing to order aggregation Settings CRUD (get_settings/put_settings) for DynamoDB CONFIG#SETTINGS item. Aggregation now tracks bulk_price and employee_price separately, with grand_total (company cost) and employee_total (payroll deductions). * Add Google OAuth, server-side discounts, and Slack order confirmations Submit order Lambda now verifies Google ID tokens via tokeninfo endpoint, calculates two-tier discount pricing server-side, and async-invokes the Slack notifier for per-employee order confirmation DMs. Deadlines updated to Thursday 11:59pm across all Slack messages. * Update SAM template for Google auth, Slack invocation, and deadline change Add SLACK_NOTIFIER_ARN and GOOGLE_CLIENT_ID_PARAM env vars to submit order function with lambda:InvokeFunction policy. Move close-form schedule to Thursday 11:59pm EST/EDT. * Update order form UI and CI workflow for new features Form now shows discount pricing, responsive grid layout, Google Sign-In overlay, and closed-orders page with countdown timer. CI workflow fetches discount settings from DynamoDB and Google Client ID from SSM. * Add SSM GetParameter permission to submit order Lambda Required for reading the Google Client ID from Parameter Store at /meal-order-manager/google-client-id. * Harden auth, pricing, and reliability in order handlers Enforce Google auth when configured (reject missing tokens with 403), return 503 on token verification outages, switch to Decimal with ROUND_HALF_UP for financial precision, clamp discount bounds 0-100, use email-based slugs, add 5-min cache TTL with time.monotonic(), wrap Slack invocation in try/except, add reopen_at timestamp to closed form status, add reminder dedup guards for dual EST/EDT crons, escape Slack mrkdwn special characters, and handle empty employee names. * Fix XSS risks and add closed-form UX to order page Add escapeHtml() for all scraped content in innerHTML, fix script injection via in JSON, fix JWT base64url decoding, match backend two-step rounding in JS employeePrice(), disable qty buttons and submit when form is closed, add server-driven countdown from reopen_at, add duplicate order warning via localStorage, add back button after submission, embed favicon, use :g format for fractional discounts, and exclude dead loadRoster code when Google auth enabled. * Document CORS, cron idempotency, and SSM config in template Add comments explaining CORS dev server strategy, dual EST/EDT cron idempotency, and manual SSM parameter creation for Google Client ID. * Add unit tests for submit, notify, and aggregate handlers 50 tests covering pricing pipeline (Decimal rounding, clamping, totals), Google auth (enforcement, bypass prevention, audience/domain validation, 503 on outage), email slug generation, form status with reopen_at, input validation, Slack failure resilience, reminder dedup guards, order confirmation DMs, aggregated summaries, CSV generation, and mrkdwn escaping. * Use full email as order slug for defense-in-depth Replace email-prefix slug with full lowercase email to eliminate any possibility of cross-domain collisions, per senior review sign-off. * Remove unused imports flagged by ruff * Apply ruff formatting * Fix PR review findings: auth, rounding, and close-form guard - Remove dead elif branch in submit_order auth (always returned 403) - Catch HTTPError before URLError so expired tokens return 403 not 503 - Wrap SSM get_parameter in try/except for fresh deployments - Add wall-clock guard to close_form handler (Friday >= 11 PM ET) - Add epsilon nudge to JS employeePrice for IEEE 754 boundary match - Switch Flask dev server from round() to Decimal ROUND_HALF_UP - Add tests for HTTPError handling and close_form guard (6 new tests) * Fix close-form weekday guard and SSM auth fail-open - Close form guard: check weekday == 3 (Thursday), not 4 (Friday) — the crons fire at Thursday 11:59 PM ET, when weekday() is 3 - SSM fail-closed: separate _google_auth_configured() (checks env var) from _get_google_client_id() (fetches value). If auth is configured but the SSM fetch fails, return 503 instead of silently falling back to manual auth - Update close_form tests to use Thursday dates - Add test_ssm_failure_fails_closed * Harden Flask dev server auth and escaping - Add hosted domain check to _verify_google_token (mirror Lambda) - Gate auth on config (client_id presence), not request body — prevents bypass by omitting google_id_token when auth is configured - Add discount percentage clamping to match Lambda handler - Add escaping to google_client_id_json * fix: Email order filenames, SSM param TTL, DST-safe reopen_at - Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo) - shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes - form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta) - Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case Co-authored-by: Adam Moussa * Apply ruff formatting to submit_order handler * fix(server): retry SSM for Google client id after TTL on failure Transient SSM errors no longer cache empty client id for the process lifetime; matches Lambda handler refresh behavior (300s TTL). Co-authored-by: Cursor * style(server): ruff-format Google client id cache helper Co-authored-by: Cursor * fix(close-form): accept Fri 00–03 ET catch-up after Thu close cron EventBridge can deliver past midnight ET; widen the wall-clock guard so a delayed Thursday 23:59 UTC cron still closes the form. Idempotent when already closed. Adds test for early Friday; past-window skip now starts Fri 04:00 ET. Co-authored-by: Adam Moussa * fix(submit-order): bill from Dynamo menu retail, not client JSON Load authoritative meal prices from get_menu(week); reject unknown meal names and return 503 when the menu has no priced meals. Use meal_name in the pricing loop to avoid shadowing the employee name. Adds regression tests for tampering, unknown meals, and empty menu meals. Co-authored-by: Adam Moussa * fix: use single braces in loadRoster JS nested string Co-authored-by: Cursor * Fix Eastern fallback countdown * Fix pricing validation and JWT display decoding * Fix optional Google auth detection * Format app.py line length for ruff compliance * Fix auth config check and URL escaping in form - _google_auth_configured() now checks env var presence (intent), not the fetched SSM value — prevents silent auth bypass if SSM param is deleted - Add escaping to URL values in generate_form.py for consistency with other injected values --------- Co-authored-by: Cursor Agent Co-authored-by: Adam Moussa --- .github/workflows/weekly-menu.yml | 37 +- config.json | 5 +- functions/aggregate_orders/handler.py | 18 +- functions/close_form/handler.py | 15 + functions/slack_notifier/handler.py | 88 +- functions/submit_order/handler.py | 265 ++++- src/aggregator/aggregate.py | 26 +- src/server/app.py | 150 ++- src/server/generate_form.py | 531 ++++++++-- src/shared/shared/db.py | 16 + src/shared/shared/secrets.py | 29 +- template.yaml | 25 +- tests/conftest.py | 9 + tests/test_aggregate_orders.py | 503 +++++++++ tests/test_close_form.py | 117 ++ tests/test_secrets.py | 50 + tests/test_slack_notifier.py | 375 +++++++ tests/test_submit_order.py | 1415 +++++++++++++++++++++++++ 18 files changed, 3530 insertions(+), 144 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_aggregate_orders.py create mode 100644 tests/test_close_form.py create mode 100644 tests/test_secrets.py create mode 100644 tests/test_slack_notifier.py create mode 100644 tests/test_submit_order.py diff --git a/.github/workflows/weekly-menu.yml b/.github/workflows/weekly-menu.yml index 3c03083..214fbf0 100644 --- a/.github/workflows/weekly-menu.yml +++ b/.github/workflows/weekly-menu.yml @@ -72,11 +72,46 @@ jobs: echo "::add-mask::$API_KEY" echo "api_key=$API_KEY" >> $GITHUB_OUTPUT + - name: Get discount settings + id: discount + run: | + RESULT=$(aws dynamodb get-item \ + --table-name meal-order-manager-orders \ + --key '{"PK":{"S":"CONFIG"},"SK":{"S":"SETTINGS"}}' \ + --output json 2>/dev/null || echo '{}') + BULK=$(echo "$RESULT" | python3 -c " + import sys, json + d = json.load(sys.stdin) + print(d.get('Item',{}).get('bulk_discount_percent',{}).get('N','0')) + " 2>/dev/null || echo "0") + SUBSIDY=$(echo "$RESULT" | python3 -c " + import sys, json + d = json.load(sys.stdin) + print(d.get('Item',{}).get('company_subsidy_percent',{}).get('N','0')) + " 2>/dev/null || echo "0") + echo "bulk_discount=$BULK" >> $GITHUB_OUTPUT + echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT + + - name: Get Google Client ID + id: google + run: | + GOOGLE_CLIENT_ID=$(aws ssm get-parameter \ + --name /meal-order-manager/google-client-id \ + --query 'Parameter.Value' \ + --output text 2>/dev/null || echo "") + if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then + GOOGLE_CLIENT_ID="" + fi + echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT + - name: Generate order form run: | python3 src/server/generate_form.py \ --api-url "${{ steps.stack.outputs.api_url }}" \ - --api-key "${{ steps.apikey.outputs.api_key }}" + --api-key "${{ steps.apikey.outputs.api_key }}" \ + --bulk-discount "${{ steps.discount.outputs.bulk_discount }}" \ + --company-subsidy "${{ steps.discount.outputs.company_subsidy }}" \ + ${{ steps.google.outputs.client_id && format('--google-client-id "{0}"', steps.google.outputs.client_id) || '' }} - name: Upload menu to DynamoDB run: python3 scripts/upload_menu.py diff --git a/config.json b/config.json index 6904a34..002e184 100644 --- a/config.json +++ b/config.json @@ -1,8 +1,11 @@ { "menu_url": "https://redefinemeals.com/menu", - "order_deadline": "Wednesday 11:59 PM", + "order_deadline": "Thursday at 11:59 PM", "output_dir": "output", "orders_dir": "orders", + "bulk_discount_percent": 10, + "company_subsidy_percent": 50, + "google_client_id": "", "roster": [ {"name": "Example Employee", "email": "example@seahavenind.com"} ] diff --git a/functions/aggregate_orders/handler.py b/functions/aggregate_orders/handler.py index 2586d6a..897ad82 100644 --- a/functions/aggregate_orders/handler.py +++ b/functions/aggregate_orders/handler.py @@ -65,13 +65,18 @@ def lambda_handler(event, context): def build_summary(orders: list[dict], week: str) -> dict: - meal_totals = defaultdict(lambda: {"quantity": 0, "unit_price": 0}) + meal_totals = defaultdict( + lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0} + ) for order in orders: for item in order.get("items", []): name = item["name"] qty = int(item.get("quantity", 0)) meal_totals[name]["quantity"] += qty - meal_totals[name]["unit_price"] = float(item.get("price", 0)) + meal_totals[name]["bulk_price"] = float( + item.get("bulk_price", item.get("price", 0)) + ) + meal_totals[name]["employee_price"] = float(item.get("price", 0)) meals = [] for name, data in sorted(meal_totals.items()): @@ -79,8 +84,12 @@ def build_summary(orders: list[dict], week: str) -> dict: { "meal": name, "quantity": data["quantity"], - "unit_price": data["unit_price"], - "line_total": round(data["unit_price"] * data["quantity"], 2), + "unit_price": data["bulk_price"], + "employee_unit_price": data["employee_price"], + "line_total": round(data["bulk_price"] * data["quantity"], 2), + "employee_line_total": round( + data["employee_price"] * data["quantity"], 2 + ), } ) @@ -90,6 +99,7 @@ def build_summary(orders: list[dict], week: str) -> dict: "total_employees": len(orders), "total_meals": sum(m["quantity"] for m in meals), "grand_total": round(sum(m["line_total"] for m in meals), 2), + "employee_total": round(sum(m["employee_line_total"] for m in meals), 2), "meals": meals, } diff --git a/functions/close_form/handler.py b/functions/close_form/handler.py index 14e1ee3..2a509d9 100644 --- a/functions/close_form/handler.py +++ b/functions/close_form/handler.py @@ -1,14 +1,29 @@ import json import os +from datetime import datetime +from zoneinfo import ZoneInfo import boto3 from shared.db import current_week, get_form_status, set_form_status _lambda = boto3.client("lambda") +EASTERN = ZoneInfo("America/New_York") def lambda_handler(event, context): + now_et = datetime.now(EASTERN) + # EventBridge can fire slightly after midnight ET; accept Thu 23:xx or Fri 00–03 + # ET so a delayed cron still closes the form. Idempotency: already-closed is a no-op. + in_close_window = (now_et.weekday() == 3 and now_et.hour == 23) or ( + now_et.weekday() == 4 and now_et.hour < 4 + ) + if not in_close_window: + return { + "status": "skipped", + "reason": "outside close window (must be Thu 23:xx or Fri 00–03 ET)", + } + week = event.get("week", current_week()) status = get_form_status(week) diff --git a/functions/slack_notifier/handler.py b/functions/slack_notifier/handler.py index 86966db..d9387dd 100644 --- a/functions/slack_notifier/handler.py +++ b/functions/slack_notifier/handler.py @@ -1,11 +1,18 @@ import json import os +from datetime import datetime from decimal import Decimal +from zoneinfo import ZoneInfo from shared.db import current_week, get_orders, get_roster, get_summary from shared.slack import post_channel_message, send_dm +def _escape_mrkdwn(text: str) -> str: + """Escape Slack mrkdwn special characters.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Decimal): @@ -21,7 +28,13 @@ def lambda_handler(event, context): elif event_type == "orders_aggregated": return handle_orders_aggregated(event) elif event_type == "reminder": + # Guard against duplicate triggers from dual EST/EDT schedules + now_et = datetime.now(ZoneInfo("America/New_York")) + if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday + return {"status": "skipped", "reason": "outside reminder window"} return handle_reminder(event) + elif event_type == "order_confirmed": + return handle_order_confirmed(event) return {"error": f"Unknown event type: {event_type}"} @@ -31,7 +44,7 @@ def handle_menu_published(event): week = event.get("week", current_week()) meal_count = event.get("meal_count", "") - text = "This week's meal order is open! Deadline: Thursday 6pm." + text = "This week's meal order is open! Deadline: Thursday at 11:59pm." blocks = [ { "type": "header", @@ -43,7 +56,7 @@ def handle_menu_published(event): "type": "mrkdwn", "text": ( f"*<{form_url}|Place your order>*\n\n" - f"*Deadline:* Thursday 6pm\n" + f"*Deadline:* Thursday at 11:59pm\n" f"*Menu:* {meal_count} meals available" ), }, @@ -63,12 +76,24 @@ def handle_orders_aggregated(event): total_employees = int(summary.get("total_employees", 0)) total_meals = int(summary.get("total_meals", 0)) grand_total = float(summary.get("grand_total", 0)) + employee_total = float(summary.get("employee_total", grand_total)) meal_lines = [] for m in summary.get("meals", []): - meal_lines.append(f"{m['meal']}: *{int(m['quantity'])}*") + meal_lines.append(f"{_escape_mrkdwn(m['meal'])}: *{int(m['quantity'])}*") meal_list = "\n".join(meal_lines) + has_subsidy = employee_total < grand_total + totals_text = ( + f"*${grand_total:.2f}* order total (bulk rate)\n" + f"*${employee_total:.2f}* payroll deductions" + + ( + f"\n*${grand_total - employee_total:.2f}* company subsidy" + if has_subsidy + else "" + ) + ) + text = f"Meal orders closed for {week}. {total_employees} employees, {total_meals} meals, ${grand_total:.2f} total." blocks = [ { @@ -82,7 +107,7 @@ def handle_orders_aggregated(event): "text": ( f"*{total_employees}* employees ordered\n" f"*{total_meals}* total meals\n" - f"*${grand_total:.2f}* grand total" + f"{totals_text}" ), }, }, @@ -100,7 +125,58 @@ def handle_orders_aggregated(event): return {"status": "notified", "event": "orders_aggregated", "week": week} +def handle_order_confirmed(event): + email = event.get("employee_email", "").lower() + name = event.get("employee_name", "") + items = event.get("items", []) + total = float(event.get("total", 0)) + week = event.get("week", current_week()) + + roster = get_roster() + employee = next((e for e in roster if e["email"].lower() == email), None) + if not employee or not employee.get("slack_user_id"): + return {"status": "no_slack_id", "email": email} + + item_lines = [] + for item in items: + qty = int(item.get("quantity", 0)) + price = float(item.get("price", 0)) + item_lines.append( + f"{_escape_mrkdwn(item['name'])} x{qty} — your cost: ${price * qty:.2f}" + ) + item_list = "\n".join(item_lines) + + send_dm( + employee["slack_user_id"], + f"Order confirmed for {week}: ${total:.2f} total.", + blocks=[ + { + "type": "header", + "text": {"type": "plain_text", "text": f"Order Confirmed — {week}"}, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"Hey {name.split()[0] if name.strip() else 'there'}! Your meal order has been submitted.\n\n" + f"{item_list}\n\n" + f"*Your total: ${total:.2f}* (payroll deduction)" + ), + }, + }, + ], + ) + + return {"status": "confirmed", "week": week, "employee": name} + + def handle_reminder(event): + # Guard against duplicate triggers from dual EST/EDT schedules + now_et = datetime.now(ZoneInfo("America/New_York")) + if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday + return {"status": "skipped", "reason": "outside reminder window"} + week = event.get("week", current_week()) form_url = os.environ.get("FORM_URL", "") @@ -120,14 +196,14 @@ def handle_reminder(event): continue send_dm( slack_id, - f"Meal orders close at 6pm today. Place your order: {form_url}", + f"Meal orders close today at 11:59pm. Place your order: {form_url}", blocks=[ { "type": "section", "text": { "type": "mrkdwn", "text": ( - f"Hey {emp['name'].split()[0]}! Meal orders close at *6pm today*.\n\n" + f"Hey {emp['name'].split()[0] if emp['name'].strip() else 'there'}! Meal orders close today at *11:59pm*.\n\n" f"*<{form_url}|Place your order>*" ), }, diff --git a/functions/submit_order/handler.py b/functions/submit_order/handler.py index 2841c6b..5dbd7fc 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -1,13 +1,47 @@ import json +import logging import os -from datetime import datetime +import sys +import time +import urllib.error +import urllib.request +import datetime as _dt +from datetime import timedelta +from decimal import Decimal, ROUND_HALF_UP from zoneinfo import ZoneInfo -from shared.db import current_week, get_form_status, get_roster, put_order -from shared.secrets import get_secret +import boto3 + +from shared.db import ( + current_week, + get_form_status, + get_menu, + get_roster, + get_settings, + put_order, +) +from shared.secrets import get_parameter, get_secret + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +if not logger.handlers: + logger.addHandler(logging.StreamHandler(sys.stderr)) EASTERN = ZoneInfo("America/New_York") +CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values + + +def _eastern_now() -> _dt.datetime: + """Wall-clock 'now' in Eastern time (patch target for form-status tests).""" + return _dt.datetime.now(EASTERN) + + _api_key = None +_settings = None +_settings_ts = 0.0 +_google_client_id = None +_google_client_id_ts = 0.0 +_lambda = boto3.client("lambda") def _get_api_key() -> str: @@ -17,6 +51,101 @@ def _get_api_key() -> str: return _api_key +def _get_discount_settings() -> tuple[Decimal, Decimal]: + global _settings, _settings_ts + now = time.monotonic() + if _settings is None or (now - _settings_ts) > CACHE_TTL_SECONDS: + s = get_settings() + _settings = ( + Decimal(str(s.get("bulk_discount_percent", 0))), + Decimal(str(s.get("company_subsidy_percent", 0))), + ) + _settings_ts = now + return _settings + + +def _google_auth_configured() -> bool: + return bool(os.environ.get("GOOGLE_CLIENT_ID_PARAM", "")) + + +def _is_parameter_not_found(exc: Exception) -> bool: + response_data = getattr(exc, "response", {}) + if not isinstance(response_data, dict): + return False + return response_data.get("Error", {}).get("Code") == "ParameterNotFound" + + +def _official_menu_retail_by_name(week: str) -> dict[str, Decimal]: + """Map meal name -> retail price from Dynamo menu (authoritative for billing).""" + row = get_menu(week) + meals = (row or {}).get("meals") or [] + out: dict[str, Decimal] = {} + for m in meals: + name = (m.get("name") or "").strip() + if not name or m.get("price") is None: + continue + out[name] = Decimal(str(m["price"])) + return out + + +def _get_google_client_id() -> str: + global _google_client_id, _google_client_id_ts + now = time.monotonic() + if _google_client_id is None or (now - _google_client_id_ts) > CACHE_TTL_SECONDS: + param = os.environ.get("GOOGLE_CLIENT_ID_PARAM", "") + if param: + try: + _google_client_id = get_parameter(param, decrypt=False) or "" + except Exception as exc: + if not _is_parameter_not_found(exc): + raise + _google_client_id = "" + else: + _google_client_id = "" + _google_client_id_ts = now + return _google_client_id + + +def _verify_google_token(token: str) -> tuple[dict | None, str]: + """Verify a Google ID token via the tokeninfo endpoint. + + Returns a tuple of (user_info, error_kind) where: + - ({"name": ..., "email": ...}, "ok") on success + - (None, "invalid") for bad/expired tokens or wrong audience/domain + - (None, "unavailable") when the Google verification service is unreachable + + NOTE: The token is passed as a query parameter to Google's tokeninfo endpoint. + This is acceptable because ID tokens are short-lived (typically ~1 hour) and + this is Google's own documented verification method, but be aware that the + token will appear in Google's server access logs. + """ + client_id = _get_google_client_id() + if not client_id: + return None, "invalid" + try: + req = urllib.request.Request( + f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + ) + with urllib.request.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read()) + if data.get("aud") != client_id: + logger.warning("Google token audience mismatch: got %s", data.get("aud")) + return None, "invalid" + if data.get("hd") != "seahavenind.com": + logger.warning("Google token domain mismatch: got %s", data.get("hd")) + return None, "invalid" + return {"name": data.get("name", ""), "email": data.get("email", "")}, "ok" + except urllib.error.HTTPError as exc: + logger.warning("Google token rejected (HTTP %s): %s", exc.code, exc) + return None, "invalid" + except (urllib.error.URLError, TimeoutError, OSError) as exc: + logger.error("Google token verification service unavailable: %s", exc) + return None, "unavailable" + except Exception as exc: + logger.error("Google token verification failed (bad token data): %s", exc) + return None, "invalid" + + def lambda_handler(event, context): method = event.get("requestContext", {}).get("http", {}).get("method", "GET") path = event.get("rawPath", "") @@ -36,7 +165,21 @@ def lambda_handler(event, context): def handle_form_status(event): week = event.get("pathParameters", {}).get("week", current_week()) status = get_form_status(week) - return response(200, {"week": week, "status": status}) + result = {"week": week, "status": status} + if status == "closed": + # Next Monday 8:00 AM Eastern: use calendar date math + combine() so reopen_at + # stays on the correct civil Monday across DST (timedelta(days=n) is always 24n hours). + now_et = _eastern_now() + today = now_et.date() + weekday = today.weekday() # Monday=0 ... Sunday=6 + days_until_monday = (7 - weekday) % 7 + if days_until_monday == 0 and now_et.hour >= 8: + # If today is Monday past 8am, next Monday is 7 days away + days_until_monday = 7 + reopen_date = today + timedelta(days=days_until_monday) + next_monday = _dt.datetime.combine(reopen_date, _dt.time(8, 0), tzinfo=EASTERN) + result["reopen_at"] = int(next_monday.timestamp()) + return response(200, result) def handle_roster(): @@ -55,8 +198,40 @@ def handle_submit(event): except json.JSONDecodeError: return response(400, {"error": "Invalid JSON"}) - name = body.get("employee_name", "").strip() - email = body.get("employee_email", "").strip() + # --- Authentication --- + # If Google auth is configured (SSM param contains a client ID), require a valid + # google_id_token. Manual fallback is only allowed when auth is NOT configured. + # If SSM fetch fails for any other reason, fail closed (503). + google_token = body.get("google_id_token") + + if _google_auth_configured(): + try: + client_id = _get_google_client_id() + except Exception as exc: + logger.error("SSM fetch failed for Google client ID: %s", exc) + return response( + 503, {"error": "Authentication service temporarily unavailable"} + ) + if not client_id: + logger.error("Google auth configured but client ID is empty") + return response( + 503, {"error": "Authentication service temporarily unavailable"} + ) + if not google_token: + return response(403, {"error": "Google authentication is required"}) + user_info, verify_status = _verify_google_token(google_token) + if verify_status == "unavailable": + return response( + 503, {"error": "Authentication service temporarily unavailable"} + ) + if user_info is None: + return response(403, {"error": "Invalid or unauthorized Google account"}) + name = user_info["name"] + email = user_info["email"] + else: + name = body.get("employee_name", "").strip() + email = body.get("employee_email", "").strip() + items = body.get("items", []) if not name: @@ -74,25 +249,87 @@ def handle_submit(event): return response(404, {"error": "No menu available for this week"}) filtered_items = [i for i in items if i.get("quantity", 0) > 0] - total = sum((i.get("price", 0) or 0) * i.get("quantity", 0) for i in filtered_items) - slug = ( - "".join(c if c.isalnum() or c in "- " else "" for c in name) - .strip() - .replace(" ", "-") - .lower() + official_retail = _official_menu_retail_by_name(week) + if not official_retail: + logger.error("Week %s: menu has no priced meals; refusing order", week) + return response(503, {"error": "Menu temporarily unavailable"}) + for item in filtered_items: + meal_name = (item.get("name") or "").strip() + if meal_name not in official_retail: + return response( + 400, + {"error": "One or more meals are not on this week's menu"}, + ) + + # --- Price calculation using Decimal for financial precision --- + # Rounding approach (two-step intermediate rounding): + # 1. bulk_price = retail * bulk_mult, rounded to 2 decimal places + # 2. emp_price = bulk_price * subsidy_mult, rounded to 2 decimal places + # The frontend should match this two-step rounding to avoid discrepancies. + TWO_PLACES = Decimal("0.01") + bulk_pct, subsidy_pct = _get_discount_settings() + bulk_pct = max(Decimal("0"), min(Decimal("100"), bulk_pct)) + subsidy_pct = max(Decimal("0"), min(Decimal("100"), subsidy_pct)) + bulk_mult = Decimal("1") - (bulk_pct / Decimal("100")) + subsidy_mult = Decimal("1") - (subsidy_pct / Decimal("100")) + + for item in filtered_items: + meal_name = (item.get("name") or "").strip() + retail = official_retail[meal_name] + qty = Decimal(str(item.get("quantity", 0))) + # Step 1: apply bulk discount and round + bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + # Step 2: apply company subsidy and round + emp_price = (bulk_price * subsidy_mult).quantize( + TWO_PLACES, rounding=ROUND_HALF_UP + ) + subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + # Convert back to float for JSON serialization + item["retail_price"] = float(retail) + item["bulk_price"] = float(bulk_price) + item["price"] = float(emp_price) + item["subtotal"] = float(subtotal) + + total = float( + sum(Decimal(str(i["subtotal"])) for i in filtered_items).quantize( + TWO_PLACES, rounding=ROUND_HALF_UP + ) ) + slug = email.lower() + order_data = { "employee_name": name, "employee_email": email, - "submitted_at": datetime.now(EASTERN).isoformat(), + "submitted_at": _eastern_now().isoformat(), "items": filtered_items, - "total": round(total, 2), + "total": total, } put_order(week, slug, order_data) + # Slack notification is best-effort — order is already persisted above, + # so we return success to the user even if this invocation fails. + try: + _lambda.invoke( + FunctionName=os.environ["SLACK_NOTIFIER_ARN"], + InvocationType="Event", + Payload=json.dumps( + { + "event": "order_confirmed", + "employee_name": name, + "employee_email": email, + "items": filtered_items, + "total": order_data["total"], + "week": week, + }, + default=float, + ), + ) + except Exception as exc: + logger.error("Slack notifier invocation failed (order already saved): %s", exc) + return response( 200, { diff --git a/src/aggregator/aggregate.py b/src/aggregator/aggregate.py index 490ef32..156919a 100644 --- a/src/aggregator/aggregate.py +++ b/src/aggregator/aggregate.py @@ -41,12 +41,17 @@ def load_orders(week: str) -> list[dict]: def generate_order_summary(orders: list[dict]) -> dict: """Aggregate all meals across employees into a single order for Redefine.""" - meal_totals = defaultdict(lambda: {"quantity": 0, "unit_price": 0}) + meal_totals = defaultdict( + lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0} + ) for order in orders: for item in order.get("items", []): name = item["name"] meal_totals[name]["quantity"] += item.get("quantity", 0) - meal_totals[name]["unit_price"] = item.get("price", 0) + meal_totals[name]["bulk_price"] = item.get( + "bulk_price", item.get("price", 0) + ) + meal_totals[name]["employee_price"] = item.get("price", 0) summary = [] for name, data in sorted(meal_totals.items()): @@ -54,12 +59,17 @@ def generate_order_summary(orders: list[dict]) -> dict: { "meal": name, "quantity": data["quantity"], - "unit_price": data["unit_price"], - "line_total": round(data["unit_price"] * data["quantity"], 2), + "unit_price": data["bulk_price"], + "employee_unit_price": data["employee_price"], + "line_total": round(data["bulk_price"] * data["quantity"], 2), + "employee_line_total": round( + data["employee_price"] * data["quantity"], 2 + ), } ) grand_total = sum(s["line_total"] for s in summary) + employee_total = sum(s["employee_line_total"] for s in summary) total_meals = sum(s["quantity"] for s in summary) return { @@ -68,6 +78,7 @@ def generate_order_summary(orders: list[dict]) -> dict: "total_employees": len(orders), "total_meals": total_meals, "grand_total": round(grand_total, 2), + "employee_total": round(employee_total, 2), "meals": summary, } @@ -150,6 +161,13 @@ def main(): print(f"{meal['meal']:<45} {meal['quantity']:>4} ${meal['line_total']:>7.2f}") print("-" * 60) print(f"{'TOTAL':<45} {summary['total_meals']:>4} ${summary['grand_total']:>7.2f}") + if ( + summary.get("employee_total") is not None + and summary["employee_total"] != summary["grand_total"] + ): + print(f"{'PAYROLL DEDUCTIONS':<45} ${summary['employee_total']:>7.2f}") + subsidy = round(summary["grand_total"] - summary["employee_total"], 2) + print(f"{'COMPANY SUBSIDY':<45} ${subsidy:>7.2f}") print(f"\n{summary['total_employees']} employees ordered") print("\nFiles generated:") diff --git a/src/server/app.py b/src/server/app.py index 296358c..512102a 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -6,9 +6,13 @@ Orders are saved as JSON files in the orders directory, one per employee per wee """ import json +import time +import urllib.request from datetime import datetime +from decimal import Decimal, ROUND_HALF_UP from pathlib import Path +import boto3 from flask import Flask, jsonify, request, send_file PROJECT_ROOT = Path(__file__).resolve().parents[2] @@ -33,6 +37,21 @@ def latest_menu_file() -> Path | None: return files[0] if files else None +def _official_menu_retail_by_name() -> dict[str, Decimal]: + menu_file = latest_menu_file() + if not menu_file: + return {} + with open(menu_file) as f: + meals = (json.load(f) or {}).get("meals") or [] + out: dict[str, Decimal] = {} + for meal in meals: + name = (meal.get("name") or "").strip() + if not name or meal.get("price") is None: + continue + out[name] = Decimal(str(meal["price"])) + return out + + @app.route("/") def index(): form_file = OUTPUT_DIR / f"order-form-{current_week()}.html" @@ -58,14 +77,79 @@ def get_roster(): return jsonify(config.get("roster", [])) +# Match functions/submit_order/handler.py: TTL so a transient SSM failure cannot +# pin client_id to "" for the process lifetime (which would skip Google auth). +_GOOGLE_CLIENT_ID_CACHE_TTL_SECONDS = 300 + +_google_client_id_cache: str | None = None +_google_client_id_cache_ts = 0.0 + + +def _get_google_client_id() -> str: + global _google_client_id_cache, _google_client_id_cache_ts + now = time.monotonic() + if ( + _google_client_id_cache is not None + and (now - _google_client_id_cache_ts) <= _GOOGLE_CLIENT_ID_CACHE_TTL_SECONDS + ): + return _google_client_id_cache + + config = load_config() + from_config = (config.get("google_client_id") or "").strip() + if from_config: + _google_client_id_cache = from_config + _google_client_id_cache_ts = now + return _google_client_id_cache + + try: + ssm = boto3.client("ssm") + resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id") + _google_client_id_cache = (resp["Parameter"].get("Value") or "").strip() + except Exception: + _google_client_id_cache = "" + _google_client_id_cache_ts = now + return _google_client_id_cache + + +def _verify_google_token(token: str, client_id: str) -> dict | None: + if not client_id: + return None + try: + req = urllib.request.Request( + f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" + ) + with urllib.request.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read()) + if data.get("aud") != client_id: + return None + if data.get("hd") != "seahavenind.com": + return None + return {"name": data.get("name", ""), "email": data.get("email", "")} + except Exception: + return None + + @app.route("/api/submit-order", methods=["POST"]) def submit_order(): data = request.get_json() if not data: return jsonify({"error": "No data received"}), 400 - name = data.get("employee_name", "").strip() - email = data.get("employee_email", "").strip() + client_id = _get_google_client_id() + google_token = data.get("google_id_token") + + if client_id: + if not google_token: + return jsonify({"error": "Google authentication is required"}), 403 + user_info = _verify_google_token(google_token, client_id) + if not user_info: + return jsonify({"error": "Invalid or unauthorized Google account"}), 403 + name = user_info["name"] + email = user_info["email"] + else: + name = data.get("employee_name", "").strip() + email = data.get("employee_email", "").strip() + items = data.get("items", []) if not name: @@ -75,29 +159,62 @@ def submit_order(): if not items or not any(i.get("quantity", 0) > 0 for i in items): return jsonify({"error": "Please select at least one meal"}), 400 + config = load_config() + TWO_PLACES = Decimal("0.01") + bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0))) + subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0))) + bulk_pct = max(Decimal("0"), min(Decimal("100"), bulk_pct)) + subsidy_pct = max(Decimal("0"), min(Decimal("100"), subsidy_pct)) + bulk_mult = Decimal("1") - (bulk_pct / Decimal("100")) + subsidy_mult = Decimal("1") - (subsidy_pct / Decimal("100")) + + filtered = [i for i in items if i.get("quantity", 0) > 0] + official_retail = _official_menu_retail_by_name() + if not official_retail: + return jsonify({"error": "Menu temporarily unavailable"}), 503 + for item in filtered: + meal_name = (item.get("name") or "").strip() + if meal_name not in official_retail: + return jsonify( + {"error": "One or more meals are not on this week's menu"} + ), 400 + + for item in filtered: + meal_name = (item.get("name") or "").strip() + retail = official_retail[meal_name] + qty = Decimal(str(item.get("quantity", 0))) + bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + emp_price = (bulk_price * subsidy_mult).quantize( + TWO_PLACES, rounding=ROUND_HALF_UP + ) + subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP) + item["retail_price"] = float(retail) + item["bulk_price"] = float(bulk_price) + item["price"] = float(emp_price) + item["subtotal"] = float(subtotal) + week = current_week() week_dir = ORDERS_DIR / week week_dir.mkdir(parents=True, exist_ok=True) - safe_name = ( - "".join(c if c.isalnum() or c in "-_ " else "" for c in name) - .strip() - .replace(" ", "-") - .lower() + # Match Lambda: one order file per employee email (not display name). + slug = email.strip().lower() + slug_safe = slug.replace("/", "_").replace("\\", "_") + order_file = week_dir / f"{slug_safe}.json" + + total = float( + sum(Decimal(str(i["subtotal"])) for i in filtered).quantize( + TWO_PLACES, rounding=ROUND_HALF_UP + ) ) - order_file = week_dir / f"{safe_name}.json" order = { "employee_name": name, "employee_email": email, "week": week, "submitted_at": datetime.now().isoformat(), - "items": [i for i in items if i.get("quantity", 0) > 0], - "total": sum( - (i.get("price", 0) or 0) * i.get("quantity", 0) - for i in items - if i.get("quantity", 0) > 0 - ), + "items": filtered, + "total": total, } with open(order_file, "w") as f: @@ -108,6 +225,11 @@ def submit_order(): ) +@app.route("/api/form-status/") +def form_status(week: str): + return jsonify({"week": week, "status": "open"}) + + @app.route("/api/orders/") def get_orders(week: str): week_dir = ORDERS_DIR / week diff --git a/src/server/generate_form.py b/src/server/generate_form.py index e601e08..c03fb64 100644 --- a/src/server/generate_form.py +++ b/src/server/generate_form.py @@ -35,17 +35,201 @@ def latest_menu() -> dict: def generate_form( - menu: dict, config: dict, api_url: str = "", api_key: str = "" + menu: dict, + config: dict, + api_url: str = "", + api_key: str = "", + bulk_discount: float = 0, + company_subsidy: float = 0, + google_client_id: str = "", ) -> str: - meals_json = json.dumps(menu["meals"]) - roster_json = json.dumps(config.get("roster", [])) + meals_json = json.dumps(menu["meals"]).replace(" 0 or company_subsidy > 0 + use_google_auth = bool(google_client_id) + google_client_id_json = json.dumps(google_client_id).replace(" +
+
+ +
+
+
+
+ +
+
+ """ + else: + auth_section_html = """
+ + + +
""" + + if use_google_auth: + google_auth_js = """ +function waitForGoogleAuth() { + if (typeof google !== 'undefined' && google.accounts && google.accounts.id) { + initGoogleAuth(); + } else { + setTimeout(waitForGoogleAuth, 50); + } +} + +function initGoogleAuth() { + google.accounts.id.initialize({ + client_id: GOOGLE_CLIENT_ID, + callback: handleCredentialResponse, + hosted_domain: 'seahavenind.com', + auto_select: true, + }); + google.accounts.id.renderButton( + document.getElementById('g-signin-btn'), + { theme: 'outline', size: 'large', text: 'signin_with', width: 300 } + ); +} + +function handleCredentialResponse(response) { + googleCredential = response.credential; + const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + const payloadBytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)); + const payload = JSON.parse(new TextDecoder().decode(payloadBytes)); + googleUser = { name: payload.name, email: payload.email }; + + document.getElementById('auth-overlay').style.display = 'none'; + document.getElementById('app').style.display = 'block'; + var footer = document.getElementById('sticky-footer'); + if (footer) footer.style.display = 'block'; + document.getElementById('user-name').textContent = payload.name; + document.getElementById('user-email').textContent = payload.email; + if (payload.picture) { + const avatar = document.getElementById('user-avatar'); + avatar.src = payload.picture; + avatar.style.display = 'block'; + } + + updateTotal(); + checkDuplicateOrder(); +} + +function signOut() { + googleCredential = null; + googleUser = null; + google.accounts.id.disableAutoSelect(); + document.getElementById('auth-overlay').style.display = 'flex'; + document.getElementById('app').style.display = 'none'; + var footer = document.getElementById('sticky-footer'); + if (footer) footer.style.display = 'none'; +} +""" + else: + google_auth_js = "" + + if use_google_auth: + submit_order_js = """ +async function submitOrder() { + if (formClosed) { alert('Orders are closed.'); return; } + if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; } + + const items = Object.entries(quantities).map(([i, qty]) => ({ + name: MEALS[i].name, + retail_price: MEALS[i].price, + quantity: qty, + })); + + const btn = document.getElementById('submit-btn'); + btn.disabled = true; + btn.textContent = 'Submitting...'; + + try { + const headers = { 'Content-Type': 'application/json' }; + if (API_KEY) headers['x-api-key'] = API_KEY; + const res = await fetch(SUBMIT_URL, { + method: 'POST', + headers, + body: JSON.stringify({ google_id_token: googleCredential, items }), + }); + const data = await res.json(); + if (res.ok) { + for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; } + document.getElementById('success').style.display = 'block'; + document.getElementById('success-detail').textContent = `${googleUser.name} — $${(data.total || 0).toFixed(2)} total. You're all set!`; + document.querySelector('.sticky-footer').style.display = 'none'; + try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {} + } else { + alert(data.error || 'Something went wrong.'); + btn.disabled = false; + btn.textContent = 'Submit Order'; + } + } catch (e) { + alert('Failed to submit. Check your connection and try again.'); + btn.disabled = false; + btn.textContent = 'Submit Order'; + } +} +""" + else: + submit_order_js = """ +async function submitOrder() { + if (formClosed) { alert('Orders are closed.'); return; } + const name = document.getElementById('emp-name').value.trim(); + const email = document.getElementById('emp-email').value.trim(); + if (!name) { alert('Please enter your name.'); return; } + if (!email) { alert('Please enter your email.'); return; } + + const items = Object.entries(quantities).map(([i, qty]) => ({ + name: MEALS[i].name, + retail_price: MEALS[i].price, + quantity: qty, + })); + + const btn = document.getElementById('submit-btn'); + btn.disabled = true; + btn.textContent = 'Submitting...'; + + try { + const headers = { 'Content-Type': 'application/json' }; + if (API_KEY) headers['x-api-key'] = API_KEY; + const res = await fetch(SUBMIT_URL, { + method: 'POST', + headers, + body: JSON.stringify({ employee_name: name, employee_email: email, items }), + }); + const data = await res.json(); + if (res.ok) { + for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; } + document.getElementById('success').style.display = 'block'; + document.getElementById('success-detail').textContent = `${name} — $${(data.total || 0).toFixed(2)} total. You're all set!`; + document.querySelector('.sticky-footer').style.display = 'none'; + try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {} + } else { + alert(data.error || 'Something went wrong.'); + btn.disabled = false; + btn.textContent = 'Submit Order'; + } + } catch (e) { + alert('Failed to submit. Check your connection and try again.'); + btn.disabled = false; + btn.textContent = 'Submit Order'; + } +} +""" return f""" @@ -53,6 +237,7 @@ def generate_form( Sea Haven — Meal Order ({week}) + +{ + '' + if use_google_auth + else "" + } - -
+
+
+ +

Orders Are Closed

+

Orders open again Monday at 8:00 AM ET

+
+
until orders open
+
+
+{ + '

Sea Haven Meal Order

Sign in with your company Google account to place your order.

' + if use_google_auth + else "" + } +

Sea Haven Meal Order

Week of {week} · Menu scraped {scraped_at[:10]}

Order deadline: {deadline}
+ + { + '
Prices reflect employee cost after ' + + (f"{bulk_discount:g}% bulk discount" if bulk_discount > 0 else "") + + (" + " if bulk_discount > 0 and company_subsidy > 0 else "") + + (f"{company_subsidy:g}% company subsidy" if company_subsidy > 0 else "") + + "
" + if has_discount + else "" + } -
- - - -
+{auth_section_html} @@ -117,7 +353,9 @@ body {{ padding-bottom: 80px; }}
-