This commit is contained in:
Adam Moussa 2026-05-13 17:39:35 +00:00 • committed by GitHub
commit 9ccf3ac38c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2947 additions and 117 deletions

View file

@ -72,11 +72,46 @@ jobs:
echo "::add-mask::$API_KEY" echo "::add-mask::$API_KEY"
echo "api_key=$API_KEY" >> $GITHUB_OUTPUT 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 - name: Generate order form
run: | run: |
python3 src/server/generate_form.py \ python3 src/server/generate_form.py \
--api-url "${{ steps.stack.outputs.api_url }}" \ --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 - name: Upload menu to DynamoDB
run: python3 scripts/upload_menu.py run: python3 scripts/upload_menu.py

View file

@ -1,8 +1,11 @@
{ {
"menu_url": "https://redefinemeals.com/menu", "menu_url": "https://redefinemeals.com/menu",
"order_deadline": "Wednesday 11:59 PM", "order_deadline": "Thursday at 11:59 PM",
"output_dir": "output", "output_dir": "output",
"orders_dir": "orders", "orders_dir": "orders",
"bulk_discount_percent": 10,
"company_subsidy_percent": 50,
"google_client_id": "",
"roster": [ "roster": [
{"name": "Example Employee", "email": "example@seahavenind.com"} {"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: 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 order in orders:
for item in order.get("items", []): for item in order.get("items", []):
name = item["name"] name = item["name"]
qty = int(item.get("quantity", 0)) qty = int(item.get("quantity", 0))
meal_totals[name]["quantity"] += qty 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 = [] meals = []
for name, data in sorted(meal_totals.items()): for name, data in sorted(meal_totals.items()):
@ -79,8 +84,12 @@ def build_summary(orders: list[dict], week: str) -> dict:
{ {
"meal": name, "meal": name,
"quantity": data["quantity"], "quantity": data["quantity"],
"unit_price": data["unit_price"], "unit_price": data["bulk_price"],
"line_total": round(data["unit_price"] * data["quantity"], 2), "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_employees": len(orders),
"total_meals": sum(m["quantity"] for m in meals), "total_meals": sum(m["quantity"] for m in meals),
"grand_total": round(sum(m["line_total"] for m in meals), 2), "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, "meals": meals,
} }

View file

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

View file

@ -1,11 +1,18 @@
import json import json
import os import os
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from zoneinfo import ZoneInfo
from shared.db import current_week, get_orders, get_roster, get_summary from shared.db import current_week, get_orders, get_roster, get_summary
from shared.slack import post_channel_message, send_dm 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): class DecimalEncoder(json.JSONEncoder):
def default(self, o): def default(self, o):
if isinstance(o, Decimal): if isinstance(o, Decimal):
@ -21,7 +28,13 @@ def lambda_handler(event, context):
elif event_type == "orders_aggregated": elif event_type == "orders_aggregated":
return handle_orders_aggregated(event) return handle_orders_aggregated(event)
elif event_type == "reminder": 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) return handle_reminder(event)
elif event_type == "order_confirmed":
return handle_order_confirmed(event)
return {"error": f"Unknown event type: {event_type}"} return {"error": f"Unknown event type: {event_type}"}
@ -31,7 +44,7 @@ def handle_menu_published(event):
week = event.get("week", current_week()) week = event.get("week", current_week())
meal_count = event.get("meal_count", "") 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 = [ blocks = [
{ {
"type": "header", "type": "header",
@ -43,7 +56,7 @@ def handle_menu_published(event):
"type": "mrkdwn", "type": "mrkdwn",
"text": ( "text": (
f"*<{form_url}|Place your order>*\n\n" 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" f"*Menu:* {meal_count} meals available"
), ),
}, },
@ -63,12 +76,24 @@ def handle_orders_aggregated(event):
total_employees = int(summary.get("total_employees", 0)) total_employees = int(summary.get("total_employees", 0))
total_meals = int(summary.get("total_meals", 0)) total_meals = int(summary.get("total_meals", 0))
grand_total = float(summary.get("grand_total", 0)) grand_total = float(summary.get("grand_total", 0))
employee_total = float(summary.get("employee_total", grand_total))
meal_lines = [] meal_lines = []
for m in summary.get("meals", []): 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) 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." text = f"Meal orders closed for {week}. {total_employees} employees, {total_meals} meals, ${grand_total:.2f} total."
blocks = [ blocks = [
{ {
@ -82,7 +107,7 @@ def handle_orders_aggregated(event):
"text": ( "text": (
f"*{total_employees}* employees ordered\n" f"*{total_employees}* employees ordered\n"
f"*{total_meals}* total meals\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} 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): 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()) week = event.get("week", current_week())
form_url = os.environ.get("FORM_URL", "") form_url = os.environ.get("FORM_URL", "")
@ -120,14 +196,14 @@ def handle_reminder(event):
continue continue
send_dm( send_dm(
slack_id, 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=[ blocks=[
{ {
"type": "section", "type": "section",
"text": { "text": {
"type": "mrkdwn", "type": "mrkdwn",
"text": ( "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>*" f"*<{form_url}|Place your order>*"
), ),
}, },

View file

@ -1,13 +1,33 @@
import json import json
import logging
import os import os
from datetime import datetime import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from shared.db import current_week, get_form_status, get_roster, put_order import boto3
from shared.secrets import get_secret
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") EASTERN = ZoneInfo("America/New_York")
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
_api_key = None _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: def _get_api_key() -> str:
@ -17,6 +37,78 @@ def _get_api_key() -> str:
return _api_key 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 _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:
try:
_google_client_id = get_parameter(param, decrypt=False) or ""
except Exception as exc:
logger.warning(
"Could not fetch Google client ID from SSM (%s): %s", param, exc
)
_google_client_id = ""
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): def lambda_handler(event, context):
method = event.get("requestContext", {}).get("http", {}).get("method", "GET") method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
path = event.get("rawPath", "") path = event.get("rawPath", "")
@ -36,7 +128,19 @@ def lambda_handler(event, context):
def handle_form_status(event): def handle_form_status(event):
week = event.get("pathParameters", {}).get("week", current_week()) week = event.get("pathParameters", {}).get("week", current_week())
status = get_form_status(week) status = get_form_status(week)
return response(200, {"week": week, "status": status}) result = {"week": week, "status": status}
if status == "closed":
# Compute next Monday 8:00 AM Eastern, accounting for DST
now_et = datetime.now(EASTERN)
days_until_monday = (7 - now_et.weekday()) % 7
if days_until_monday == 0 and now_et.hour >= 8:
# If today is Monday past 8am, next Monday is 7 days away
days_until_monday = 7
next_monday = (now_et + timedelta(days=days_until_monday)).replace(
hour=8, minute=0, second=0, microsecond=0
)
result["reopen_at"] = int(next_monday.timestamp())
return response(200, result)
def handle_roster(): def handle_roster():
@ -55,8 +159,28 @@ def handle_submit(event):
except json.JSONDecodeError: except json.JSONDecodeError:
return response(400, {"error": "Invalid JSON"}) return response(400, {"error": "Invalid JSON"})
name = body.get("employee_name", "").strip() # --- Authentication ---
email = body.get("employee_email", "").strip() # If Google auth is configured, require a valid google_id_token.
# Manual name/email fallback is only allowed when Google auth is NOT configured.
google_auth_enabled = bool(_get_google_client_id())
google_token = body.get("google_id_token")
if google_auth_enabled:
if not google_token:
return response(403, {"error": "Google authentication is required"})
user_info, verify_status = _verify_google_token(google_token)
if verify_status == "unavailable":
return response(
503, {"error": "Authentication service temporarily unavailable"}
)
if user_info is None:
return response(403, {"error": "Invalid or unauthorized Google account"})
name = user_info["name"]
email = user_info["email"]
else:
name = body.get("employee_name", "").strip()
email = body.get("employee_email", "").strip()
items = body.get("items", []) items = body.get("items", [])
if not name: if not name:
@ -74,25 +198,74 @@ def handle_submit(event):
return response(404, {"error": "No menu available for this week"}) return response(404, {"error": "No menu available for this week"})
filtered_items = [i for i in items if i.get("quantity", 0) > 0] 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 = ( # --- Price calculation using Decimal for financial precision ---
"".join(c if c.isalnum() or c in "- " else "" for c in name) # Rounding approach (two-step intermediate rounding):
.strip() # 1. bulk_price = retail * bulk_mult, rounded to 2 decimal places
.replace(" ", "-") # 2. emp_price = bulk_price * subsidy_mult, rounded to 2 decimal places
.lower() # 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 = { order_data = {
"employee_name": name, "employee_name": name,
"employee_email": email, "employee_email": email,
"submitted_at": datetime.now(EASTERN).isoformat(), "submitted_at": datetime.now(EASTERN).isoformat(),
"items": filtered_items, "items": filtered_items,
"total": round(total, 2), "total": total,
} }
put_order(week, slug, order_data) 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( return response(
200, 200,
{ {

View file

@ -41,12 +41,17 @@ def load_orders(week: str) -> list[dict]:
def generate_order_summary(orders: list[dict]) -> dict: def generate_order_summary(orders: list[dict]) -> dict:
"""Aggregate all meals across employees into a single order for Redefine.""" """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 order in orders:
for item in order.get("items", []): for item in order.get("items", []):
name = item["name"] name = item["name"]
meal_totals[name]["quantity"] += item.get("quantity", 0) 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 = [] summary = []
for name, data in sorted(meal_totals.items()): for name, data in sorted(meal_totals.items()):
@ -54,12 +59,17 @@ def generate_order_summary(orders: list[dict]) -> dict:
{ {
"meal": name, "meal": name,
"quantity": data["quantity"], "quantity": data["quantity"],
"unit_price": data["unit_price"], "unit_price": data["bulk_price"],
"line_total": round(data["unit_price"] * data["quantity"], 2), "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) 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) total_meals = sum(s["quantity"] for s in summary)
return { return {
@ -68,6 +78,7 @@ def generate_order_summary(orders: list[dict]) -> dict:
"total_employees": len(orders), "total_employees": len(orders),
"total_meals": total_meals, "total_meals": total_meals,
"grand_total": round(grand_total, 2), "grand_total": round(grand_total, 2),
"employee_total": round(employee_total, 2),
"meals": summary, "meals": summary,
} }
@ -150,6 +161,13 @@ def main():
print(f"{meal['meal']:<45} {meal['quantity']:>4} ${meal['line_total']:>7.2f}") print(f"{meal['meal']:<45} {meal['quantity']:>4} ${meal['line_total']:>7.2f}")
print("-" * 60) print("-" * 60)
print(f"{'TOTAL':<45} {summary['total_meals']:>4} ${summary['grand_total']:>7.2f}") 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(f"\n{summary['total_employees']} employees ordered")
print("\nFiles generated:") 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 json
import urllib.request
from datetime import datetime from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path from pathlib import Path
import boto3
from flask import Flask, jsonify, request, send_file from flask import Flask, jsonify, request, send_file
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
@ -58,14 +61,58 @@ def get_roster():
return jsonify(config.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"]) @app.route("/api/submit-order", methods=["POST"])
def submit_order(): def submit_order():
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({"error": "No data received"}), 400 return jsonify({"error": "No data received"}), 400
name = data.get("employee_name", "").strip() google_token = data.get("google_id_token")
email = data.get("employee_email", "").strip() 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", []) items = data.get("items", [])
if not name: if not name:
@ -75,6 +122,27 @@ def submit_order():
if not items or not any(i.get("quantity", 0) > 0 for i in items): 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 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_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 = current_week()
week_dir = ORDERS_DIR / week week_dir = ORDERS_DIR / week
week_dir.mkdir(parents=True, exist_ok=True) week_dir.mkdir(parents=True, exist_ok=True)
@ -87,17 +155,19 @@ def submit_order():
) )
order_file = week_dir / f"{safe_name}.json" order_file = week_dir / f"{safe_name}.json"
total = float(
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
)
order = { order = {
"employee_name": name, "employee_name": name,
"employee_email": email, "employee_email": email,
"week": week, "week": week,
"submitted_at": datetime.now().isoformat(), "submitted_at": datetime.now().isoformat(),
"items": [i for i in items if i.get("quantity", 0) > 0], "items": filtered,
"total": sum( "total": total,
(i.get("price", 0) or 0) * i.get("quantity", 0)
for i in items
if i.get("quantity", 0) > 0
),
} }
with open(order_file, "w") as f: with open(order_file, "w") as f:
@ -108,6 +178,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>") @app.route("/api/orders/<week>")
def get_orders(week: str): def get_orders(week: str):
week_dir = ORDERS_DIR / week week_dir = ORDERS_DIR / week

View file

@ -35,17 +35,196 @@ def latest_menu() -> dict:
def generate_form( 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: ) -> str:
meals_json = json.dumps(menu["meals"]) meals_json = json.dumps(menu["meals"]).replace("</", "<\\/")
roster_json = json.dumps(config.get("roster", [])) roster_json = json.dumps(config.get("roster", [])).replace("</", "<\\/")
deadline = config.get("order_deadline", "Thursday 11:59 PM") deadline = config.get("order_deadline", "Thursday 11:59 PM")
week = datetime.now().strftime("%Y-W%U") week = datetime.now().strftime("%Y-W%U")
scraped_at = menu.get("scraped_at", "unknown") scraped_at = menu.get("scraped_at", "unknown")
submit_url = f"{api_url}/api/submit-order" if api_url else "/api/submit-order" 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" 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)
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> return f"""<!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -53,6 +232,7 @@ def generate_form(
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sea Haven — Meal Order ({week})</title> <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> <style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }} * {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; color: #1d1d1f; }} 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; }} .submit-btn:hover:not(:disabled) {{ opacity: 0.85; }}
.success-msg {{ text-align: center; padding: 60px 20px; }} .success-msg {{ text-align: center; padding: 60px 20px; }}
.success-msg h2 {{ color: #15803d; margin-bottom: 8px; }} .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; }} .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; }} .search-bar {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 16px; }}
body {{ padding-bottom: 80px; }} 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; }} .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> </style>
{
'<script src="https://accounts.google.com/gsi/client" async defer></script>'
if use_google_auth
else ""
}
</head> </head>
<body> <body>
<div class="closed-overlay" id="closed-overlay">
<div class="container" id="app"> <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> <header>
<h1>Sea Haven Meal Order</h1> <h1>Sea Haven Meal Order</h1>
<p>Week of {week} &middot; Menu scraped {scraped_at[:10]}</p> <p>Week of {week} &middot; Menu scraped {scraped_at[:10]}</p>
</header> </header>
<div class="deadline">Order deadline: {deadline}</div> <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"> {auth_section_html}
<label for="emp-name">Your Name</label>
<select id="emp-name"><option value="">Loading...</option></select>
<input type="hidden" id="emp-email">
</div>
<input type="text" class="search-bar" id="search" placeholder="Search meals..."> <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 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 class="inner">
<div> <div>
<span class="total" id="total-display">$0.00</span> <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;"> <div id="success" class="success-msg" style="display:none;">
<h2>Order submitted!</h2> <h2>Order submitted!</h2>
<p id="success-detail"></p> <p id="success-detail"></p>
<button class="back-btn" onclick="location.reload()">Back to Menu</button>
</div> </div>
</div> </div>
@ -141,12 +375,50 @@ const STATUS_URL = '{status_url}';
const ROSTER_URL = '{roster_url}'; const ROSTER_URL = '{roster_url}';
const API_KEY = {api_key_json}; const API_KEY = {api_key_json};
const WEEK = '{week}'; const WEEK = '{week}';
const BULK_DISCOUNT = {bulk_discount};
const COMPANY_SUBSIDY = {company_subsidy};
const quantities = {{}}; const quantities = {{}};
let formClosed = false; 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() {{ function init() {{
checkFormStatus(); checkFormStatus();
loadRoster(); {
"waitForGoogleAuth();"
if use_google_auth
else "loadRoster(); checkDuplicateOrder();"
}
// Build filter buttons // Build filter buttons
const tags = new Set(); const tags = new Set();
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t))); MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t)));
@ -186,23 +458,29 @@ function renderMeals() {{
let tagsHtml = ''; let tagsHtml = '';
if (meal.is_new) tagsHtml += '<span class="new">NEW</span>'; 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 descHtml = meal.description ? `<div class="meal-desc">${{escapeHtml(meal.description)}}</div>` : '';
const price = meal.price ? `$${{meal.price.toFixed(2)}}` : '—'; 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 = ` 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-info">
<div class="meal-name">${{meal.name}}</div> <div class="meal-name">${{safeName}}</div>
${{descHtml}} ${{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 class="meal-tags">${{tagsHtml}}</div>
</div> </div>
<div class="qty-control"> <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> <input class="qty" type="text" value="${{qty}}" readonly>
<button onclick="changeQty(${{i}}, 1)">+</button> <button onclick="changeQty(${{i}}, 1)" ${{formClosed ? 'disabled' : ''}}>+</button>
</div> </div>
`; `;
list.appendChild(card); list.appendChild(card);
@ -221,58 +499,22 @@ function changeQty(index, delta) {{
function updateTotal() {{ function updateTotal() {{
let total = 0, count = 0; let total = 0, count = 0;
Object.entries(quantities).forEach(([i, qty]) => {{ Object.entries(quantities).forEach(([i, qty]) => {{
total += (MEALS[i].price || 0) * qty; total += employeePrice(MEALS[i].price || 0) * qty;
count += qty; count += qty;
}}); }});
document.getElementById('total-display').textContent = `$${{total.toFixed(2)}}`; document.getElementById('total-display').textContent = `$${{total.toFixed(2)}}`;
document.getElementById('count-display').textContent = `${{count}} meal${{count !== 1 ? 's' : ''}}`; 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() {{ {submit_order_js}
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, ""
price: MEALS[i].price, if use_google_auth
quantity: qty, else '''async function loadRoster() {{
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() {{
try {{ try {{
const res = await fetch(ROSTER_URL); const res = await fetch(ROSTER_URL);
const data = await res.json(); const data = await res.json();
@ -301,7 +543,8 @@ async function loadRoster() {{
}}); }});
}} }}
}} }}
'''
}
async function checkFormStatus() {{ async function checkFormStatus() {{
if (!STATUS_URL) return; if (!STATUS_URL) return;
try {{ try {{
@ -309,18 +552,56 @@ async function checkFormStatus() {{
const data = await res.json(); const data = await res.json();
if (data.status === 'closed') {{ if (data.status === 'closed') {{
formClosed = true; formClosed = true;
const banner = document.createElement('div'); document.getElementById('closed-overlay').style.display = 'flex';
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.querySelectorAll('.qty-control button').forEach(b => b.disabled = true); 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) {{}} }} 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(); init();
</script> </script>
</body> </body>
@ -335,11 +616,58 @@ def main():
parser.add_argument( parser.add_argument(
"--api-key", default="", help="API key for order submission (cloud mode)" "--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() args = parser.parse_args()
config = load_config() config = load_config()
menu = latest_menu() 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") week = datetime.now().strftime("%Y-W%U")
output_file = OUTPUT_DIR / f"order-form-{week}.html" 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(), "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

@ -202,6 +202,8 @@ Resources:
Type: AWS::Serverless::HttpApi Type: AWS::Serverless::HttpApi
Properties: Properties:
StageName: $default 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: CorsConfiguration:
AllowOrigins: AllowOrigins:
- !If - !If
@ -230,6 +232,8 @@ Resources:
Environment: Environment:
Variables: Variables:
FORM_API_KEY_SECRET: meal-order-manager/form-api-key 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: Policies:
- DynamoDBCrudPolicy: - DynamoDBCrudPolicy:
TableName: !Ref OrdersTable TableName: !Ref OrdersTable
@ -237,6 +241,12 @@ Resources:
- Effect: Allow - Effect: Allow
Action: secretsmanager:GetSecretValue Action: secretsmanager:GetSecretValue
Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*' 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: Events:
SubmitOrder: SubmitOrder:
Type: HttpApi Type: HttpApi
@ -275,18 +285,20 @@ Resources:
Environment: Environment:
Variables: Variables:
AGGREGATE_FUNCTION_ARN: !GetAtt AggregateOrdersFunction.Arn 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: Events:
CloseEST: CloseEST:
Type: Schedule Type: Schedule
Properties: Properties:
Schedule: cron(0 23 ? * THU *) Schedule: cron(59 4 ? * FRI *)
Description: 'Close form Thursday 6pm EST (23:00 UTC)' Description: 'Close form Thursday 11:59pm EST (04:59 UTC Friday)'
Enabled: true Enabled: true
CloseEDT: CloseEDT:
Type: Schedule Type: Schedule
Properties: Properties:
Schedule: cron(0 22 ? * THU *) Schedule: cron(59 3 ? * FRI *)
Description: 'Close form Thursday 6pm EDT (22:00 UTC)' Description: 'Close form Thursday 11:59pm EDT (03:59 UTC Friday)'
Enabled: true Enabled: true
AggregateOrdersFunction: AggregateOrdersFunction:
@ -460,6 +472,11 @@ Resources:
Value: CHANGE_ME Value: CHANGE_ME
Description: Slack channel ID for meal order notifications 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: Outputs:
ApiUrl: ApiUrl:
Description: API Gateway endpoint URL 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_thursday(self, mock_dt):
"""Thursday 11pm ET -> skipped (not Friday)."""
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23) # Thursday
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "skipped"
@patch("close_form_handler.datetime")
def test_skipped_friday_before_11pm(self, mock_dt):
"""Friday 3am ET (UTC cron fires but too early) -> 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.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_friday_at_11pm(
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
):
"""Friday 11pm ET -> proceeds to close."""
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 23) # Friday 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_friday_at_1159pm(
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
):
"""Friday 11:59pm ET -> proceeds to close."""
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 23, 59)
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "closed"
@patch("close_form_handler.datetime")
def test_skipped_on_wednesday(self, mock_dt):
"""Wednesday at any hour -> skipped."""
mock_dt.now.return_value = _make_datetime(2026, 5, 13, 23)
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "skipped"
@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):
"""Friday 11pm but already closed -> returns already_closed."""
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 23)
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "already_closed"

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"
)

1081
tests/test_submit_order.py Normal file

File diff suppressed because it is too large Load diff