meal-order-manager/functions/submit_order/handler.py
Adam Moussa bcce15b9e6 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.
2026-05-13 12:58:56 -04:00

274 lines
10 KiB
Python

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:
_google_client_id = get_parameter(param, decrypt=False) or ""
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.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"]
elif google_token:
# Google auth not configured but token provided — verify it anyway
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:
# Manual fallback — only when Google auth is not configured
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.split("@")[0].lower().replace(".", "-")
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),
}