From e6bef44a74a71103f7af24f62fc3eb7b70df6993 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 13 May 2026 11:35:48 -0400 Subject: [PATCH 01/17] 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). --- config.json | 5 ++++- functions/aggregate_orders/handler.py | 18 ++++++++++++++---- src/aggregator/aggregate.py | 23 +++++++++++++++++++---- src/shared/shared/db.py | 16 ++++++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) 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/src/aggregator/aggregate.py b/src/aggregator/aggregate.py index 490ef32..292c066 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,10 @@ 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/shared/shared/db.py b/src/shared/shared/db.py index 363ffb7..9557004 100644 --- a/src/shared/shared/db.py +++ b/src/shared/shared/db.py @@ -128,3 +128,19 @@ def put_roster(employees: list[dict]): "updated_at": datetime.now(EASTERN).isoformat(), } ) + + +def get_settings() -> dict: + resp = _get_table().get_item(Key={"PK": "CONFIG", "SK": "SETTINGS"}) + return resp.get("Item", {}) + + +def put_settings(settings: dict): + _get_table().put_item( + Item={ + "PK": "CONFIG", + "SK": "SETTINGS", + **_to_decimal(settings), + "updated_at": datetime.now(EASTERN).isoformat(), + } + ) From 81543c3b7f85d4f931f4e66987199987ebc803d4 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 13 May 2026 11:35:56 -0400 Subject: [PATCH 02/17] 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. --- functions/slack_notifier/handler.py | 64 ++++++++++++++++++-- functions/submit_order/handler.py | 94 +++++++++++++++++++++++++++-- src/server/app.py | 82 ++++++++++++++++++++++--- 3 files changed, 222 insertions(+), 18 deletions(-) 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 From ef4726aa3f25d49a3a79f3500f6459109088c310 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 13 May 2026 11:36:05 -0400 Subject: [PATCH 03/17] 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. --- template.yaml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/template.yaml b/template.yaml index b82969f..8e2d1b8 100644 --- a/template.yaml +++ b/template.yaml @@ -230,6 +230,8 @@ Resources: Environment: Variables: FORM_API_KEY_SECRET: meal-order-manager/form-api-key + SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn + GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id Policies: - DynamoDBCrudPolicy: TableName: !Ref OrdersTable @@ -237,6 +239,9 @@ Resources: - Effect: Allow Action: secretsmanager:GetSecretValue Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*' + - Effect: Allow + Action: lambda:InvokeFunction + Resource: !GetAtt SlackNotifierFunction.Arn Events: SubmitOrder: Type: HttpApi @@ -279,14 +284,14 @@ Resources: CloseEST: Type: Schedule Properties: - Schedule: cron(0 23 ? * THU *) - Description: 'Close form Thursday 6pm EST (23:00 UTC)' + Schedule: cron(59 4 ? * FRI *) + Description: 'Close form Thursday 11:59pm EST (04:59 UTC Friday)' Enabled: true CloseEDT: Type: Schedule Properties: - Schedule: cron(0 22 ? * THU *) - Description: 'Close form Thursday 6pm EDT (22:00 UTC)' + Schedule: cron(59 3 ? * FRI *) + Description: 'Close form Thursday 11:59pm EDT (03:59 UTC Friday)' Enabled: true AggregateOrdersFunction: From f437ca8f0f57432d13912f2167a46d27448c733c Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 13 May 2026 11:36:14 -0400 Subject: [PATCH 04/17] 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. --- .github/workflows/weekly-menu.yml | 37 ++- src/server/generate_form.py | 377 ++++++++++++++++++++++++------ 2 files changed, 347 insertions(+), 67 deletions(-) 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/src/server/generate_form.py b/src/server/generate_form.py index e601e08..4d28a65 100644 --- a/src/server/generate_form.py +++ b/src/server/generate_form.py @@ -15,6 +15,8 @@ import sys from datetime import datetime from pathlib import Path +import boto3 + PROJECT_ROOT = Path(__file__).resolve().parents[2] OUTPUT_DIR = PROJECT_ROOT / "output" CONFIG_PATH = PROJECT_ROOT / "config.json" @@ -35,7 +37,13 @@ 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", [])) @@ -43,9 +51,174 @@ def generate_form( week = datetime.now().strftime("%Y-W%U") scraped_at = menu.get("scraped_at", "unknown") submit_url = f"{api_url}/api/submit-order" if api_url else "/api/submit-order" - status_url = f"{api_url}/api/form-status/{week}" if api_url else "" + status_url = f"{api_url}/api/form-status/{week}" if api_url else f"/api/form-status/{week}" roster_url = f"{api_url}/api/roster" if api_url else "/api/roster" api_key_json = json.dumps(api_key) + has_discount = bulk_discount > 0 or company_subsidy > 0 + use_google_auth = bool(google_client_id) + google_client_id_json = json.dumps(google_client_id) + + if use_google_auth: + auth_section_html = """
+
+
+ +
+
+
+
+ +
+
+
""" + 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 payload = JSON.parse(atob(response.credential.split('.')[1])); + 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(); +} + +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 (!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.toFixed(2)} total. You're all set!`; + document.querySelector('.sticky-footer').style.display = 'none'; + } 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() { + 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.toFixed(2)} total. You're all set!`; + document.querySelector('.sticky-footer').style.display = 'none'; + } 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""" @@ -93,23 +266,54 @@ header p {{ font-size: 0.85rem; opacity: 0.8; }} .search-bar {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 16px; }} body {{ padding-bottom: 80px; }} .closed-banner {{ background: #fee2e2; color: #991b1b; padding: 16px; border-radius: 8px; margin-bottom: 20px; text-align: center; font-weight: 600; font-size: 1rem; }} +@media (min-width: 768px) {{ + .container {{ max-width: 1100px; }} + #meals-list {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }} + #meals-list .meal-card {{ margin-bottom: 0; }} +}} +@media (min-width: 1200px) {{ + .container {{ max-width: 1400px; }} + #meals-list {{ grid-template-columns: repeat(3, 1fr); }} +}} +.discount-banner {{ background: #dcfce7; color: #15803d; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.85rem; text-align: center; font-weight: 500; }} +.price-retail {{ text-decoration: line-through; color: #9ca3af; font-size: 0.75rem; margin-right: 4px; }} +.price-employee {{ color: #15803d; font-weight: 600; }} +.auth-overlay {{ position: fixed; inset: 0; background: #1a1a2e; display: flex; align-items: center; justify-content: center; z-index: 1000; }} +.auth-card {{ background: #fff; border-radius: 16px; padding: 48px 40px; text-align: center; max-width: 400px; width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }} +.auth-card h1 {{ font-size: 1.5rem; margin-bottom: 4px; color: #1d1d1f; }} +.auth-card p {{ font-size: 0.9rem; color: #6b7280; margin-bottom: 32px; }} +.auth-card .logo {{ font-size: 2rem; margin-bottom: 16px; }} +.closed-overlay {{ position: fixed; inset: 0; background: #1a1a2e; display: none; align-items: center; justify-content: center; z-index: 2000; }} +.closed-card {{ background: #fff; border-radius: 16px; padding: 48px 40px; text-align: center; max-width: 420px; width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }} +.closed-card h1 {{ font-size: 1.5rem; margin-bottom: 8px; color: #1d1d1f; }} +.closed-card p {{ font-size: 0.9rem; color: #6b7280; margin-bottom: 24px; }} +.closed-card .logo {{ font-size: 2.5rem; margin-bottom: 16px; }} +.countdown {{ font-size: 2rem; font-weight: 700; color: #1a1a2e; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }} +.countdown-label {{ font-size: 0.75rem; color: #9ca3af; margin-top: 4px; }} +{'' 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"{int(bulk_discount)}% bulk discount" if bulk_discount > 0 else "") + (" + " if bulk_discount > 0 and company_subsidy > 0 else "") + (f"{int(company_subsidy)}% company subsidy" if company_subsidy > 0 else "") + "
" if has_discount else ""} -
- - - -
+{auth_section_html} @@ -117,7 +321,7 @@ body {{ padding-bottom: 80px; }}
-