This commit is contained in:
Adam Moussa 2026-05-13 14:38:00 -04:00 • committed by GitHub
commit f0d52dba71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 3139 additions and 132 deletions

View file

@ -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

View file

@ -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"}
]

View file

@ -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,
}

View file

@ -1,14 +1,24 @@
import json
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import boto3
from shared.db import current_week, get_form_status, set_form_status
_lambda = boto3.client("lambda")
EASTERN = ZoneInfo("America/New_York")
def lambda_handler(event, context):
now_et = datetime.now(EASTERN)
if not (now_et.weekday() == 3 and now_et.hour >= 23):
return {
"status": "skipped",
"reason": "outside close window (must be Thursday >= 11 PM ET)",
}
week = event.get("week", current_week())
status = get_form_status(week)

View file

@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class DecimalEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Decimal):
@ -21,7 +28,13 @@ 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)
return {"error": f"Unknown event type: {event_type}"}
@ -31,7 +44,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 +56,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 +76,24 @@ 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_lines.append(f"{_escape_mrkdwn(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 +107,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,7 +125,58 @@ 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"{_escape_mrkdwn(item['name'])} x{qty} — your cost: ${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] if name.strip() else 'there'}! Your meal order has been submitted.\n\n"
f"{item_list}\n\n"
f"*Your total: ${total:.2f}* (payroll deduction)"
),
},
},
],
)
return {"status": "confirmed", "week": week, "employee": name}
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", "")
@ -120,14 +196,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] if emp['name'].strip() else 'there'}! Meal orders close today at *11:59pm*.\n\n"
f"*<{form_url}|Place your order>*"
),
},

View file

