import json import logging import os import sys import time import urllib.error import urllib.request from datetime import datetime, timedelta from decimal import Decimal, ROUND_HALF_UP from zoneinfo import ZoneInfo import boto3 from shared.db import current_week, get_form_status, 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 _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: global _api_key if _api_key is None: _api_key = get_secret(os.environ["FORM_API_KEY_SECRET"]) 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 _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: logger.warning( "Could not fetch Google client ID from SSM (%s): %s", param, exc ) _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", "") if "/form-status/" in path: return handle_form_status(event) if "/roster" in path: return handle_roster() if method == "POST": return handle_submit(event) return response(405, {"error": "Method not allowed"}) def handle_form_status(event): week = event.get("pathParameters", {}).get("week", current_week()) status = get_form_status(week) result = {"week": week, "status": status} if status == "closed": # Compute next Monday 8:00 AM Eastern, accounting for DST now_et = datetime.now(EASTERN) days_until_monday = (7 - now_et.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 next_monday = (now_et + timedelta(days=days_until_monday)).replace( hour=8, minute=0, second=0, microsecond=0 ) result["reopen_at"] = int(next_monday.timestamp()) return response(200, result) def handle_roster(): roster = get_roster() employees = [{"name": e["name"], "email": e["email"]} for e in roster] return response(200, {"employees": employees}) def handle_submit(event): api_key = event.get("headers", {}).get("x-api-key", "") if api_key != _get_api_key(): return response(403, {"error": "Invalid API key"}) try: body = json.loads(event.get("body", "{}")) except json.JSONDecodeError: return response(400, {"error": "Invalid JSON"}) # --- Authentication --- # If Google auth is configured, require a valid google_id_token. # Manual name/email fallback is only allowed when Google auth is NOT configured. google_auth_enabled = bool(_get_google_client_id()) google_token = body.get("google_id_token") if google_auth_enabled: 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: return response(400, {"error": "Employee name is required"}) if not email: return response(400, {"error": "Employee email is required"}) if not items or not any(i.get("quantity", 0) > 0 for i in items): return response(400, {"error": "Please select at least one meal"}) week = current_week() status = get_form_status(week) if status == "closed": return response(410, {"error": "Orders are closed for this week"}) if status == "not_found": return response(404, {"error": "No menu available for this week"}) filtered_items = [i for i in items if i.get("quantity", 0) > 0] # --- 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: retail = Decimal(str(item.get("retail_price", item.get("price", 0)) or 0)) 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(), "items": filtered_items, "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, { "status": "ok", "message": f"Order saved for {name}", "total": order_data["total"], }, ) def response(status_code: int, body: dict) -> dict: return { "statusCode": status_code, "headers": {"Content-Type": "application/json"}, "body": json.dumps(body), }