mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 06:33:12 +00:00
Add discount pricing, Google auth, and order hardening (#10)
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
* Add discount settings and two-tier pricing to order aggregation Settings CRUD (get_settings/put_settings) for DynamoDB CONFIG#SETTINGS item. Aggregation now tracks bulk_price and employee_price separately, with grand_total (company cost) and employee_total (payroll deductions). * Add Google OAuth, server-side discounts, and Slack order confirmations Submit order Lambda now verifies Google ID tokens via tokeninfo endpoint, calculates two-tier discount pricing server-side, and async-invokes the Slack notifier for per-employee order confirmation DMs. Deadlines updated to Thursday 11:59pm across all Slack messages. * Update SAM template for Google auth, Slack invocation, and deadline change Add SLACK_NOTIFIER_ARN and GOOGLE_CLIENT_ID_PARAM env vars to submit order function with lambda:InvokeFunction policy. Move close-form schedule to Thursday 11:59pm EST/EDT. * Update order form UI and CI workflow for new features Form now shows discount pricing, responsive grid layout, Google Sign-In overlay, and closed-orders page with countdown timer. CI workflow fetches discount settings from DynamoDB and Google Client ID from SSM. * Add SSM GetParameter permission to submit order Lambda Required for reading the Google Client ID from Parameter Store at /meal-order-manager/google-client-id. * Harden auth, pricing, and reliability in order handlers Enforce Google auth when configured (reject missing tokens with 403), return 503 on token verification outages, switch to Decimal with ROUND_HALF_UP for financial precision, clamp discount bounds 0-100, use email-based slugs, add 5-min cache TTL with time.monotonic(), wrap Slack invocation in try/except, add reopen_at timestamp to closed form status, add reminder dedup guards for dual EST/EDT crons, escape Slack mrkdwn special characters, and handle empty employee names. * Fix XSS risks and add closed-form UX to order page Add escapeHtml() for all scraped content in innerHTML, fix script injection via </script> in JSON, fix JWT base64url decoding, match backend two-step rounding in JS employeePrice(), disable qty buttons and submit when form is closed, add server-driven countdown from reopen_at, add duplicate order warning via localStorage, add back button after submission, embed favicon, use :g format for fractional discounts, and exclude dead loadRoster code when Google auth enabled. * Document CORS, cron idempotency, and SSM config in template Add comments explaining CORS dev server strategy, dual EST/EDT cron idempotency, and manual SSM parameter creation for Google Client ID. * Add unit tests for submit, notify, and aggregate handlers 50 tests covering pricing pipeline (Decimal rounding, clamping, totals), Google auth (enforcement, bypass prevention, audience/domain validation, 503 on outage), email slug generation, form status with reopen_at, input validation, Slack failure resilience, reminder dedup guards, order confirmation DMs, aggregated summaries, CSV generation, and mrkdwn escaping. * Use full email as order slug for defense-in-depth Replace email-prefix slug with full lowercase email to eliminate any possibility of cross-domain collisions, per senior review sign-off. * Remove unused imports flagged by ruff * Apply ruff formatting * Fix PR review findings: auth, rounding, and close-form guard - Remove dead elif branch in submit_order auth (always returned 403) - Catch HTTPError before URLError so expired tokens return 403 not 503 - Wrap SSM get_parameter in try/except for fresh deployments - Add wall-clock guard to close_form handler (Friday >= 11 PM ET) - Add epsilon nudge to JS employeePrice for IEEE 754 boundary match - Switch Flask dev server from round() to Decimal ROUND_HALF_UP - Add tests for HTTPError handling and close_form guard (6 new tests) * Fix close-form weekday guard and SSM auth fail-open - Close form guard: check weekday == 3 (Thursday), not 4 (Friday) — the crons fire at Thursday 11:59 PM ET, when weekday() is 3 - SSM fail-closed: separate _google_auth_configured() (checks env var) from _get_google_client_id() (fetches value). If auth is configured but the SSM fetch fails, return 503 instead of silently falling back to manual auth - Update close_form tests to use Thursday dates - Add test_ssm_failure_fails_closed * Harden Flask dev server auth and escaping - Add hosted domain check to _verify_google_token (mirror Lambda) - Gate auth on config (client_id presence), not request body — prevents bypass by omitting google_id_token when auth is configured - Add discount percentage clamping to match Lambda handler - Add </script> escaping to google_client_id_json * fix: Email order filenames, SSM param TTL, DST-safe reopen_at - Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo) - shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes - form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta) - Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com> * Apply ruff formatting to submit_order handler * fix(server): retry SSM for Google client id after TTL on failure Transient SSM errors no longer cache empty client id for the process lifetime; matches Lambda handler refresh behavior (300s TTL). Co-authored-by: Cursor <cursoragent@cursor.com> * style(server): ruff-format Google client id cache helper Co-authored-by: Cursor <cursoragent@cursor.com> * fix(close-form): accept Fri 00–03 ET catch-up after Thu close cron EventBridge can deliver past midnight ET; widen the wall-clock guard so a delayed Thursday 23:59 UTC cron still closes the form. Idempotent when already closed. Adds test for early Friday; past-window skip now starts Fri 04:00 ET. Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com> * fix(submit-order): bill from Dynamo menu retail, not client JSON Load authoritative meal prices from get_menu(week); reject unknown meal names and return 503 when the menu has no priced meals. Use meal_name in the pricing loop to avoid shadowing the employee name. Adds regression tests for tampering, unknown meals, and empty menu meals. Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com> * fix: use single braces in loadRoster JS nested string Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Eastern fallback countdown * Fix pricing validation and JWT display decoding * Fix optional Google auth detection * Format app.py line length for ruff compliance * Fix auth config check and URL escaping in form - _google_auth_configured() now checks env var presence (intent), not the fetched SSM value — prevents silent auth bypass if SSM param is deleted - Add </script> escaping to URL values in generate_form.py for consistency with other injected values --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
This commit is contained in:
parent
a8adbdc116
commit
a752c24e0f
18 changed files with 3530 additions and 144 deletions
37
.github/workflows/weekly-menu.yml
vendored
37
.github/workflows/weekly-menu.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,29 @@
|
||||||
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)
|
||||||
|
# EventBridge can fire slightly after midnight ET; accept Thu 23:xx or Fri 00–03
|
||||||
|
# ET so a delayed cron still closes the form. Idempotency: already-closed is a no-op.
|
||||||
|
in_close_window = (now_et.weekday() == 3 and now_et.hour == 23) or (
|
||||||
|
now_et.weekday() == 4 and now_et.hour < 4
|
||||||
|
)
|
||||||
|
if not in_close_window:
|
||||||
|
return {
|
||||||
|
"status": "skipped",
|
||||||
|
"reason": "outside close window (must be Thu 23:xx or Fri 00–03 ET)",
|
||||||
|
}
|
||||||
|
|
||||||
week = event.get("week", current_week())
|
week = event.get("week", current_week())
|
||||||
|
|
||||||
status = get_form_status(week)
|
status = get_form_status(week)
|
||||||
|
|
|
||||||
|
|
@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
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>*"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,47 @@
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
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 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_menu,
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
_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 +51,101 @@ 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 _google_auth_configured() -> bool:
|
||||||
|
return bool(os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_parameter_not_found(exc: Exception) -> bool:
|
||||||
|
response_data = getattr(exc, "response", {})
|
||||||
|
if not isinstance(response_data, dict):
|
||||||
|
return False
|
||||||
|
return response_data.get("Error", {}).get("Code") == "ParameterNotFound"
|
||||||
|
|
||||||
|
|
||||||
|
def _official_menu_retail_by_name(week: str) -> dict[str, Decimal]:
|
||||||
|
"""Map meal name -> retail price from Dynamo menu (authoritative for billing)."""
|
||||||
|
row = get_menu(week)
|
||||||
|
meals = (row or {}).get("meals") or []
|
||||||
|
out: dict[str, Decimal] = {}
|
||||||
|
for m in meals:
|
||||||
|
name = (m.get("name") or "").strip()
|
||||||
|
if not name or m.get("price") is None:
|
||||||
|
continue
|
||||||
|
out[name] = Decimal(str(m["price"]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
if not _is_parameter_not_found(exc):
|
||||||
|
raise
|
||||||
|
_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 +165,21 @@ 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":
|
||||||
|
# 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():
|
def handle_roster():
|
||||||
|
|
@ -55,8 +198,40 @@ 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 (SSM param contains a client ID), require a valid
|
||||||
|
# google_id_token. Manual fallback is only allowed when auth is NOT configured.
|
||||||
|
# If SSM fetch fails for any other reason, fail closed (503).
|
||||||
|
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", [])
|
items = body.get("items", [])
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
|
|
@ -74,25 +249,87 @@ 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 = (
|
official_retail = _official_menu_retail_by_name(week)
|
||||||
"".join(c if c.isalnum() or c in "- " else "" for c in name)
|
if not official_retail:
|
||||||
.strip()
|
logger.error("Week %s: menu has no priced meals; refusing order", week)
|
||||||
.replace(" ", "-")
|
return response(503, {"error": "Menu temporarily unavailable"})
|
||||||
.lower()
|
for item in filtered_items:
|
||||||
|
meal_name = (item.get("name") or "").strip()
|
||||||
|
if meal_name not in official_retail:
|
||||||
|
return response(
|
||||||
|
400,
|
||||||
|
{"error": "One or more meals are not on this week's menu"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 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:
|
||||||
|
meal_name = (item.get("name") or "").strip()
|
||||||
|
retail = official_retail[meal_name]
|
||||||
|
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": _eastern_now().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,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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:")
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,13 @@ Orders are saved as JSON files in the orders directory, one per employee per wee
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
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]
|
||||||
|
|
@ -33,6 +37,21 @@ def latest_menu_file() -> Path | None:
|
||||||
return files[0] if files else None
|
return files[0] if files else None
|
||||||
|
|
||||||
|
|
||||||
|
def _official_menu_retail_by_name() -> dict[str, Decimal]:
|
||||||
|
menu_file = latest_menu_file()
|
||||||
|
if not menu_file:
|
||||||
|
return {}
|
||||||
|
with open(menu_file) as f:
|
||||||
|
meals = (json.load(f) or {}).get("meals") or []
|
||||||
|
out: dict[str, Decimal] = {}
|
||||||
|
for meal in meals:
|
||||||
|
name = (meal.get("name") or "").strip()
|
||||||
|
if not name or meal.get("price") is None:
|
||||||
|
continue
|
||||||
|
out[name] = Decimal(str(meal["price"]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
form_file = OUTPUT_DIR / f"order-form-{current_week()}.html"
|
form_file = OUTPUT_DIR / f"order-form-{current_week()}.html"
|
||||||
|
|
@ -58,14 +77,79 @@ def get_roster():
|
||||||
return jsonify(config.get("roster", []))
|
return jsonify(config.get("roster", []))
|
||||||
|
|
||||||
|
|
||||||
|
# Match functions/submit_order/handler.py: TTL so a transient SSM failure cannot
|
||||||
|
# pin client_id to "" for the process lifetime (which would skip Google auth).
|
||||||
|
_GOOGLE_CLIENT_ID_CACHE_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
_google_client_id_cache: str | None = None
|
||||||
|
_google_client_id_cache_ts = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_google_client_id() -> str:
|
||||||
|
global _google_client_id_cache, _google_client_id_cache_ts
|
||||||
|
now = time.monotonic()
|
||||||
|
if (
|
||||||
|
_google_client_id_cache is not None
|
||||||
|
and (now - _google_client_id_cache_ts) <= _GOOGLE_CLIENT_ID_CACHE_TTL_SECONDS
|
||||||
|
):
|
||||||
|
return _google_client_id_cache
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
from_config = (config.get("google_client_id") or "").strip()
|
||||||
|
if from_config:
|
||||||
|
_google_client_id_cache = from_config
|
||||||
|
_google_client_id_cache_ts = now
|
||||||
|
return _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"].get("Value") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
_google_client_id_cache = ""
|
||||||
|
_google_client_id_cache_ts = now
|
||||||
|
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"])
|
@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()
|
client_id = _get_google_client_id()
|
||||||
email = data.get("employee_email", "").strip()
|
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", [])
|
items = data.get("items", [])
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
|
|
@ -75,29 +159,62 @@ 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_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]
|
||||||
|
official_retail = _official_menu_retail_by_name()
|
||||||
|
if not official_retail:
|
||||||
|
return jsonify({"error": "Menu temporarily unavailable"}), 503
|
||||||
|
for item in filtered:
|
||||||
|
meal_name = (item.get("name") or "").strip()
|
||||||
|
if meal_name not in official_retail:
|
||||||
|
return jsonify(
|
||||||
|
{"error": "One or more meals are not on this week's menu"}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
for item in filtered:
|
||||||
|
meal_name = (item.get("name") or "").strip()
|
||||||
|
retail = official_retail[meal_name]
|
||||||
|
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)
|
||||||
|
|
||||||
safe_name = (
|
# Match Lambda: one order file per employee email (not display name).
|
||||||
"".join(c if c.isalnum() or c in "-_ " else "" for c in name)
|
slug = email.strip().lower()
|
||||||
.strip()
|
slug_safe = slug.replace("/", "_").replace("\\", "_")
|
||||||
.replace(" ", "-")
|
order_file = week_dir / f"{slug_safe}.json"
|
||||||
.lower()
|
|
||||||
|
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 = {
|
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 +225,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
|
||||||
|
|
|
||||||
|
|
@ -35,17 +35,201 @@ 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 = (
|
||||||
status_url = f"{api_url}/api/form-status/{week}" if api_url else ""
|
f"{api_url}/api/submit-order" if api_url else "/api/submit-order"
|
||||||
roster_url = f"{api_url}/api/roster" if api_url else "/api/roster"
|
).replace("</", "<\\/")
|
||||||
api_key_json = json.dumps(api_key)
|
status_url = (
|
||||||
|
f"{api_url}/api/form-status/{week}" if api_url else f"/api/form-status/{week}"
|
||||||
|
).replace("</", "<\\/")
|
||||||
|
roster_url = (f"{api_url}/api/roster" if api_url else "/api/roster").replace(
|
||||||
|
"</", "<\\/"
|
||||||
|
)
|
||||||
|
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 payloadBytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||||
|
const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
|
||||||
|
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 +237,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 +274,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">🍽</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">🍽</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} · Menu scraped {scraped_at[:10]}</p>
|
<p>Week of {week} · 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 +353,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 +368,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 +380,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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}}
|
||||||
|
|
||||||
|
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 +463,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}} · ${{meal.calories || '?'}} cal · ${{meal.protein || '?'}} protein</div>
|
<div class="meal-meta">${{priceHtml}} · ${{escapeHtml(meal.calories || '?')}} cal · ${{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)">−</button>
|
<button onclick="changeQty(${{i}}, -1)" ${{formClosed ? 'disabled' : ''}}>−</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,87 +504,52 @@ 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,
|
try {
|
||||||
}}));
|
|
||||||
|
|
||||||
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 {{
|
|
||||||
const res = await fetch(ROSTER_URL);
|
const res = await fetch(ROSTER_URL);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const select = document.getElementById('emp-name');
|
const select = document.getElementById('emp-name');
|
||||||
select.innerHTML = '<option value="">Select your name</option>';
|
select.innerHTML = '<option value="">Select your name</option>';
|
||||||
(data.employees || ROSTER).forEach(emp => {{
|
(data.employees || ROSTER).forEach(emp => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = emp.name;
|
opt.value = emp.name;
|
||||||
opt.dataset.email = emp.email;
|
opt.dataset.email = emp.email;
|
||||||
opt.textContent = emp.name;
|
opt.textContent = emp.name;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
}});
|
});
|
||||||
select.addEventListener('change', () => {{
|
select.addEventListener('change', () => {
|
||||||
const selected = select.options[select.selectedIndex];
|
const selected = select.options[select.selectedIndex];
|
||||||
document.getElementById('emp-email').value = selected?.dataset?.email || '';
|
document.getElementById('emp-email').value = selected?.dataset?.email || '';
|
||||||
}});
|
});
|
||||||
}} catch (e) {{
|
} catch (e) {
|
||||||
const select = document.getElementById('emp-name');
|
const select = document.getElementById('emp-name');
|
||||||
select.innerHTML = '<option value="">Select your name</option>';
|
select.innerHTML = '<option value="">Select your name</option>';
|
||||||
ROSTER.forEach(emp => {{
|
ROSTER.forEach(emp => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = emp.name;
|
opt.value = emp.name;
|
||||||
opt.dataset.email = emp.email;
|
opt.dataset.email = emp.email;
|
||||||
opt.textContent = emp.name;
|
opt.textContent = emp.name;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
}});
|
});
|
||||||
}}
|
}
|
||||||
}}
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
async function checkFormStatus() {{
|
async function checkFormStatus() {{
|
||||||
if (!STATUS_URL) return;
|
if (!STATUS_URL) return;
|
||||||
try {{
|
try {{
|
||||||
|
|
@ -309,18 +557,78 @@ 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;
|
||||||
|
|
||||||
|
const easternFormatter = new Intl.DateTimeFormat('en-US', {{
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
}});
|
||||||
|
|
||||||
|
function getEasternParts(date) {{
|
||||||
|
const values = {{}};
|
||||||
|
easternFormatter.formatToParts(date).forEach(part => {{
|
||||||
|
if (part.type !== 'literal') values[part.type] = Number(part.value);
|
||||||
|
}});
|
||||||
|
return values;
|
||||||
|
}}
|
||||||
|
|
||||||
|
function getEasternOffsetMs(date) {{
|
||||||
|
const parts = getEasternParts(date);
|
||||||
|
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - date.getTime();
|
||||||
|
}}
|
||||||
|
|
||||||
|
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 etNow = getEasternParts(now);
|
||||||
|
const day = new Date(Date.UTC(etNow.year, etNow.month - 1, etNow.day)).getUTCDay();
|
||||||
|
const hour = etNow.hour;
|
||||||
|
|
||||||
|
let daysUntil = (1 - day + 7) % 7;
|
||||||
|
if (daysUntil === 0 && hour >= 8) daysUntil = 7;
|
||||||
|
|
||||||
|
const targetEtMs = Date.UTC(etNow.year, etNow.month - 1, etNow.day + daysUntil, 8, 0, 0);
|
||||||
|
target = new Date(targetEtMs - getEasternOffsetMs(new Date(targetEtMs)));
|
||||||
|
}}
|
||||||
|
|
||||||
|
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 +643,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"
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,32 @@
|
||||||
|
import time
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
|
|
||||||
_cache = {}
|
_secret_cache: dict[str, str] = {}
|
||||||
|
_parameter_cache: dict[str, tuple[str, float]] = {}
|
||||||
_sm = boto3.client("secretsmanager")
|
_sm = boto3.client("secretsmanager")
|
||||||
_ssm = boto3.client("ssm")
|
_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:
|
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)
|
resp = _sm.get_secret_value(SecretId=secret_id)
|
||||||
_cache[secret_id] = resp["SecretString"]
|
_secret_cache[secret_id] = resp["SecretString"]
|
||||||
return _cache[secret_id]
|
return _secret_cache[secret_id]
|
||||||
|
|
||||||
|
|
||||||
def get_parameter(name: str, decrypt: bool = True) -> str:
|
def get_parameter(name: str, decrypt: bool = True) -> str:
|
||||||
if name not in _cache:
|
now = time.monotonic()
|
||||||
resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt)
|
entry = _parameter_cache.get(name)
|
||||||
_cache[name] = resp["Parameter"]["Value"]
|
if entry is not None:
|
||||||
return _cache[name]
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
9
tests/conftest.py
Normal 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))
|
||||||
503
tests/test_aggregate_orders.py
Normal file
503
tests/test_aggregate_orders.py
Normal 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"
|
||||||
117
tests/test_close_form.py
Normal file
117
tests/test_close_form.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""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_after_catchup_window(self, mock_dt):
|
||||||
|
"""Friday 4am ET -> skipped (past Thu 23 / Fri 00–03 catch-up window)."""
|
||||||
|
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 4) # Friday 4am
|
||||||
|
|
||||||
|
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.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_just_after_midnight(
|
||||||
|
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
|
||||||
|
):
|
||||||
|
"""Friday 12:30am ET -> proceeds (delayed EventBridge past Thu 23:59)."""
|
||||||
|
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 0, 30)
|
||||||
|
|
||||||
|
result = close_form_handler.lambda_handler({}, None)
|
||||||
|
|
||||||
|
assert result["status"] == "closed"
|
||||||
|
mock_set.assert_called_once()
|
||||||
|
|
||||||
|
@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
50
tests/test_secrets.py
Normal 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
|
||||||
375
tests/test_slack_notifier.py
Normal file
375
tests/test_slack_notifier.py
Normal 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 & B <C>'."""
|
||||||
|
assert handler._escape_mrkdwn("A & B <C>") == "A & B <C>"
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 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"
|
||||||
|
)
|
||||||
1415
tests/test_submit_order.py
Normal file
1415
tests/test_submit_order.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue