diff --git a/functions/slack_notifier/handler.py b/functions/slack_notifier/handler.py index e8eb393..2b3dbd5 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,6 +28,10 @@ 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) @@ -69,7 +80,7 @@ def handle_orders_aggregated(event): 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 @@ -126,7 +137,7 @@ def handle_order_confirmed(event): for item in items: qty = int(item.get("quantity", 0)) price = float(item.get("price", 0)) - item_lines.append(f"{item['name']} x{qty} — ${price * qty:.2f}") + item_lines.append(f"{_escape_mrkdwn(item['name'])} x{qty} — your cost: ${price * qty:.2f}") item_list = "\n".join(item_lines) send_dm( @@ -142,9 +153,9 @@ def handle_order_confirmed(event): "text": { "type": "mrkdwn", "text": ( - f"Hey {name.split()[0]}! Your meal order has been submitted.\n\n" + f"Hey {name.split()[0] if name.strip() else 'there'}! Your meal order has been submitted.\n\n" f"{item_list}\n\n" - f"*Total: ${total:.2f}*" + f"*Your total: ${total:.2f}* (payroll deduction)" ), }, }, @@ -155,6 +166,11 @@ def handle_order_confirmed(event): 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", "") @@ -181,7 +197,7 @@ def handle_reminder(event): "text": { "type": "mrkdwn", "text": ( - f"Hey {emp['name'].split()[0]}! Meal orders close today at *11:59pm*.\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 b9d4a85..641b77b 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -1,7 +1,12 @@ import json +import logging import os +import sys +import time +import urllib.error import urllib.request -from datetime import datetime +from datetime import datetime, timedelta +from decimal import Decimal, ROUND_HALF_UP from zoneinfo import ZoneInfo import boto3 @@ -9,10 +14,19 @@ 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") @@ -23,32 +37,48 @@ def _get_api_key() -> str: return _api_key -def _get_discount_settings() -> tuple[float, float]: - global _settings - if _settings is None: +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 = ( - float(s.get("bulk_discount_percent", 0)), - float(s.get("company_subsidy_percent", 0)), + 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 - if _google_client_id is None: + 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) -> dict | None: +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 + return None, "invalid" try: req = urllib.request.Request( f"https://oauth2.googleapis.com/tokeninfo?id_token={token}" @@ -56,12 +86,18 @@ def _verify_google_token(token: str) -> dict | None: with urllib.request.urlopen(req, timeout=5) as resp: data = json.loads(resp.read()) if data.get("aud") != client_id: - return None + logger.warning("Google token audience mismatch: got %s", data.get("aud")) + return None, "invalid" if data.get("hd") != "seahavenind.com": - return None - return {"name": data.get("name", ""), "email": data.get("email", "")} - except Exception: - return None + 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): @@ -83,7 +119,19 @@ 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": + # 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(): @@ -102,14 +150,33 @@ def handle_submit(event): 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_token: - user_info = _verify_google_token(google_token) - if not user_info: + + 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() @@ -131,51 +198,63 @@ def handle_submit(event): 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_mult = 1 - (bulk_pct / 100) - subsidy_mult = 1 - (subsidy_pct / 100) + 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 = item.get("retail_price", item.get("price", 0)) or 0 - qty = item.get("quantity", 0) - bulk_price = round(retail * bulk_mult, 2) - emp_price = round(bulk_price * subsidy_mult, 2) - item["retail_price"] = retail - item["bulk_price"] = bulk_price - item["price"] = emp_price - item["subtotal"] = round(emp_price * qty, 2) + 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 = sum(i["subtotal"] for i in filtered_items) + total = float(sum(Decimal(str(i["subtotal"])) for i in filtered_items).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)) - slug = ( - "".join(c if c.isalnum() or c in "- " else "" for c in name) - .strip() - .replace(" ", "-") - .lower() - ) + slug = email.split("@")[0].lower().replace(".", "-") order_data = { "employee_name": name, "employee_email": email, "submitted_at": datetime.now(EASTERN).isoformat(), "items": filtered_items, - "total": round(total, 2), + "total": total, } put_order(week, slug, order_data) - _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), - ) + # 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,