mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-04 03:03:16 +00:00
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.
This commit is contained in:
parent
e6bef44a74
commit
81543c3b7f
3 changed files with 222 additions and 18 deletions
|
|
@ -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>*"
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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/<week>")
|
||||
def form_status(week: str):
|
||||
return jsonify({"week": week, "status": "open"})
|
||||
|
||||
|
||||
@app.route("/api/orders/<week>")
|
||||
def get_orders(week: str):
|
||||
week_dir = ORDERS_DIR / week
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue