diff --git a/functions/slack_notifier/handler.py b/functions/slack_notifier/handler.py index 86966db..e8eb393 100644 --- a/functions/slack_notifier/handler.py +++ b/functions/slack_notifier/handler.py @@ -22,6 +22,8 @@ def lambda_handler(event, context): return handle_orders_aggregated(event) elif event_type == "reminder": return handle_reminder(event) + elif event_type == "order_confirmed": + return handle_order_confirmed(event) return {"error": f"Unknown event type: {event_type}"} @@ -31,7 +33,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 +45,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 +65,20 @@ 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_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 +92,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,6 +110,50 @@ 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"{item['name']} x{qty} — ${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]}! Your meal order has been submitted.\n\n" + f"{item_list}\n\n" + f"*Total: ${total:.2f}*" + ), + }, + }, + ], + ) + + return {"status": "confirmed", "week": week, "employee": name} + + def handle_reminder(event): week = event.get("week", current_week()) form_url = os.environ.get("FORM_URL", "") @@ -120,14 +174,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]}! 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..b9d4a85 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -1,13 +1,19 @@ import json import os +import urllib.request from datetime import datetime 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_roster, get_settings, put_order +from shared.secrets import get_parameter, get_secret EASTERN = ZoneInfo("America/New_York") _api_key = None +_settings = None +_google_client_id = None +_lambda = boto3.client("lambda") def _get_api_key() -> str: @@ -17,6 +23,47 @@ def _get_api_key() -> str: return _api_key +def _get_discount_settings() -> tuple[float, float]: + global _settings + if _settings is None: + s = get_settings() + _settings = ( + float(s.get("bulk_discount_percent", 0)), + float(s.get("company_subsidy_percent", 0)), + ) + return _settings + + +def _get_google_client_id() -> str: + global _google_client_id + if _google_client_id is None: + param = os.environ.get("GOOGLE_CLIENT_ID_PARAM", "") + if param: + _google_client_id = get_parameter(param, decrypt=False) or "" + else: + _google_client_id = "" + return _google_client_id + + +def _verify_google_token(token: str) -> dict | None: + client_id = _get_google_client_id() + 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 + + def lambda_handler(event, context): method = event.get("requestContext", {}).get("http", {}).get("method", "GET") path = event.get("rawPath", "") @@ -55,8 +102,17 @@ 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() + google_token = body.get("google_id_token") + if google_token: + user_info = _verify_google_token(google_token) + if not user_info: + 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,7 +130,22 @@ 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) + + bulk_pct, subsidy_pct = _get_discount_settings() + bulk_mult = 1 - (bulk_pct / 100) + subsidy_mult = 1 - (subsidy_pct / 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) + + total = sum(i["subtotal"] for i in filtered_items) slug = ( "".join(c if c.isalnum() or c in "- " else "" for c in name) @@ -93,6 +164,19 @@ def handle_submit(event): 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), + ) + return response( 200, { diff --git a/src/server/app.py b/src/server/app.py index 296358c..0d1aca6 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -6,9 +6,11 @@ Orders are saved as JSON files in the orders directory, one per employee per wee """ import json +import urllib.request from datetime import datetime from pathlib import Path +import boto3 from flask import Flask, jsonify, request, send_file PROJECT_ROOT = Path(__file__).resolve().parents[2] @@ -58,14 +60,58 @@ def get_roster(): return jsonify(config.get("roster", [])) +_google_client_id_cache = None + + +def _get_google_client_id() -> str: + global _google_client_id_cache + if _google_client_id_cache is None: + config = load_config() + _google_client_id_cache = config.get("google_client_id", "") + if not _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"]["Value"] + except Exception: + _google_client_id_cache = "" + 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 + 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() + google_token = data.get("google_id_token") + if google_token: + client_id = _get_google_client_id() + 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,6 +121,23 @@ 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() + bulk_pct = config.get("bulk_discount_percent", 0) + subsidy_pct = config.get("company_subsidy_percent", 0) + bulk_mult = 1 - (bulk_pct / 100) + subsidy_mult = 1 - (subsidy_pct / 100) + + filtered = [i for i in items if i.get("quantity", 0) > 0] + for item in filtered: + 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) + week = current_week() week_dir = ORDERS_DIR / week week_dir.mkdir(parents=True, exist_ok=True) @@ -87,17 +150,15 @@ def submit_order(): ) order_file = week_dir / f"{safe_name}.json" + total = round(sum(i["subtotal"] for i in filtered), 2) + 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 +169,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