Add discount pricing, Google auth, and order hardening (#10)
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:
Adam Moussa 2026-05-13 18:00:21 -04:00 • committed by GitHub
parent a8adbdc116
commit a752c24e0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 3530 additions and 144 deletions

View file

@ -72,11 +72,46 @@ jobs:
echo "::add-mask::$API_KEY" echo "::add-mask::$API_KEY"
echo "api_key=$API_KEY" >> $GITHUB_OUTPUT echo "api_key=$API_KEY" >> $GITHUB_OUTPUT
- name: Get discount settings
id: discount
run: |
RESULT=$(aws dynamodb get-item \
--table-name meal-order-manager-orders \
--key '{"PK":{"S":"CONFIG"},"SK":{"S":"SETTINGS"}}' \
--output json 2>/dev/null || echo '{}')
BULK=$(echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('Item',{}).get('bulk_discount_percent',{}).get('N','0'))
" 2>/dev/null || echo "0")
SUBSIDY=$(echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('Item',{}).get('company_subsidy_percent',{}).get('N','0'))
" 2>/dev/null || echo "0")
echo "bulk_discount=$BULK" >> $GITHUB_OUTPUT
echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT
- name: Get Google Client ID
id: google
run: |
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
--name /meal-order-manager/google-client-id \
--query 'Parameter.Value' \
--output text 2>/dev/null || echo "")
if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then
GOOGLE_CLIENT_ID=""
fi
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
- name: Generate order form - name: Generate order form
run: | run: |
python3 src/server/generate_form.py \ python3 src/server/generate_form.py \
--api-url "${{ steps.stack.outputs.api_url }}" \ --api-url "${{ steps.stack.outputs.api_url }}" \
--api-key "${{ steps.apikey.outputs.api_key }}" --api-key "${{ steps.apikey.outputs.api_key }}" \
--bulk-discount "${{ steps.discount.outputs.bulk_discount }}" \
--company-subsidy "${{ steps.discount.outputs.company_subsidy }}" \
${{ steps.google.outputs.client_id && format('--google-client-id "{0}"', steps.google.outputs.client_id) || '' }}
- name: Upload menu to DynamoDB - name: Upload menu to DynamoDB
run: python3 scripts/upload_menu.py run: python3 scripts/upload_menu.py

View file

@ -1,8 +1,11 @@
{ {
"menu_url": "https://redefinemeals.com/menu", "menu_url": "https://redefinemeals.com/menu",
"order_deadline": "Wednesday 11:59 PM", "order_deadline": "Thursday at 11:59 PM",
"output_dir": "output", "output_dir": "output",
"orders_dir": "orders", "orders_dir": "orders",
"bulk_discount_percent": 10,
"company_subsidy_percent": 50,
"google_client_id": "",
"roster": [ "roster": [
{"name": "Example Employee", "email": "example@seahavenind.com"} {"name": "Example Employee", "email": "example@seahavenind.com"}
] ]

View file

@ -65,13 +65,18 @@ def lambda_handler(event, context):
def build_summary(orders: list[dict], week: str) -> dict: def build_summary(orders: list[dict], week: str) -> dict:
meal_totals = defaultdict(lambda: {"quantity": 0, "unit_price": 0}) meal_totals = defaultdict(
lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0}
)
for order in orders: for order in orders:
for item in order.get("items", []): for item in order.get("items", []):
name = item["name"] name = item["name"]
qty = int(item.get("quantity", 0)) qty = int(item.get("quantity", 0))
meal_totals[name]["quantity"] += qty meal_totals[name]["quantity"] += qty
meal_totals[name]["unit_price"] = float(item.get("price", 0)) meal_totals[name]["bulk_price"] = float(
item.get("bulk_price", item.get("price", 0))
)
meal_totals[name]["employee_price"] = float(item.get("price", 0))
meals = [] meals = []
for name, data in sorted(meal_totals.items()): for name, data in sorted(meal_totals.items()):
@ -79,8 +84,12 @@ def build_summary(orders: list[dict], week: str) -> dict:
{ {
"meal": name, "meal": name,
"quantity": data["quantity"], "quantity": data["quantity"],
"unit_price": data["unit_price"], "unit_price": data["bulk_price"],
"line_total": round(data["unit_price"] * data["quantity"], 2), "employee_unit_price": data["employee_price"],
"line_total": round(data["bulk_price"] * data["quantity"], 2),
"employee_line_total": round(
data["employee_price"] * data["quantity"], 2
),
} }
) )
@ -90,6 +99,7 @@ def build_summary(orders: list[dict], week: str) -> dict:
"total_employees": len(orders), "total_employees": len(orders),
"total_meals": sum(m["quantity"] for m in meals), "total_meals": sum(m["quantity"] for m in meals),
"grand_total": round(sum(m["line_total"] for m in meals), 2), "grand_total": round(sum(m["line_total"] for m in meals), 2),
"employee_total": round(sum(m["employee_line_total"] for m in meals), 2),
"meals": meals, "meals": meals,
} }

View file

@ -1,14 +1,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)

View file

@ -1,11 +1,18 @@
import json import json
import os import os
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from zoneinfo import ZoneInfo
from shared.db import current_week, get_orders, get_roster, get_summary from shared.db import current_week, get_orders, get_roster, get_summary
from shared.slack import post_channel_message, send_dm from shared.slack import post_channel_message, send_dm
def _escape_mrkdwn(text: str) -> str:
"""Escape Slack mrkdwn special characters."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class DecimalEncoder(json.JSONEncoder): class DecimalEncoder(json.JSONEncoder):
def default(self, o): def default(self, o):
if isinstance(o, Decimal): if isinstance(o, Decimal):
@ -21,7 +28,13 @@ def lambda_handler(event, context):
elif event_type == "orders_aggregated": elif event_type == "orders_aggregated":
return handle_orders_aggregated(event) return handle_orders_aggregated(event)
elif event_type == "reminder": elif event_type == "reminder":
# Guard against duplicate triggers from dual EST/EDT schedules
now_et = datetime.now(ZoneInfo("America/New_York"))
if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday
return {"status": "skipped", "reason": "outside reminder window"}
return handle_reminder(event) return handle_reminder(event)
elif event_type == "order_confirmed":
return handle_order_confirmed(event)
return {"error": f"Unknown event type: {event_type}"} return {"error": f"Unknown event type: {event_type}"}
@ -31,7 +44,7 @@ def handle_menu_published(event):
week = event.get("week", current_week()) week = event.get("week", current_week())
meal_count = event.get("meal_count", "") meal_count = event.get("meal_count", "")
text = "This week's meal order is open! Deadline: Thursday 6pm." text = "This week's meal order is open! Deadline: Thursday at 11:59pm."
blocks = [ blocks = [
{ {
"type": "header", "type": "header",
@ -43,7 +56,7 @@ def handle_menu_published(event):
"type": "mrkdwn", "type": "mrkdwn",
"text": ( "text": (
f"*<{form_url}|Place your order>*\n\n" f"*<{form_url}|Place your order>*\n\n"
f"*Deadline:* Thursday 6pm\n" f"*Deadline:* Thursday at 11:59pm\n"
f"*Menu:* {meal_count} meals available" f"*Menu:* {meal_count} meals available"
), ),
}, },
@ -63,12 +76,24 @@ def handle_orders_aggregated(event):
total_employees = int(summary.get("total_employees", 0)) total_employees = int(summary.get("total_employees", 0))
total_meals = int(summary.get("total_meals", 0)) total_meals = int(summary.get("total_meals", 0))
grand_total = float(summary.get("grand_total", 0)) grand_total = float(summary.get("grand_total", 0))
employee_total = float(summary.get("employee_total", grand_total))
meal_lines = [] meal_lines = []
for m in summary.get("meals", []): for m in summary.get("meals", []):
meal_lines.append(f"{m['meal']}: *{int(m['quantity'])}*") meal_lines.append(f"{_escape_mrkdwn(m['meal'])}: *{int(m['quantity'])}*")
meal_list = "\n".join(meal_lines) meal_list = "\n".join(meal_lines)
has_subsidy = employee_total < grand_total
totals_text = (
f"*${grand_total:.2f}* order total (bulk rate)\n"
f"*${employee_total:.2f}* payroll deductions"
+ (
f"\n*${grand_total - employee_total:.2f}* company subsidy"
if has_subsidy
else ""
)
)
text = f"Meal orders closed for {week}. {total_employees} employees, {total_meals} meals, ${grand_total:.2f} total." text = f"Meal orders closed for {week}. {total_employees} employees, {total_meals} meals, ${grand_total:.2f} total."
blocks = [ blocks = [
{ {
@ -82,7 +107,7 @@ def handle_orders_aggregated(event):
"text": ( "text": (
f"*{total_employees}* employees ordered\n" f"*{total_employees}* employees ordered\n"
f"*{total_meals}* total meals\n" f"*{total_meals}* total meals\n"
f"*${grand_total:.2f}* grand total" f"{totals_text}"
), ),
}, },
}, },
@ -100,7 +125,58 @@ def handle_orders_aggregated(event):
return {"status": "notified", "event": "orders_aggregated", "week": week} return {"status": "notified", "event": "orders_aggregated", "week": week}
def handle_order_confirmed(event):
email = event.get("employee_email", "").lower()
name = event.get("employee_name", "")
items = event.get("items", [])
total = float(event.get("total", 0))
week = event.get("week", current_week())
roster = get_roster()
employee = next((e for e in roster if e["email"].lower() == email), None)
if not employee or not employee.get("slack_user_id"):
return {"status": "no_slack_id", "email": email}
item_lines = []
for item in items:
qty = int(item.get("quantity", 0))
price = float(item.get("price", 0))
item_lines.append(
f"{_escape_mrkdwn(item['name'])} x{qty} — your cost: ${price * qty:.2f}"
)
item_list = "\n".join(item_lines)
send_dm(
employee["slack_user_id"],
f"Order confirmed for {week}: ${total:.2f} total.",
blocks=[
{
"type": "header",
"text": {"type": "plain_text", "text": f"Order Confirmed — {week}"},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"Hey {name.split()[0] if name.strip() else 'there'}! Your meal order has been submitted.\n\n"
f"{item_list}\n\n"
f"*Your total: ${total:.2f}* (payroll deduction)"
),
},
},
],
)
return {"status": "confirmed", "week": week, "employee": name}
def handle_reminder(event): def handle_reminder(event):
# Guard against duplicate triggers from dual EST/EDT schedules
now_et = datetime.now(ZoneInfo("America/New_York"))
if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday
return {"status": "skipped", "reason": "outside reminder window"}
week = event.get("week", current_week()) week = event.get("week", current_week())
form_url = os.environ.get("FORM_URL", "") form_url = os.environ.get("FORM_URL", "")
@ -120,14 +196,14 @@ def handle_reminder(event):
continue continue
send_dm( send_dm(
slack_id, slack_id,
f"Meal orders close at 6pm today. Place your order: {form_url}", f"Meal orders close today at 11:59pm. Place your order: {form_url}",
blocks=[ blocks=[
{ {
"type": "section", "type": "section",
"text": { "text": {
"type": "mrkdwn", "type": "mrkdwn",
"text": ( "text": (
f"Hey {emp['name'].split()[0]}! Meal orders close at *6pm today*.\n\n" f"Hey {emp['name'].split()[0] if emp['name'].strip() else 'there'}! Meal orders close today at *11:59pm*.\n\n"
f"*<{form_url}|Place your order>*" f"*<{form_url}|Place your order>*"
), ),
}, },

View file

@ -1,13 +1,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,
{ {

View file

@ -41,12 +41,17 @@ def load_orders(week: str) -> list[dict]:
def generate_order_summary(orders: list[dict]) -> dict: def generate_order_summary(orders: list[dict]) -> dict:
"""Aggregate all meals across employees into a single order for Redefine.""" """Aggregate all meals across employees into a single order for Redefine."""
meal_totals = defaultdict(lambda: {"quantity": 0, "unit_price": 0}) meal_totals = defaultdict(
lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0}
)
for order in orders: for order in orders:
for item in order.get("items", []): for item in order.get("items", []):
name = item["name"] name = item["name"]
meal_totals[name]["quantity"] += item.get("quantity", 0) meal_totals[name]["quantity"] += item.get("quantity", 0)
meal_totals[name]["unit_price"] = item.get("price", 0) meal_totals[name]["bulk_price"] = item.get(
"bulk_price", item.get("price", 0)
)
meal_totals[name]["employee_price"] = item.get("price", 0)
summary = [] summary = []
for name, data in sorted(meal_totals.items()): for name, data in sorted(meal_totals.items()):
@ -54,12 +59,17 @@ def generate_order_summary(orders: list[dict]) -> dict:
{ {
"meal": name, "meal": name,
"quantity": data["quantity"], "quantity": data["quantity"],
"unit_price": data["unit_price"], "unit_price": data["bulk_price"],
"line_total": round(data["unit_price"] * data["quantity"], 2), "employee_unit_price": data["employee_price"],
"line_total": round(data["bulk_price"] * data["quantity"], 2),
"employee_line_total": round(
data["employee_price"] * data["quantity"], 2
),
} }
) )
grand_total = sum(s["line_total"] for s in summary) grand_total = sum(s["line_total"] for s in summary)
employee_total = sum(s["employee_line_total"] for s in summary)
total_meals = sum(s["quantity"] for s in summary) total_meals = sum(s["quantity"] for s in summary)
return { return {
@ -68,6 +78,7 @@ def generate_order_summary(orders: list[dict]) -> dict:
"total_employees": len(orders), "total_employees": len(orders),
"total_meals": total_meals, "total_meals": total_meals,
"grand_total": round(grand_total, 2), "grand_total": round(grand_total, 2),
"employee_total": round(employee_total, 2),
"meals": summary, "meals": summary,
} }
@ -150,6 +161,13 @@ def main():
print(f"{meal['meal']:<45} {meal['quantity']:>4} ${meal['line_total']:>7.2f}") print(f"{meal['meal']:<45} {meal['quantity']:>4} ${meal['line_total']:>7.2f}")
print("-" * 60) print("-" * 60)
print(f"{'TOTAL':<45} {summary['total_meals']:>4} ${summary['grand_total']:>7.2f}") print(f"{'TOTAL':<45} {summary['total_meals']:>4} ${summary['grand_total']:>7.2f}")
if (
summary.get("employee_total") is not None
and summary["employee_total"] != summary["grand_total"]
):
print(f"{'PAYROLL DEDUCTIONS':<45} ${summary['employee_total']:>7.2f}")
subsidy = round(summary["grand_total"] - summary["employee_total"], 2)
print(f"{'COMPANY SUBSIDY':<45} ${subsidy:>7.2f}")
print(f"\n{summary['total_employees']} employees ordered") print(f"\n{summary['total_employees']} employees ordered")
print("\nFiles generated:") print("\nFiles generated:")

View file

@ -6,9 +6,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

View file

@ -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">&#127869;</div>
<h1>Orders Are Closed</h1>
<p>Orders open again Monday at 8:00 AM ET</p>
<div class="countdown" id="countdown"></div>
<div class="countdown-label">until orders open</div>
</div>
</div>
{
'<div class="auth-overlay" id="auth-overlay"><div class="auth-card"><div class="logo">&#127869;</div><h1>Sea Haven Meal Order</h1><p>Sign in with your company Google account to place your order.</p><div id="g-signin-btn" style="display:flex;justify-content:center;"></div></div></div>'
if use_google_auth
else ""
}
<div class="container" id="app" {'style="display:none;"' if use_google_auth else ""}>
<header> <header>
<h1>Sea Haven Meal Order</h1> <h1>Sea Haven Meal Order</h1>
<p>Week of {week} &middot; Menu scraped {scraped_at[:10]}</p> <p>Week of {week} &middot; Menu scraped {scraped_at[:10]}</p>
</header> </header>
<div class="deadline">Order deadline: {deadline}</div> <div class="deadline">Order deadline: {deadline}</div>
<div class="duplicate-warning" id="duplicate-warning" style="display:none;">You've already submitted an order this week. Submitting again will replace your previous order.</div>
{
'<div class="discount-banner">Prices reflect employee cost after '
+ (f"{bulk_discount:g}% bulk discount" if bulk_discount > 0 else "")
+ (" + " if bulk_discount > 0 and company_subsidy > 0 else "")
+ (f"{company_subsidy:g}% company subsidy" if company_subsidy > 0 else "")
+ "</div>"
if has_discount
else ""
}
<div class="employee-info"> {auth_section_html}
<label for="emp-name">Your Name</label>
<select id="emp-name"><option value="">Loading...</option></select>
<input type="hidden" id="emp-email">
</div>
<input type="text" class="search-bar" id="search" placeholder="Search meals..."> <input type="text" class="search-bar" id="search" placeholder="Search meals...">
@ -117,7 +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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}}
function employeePrice(retail) {{
if (!retail) return 0;
const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100;
const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100;
return empPrice;
}}
{google_auth_js}
function checkDuplicateOrder() {{
try {{
if (localStorage.getItem('lastOrderWeek') === WEEK) {{
document.getElementById('duplicate-warning').style.display = 'block';
}}
}} catch (e) {{}}
}}
function init() {{ function init() {{
checkFormStatus(); checkFormStatus();
loadRoster(); {
"waitForGoogleAuth();"
if use_google_auth
else "loadRoster(); checkDuplicateOrder();"
}
// Build filter buttons // Build filter buttons
const tags = new Set(); const tags = new Set();
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t))); MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t)));
@ -186,23 +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}} &middot; ${{meal.calories || '?'}} cal &middot; ${{meal.protein || '?'}} protein</div> <div class="meal-meta">${{priceHtml}} &middot; ${{escapeHtml(meal.calories || '?')}} cal &middot; ${{escapeHtml(meal.protein || '?')}} protein</div>
<div class="meal-tags">${{tagsHtml}}</div> <div class="meal-tags">${{tagsHtml}}</div>
</div> </div>
<div class="qty-control"> <div class="qty-control">
<button onclick="changeQty(${{i}}, -1)">&minus;</button> <button onclick="changeQty(${{i}}, -1)" ${{formClosed ? 'disabled' : ''}}>&minus;</button>
<input class="qty" type="text" value="${{qty}}" readonly> <input class="qty" type="text" value="${{qty}}" readonly>
<button onclick="changeQty(${{i}}, 1)">+</button> <button onclick="changeQty(${{i}}, 1)" ${{formClosed ? 'disabled' : ''}}>+</button>
</div> </div>
`; `;
list.appendChild(card); list.appendChild(card);
@ -221,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"

View file

@ -128,3 +128,19 @@ def put_roster(employees: list[dict]):
"updated_at": datetime.now(EASTERN).isoformat(), "updated_at": datetime.now(EASTERN).isoformat(),
} }
) )
def get_settings() -> dict:
resp = _get_table().get_item(Key={"PK": "CONFIG", "SK": "SETTINGS"})
return resp.get("Item", {})
def put_settings(settings: dict):
_get_table().put_item(
Item={
"PK": "CONFIG",
"SK": "SETTINGS",
**_to_decimal(settings),
"updated_at": datetime.now(EASTERN).isoformat(),
}
)

View file

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

View file

@ -202,6 +202,8 @@ Resources:
Type: AWS::Serverless::HttpApi Type: AWS::Serverless::HttpApi
Properties: Properties:
StageName: $default StageName: $default
# CORS only allows the production domain. For local development, use the
# Flask dev server (app.py) which proxies API requests and doesn't enforce CORS.
CorsConfiguration: CorsConfiguration:
AllowOrigins: AllowOrigins:
- !If - !If
@ -230,6 +232,8 @@ Resources:
Environment: Environment:
Variables: Variables:
FORM_API_KEY_SECRET: meal-order-manager/form-api-key FORM_API_KEY_SECRET: meal-order-manager/form-api-key
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
Policies: Policies:
- DynamoDBCrudPolicy: - DynamoDBCrudPolicy:
TableName: !Ref OrdersTable TableName: !Ref OrdersTable
@ -237,6 +241,12 @@ Resources:
- Effect: Allow - Effect: Allow
Action: secretsmanager:GetSecretValue Action: secretsmanager:GetSecretValue
Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*' Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*'
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt SlackNotifierFunction.Arn
- Effect: Allow
Action: ssm:GetParameter
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*'
Events: Events:
SubmitOrder: SubmitOrder:
Type: HttpApi Type: HttpApi
@ -275,18 +285,20 @@ Resources:
Environment: Environment:
Variables: Variables:
AGGREGATE_FUNCTION_ARN: !GetAtt AggregateOrdersFunction.Arn AGGREGATE_FUNCTION_ARN: !GetAtt AggregateOrdersFunction.Arn
# Both EST and EDT schedules fire every week year-round. The handler is
# idempotent, so the "wrong timezone" firing is a harmless no-op.
Events: Events:
CloseEST: CloseEST:
Type: Schedule Type: Schedule
Properties: Properties:
Schedule: cron(0 23 ? * THU *) Schedule: cron(59 4 ? * FRI *)
Description: 'Close form Thursday 6pm EST (23:00 UTC)' Description: 'Close form Thursday 11:59pm EST (04:59 UTC Friday)'
Enabled: true Enabled: true
CloseEDT: CloseEDT:
Type: Schedule Type: Schedule
Properties: Properties:
Schedule: cron(0 22 ? * THU *) Schedule: cron(59 3 ? * FRI *)
Description: 'Close form Thursday 6pm EDT (22:00 UTC)' Description: 'Close form Thursday 11:59pm EDT (03:59 UTC Friday)'
Enabled: true Enabled: true
AggregateOrdersFunction: AggregateOrdersFunction:
@ -460,6 +472,11 @@ Resources:
Value: CHANGE_ME Value: CHANGE_ME
Description: Slack channel ID for meal order notifications Description: Slack channel ID for meal order notifications
# GoogleClientIdParam (/meal-order-manager/google-client-id) is managed
# manually via AWS CLI since it varies per environment. Create it with:
# aws ssm put-parameter --name /meal-order-manager/google-client-id \
# --type String --value "<YOUR_GOOGLE_CLIENT_ID>"
Outputs: Outputs:
ApiUrl: ApiUrl:
Description: API Gateway endpoint URL Description: API Gateway endpoint URL

9
tests/conftest.py Normal file
View file

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

View file

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

117
tests/test_close_form.py Normal file
View 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
View file

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

View file

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

1415
tests/test_submit_order.py Normal file

File diff suppressed because it is too large Load diff