@ -1,13 +1,40 @@
import json
import logging
import os
from datetime import datetime
import sys
import time
import urllib.error
import urllib.request
import datetime as _dt
from datetime import timedelta
from decimal import Decimal, ROUND_HALF_UP
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
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
def _eastern_now() -> _dt.datetime:
"""Wall-clock 'now' in Eastern time (patch target for form-status tests)."""
return _dt.datetime.now(EASTERN)
_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:
@ -17,6 +44,76 @@ def _get_api_key() -> str:
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 _google_auth_configured() -> bool:
return bool(os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""))
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.HTTPError as exc:
logger.warning("Google token rejected (HTTP %s): %s", exc.code, exc)
return None, "invalid"
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", "")
@ -36,7 +133,21 @@ 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":
# Next Monday 8:00 AM Eastern: use calendar date math + combine() so reopen_at
# stays on the correct civil Monday across DST (timedelta(days=n) is always 24n hours).
now_et = _eastern_now()
today = now_et.date()
weekday = today.weekday() # Monday=0 ... Sunday=6
days_until_monday = (7 - 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
reopen_date = today + timedelta(days=days_until_monday)
next_monday = _dt.datetime.combine(reopen_date, _dt.time(8, 0), tzinfo=EASTERN)
result["reopen_at"] = int(next_monday.timestamp())
return response(200, result)
def handle_roster():
@ -55,8 +166,40 @@ 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()
# --- Authentication ---
# If Google auth is configured (SSM param name is set), require a valid
# google_id_token. Manual fallback is only allowed when auth is NOT configured.
# If SSM fetch fails, fail closed (503) rather than silently disabling auth.
google_token = body.get("google_id_token")
if _google_auth_configured():
try:
client_id = _get_google_client_id()
except Exception as exc:
logger.error("SSM fetch failed for Google client ID: %s", exc)
return response(
503, {"error": "Authentication service temporarily unavailable"}
)
if not client_id:
logger.error("Google auth configured but client ID is empty")
return response(
503, {"error": "Authentication service temporarily unavailable"}
)
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"]
else:
name = body.get("employee_name", "").strip()
email = body.get("employee_email", "").strip()
items = body.get("items", [])
if not name:
@ -74,25 +217,74 @@ 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)
slug = (
"".join(c if c.isalnum() or c in "- " else "" for c in name)
.strip()
.replace(" ", "-")
.lower()
# --- 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.lower()
order_data = {
"employee_name": name,
"employee_email": email,
"submitted_at": datetime.now(EASTERN).isoformat(),
"submitted_at": _eastern_now().isoformat(),
"items": filtered_items,
"total": round(total, 2),
"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,
{

View file

@ -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,13 @@ 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:")

View file

@ -6,9 +6,12 @@ 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 decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
import boto3
from flask import Flask, jsonify, request, send_file
PROJECT_ROOT = Path(__file__).resolve().parents[2]
@ -58,14 +61,63 @@ 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
if data.get("hd") != "seahavenind.com":
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()
client_id = _get_google_client_id()
google_token = data.get("google_id_token")
if client_id:
if not google_token:
return jsonify({"error": "Google authentication is required"}), 403
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,29 +127,51 @@ 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()
TWO_PLACES = Decimal("0.01")
bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0)))
subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0)))
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"))
filtered = [i for i in items if i.get("quantity", 0) > 0]
for item in filtered:
retail = Decimal(str(item.get("retail_price", item.get("price", 0)) or 0))
qty = Decimal(str(item.get("quantity", 0)))
bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
emp_price = (bulk_price * subsidy_mult).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
item["retail_price"] = float(retail)
item["bulk_price"] = float(bulk_price)
item["price"] = float(emp_price)
item["subtotal"] = float(subtotal)
week = current_week()
week_dir = ORDERS_DIR / week
week_dir.mkdir(parents=True, exist_ok=True)
safe_name = (
"".join(c if c.isalnum() or c in "-_ " else "" for c in name)
.strip()
.replace(" ", "-")
.lower()
# Match Lambda: one order file per employee email (not display name).
slug = email.strip().lower()
slug_safe = slug.replace("/", "_").replace("\\", "_")
order_file = week_dir / f"{slug_safe}.json"
total = float(
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
)
order_file = week_dir / f"{safe_name}.json"
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 +182,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

View file

@ -35,17 +35,196 @@ 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", []))
meals_json = json.dumps(menu["meals"]).replace("</", "<\\/")
roster_json = json.dumps(config.get("roster", [])).replace("</", "<\\/")
deadline = config.get("order_deadline", "Thursday 11:59 PM")
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)
api_key_json = json.dumps(api_key).replace("</", "<\\/")
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).replace("</", "<\\/")
if use_google_auth:
auth_section_html = """ <div class="employee-info">
<div id="user-info">
<div style="display:flex;align-items:center;gap:12px;">
<img id="user-avatar" style="width:40px;height:40px;border-radius:50%;object-fit:cover;display:none;" alt="">
<div style="flex:1;">
<div id="user-name" style="font-weight:600;font-size:0.95rem;"></div>
<div id="user-email" style="font-size:0.85rem;color:#6b7280;"></div>
</div>
<button onclick="signOut()" style="background:none;border:1px solid #d1d5db;border-radius:8px;padding:6px 14px;cursor:pointer;font-size:0.8rem;color:#6b7280;">Sign out</button>
</div>
</div>
</div>"""
else:
auth_section_html = """ <div class="employee-info">
<label for="emp-name">Your Name</label>
<select id="emp-name"><option value="">Loading...</option></select>
<input type="hidden" id="emp-email">
</div>"""
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 b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(b64));
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();
checkDuplicateOrder();
}
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 (formClosed) { alert('Orders are closed.'); return; }
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 || 0).toFixed(2)} total. You're all set!`;
document.querySelector('.sticky-footer').style.display = 'none';
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
} 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() {
if (formClosed) { alert('Orders are closed.'); return; }
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 || 0).toFixed(2)} total. You're all set!`;
document.querySelector('.sticky-footer').style.display = 'none';
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
} 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"""<!DOCTYPE html>
<html lang="en">
@ -53,6 +232,7 @@ def generate_form(
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sea Haven — Meal Order ({week})</title>
<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABX0lEQVRYhe3TPWsUURjF8d+dnSxhoxA1xEJnQQsJGbBTrIIIvoEDNqkNiJ9BrPwI4jeY2kmVoKWkSVALK6MgwWLsJIWIBBFZLGaLTTLrJrX3X96X5znnPPcSiUQikf+dcHAhr+qLuIrT2MV77Owv9ycWy6u6i3tnOu5maTg/FUzjOz5gHe/KImsXkFf1ZTzH9ZbaNdbwElvby/0fwzsJFnANN3uJO/00zJ5MxmrcwqOyyD7uE5BX9Q08xh5O4BL+ZXkXvzQp9RKcS4OzaUukh9nDg7LIVkcFGI04r2pDZw+xgrlx1U516KdB93DnGhvYxBd8w08M0MXnssgmCx7O9RZuYzEw30sszATpXCeY2R/3AC/wTMu420gnHbgyHeY1sb3FH9wfc28DT8oiezOx63EE4DeeYqllb4BXGsevj+L4IEd4M6ysf6X5HUu4oEniE9bKIts5dtdIJBKJREb4C8ngTD+C9DtvAAAAAElFTkSuQmCC">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; color: #1d1d1f; }}
@ -89,27 +269,78 @@ header p {{ font-size: 0.85rem; opacity: 0.8; }}
.submit-btn:hover:not(:disabled) {{ opacity: 0.85; }}
.success-msg {{ text-align: center; padding: 60px 20px; }}
.success-msg h2 {{ color: #15803d; margin-bottom: 8px; }}
.back-btn {{ background: transparent; color: #1a1a2e; border: 2px solid #1a1a2e; padding: 12px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 20px; }}
.back-btn:hover {{ background: #1a1a2e; color: #fff; }}
.duplicate-warning {{ background: #fef3c7; color: #92400e; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; text-align: center; font-weight: 500; border: 1px solid #fcd34d; }}
.meal-desc {{ font-size: 0.8rem; color: #6b7280; margin-bottom: 4px; }}
.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; }}
</style>
{
'<script src="https://accounts.google.com/gsi/client" async defer></script>'
if use_google_auth
else ""
}
</head>
<body>
<div class="container" id="app">
<div class="closed-overlay" id="closed-overlay">
<div class="closed-card">
<div class="logo">&#127869;</div>
<h1>Orders Are Closed</h1>
<p>Orders open again Monday at 8:00 AM ET</p>
<div class="countdown" id="countdown"></div>
<div class="countdown-label">until orders open</div>
</div>
</div>
{
'<div class="auth-overlay" id="auth-overlay"><div class="auth-card"><div class="logo">&#127869;</div><h1>Sea Haven Meal Order</h1><p>Sign in with your company Google account to place your order.</p><div id="g-signin-btn" style="display:flex;justify-content:center;"></div></div></div>'
if use_google_auth
else ""
}
<div class="container" id="app" {'style="display:none;"' if use_google_auth else ""}>
<header>
<h1>Sea Haven Meal Order</h1>
<p>Week of {week} &middot; Menu scraped {scraped_at[:10]}</p>
</header>
<div class="deadline">Order deadline: {deadline}</div>
<div class="duplicate-warning" id="duplicate-warning" style="display:none;">You've already submitted an order this week. Submitting again will replace your previous order.</div>
{
'<div class="discount-banner">Prices reflect employee cost after '
+ (f"{bulk_discount:g}% bulk discount" if bulk_discount > 0 else "")
+ (" + " if bulk_discount > 0 and company_subsidy > 0 else "")
+ (f"{company_subsidy:g}% company subsidy" if company_subsidy > 0 else "")
+ "</div>"
if has_discount
else ""
}
<div class="employee-info">
<label for="emp-name">Your Name</label>
<select id="emp-name"><option value="">Loading...</option></select>
<input type="hidden" id="emp-email">
</div>
{auth_section_html}
<input type="text" class="search-bar" id="search" placeholder="Search meals...">
@ -117,7 +348,9 @@ body {{ padding-bottom: 80px; }}
<div id="meals-list"></div>
<div class="sticky-footer">
<div class="sticky-footer" {
'style="display:none;" id="sticky-footer"' if use_google_auth else ""
}>
<div class="inner">
<div>
<span class="total" id="total-display">$0.00</span>
@ -130,6 +363,7 @@ body {{ padding-bottom: 80px; }}
<div id="success" class="success-msg" style="display:none;">
<h2>Order submitted!</h2>
<p id="success-detail"></p>
<button class="back-btn" onclick="location.reload()">Back to Menu</button>
</div>
</div>
@ -141,12 +375,50 @@ const STATUS_URL = '{status_url}';
const ROSTER_URL = '{roster_url}';
const API_KEY = {api_key_json};
const WEEK = '{week}';
const BULK_DISCOUNT = {bulk_discount};
const COMPANY_SUBSIDY = {company_subsidy};
const quantities = {{}};
let formClosed = false;
{
"const GOOGLE_CLIENT_ID = "
+ google_client_id_json
+ ";\nlet googleCredential = null;\nlet googleUser = null;"
if use_google_auth
else ""
}
function escapeHtml(str) {{
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}}
function employeePrice(retail) {{
if (!retail) return 0;
const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100;
const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100;
return empPrice;
}}
{google_auth_js}
function checkDuplicateOrder() {{
try {{
if (localStorage.getItem('lastOrderWeek') === WEEK) {{
document.getElementById('duplicate-warning').style.display = 'block';
}}
}} catch (e) {{}}
}}
function init() {{
checkFormStatus();
loadRoster();
{
"waitForGoogleAuth();"
if use_google_auth
else "loadRoster(); checkDuplicateOrder();"
}
// Build filter buttons
const tags = new Set();
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t)));
@ -186,23 +458,29 @@ function renderMeals() {{
let tagsHtml = '';
if (meal.is_new) tagsHtml += '<span class="new">NEW</span>';
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{t}}</span>`; }});
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{escapeHtml(t)}}</span>`; }});
const descHtml = meal.description ? `<div class="meal-desc">${{meal.description}}</div>` : '';
const price = meal.price ? `$${{meal.price.toFixed(2)}}` : '—';
const descHtml = meal.description ? `<div class="meal-desc">${{escapeHtml(meal.description)}}</div>` : '';
const hasDiscount = (BULK_DISCOUNT > 0 || COMPANY_SUBSIDY > 0) && meal.price;
const empPrice = employeePrice(meal.price);
const priceHtml = hasDiscount
? `<span class="price-retail">$${{meal.price.toFixed(2)}}</span><span class="price-employee">$${{empPrice.toFixed(2)}}</span>`
: (meal.price ? `$${{meal.price.toFixed(2)}}` : '—');
const safeName = escapeHtml(meal.name);
const safeImageUrl = escapeHtml(meal.image_url);
card.innerHTML = `
${{meal.image_url ? `<img class="meal-img" src="${{meal.image_url}}" alt="${{meal.name}}" loading="lazy">` : ''}}
${{meal.image_url ? `<img class="meal-img" src="${{safeImageUrl}}" alt="${{safeName}}" loading="lazy">` : ''}}
<div class="meal-info">
<div class="meal-name">${{meal.name}}</div>
<div class="meal-name">${{safeName}}</div>
${{descHtml}}
<div class="meal-meta">${{price}} &middot; ${{meal.calories || '?'}} cal &middot; ${{meal.protein || '?'}} protein</div>
<div class="meal-meta">${{priceHtml}} &middot; ${{escapeHtml(meal.calories || '?')}} cal &middot; ${{escapeHtml(meal.protein || '?')}} protein</div>
<div class="meal-tags">${{tagsHtml}}</div>
</div>
<div class="qty-control">
<button onclick="changeQty(${{i}}, -1)">&minus;</button>
<button onclick="changeQty(${{i}}, -1)" ${{formClosed ? 'disabled' : ''}}>&minus;</button>
<input class="qty" type="text" value="${{qty}}" readonly>
<button onclick="changeQty(${{i}}, 1)">+</button>
<button onclick="changeQty(${{i}}, 1)" ${{formClosed ? 'disabled' : ''}}>+</button>
</div>
`;
list.appendChild(card);
@ -221,58 +499,22 @@ function changeQty(index, delta) {{
function updateTotal() {{
let total = 0, count = 0;
Object.entries(quantities).forEach(([i, qty]) => {{
total += (MEALS[i].price || 0) * qty;
total += employeePrice(MEALS[i].price || 0) * qty;
count += qty;
}});
document.getElementById('total-display').textContent = `$${{total.toFixed(2)}}`;
document.getElementById('count-display').textContent = `${{count}} meal${{count !== 1 ? 's' : ''}}`;
document.getElementById('submit-btn').disabled = count === 0;
document.getElementById('submit-btn').disabled = count === 0{
" || !googleCredential" if use_google_auth else ""
};
}}
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; }}
{submit_order_js}
const items = Object.entries(quantities).map(([i, qty]) => ({{
name: MEALS[i].name,
price: MEALS[i].price,
quantity: qty,
subtotal: (MEALS[i].price || 0) * 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) {{
document.getElementById('app').querySelectorAll(':not(#success)').forEach(el => 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';
}}
}}
async function loadRoster() {{
{
""
if use_google_auth
else '''async function loadRoster() {{
try {{
const res = await fetch(ROSTER_URL);
const data = await res.json();
@ -301,7 +543,8 @@ async function loadRoster() {{
}});
}}
}}
'''
}
async function checkFormStatus() {{
if (!STATUS_URL) return;
try {{
@ -309,18 +552,56 @@ async function checkFormStatus() {{
const data = await res.json();
if (data.status === 'closed') {{
formClosed = true;
const banner = document.createElement('div');
banner.className = 'closed-banner';
banner.textContent = 'Orders are closed for this week.';
const deadline = document.querySelector('.deadline');
if (deadline) deadline.replaceWith(banner);
document.getElementById('submit-btn').disabled = true;
document.getElementById('submit-btn').textContent = 'Closed';
document.getElementById('closed-overlay').style.display = 'flex';
document.querySelectorAll('.qty-control button').forEach(b => b.disabled = true);
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) submitBtn.disabled = true;
startCountdown(data);
}}
}} catch (e) {{}}
}}
let reopenAtMs = null;
function startCountdown(data) {{
if (data && data.reopen_at) {{
reopenAtMs = data.reopen_at * 1000;
}}
updateCountdown();
setInterval(updateCountdown, 1000);
}}
function updateCountdown() {{
const now = new Date();
let target;
if (reopenAtMs) {{
target = new Date(reopenAtMs);
}} else {{
const etStr = now.toLocaleString('en-US', {{ timeZone: 'America/New_York', hour12: false }});
const etDate = new Date(etStr);
const day = etDate.getDay();
const hour = etDate.getHours();
let daysUntil = (1 - day + 7) % 7;
if (daysUntil === 0 && hour >= 8) daysUntil = 7;
target = new Date(etDate);
target.setDate(target.getDate() + daysUntil);
target.setHours(8, 0, 0, 0);
}}
let diff = Math.max(0, Math.floor((target - now) / 1000));
const d = Math.floor(diff / 86400); diff %= 86400;
const h = Math.floor(diff / 3600); diff %= 3600;
const m = Math.floor(diff / 60);
const s = diff % 60;
const pad = n => String(n).padStart(2, '0');
document.getElementById('countdown').textContent =
`${{d}}d ${{pad(h)}}h ${{pad(m)}}m ${{pad(s)}}s`;
}}
init();
</script>
</body>
@ -335,11 +616,58 @@ def main():
parser.add_argument(
"--api-key", default="", help="API key for order submission (cloud mode)"
)
parser.add_argument(
"--bulk-discount",
type=float,
default=None,
help="Bulk discount percentage (e.g., 10 for 10%% off)",
)
parser.add_argument(
"--company-subsidy",
type=float,
default=None,
help="Company subsidy percentage (e.g., 50 for 50%% off after bulk discount)",
)
parser.add_argument(
"--google-client-id",
default=None,
help="Google OAuth Client ID for Sign-In authentication",
)
args = parser.parse_args()
config = load_config()
menu = latest_menu()
html = generate_form(menu, config, api_url=args.api_url, api_key=args.api_key)
bulk_discount = (
args.bulk_discount
if args.bulk_discount is not None
else config.get("bulk_discount_percent", 0)
)
company_subsidy = (
args.company_subsidy
if args.company_subsidy is not None
else config.get("company_subsidy_percent", 0)
)
google_client_id = args.google_client_id or config.get("google_client_id", "")
if not google_client_id:
try:
import boto3
ssm = boto3.client("ssm")
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
google_client_id = resp["Parameter"]["Value"]
except Exception:
pass
html = generate_form(
menu,
config,
api_url=args.api_url,
api_key=args.api_key,
bulk_discount=bulk_discount,
company_subsidy=company_subsidy,
google_client_id=google_client_id,
)
week = datetime.now().strftime("%Y-W%U")
output_file = OUTPUT_DIR / f"order-form-{week}.html"

View file

@ -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(),
}
)

View file

@ -1,19 +1,32 @@
import time
import boto3
_cache = {}
_secret_cache: dict[str, str] = {}
_parameter_cache: dict[str, tuple[str, float]] = {}
_sm = boto3.client("secretsmanager")
_ssm = boto3.client("ssm")
# SSM reads use a TTL so callers (e.g. submit_order Google client ID) can refresh
# on the same cadence as their own caches. Secrets stay cached for the process lifetime.
PARAM_CACHE_TTL_SECONDS = 300.0
def get_secret(secret_id: str) -> str:
if secret_id not in _cache:
if secret_id not in _secret_cache:
resp = _sm.get_secret_value(SecretId=secret_id)
_cache[secret_id] = resp["SecretString"]
return _cache[secret_id]
_secret_cache[secret_id] = resp["SecretString"]
return _secret_cache[secret_id]
def get_parameter(name: str, decrypt: bool = True) -> str:
if name not in _cache:
resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt)
_cache[name] = resp["Parameter"]["Value"]
return _cache[name]
now = time.monotonic()
entry = _parameter_cache.get(name)
if entry is not None:
value, cached_at = entry
if now - cached_at < PARAM_CACHE_TTL_SECONDS:
return value
resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt)
value = resp["Parameter"]["Value"]
_parameter_cache[name] = (value, now)
return value

View file

@ -202,6 +202,8 @@ Resources:
Type: AWS::Serverless::HttpApi
Properties:
StageName: $default
# CORS only allows the production domain. For local development, use the
# Flask dev server (app.py) which proxies API requests and doesn't enforce CORS.
CorsConfiguration:
AllowOrigins:
- !If
@ -230,6 +232,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 +241,12 @@ 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
- Effect: Allow
Action: ssm:GetParameter
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*'
Events:
SubmitOrder:
Type: HttpApi
@ -275,18 +285,20 @@ Resources:
Environment:
Variables:
AGGREGATE_FUNCTION_ARN: !GetAtt AggregateOrdersFunction.Arn
# Both EST and EDT schedules fire every week year-round. The handler is
# idempotent, so the "wrong timezone" firing is a harmless no-op.
Events:
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:
@ -460,6 +472,11 @@ Resources:
Value: CHANGE_ME
Description: Slack channel ID for meal order notifications
# GoogleClientIdParam (/meal-order-manager/google-client-id) is managed
# manually via AWS CLI since it varies per environment. Create it with:
# aws ssm put-parameter --name /meal-order-manager/google-client-id \
# --type String --value "<YOUR_GOOGLE_CLIENT_ID>"
Outputs:
ApiUrl:
Description: API Gateway endpoint URL

9
tests/conftest.py Normal file
View file

@ -0,0 +1,9 @@
"""Pytest configuration — add shared layer source to sys.path so handler imports resolve."""
import os
import sys
# Add the shared layer source directory so `from shared.db import ...` works
# without requiring a real Lambda layer or .aws-sam build.
_shared_layer_dir = os.path.join(os.path.dirname(__file__), os.pardir, "src", "shared")
sys.path.insert(0, os.path.abspath(_shared_layer_dir))

View file

@ -0,0 +1,503 @@
"""Unit tests for functions/aggregate_orders/handler.py."""
import csv
import io
import json
import os
from decimal import Decimal
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers — reusable order builders
# ---------------------------------------------------------------------------
def _make_order(
name: str, email: str, items: list[dict], total: float | None = None
) -> dict:
"""Build a minimal order dict matching DynamoDB shape."""
if total is None:
total = sum(
float(
i.get("subtotal", float(i.get("price", 0)) * int(i.get("quantity", 0)))
)
for i in items
)
return {
"employee_name": name,
"employee_email": email,
"items": items,
"total": Decimal(str(total)),
}
def _make_item(
name: str,
quantity: int = 1,
price: float = 10.0,
bulk_price: float | None = None,
subtotal: float | None = None,
) -> dict:
"""Build a minimal item dict."""
item = {"name": name, "quantity": quantity, "price": price}
if bulk_price is not None:
item["bulk_price"] = bulk_price
if subtotal is not None:
item["subtotal"] = subtotal
return item
# ---------------------------------------------------------------------------
# Import handler AFTER patching boto3 + env vars so module-level clients
# don't try to hit real AWS.
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _patch_env(monkeypatch):
monkeypatch.setenv("TABLE_NAME", "test-table")
monkeypatch.setenv("REPORTS_BUCKET", "test-bucket")
monkeypatch.setenv(
"SLACK_NOTIFIER_ARN",
"arn:aws:lambda:us-east-1:123456789012:function:test-notifier",
)
@pytest.fixture()
def handler_module():
"""Import the handler with boto3 patched at module level."""
with patch("boto3.client") as mock_client, patch("boto3.resource"):
mock_s3 = MagicMock()
mock_lambda = MagicMock()
mock_client.side_effect = lambda svc, **kw: {
"s3": mock_s3,
"lambda": mock_lambda,
}[svc]
import importlib
import functions.aggregate_orders.handler as mod
importlib.reload(mod)
# Inject mocked clients so tests can assert on them
mod._s3 = mock_s3
mod._lambda = mock_lambda
yield mod
# ===================================================================
# Summary Building (Critical + High)
# ===================================================================
class TestBuildSummarySingleOrder:
"""test_build_summary_single_order — one order with 2 items."""
def test_build_summary_single_order(self, handler_module):
orders = [
_make_order(
"Alice Smith",
"alice@example.com",
[
_make_item(
"Chicken Parm", quantity=1, price=12.00, bulk_price=10.00
),
_make_item("Caesar Salad", quantity=1, price=8.00, bulk_price=6.50),
],
),
]
summary = handler_module.build_summary(orders, "2026-W20")
assert summary["week"] == "2026-W20", "Week should be passed through"
assert summary["total_employees"] == 1, "Should count 1 employee"
assert summary["total_meals"] == 2, "Should count 2 total meals"
meals_by_name = {m["meal"]: m for m in summary["meals"]}
assert "Chicken Parm" in meals_by_name, "Chicken Parm should appear"
assert "Caesar Salad" in meals_by_name, "Caesar Salad should appear"
chicken = meals_by_name["Chicken Parm"]
assert chicken["quantity"] == 1
assert chicken["unit_price"] == 10.00, "unit_price should use bulk_price"
assert chicken["employee_unit_price"] == 12.00, (
"employee_unit_price should use price"
)
assert chicken["line_total"] == 10.00, "line_total = bulk_price * qty"
assert chicken["employee_line_total"] == 12.00, (
"employee_line_total = price * qty"
)
assert summary["grand_total"] == 16.50, (
"grand_total should sum bulk line totals"
)
assert summary["employee_total"] == 20.00, (
"employee_total should sum employee line totals"
)
class TestBuildSummaryMultipleOrdersSameMeal:
"""test_build_summary_multiple_orders_same_meal — 3 employees order the same meal."""
def test_build_summary_multiple_orders_same_meal(self, handler_module):
orders = [
_make_order(
"Alice",
"a@x.com",
[_make_item("Burger", quantity=1, price=10.00, bulk_price=8.00)],
),
_make_order(
"Bob",
"b@x.com",
[_make_item("Burger", quantity=2, price=10.00, bulk_price=8.00)],
),
_make_order(
"Carol",
"c@x.com",
[_make_item("Burger", quantity=1, price=10.00, bulk_price=8.00)],
),
]
summary = handler_module.build_summary(orders, "2026-W20")
assert len(summary["meals"]) == 1, (
"All three ordered the same meal — should aggregate to 1 entry"
)
burger = summary["meals"][0]
assert burger["quantity"] == 4, "Total quantity should be 1 + 2 + 1 = 4"
assert burger["line_total"] == 32.00, "line_total = 8.00 * 4"
assert burger["employee_line_total"] == 40.00, "employee_line_total = 10.00 * 4"
class TestBuildSummarySortedAlphabetically:
"""test_build_summary_sorted_alphabetically — meals appear in alphabetical order."""
def test_build_summary_sorted_alphabetically(self, handler_module):
orders = [
_make_order(
"Alice",
"a@x.com",
[
_make_item("Ziti", quantity=1, price=10.00),
_make_item("Apple Pie", quantity=1, price=5.00),
_make_item("Meatloaf", quantity=1, price=12.00),
],
),
]
summary = handler_module.build_summary(orders, "2026-W20")
meal_names = [m["meal"] for m in summary["meals"]]
assert meal_names == ["Apple Pie", "Meatloaf", "Ziti"], (
"Meals should be sorted alphabetically"
)
class TestBuildSummaryGrandTotalVsEmployeeTotal:
"""test_build_summary_grand_total_vs_employee_total — grand_total uses bulk_price, employee_total uses price."""
def test_build_summary_grand_total_vs_employee_total(self, handler_module):
orders = [
_make_order(
"Alice",
"a@x.com",
[
_make_item("Steak", quantity=2, price=15.00, bulk_price=11.00),
],
),
_make_order(
"Bob",
"b@x.com",
[
_make_item("Pasta", quantity=1, price=9.00, bulk_price=7.00),
],
),
]
summary = handler_module.build_summary(orders, "2026-W20")
# Steak: bulk 11*2=22, employee 15*2=30
# Pasta: bulk 7*1=7, employee 9*1=9
assert summary["grand_total"] == 29.00, (
"grand_total should use bulk_price (22 + 7)"
)
assert summary["employee_total"] == 39.00, (
"employee_total should use employee_price (30 + 9)"
)
assert summary["grand_total"] != summary["employee_total"], (
"grand_total and employee_total must differ when bulk != employee price"
)
class TestBuildSummaryRounding:
"""test_build_summary_rounding — totals rounded to 2 decimal places."""
def test_build_summary_rounding(self, handler_module):
# Use prices that produce repeating decimals when multiplied
orders = [
_make_order(
"Alice",
"a@x.com",
[
_make_item("Soup", quantity=3, price=3.33, bulk_price=2.77),
],
),
]
summary = handler_module.build_summary(orders, "2026-W20")
soup = summary["meals"][0]
# 2.77 * 3 = 8.31 (rounded)
assert soup["line_total"] == 8.31, "line_total should be rounded to 2 decimals"
# 3.33 * 3 = 9.99
assert soup["employee_line_total"] == 9.99, (
"employee_line_total should be rounded to 2 decimals"
)
assert summary["grand_total"] == 8.31
assert summary["employee_total"] == 9.99
# Verify they are actually rounded (no extra decimal digits)
assert summary["grand_total"] == round(summary["grand_total"], 2)
assert summary["employee_total"] == round(summary["employee_total"], 2)
# ===================================================================
# CSV Generation (High)
# ===================================================================
class TestBuildOrderSummaryCsv:
"""test_build_order_summary_csv — correct header, meal rows, total row format."""
def test_build_order_summary_csv(self, handler_module):
orders = [
_make_order(
"Alice",
"a@x.com",
[
_make_item("Burger", quantity=2, price=10.00, bulk_price=8.00),
_make_item("Fries", quantity=1, price=5.00, bulk_price=4.00),
],
),
]
summary = handler_module.build_summary(orders, "2026-W20")
csv_str = handler_module.build_order_summary_csv(summary)
reader = csv.reader(io.StringIO(csv_str))
rows = list(reader)
# Header
assert rows[0] == ["Meal", "Quantity", "Unit Price", "Line Total"], (
"First row should be the header"
)
# Meal rows (alphabetical: Burger, Fries)
assert rows[1][0] == "Burger"
assert rows[1][1] == "2"
assert rows[1][2] == "$8.00"
assert rows[1][3] == "$16.00"
assert rows[2][0] == "Fries"
assert rows[2][1] == "1"
assert rows[2][2] == "$4.00"
assert rows[2][3] == "$4.00"
# Empty separator row then total row
assert rows[3] == [], "Separator should be an empty row"
assert rows[4][0] == "TOTAL"
assert rows[4][1] == "3", "Total quantity should be 3"
assert rows[4][3] == "$20.00", "Total should be grand_total"
class TestBuildPayrollCsv:
"""test_build_payroll_csv — correct header, sorted employees, item format, total deduction."""
def test_build_payroll_csv(self, handler_module):
orders = [
_make_order(
"Zara Adams",
"zara@x.com",
[
_make_item("Pasta", quantity=2, price=9.00, subtotal=18.00),
],
total=18.00,
),
_make_order(
"Alice Brown",
"alice@x.com",
[
_make_item("Burger", quantity=1, price=10.00, subtotal=10.00),
_make_item("Fries", quantity=2, price=5.00, subtotal=10.00),
],
total=20.00,
),
]
csv_str = handler_module.build_payroll_csv(orders)
reader = csv.reader(io.StringIO(csv_str))
rows = list(reader)
# Header
assert rows[0] == [
"Employee Name",
"Employee Email",
"Items Ordered",
"Total Deduction",
]
# Sorted alphabetically by employee_name: Alice Brown before Zara Adams
assert rows[1][0] == "Alice Brown", "Employees should be sorted by name"
assert rows[1][1] == "alice@x.com"
assert "Burger x1 ($10.00)" in rows[1][2]
assert "Fries x2 ($10.00)" in rows[1][2]
assert "; " in rows[1][2], "Items should be separated by '; '"
assert rows[1][3] == "$20.00"
assert rows[2][0] == "Zara Adams"
assert rows[2][1] == "zara@x.com"
assert "Pasta x2 ($18.00)" in rows[2][2]
assert rows[2][3] == "$18.00"
class TestBuildPayrollCsvSubtotalFallback:
"""test_build_payroll_csv_subtotal_fallback — when item lacks 'subtotal', falls back to price*quantity."""
def test_build_payroll_csv_subtotal_fallback(self, handler_module):
orders = [
_make_order(
"Alice Brown",
"alice@x.com",
[
_make_item("Burger", quantity=3, price=10.00), # no subtotal key
],
total=30.00,
),
]
csv_str = handler_module.build_payroll_csv(orders)
reader = csv.reader(io.StringIO(csv_str))
rows = list(reader)
# price(10) * quantity(3) = 30.00
assert "Burger x3 ($30.00)" in rows[1][2], (
"Without 'subtotal' key, should fall back to price * quantity"
)
# ===================================================================
# Lambda Handler Flow (High)
# ===================================================================
class TestAggregateAlreadyAggregated:
"""test_aggregate_already_aggregated — summary exists, returns early, no S3 upload."""
@patch("functions.aggregate_orders.handler.get_summary")
@patch("functions.aggregate_orders.handler.get_orders")
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
def test_aggregate_already_aggregated(
self, mock_week, mock_orders, mock_summary, handler_module
):
mock_summary.return_value = {"week": "2026-W20", "meals": []}
result = handler_module.lambda_handler({}, None)
assert result["status"] == "already_aggregated", (
"Should return already_aggregated status"
)
assert result["week"] == "2026-W20"
handler_module._s3.put_object.assert_not_called()
mock_orders.assert_not_called()
class TestAggregateNoOrders:
"""test_aggregate_no_orders — no orders returns no_orders status."""
@patch("functions.aggregate_orders.handler.get_summary")
@patch("functions.aggregate_orders.handler.get_orders")
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
def test_aggregate_no_orders(
self, mock_week, mock_orders, mock_summary, handler_module
):
mock_summary.return_value = None
mock_orders.return_value = []
result = handler_module.lambda_handler({}, None)
assert result["status"] == "no_orders", (
"Should return no_orders when order list is empty"
)
assert result["week"] == "2026-W20"
handler_module._s3.put_object.assert_not_called()
class TestAggregateHappyPath:
"""test_aggregate_happy_path — orders exist, builds summary, uploads CSVs, saves, triggers Slack."""
@patch("functions.aggregate_orders.handler.put_summary")
@patch("functions.aggregate_orders.handler.get_summary")
@patch("functions.aggregate_orders.handler.get_orders")
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
def test_aggregate_happy_path(
self, mock_week, mock_orders, mock_get_summary, mock_put_summary, handler_module
):
mock_get_summary.return_value = None
mock_orders.return_value = [
_make_order(
"Alice",
"alice@x.com",
[
_make_item(
"Burger",
quantity=1,
price=10.00,
bulk_price=8.00,
subtotal=10.00,
),
],
total=10.00,
),
_make_order(
"Bob",
"bob@x.com",
[
_make_item(
"Pasta", quantity=2, price=9.00, bulk_price=7.00, subtotal=18.00
),
],
total=18.00,
),
]
result = handler_module.lambda_handler({}, None)
# Verify return
assert result["status"] == "aggregated"
assert result["week"] == "2026-W20"
assert result["total_employees"] == 2
# Verify 2 S3 uploads (order summary CSV + payroll CSV)
s3_calls = handler_module._s3.put_object.call_args_list
assert len(s3_calls) == 2, "Should upload exactly 2 CSVs to S3"
s3_keys = [call.kwargs["Key"] for call in s3_calls]
assert "reports/2026-W20/order-summary.csv" in s3_keys
assert "reports/2026-W20/payroll-deductions.csv" in s3_keys
for call in s3_calls:
assert call.kwargs["Bucket"] == "test-bucket"
assert call.kwargs["ContentType"] == "text/csv"
# Verify summary saved to DynamoDB
mock_put_summary.assert_called_once()
saved_summary = mock_put_summary.call_args[0][1]
assert saved_summary["week"] == "2026-W20"
assert "order_csv_s3_key" in saved_summary
assert "payroll_csv_s3_key" in saved_summary
# Verify Slack notifier Lambda invoked asynchronously
handler_module._lambda.invoke.assert_called_once()
invoke_kwargs = handler_module._lambda.invoke.call_args.kwargs
assert invoke_kwargs["FunctionName"] == os.environ["SLACK_NOTIFIER_ARN"]
assert invoke_kwargs["InvocationType"] == "Event"
payload = json.loads(invoke_kwargs["Payload"])
assert payload["event"] == "orders_aggregated"
assert payload["week"] == "2026-W20"

101
tests/test_close_form.py Normal file
View file

@ -0,0 +1,101 @@
"""Unit tests for functions/close_form/handler.py — wall-clock guard."""
import os
import sys
from datetime import datetime
from unittest.mock import patch
from zoneinfo import ZoneInfo
os.environ.setdefault(
"AGGREGATE_FUNCTION_ARN",
"arn:aws:lambda:us-east-1:000000000000:function:test-aggregate",
)
import importlib.util
_handler_path = os.path.join(
os.path.dirname(__file__), os.pardir, "functions", "close_form", "handler.py"
)
_spec = importlib.util.spec_from_file_location(
"close_form_handler", os.path.abspath(_handler_path)
)
close_form_handler = importlib.util.module_from_spec(_spec)
sys.modules["close_form_handler"] = close_form_handler
_spec.loader.exec_module(close_form_handler)
ET = ZoneInfo("America/New_York")
def _make_datetime(year, month, day, hour, minute=0):
return datetime(year, month, day, hour, minute, tzinfo=ET)
class TestCloseFormGuard:
@patch("close_form_handler.datetime")
def test_skipped_on_wednesday(self, mock_dt):
"""Wednesday 11pm ET -> skipped (not Thursday)."""
mock_dt.now.return_value = _make_datetime(2026, 5, 13, 23) # Wednesday
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "skipped"
@patch("close_form_handler.datetime")
def test_skipped_on_friday(self, mock_dt):
"""Friday 3am ET (wrong-tz EDT cron during EST) -> skipped."""
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 3) # Friday 3am
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "skipped"
@patch("close_form_handler.datetime")
def test_skipped_thursday_before_11pm(self, mock_dt):
"""Thursday 10pm ET -> skipped (too early)."""
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 22) # Thursday 10pm
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "skipped"
@patch("close_form_handler.set_form_status")
@patch("close_form_handler.get_form_status", return_value="open")
@patch("close_form_handler.current_week", return_value="2026-W19")
@patch("close_form_handler._lambda")
@patch("close_form_handler.datetime")
def test_runs_thursday_at_11pm(
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
):
"""Thursday 11pm ET -> proceeds to close."""
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23) # Thursday 11pm
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "closed"
mock_set.assert_called_once()
@patch("close_form_handler.set_form_status")
@patch("close_form_handler.get_form_status", return_value="open")
@patch("close_form_handler.current_week", return_value="2026-W19")
@patch("close_form_handler._lambda")
@patch("close_form_handler.datetime")
def test_runs_thursday_at_1159pm(
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
):
"""Thursday 11:59pm ET -> proceeds to close."""
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23, 59)
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "closed"
@patch("close_form_handler.get_form_status", return_value="closed")
@patch("close_form_handler.current_week", return_value="2026-W19")
@patch("close_form_handler.datetime")
def test_already_closed(self, mock_dt, mock_week, mock_status):
"""Thursday 11pm but already closed -> returns already_closed."""
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23)
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "already_closed"

50
tests/test_secrets.py Normal file
View file

@ -0,0 +1,50 @@
"""Tests for shared.secrets caching."""
from unittest.mock import MagicMock, patch
def test_get_parameter_refetches_after_ttl():
"""SSM parameter values expire so callers can observe rotations."""
from shared import secrets
secrets._secret_cache.clear()
secrets._parameter_cache.clear()
mock_ssm = MagicMock()
mock_ssm.get_parameter.side_effect = [
{"Parameter": {"Value": "first"}},
{"Parameter": {"Value": "second"}},
]
with patch.object(secrets, "_ssm", mock_ssm):
with patch.object(secrets, "PARAM_CACHE_TTL_SECONDS", 10.0):
with patch(
"shared.secrets.time.monotonic",
side_effect=[0.0, 5.0, 15.0],
):
assert secrets.get_parameter("/test/param") == "first"
assert secrets.get_parameter("/test/param") == "first"
assert secrets.get_parameter("/test/param") == "second"
assert mock_ssm.get_parameter.call_count == 2
def test_get_secret_stays_cached():
"""Secrets Manager values remain cached (no TTL)."""
from shared import secrets
secrets._secret_cache.clear()
secrets._parameter_cache.clear()
mock_sm = MagicMock()
mock_sm.get_secret_value.side_effect = [
{"SecretString": "a"},
{"SecretString": "b"},
]
with patch.object(secrets, "_sm", mock_sm):
with patch("shared.secrets.time.monotonic", side_effect=[0.0, 5000.0]):
assert secrets.get_secret("arn:aws:secret") == "a"
assert secrets.get_secret("arn:aws:secret") == "a"
assert mock_sm.get_secret_value.call_count == 1

View file

@ -0,0 +1,375 @@
"""Unit tests for the slack_notifier handler."""
import sys
import os
from datetime import datetime
from decimal import Decimal
from unittest.mock import patch
from zoneinfo import ZoneInfo
# ---------------------------------------------------------------------------
# Ensure the handler module can be imported. The shared layer lives under
# src/shared/ and the handler lives under functions/slack_notifier/.
# ---------------------------------------------------------------------------
import importlib.util
_repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(_repo, "src", "shared"))
_handler_path = os.path.join(_repo, "functions", "slack_notifier", "handler.py")
_spec = importlib.util.spec_from_file_location("slack_notifier_handler", _handler_path)
handler = importlib.util.module_from_spec(_spec)
sys.modules["slack_notifier_handler"] = handler
_spec.loader.exec_module(handler)
ET = ZoneInfo("America/New_York")
# ────────────────────────────────────────────────────────────────────────────
# Helpers
# ────────────────────────────────────────────────────────────────────────────
def _make_datetime(year, month, day, hour, minute=0):
"""Return a timezone-aware datetime in America/New_York."""
return datetime(year, month, day, hour, minute, tzinfo=ET)
def _thursday_10am():
"""2026-05-14 is a Thursday."""
return _make_datetime(2026, 5, 14, 10)
def _thursday_11am():
return _make_datetime(2026, 5, 14, 11)
def _wednesday_10am():
"""2026-05-13 is a Wednesday."""
return _make_datetime(2026, 5, 13, 10)
# ────────────────────────────────────────────────────────────────────────────
# Reminder Dedup Guard (Critical)
# ────────────────────────────────────────────────────────────────────────────
class TestReminderDedupGuard:
@patch("slack_notifier_handler.datetime")
def test_reminder_skipped_wrong_hour(self, mock_dt):
"""Invoked at 11am Thursday ET -> returns skipped."""
mock_dt.now.return_value = _thursday_11am()
result = handler.handle_reminder({"event": "reminder"})
assert result["status"] == "skipped", "Should skip when hour is not 10"
assert "outside reminder window" in result["reason"]
@patch("slack_notifier_handler.datetime")
def test_reminder_skipped_wrong_day(self, mock_dt):
"""Invoked at 10am Wednesday ET -> returns skipped."""
mock_dt.now.return_value = _wednesday_10am()
result = handler.handle_reminder({"event": "reminder"})
assert result["status"] == "skipped", "Should skip when day is not Thursday"
assert "outside reminder window" in result["reason"]
@patch("slack_notifier_handler.send_dm")
@patch("slack_notifier_handler.get_orders", return_value=[])
@patch("slack_notifier_handler.get_roster", return_value=[])
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
@patch("slack_notifier_handler.datetime")
def test_reminder_runs_at_correct_time(
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
):
"""Invoked at 10am Thursday ET -> proceeds (does not skip)."""
mock_dt.now.return_value = _thursday_10am()
result = handler.handle_reminder({"event": "reminder"})
assert result["status"] != "skipped", "Should not skip at 10am Thursday"
@patch("slack_notifier_handler.datetime")
def test_lambda_handler_reminder_guard(self, mock_dt):
"""lambda_handler lines 32-34 also return skipped for wrong time."""
mock_dt.now.return_value = _thursday_11am()
result = handler.lambda_handler({"event": "reminder"}, None)
assert result["status"] == "skipped", (
"lambda_handler should short-circuit before calling handle_reminder"
)
assert "outside reminder window" in result["reason"]
# ────────────────────────────────────────────────────────────────────────────
# Reminder DMs (High)
# ────────────────────────────────────────────────────────────────────────────
class TestReminderDMs:
@patch("slack_notifier_handler.send_dm")
@patch("slack_notifier_handler.get_orders")
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
@patch("slack_notifier_handler.datetime")
def test_reminder_sends_to_non_ordered(
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
):
"""Roster of 3, 1 has ordered -> DMs sent to 2 others."""
mock_dt.now.return_value = _thursday_10am()
mock_roster.return_value = [
{"email": "alice@x.com", "name": "Alice A", "slack_user_id": "U001"},
{"email": "bob@x.com", "name": "Bob B", "slack_user_id": "U002"},
{"email": "carol@x.com", "name": "Carol C", "slack_user_id": "U003"},
]
mock_orders.return_value = [
{"employee_email": "alice@x.com"},
]
result = handler.handle_reminder({"event": "reminder"})
assert result["dm_count"] == 2, "Should DM the 2 employees who haven't ordered"
assert result["missing_count"] == 2
assert mock_dm.call_count == 2
dm_user_ids = {call.args[0] for call in mock_dm.call_args_list}
assert dm_user_ids == {"U002", "U003"}, "Should DM Bob and Carol, not Alice"
@patch("slack_notifier_handler.send_dm")
@patch("slack_notifier_handler.get_orders", return_value=[])
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
@patch("slack_notifier_handler.datetime")
def test_reminder_skips_no_slack_id(
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
):
"""Employee without slack_user_id is counted as missing but not DM'd."""
mock_dt.now.return_value = _thursday_10am()
mock_roster.return_value = [
{"email": "dave@x.com", "name": "Dave D"}, # no slack_user_id
{"email": "eve@x.com", "name": "Eve E", "slack_user_id": "U005"},
]
result = handler.handle_reminder({"event": "reminder"})
assert result["missing_count"] == 2, "Both are missing (no orders at all)"
assert result["dm_count"] == 1, "Only Eve should be DM'd (Dave has no Slack ID)"
mock_dm.assert_called_once()
assert mock_dm.call_args.args[0] == "U005"
@patch("slack_notifier_handler.send_dm")
@patch("slack_notifier_handler.get_orders")
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
@patch("slack_notifier_handler.datetime")
def test_reminder_case_insensitive_email(
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
):
"""'Adam@x.com' in roster matches 'adam@x.com' in orders."""
mock_dt.now.return_value = _thursday_10am()
mock_roster.return_value = [
{"email": "Adam@x.com", "name": "Adam M", "slack_user_id": "U010"},
]
mock_orders.return_value = [
{"employee_email": "adam@x.com"},
]
result = handler.handle_reminder({"event": "reminder"})
assert result["dm_count"] == 0, (
"Adam already ordered (case-insensitive match) — no DM expected"
)
mock_dm.assert_not_called()
# ────────────────────────────────────────────────────────────────────────────
# Order Confirmed (High)
# ────────────────────────────────────────────────────────────────────────────
class TestOrderConfirmed:
@patch("slack_notifier_handler.send_dm")
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
def test_order_confirmed_dm_format(self, mock_week, mock_roster, mock_dm):
"""DM contains item breakdown with 'your cost' labels and payroll total."""
mock_roster.return_value = [
{"email": "alice@x.com", "name": "Alice Adams", "slack_user_id": "U001"},
]
event = {
"event": "order_confirmed",
"employee_email": "alice@x.com",
"employee_name": "Alice Adams",
"items": [
{"name": "Grilled Chicken", "quantity": 2, "price": 8.50},
{"name": "Caesar Salad", "quantity": 1, "price": 6.00},
],
"total": 23.00,
"week": "2026-W19",
}
handler.handle_order_confirmed(event)
mock_dm.assert_called_once()
call_kwargs = mock_dm.call_args
blocks = call_kwargs.kwargs.get("blocks") or call_kwargs[1].get("blocks")
body_text = blocks[1]["text"]["text"]
assert "your cost: $17.00" in body_text, "Should show Grilled Chicken x2 cost"
assert "your cost: $6.00" in body_text, "Should show Caesar Salad x1 cost"
assert "$23.00" in body_text, "Should show payroll deduction total"
assert "payroll deduction" in body_text.lower()
@patch("slack_notifier_handler.send_dm")
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
def test_order_confirmed_no_slack_id(self, mock_week, mock_roster, mock_dm):
"""Employee not in roster -> returns {'status': 'no_slack_id'}."""
mock_roster.return_value = [] # empty roster
event = {
"event": "order_confirmed",
"employee_email": "nobody@x.com",
"employee_name": "Nobody",
"items": [],
"total": 0,
}
result = handler.handle_order_confirmed(event)
assert result["status"] == "no_slack_id"
mock_dm.assert_not_called()
@patch("slack_notifier_handler.send_dm")
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
def test_order_confirmed_empty_name(self, mock_week, mock_roster, mock_dm):
"""Empty name -> greeting says 'Hey there!' not crash."""
mock_roster.return_value = [
{"email": "anon@x.com", "name": "", "slack_user_id": "U099"},
]
event = {
"event": "order_confirmed",
"employee_email": "anon@x.com",
"employee_name": "",
"items": [{"name": "Soup", "quantity": 1, "price": 5.00}],
"total": 5.00,
}
result = handler.handle_order_confirmed(event)
assert result["status"] == "confirmed", "Should not crash on empty name"
blocks = mock_dm.call_args.kwargs.get("blocks") or mock_dm.call_args[1].get(
"blocks"
)
body_text = blocks[1]["text"]["text"]
assert "Hey there!" in body_text, (
"Should greet with 'Hey there!' when name is empty"
)
# ────────────────────────────────────────────────────────────────────────────
# Orders Aggregated (High)
# ────────────────────────────────────────────────────────────────────────────
class TestOrdersAggregated:
@patch("slack_notifier_handler.post_channel_message")
@patch("slack_notifier_handler.get_summary")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
def test_orders_aggregated_with_subsidy(self, mock_week, mock_summary, mock_post):
"""employee_total < grand_total -> message includes company subsidy line."""
mock_summary.return_value = {
"total_employees": 5,
"total_meals": 12,
"grand_total": Decimal("150.00"),
"employee_total": Decimal("120.00"),
"meals": [
{"meal": "Grilled Chicken", "quantity": Decimal("7")},
{"meal": "Veggie Bowl", "quantity": Decimal("5")},
],
}
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
assert result["status"] == "notified"
mock_post.assert_called_once()
blocks = mock_post.call_args.args[1]
section_text = blocks[1]["text"]["text"]
assert "$150.00" in section_text, "Should show grand total"
assert "$120.00" in section_text, "Should show employee payroll deductions"
assert "$30.00" in section_text, "Should show company subsidy amount"
assert "company subsidy" in section_text.lower()
@patch("slack_notifier_handler.post_channel_message")
@patch("slack_notifier_handler.get_summary")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
def test_orders_aggregated_without_subsidy(
self, mock_week, mock_summary, mock_post
):
"""Equal totals -> no subsidy line."""
mock_summary.return_value = {
"total_employees": 3,
"total_meals": 6,
"grand_total": Decimal("90.00"),
"employee_total": Decimal("90.00"),
"meals": [
{"meal": "Pasta Primavera", "quantity": Decimal("6")},
],
}
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
assert result["status"] == "notified"
blocks = mock_post.call_args.args[1]
section_text = blocks[1]["text"]["text"]
assert "company subsidy" not in section_text.lower(), (
"Should not mention subsidy when employee_total == grand_total"
)
@patch("slack_notifier_handler.post_channel_message")
@patch("slack_notifier_handler.get_summary")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
def test_orders_aggregated_no_summary(self, mock_week, mock_summary, mock_post):
"""No summary in DB -> returns {'status': 'no_summary'}."""
mock_summary.return_value = None
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
assert result["status"] == "no_summary"
mock_post.assert_not_called()
# ────────────────────────────────────────────────────────────────────────────
# Escaping (Low)
# ────────────────────────────────────────────────────────────────────────────
class TestEscaping:
def test_escape_mrkdwn(self):
"""'A & B <C>' -> 'A &amp; B &lt;C&gt;'."""
assert handler._escape_mrkdwn("A & B <C>") == "A &amp; B &lt;C&gt;"
# ────────────────────────────────────────────────────────────────────────────
# Routing
# ────────────────────────────────────────────────────────────────────────────
class TestRouting:
def test_unknown_event_type(self):
"""Unknown event type returns error message."""
result = handler.lambda_handler({"event": "bogus_event"}, None)
assert "error" in result, "Should return an error key for unknown event type"
assert "bogus_event" in result["error"], (
"Error message should include the unrecognised event type"
)

1172
tests/test_submit_order.py Normal file

File diff suppressed because it is too large Load diff