mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 08:53:13 +00:00
Merge 26e1f5bbb4 into a8adbdc116
This commit is contained in:
commit
c16d40114e
18 changed files with 3420 additions and 142 deletions
37
.github/workflows/weekly-menu.yml
vendored
37
.github/workflows/weekly-menu.yml
vendored
|
|
@ -72,11 +72,46 @@ jobs:
|
|||
echo "::add-mask::$API_KEY"
|
||||
echo "api_key=$API_KEY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get discount settings
|
||||
id: discount
|
||||
run: |
|
||||
RESULT=$(aws dynamodb get-item \
|
||||
--table-name meal-order-manager-orders \
|
||||
--key '{"PK":{"S":"CONFIG"},"SK":{"S":"SETTINGS"}}' \
|
||||
--output json 2>/dev/null || echo '{}')
|
||||
BULK=$(echo "$RESULT" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get('Item',{}).get('bulk_discount_percent',{}).get('N','0'))
|
||||
" 2>/dev/null || echo "0")
|
||||
SUBSIDY=$(echo "$RESULT" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(d.get('Item',{}).get('company_subsidy_percent',{}).get('N','0'))
|
||||
" 2>/dev/null || echo "0")
|
||||
echo "bulk_discount=$BULK" >> $GITHUB_OUTPUT
|
||||
echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Google Client ID
|
||||
id: google
|
||||
run: |
|
||||
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
|
||||
--name /meal-order-manager/google-client-id \
|
||||
--query 'Parameter.Value' \
|
||||
--output text 2>/dev/null || echo "")
|
||||
if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then
|
||||
GOOGLE_CLIENT_ID=""
|
||||
fi
|
||||
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate order form
|
||||
run: |
|
||||
python3 src/server/generate_form.py \
|
||||
--api-url "${{ steps.stack.outputs.api_url }}" \
|
||||
--api-key "${{ steps.apikey.outputs.api_key }}"
|
||||
--api-key "${{ steps.apikey.outputs.api_key }}" \
|
||||
--bulk-discount "${{ steps.discount.outputs.bulk_discount }}" \
|
||||
--company-subsidy "${{ steps.discount.outputs.company_subsidy }}" \
|
||||
${{ steps.google.outputs.client_id && format('--google-client-id "{0}"', steps.google.outputs.client_id) || '' }}
|
||||
|
||||
- name: Upload menu to DynamoDB
|
||||
run: python3 scripts/upload_menu.py
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
{
|
||||
"menu_url": "https://redefinemeals.com/menu",
|
||||
"order_deadline": "Wednesday 11:59 PM",
|
||||
"order_deadline": "Thursday at 11:59 PM",
|
||||
"output_dir": "output",
|
||||
"orders_dir": "orders",
|
||||
"bulk_discount_percent": 10,
|
||||
"company_subsidy_percent": 50,
|
||||
"google_client_id": "",
|
||||
"roster": [
|
||||
{"name": "Example Employee", "email": "example@seahavenind.com"}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -65,13 +65,18 @@ def lambda_handler(event, context):
|
|||
|
||||
|
||||
def build_summary(orders: list[dict], week: str) -> dict:
|
||||
meal_totals = defaultdict(lambda: {"quantity": 0, "unit_price": 0})
|
||||
meal_totals = defaultdict(
|
||||
lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0}
|
||||
)
|
||||
for order in orders:
|
||||
for item in order.get("items", []):
|
||||
name = item["name"]
|
||||
qty = int(item.get("quantity", 0))
|
||||
meal_totals[name]["quantity"] += qty
|
||||
meal_totals[name]["unit_price"] = float(item.get("price", 0))
|
||||
meal_totals[name]["bulk_price"] = float(
|
||||
item.get("bulk_price", item.get("price", 0))
|
||||
)
|
||||
meal_totals[name]["employee_price"] = float(item.get("price", 0))
|
||||
|
||||
meals = []
|
||||
for name, data in sorted(meal_totals.items()):
|
||||
|
|
@ -79,8 +84,12 @@ def build_summary(orders: list[dict], week: str) -> dict:
|
|||
{
|
||||
"meal": name,
|
||||
"quantity": data["quantity"],
|
||||
"unit_price": data["unit_price"],
|
||||
"line_total": round(data["unit_price"] * data["quantity"], 2),
|
||||
"unit_price": data["bulk_price"],
|
||||
"employee_unit_price": data["employee_price"],
|
||||
"line_total": round(data["bulk_price"] * data["quantity"], 2),
|
||||
"employee_line_total": round(
|
||||
data["employee_price"] * data["quantity"], 2
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -90,6 +99,7 @@ def build_summary(orders: list[dict], week: str) -> dict:
|
|||
"total_employees": len(orders),
|
||||
"total_meals": sum(m["quantity"] for m in meals),
|
||||
"grand_total": round(sum(m["line_total"] for m in meals), 2),
|
||||
"employee_total": round(sum(m["employee_line_total"] for m in meals), 2),
|
||||
"meals": meals,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,29 @@
|
|||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import boto3
|
||||
|
||||
from shared.db import current_week, get_form_status, set_form_status
|
||||
|
||||
_lambda = boto3.client("lambda")
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
now_et = datetime.now(EASTERN)
|
||||
# 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())
|
||||
|
||||
status = get_form_status(week)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from shared.db import current_week, get_orders, get_roster, get_summary
|
||||
from shared.slack import post_channel_message, send_dm
|
||||
|
||||
|
||||
def _escape_mrkdwn(text: str) -> str:
|
||||
"""Escape Slack mrkdwn special characters."""
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, Decimal):
|
||||
|
|
@ -21,7 +28,13 @@ def lambda_handler(event, context):
|
|||
elif event_type == "orders_aggregated":
|
||||
return handle_orders_aggregated(event)
|
||||
elif event_type == "reminder":
|
||||
# Guard against duplicate triggers from dual EST/EDT schedules
|
||||
now_et = datetime.now(ZoneInfo("America/New_York"))
|
||||
if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday
|
||||
return {"status": "skipped", "reason": "outside reminder window"}
|
||||
return handle_reminder(event)
|
||||
elif event_type == "order_confirmed":
|
||||
return handle_order_confirmed(event)
|
||||
|
||||
return {"error": f"Unknown event type: {event_type}"}
|
||||
|
||||
|
|
@ -31,7 +44,7 @@ def handle_menu_published(event):
|
|||
week = event.get("week", current_week())
|
||||
meal_count = event.get("meal_count", "")
|
||||
|
||||
text = "This week's meal order is open! Deadline: Thursday 6pm."
|
||||
text = "This week's meal order is open! Deadline: Thursday at 11:59pm."
|
||||
blocks = [
|
||||
{
|
||||
"type": "header",
|
||||
|
|
@ -43,7 +56,7 @@ def handle_menu_published(event):
|
|||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f"*<{form_url}|Place your order>*\n\n"
|
||||
f"*Deadline:* Thursday 6pm\n"
|
||||
f"*Deadline:* Thursday at 11:59pm\n"
|
||||
f"*Menu:* {meal_count} meals available"
|
||||
),
|
||||
},
|
||||
|
|
@ -63,12 +76,24 @@ def handle_orders_aggregated(event):
|
|||
total_employees = int(summary.get("total_employees", 0))
|
||||
total_meals = int(summary.get("total_meals", 0))
|
||||
grand_total = float(summary.get("grand_total", 0))
|
||||
employee_total = float(summary.get("employee_total", grand_total))
|
||||
|
||||
meal_lines = []
|
||||
for m in summary.get("meals", []):
|
||||
meal_lines.append(f"{m['meal']}: *{int(m['quantity'])}*")
|
||||
meal_lines.append(f"{_escape_mrkdwn(m['meal'])}: *{int(m['quantity'])}*")
|
||||
meal_list = "\n".join(meal_lines)
|
||||
|
||||
has_subsidy = employee_total < grand_total
|
||||
totals_text = (
|
||||
f"*${grand_total:.2f}* order total (bulk rate)\n"
|
||||
f"*${employee_total:.2f}* payroll deductions"
|
||||
+ (
|
||||
f"\n*${grand_total - employee_total:.2f}* company subsidy"
|
||||
if has_subsidy
|
||||
else ""
|
||||
)
|
||||
)
|
||||
|
||||
text = f"Meal orders closed for {week}. {total_employees} employees, {total_meals} meals, ${grand_total:.2f} total."
|
||||
blocks = [
|
||||
{
|
||||
|
|
@ -82,7 +107,7 @@ def handle_orders_aggregated(event):
|
|||
"text": (
|
||||
f"*{total_employees}* employees ordered\n"
|
||||
f"*{total_meals}* total meals\n"
|
||||
f"*${grand_total:.2f}* grand total"
|
||||
f"{totals_text}"
|
||||
),
|
||||
},
|
||||
},
|
||||
|
|
@ -100,7 +125,58 @@ def handle_orders_aggregated(event):
|
|||
return {"status": "notified", "event": "orders_aggregated", "week": week}
|
||||
|
||||
|
||||
def handle_order_confirmed(event):
|
||||
email = event.get("employee_email", "").lower()
|
||||
name = event.get("employee_name", "")
|
||||
items = event.get("items", [])
|
||||
total = float(event.get("total", 0))
|
||||
week = event.get("week", current_week())
|
||||
|
||||
roster = get_roster()
|
||||
employee = next((e for e in roster if e["email"].lower() == email), None)
|
||||
if not employee or not employee.get("slack_user_id"):
|
||||
return {"status": "no_slack_id", "email": email}
|
||||
|
||||
item_lines = []
|
||||
for item in items:
|
||||
qty = int(item.get("quantity", 0))
|
||||
price = float(item.get("price", 0))
|
||||
item_lines.append(
|
||||
f"{_escape_mrkdwn(item['name'])} x{qty} — your cost: ${price * qty:.2f}"
|
||||
)
|
||||
item_list = "\n".join(item_lines)
|
||||
|
||||
send_dm(
|
||||
employee["slack_user_id"],
|
||||
f"Order confirmed for {week}: ${total:.2f} total.",
|
||||
blocks=[
|
||||
{
|
||||
"type": "header",
|
||||
"text": {"type": "plain_text", "text": f"Order Confirmed — {week}"},
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f"Hey {name.split()[0] if name.strip() else 'there'}! Your meal order has been submitted.\n\n"
|
||||
f"{item_list}\n\n"
|
||||
f"*Your total: ${total:.2f}* (payroll deduction)"
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
return {"status": "confirmed", "week": week, "employee": name}
|
||||
|
||||
|
||||
def handle_reminder(event):
|
||||
# Guard against duplicate triggers from dual EST/EDT schedules
|
||||
now_et = datetime.now(ZoneInfo("America/New_York"))
|
||||
if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday
|
||||
return {"status": "skipped", "reason": "outside reminder window"}
|
||||
|
||||
week = event.get("week", current_week())
|
||||
form_url = os.environ.get("FORM_URL", "")
|
||||
|
||||
|
|
@ -120,14 +196,14 @@ def handle_reminder(event):
|
|||
continue
|
||||
send_dm(
|
||||
slack_id,
|
||||
f"Meal orders close at 6pm today. Place your order: {form_url}",
|
||||
f"Meal orders close today at 11:59pm. Place your order: {form_url}",
|
||||
blocks=[
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f"Hey {emp['name'].split()[0]}! Meal orders close at *6pm today*.\n\n"
|
||||
f"Hey {emp['name'].split()[0] if emp['name'].strip() else 'there'}! Meal orders close today at *11:59pm*.\n\n"
|
||||
f"*<{form_url}|Place your order>*"
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,13 +1,47 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import datetime as _dt
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from shared.db import current_week, get_form_status, get_roster, put_order
|
||||
from shared.secrets import get_secret
|
||||
import boto3
|
||||
|
||||
from shared.db import (
|
||||
current_week,
|
||||
get_form_status,
|
||||
get_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")
|
||||
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
|
||||
|
||||
|
||||
def _eastern_now() -> _dt.datetime:
|
||||
"""Wall-clock 'now' in Eastern time (patch target for form-status tests)."""
|
||||
return _dt.datetime.now(EASTERN)
|
||||
|
||||
|
||||
_api_key = None
|
||||
_settings = None
|
||||
_settings_ts = 0.0
|
||||
_google_client_id = None
|
||||
_google_client_id_ts = 0.0
|
||||
_lambda = boto3.client("lambda")
|
||||
|
||||
|
||||
def _get_api_key() -> str:
|
||||
|
|
@ -17,6 +51,89 @@ def _get_api_key() -> str:
|
|||
return _api_key
|
||||
|
||||
|
||||
def _get_discount_settings() -> tuple[Decimal, Decimal]:
|
||||
global _settings, _settings_ts
|
||||
now = time.monotonic()
|
||||
if _settings is None or (now - _settings_ts) > CACHE_TTL_SECONDS:
|
||||
s = get_settings()
|
||||
_settings = (
|
||||
Decimal(str(s.get("bulk_discount_percent", 0))),
|
||||
Decimal(str(s.get("company_subsidy_percent", 0))),
|
||||
)
|
||||
_settings_ts = now
|
||||
return _settings
|
||||
|
||||
|
||||
def _google_auth_configured() -> bool:
|
||||
return bool(os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""))
|
||||
|
||||
|
||||
def _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:
|
||||
_google_client_id = get_parameter(param, decrypt=False) or ""
|
||||
else:
|
||||
_google_client_id = ""
|
||||
_google_client_id_ts = now
|
||||
return _google_client_id
|
||||
|
||||
|
||||
def _verify_google_token(token: str) -> tuple[dict | None, str]:
|
||||
"""Verify a Google ID token via the tokeninfo endpoint.
|
||||
|
||||
Returns a tuple of (user_info, error_kind) where:
|
||||
- ({"name": ..., "email": ...}, "ok") on success
|
||||
- (None, "invalid") for bad/expired tokens or wrong audience/domain
|
||||
- (None, "unavailable") when the Google verification service is unreachable
|
||||
|
||||
NOTE: The token is passed as a query parameter to Google's tokeninfo endpoint.
|
||||
This is acceptable because ID tokens are short-lived (typically ~1 hour) and
|
||||
this is Google's own documented verification method, but be aware that the
|
||||
token will appear in Google's server access logs.
|
||||
"""
|
||||
client_id = _get_google_client_id()
|
||||
if not client_id:
|
||||
return None, "invalid"
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"https://oauth2.googleapis.com/tokeninfo?id_token={token}"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read())
|
||||
if data.get("aud") != client_id:
|
||||
logger.warning("Google token audience mismatch: got %s", data.get("aud"))
|
||||
return None, "invalid"
|
||||
if data.get("hd") != "seahavenind.com":
|
||||
logger.warning("Google token domain mismatch: got %s", data.get("hd"))
|
||||
return None, "invalid"
|
||||
return {"name": data.get("name", ""), "email": data.get("email", "")}, "ok"
|
||||
except urllib.error.HTTPError as exc:
|
||||
logger.warning("Google token rejected (HTTP %s): %s", exc.code, exc)
|
||||
return None, "invalid"
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
logger.error("Google token verification service unavailable: %s", exc)
|
||||
return None, "unavailable"
|
||||
except Exception as exc:
|
||||
logger.error("Google token verification failed (bad token data): %s", exc)
|
||||
return None, "invalid"
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
|
||||
path = event.get("rawPath", "")
|
||||
|
|
@ -36,7 +153,21 @@ def lambda_handler(event, context):
|
|||
def handle_form_status(event):
|
||||
week = event.get("pathParameters", {}).get("week", current_week())
|
||||
status = get_form_status(week)
|
||||
return response(200, {"week": week, "status": status})
|
||||
result = {"week": week, "status": status}
|
||||
if status == "closed":
|
||||
# Next Monday 8:00 AM Eastern: use calendar date math + combine() so reopen_at
|
||||
# stays on the correct civil Monday across DST (timedelta(days=n) is always 24n hours).
|
||||
now_et = _eastern_now()
|
||||
today = now_et.date()
|
||||
weekday = today.weekday() # Monday=0 ... Sunday=6
|
||||
days_until_monday = (7 - weekday) % 7
|
||||
if days_until_monday == 0 and now_et.hour >= 8:
|
||||
# If today is Monday past 8am, next Monday is 7 days away
|
||||
days_until_monday = 7
|
||||
reopen_date = today + timedelta(days=days_until_monday)
|
||||
next_monday = _dt.datetime.combine(reopen_date, _dt.time(8, 0), tzinfo=EASTERN)
|
||||
result["reopen_at"] = int(next_monday.timestamp())
|
||||
return response(200, result)
|
||||
|
||||
|
||||
def handle_roster():
|
||||
|
|
@ -55,8 +186,40 @@ def handle_submit(event):
|
|||
except json.JSONDecodeError:
|
||||
return response(400, {"error": "Invalid JSON"})
|
||||
|
||||
name = body.get("employee_name", "").strip()
|
||||
email = body.get("employee_email", "").strip()
|
||||
# --- Authentication ---
|
||||
# If Google auth is configured (SSM param name is set), require a valid
|
||||
# google_id_token. Manual fallback is only allowed when auth is NOT configured.
|
||||
# If SSM fetch fails, fail closed (503) rather than silently disabling auth.
|
||||
google_token = body.get("google_id_token")
|
||||
|
||||
if _google_auth_configured():
|
||||
try:
|
||||
client_id = _get_google_client_id()
|
||||
except Exception as exc:
|
||||
logger.error("SSM fetch failed for Google client ID: %s", exc)
|
||||
return response(
|
||||
503, {"error": "Authentication service temporarily unavailable"}
|
||||
)
|
||||
if not client_id:
|
||||
logger.error("Google auth configured but client ID is empty")
|
||||
return response(
|
||||
503, {"error": "Authentication service temporarily unavailable"}
|
||||
)
|
||||
if not google_token:
|
||||
return response(403, {"error": "Google authentication is required"})
|
||||
user_info, verify_status = _verify_google_token(google_token)
|
||||
if verify_status == "unavailable":
|
||||
return response(
|
||||
503, {"error": "Authentication service temporarily unavailable"}
|
||||
)
|
||||
if user_info is None:
|
||||
return response(403, {"error": "Invalid or unauthorized Google account"})
|
||||
name = user_info["name"]
|
||||
email = user_info["email"]
|
||||
else:
|
||||
name = body.get("employee_name", "").strip()
|
||||
email = body.get("employee_email", "").strip()
|
||||
|
||||
items = body.get("items", [])
|
||||
|
||||
if not name:
|
||||
|
|
@ -74,25 +237,87 @@ def handle_submit(event):
|
|||
return response(404, {"error": "No menu available for this week"})
|
||||
|
||||
filtered_items = [i for i in items if i.get("quantity", 0) > 0]
|
||||
total = sum((i.get("price", 0) or 0) * i.get("quantity", 0) for i in filtered_items)
|
||||
|
||||
slug = (
|
||||
"".join(c if c.isalnum() or c in "- " else "" for c in name)
|
||||
.strip()
|
||||
.replace(" ", "-")
|
||||
.lower()
|
||||
official_retail = _official_menu_retail_by_name(week)
|
||||
if not official_retail:
|
||||
logger.error("Week %s: menu has no priced meals; refusing order", week)
|
||||
return response(503, {"error": "Menu temporarily unavailable"})
|
||||
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 = {
|
||||
"employee_name": name,
|
||||
"employee_email": email,
|
||||
"submitted_at": datetime.now(EASTERN).isoformat(),
|
||||
"submitted_at": _eastern_now().isoformat(),
|
||||
"items": filtered_items,
|
||||
"total": round(total, 2),
|
||||
"total": total,
|
||||
}
|
||||
|
||||
put_order(week, slug, order_data)
|
||||
|
||||
# Slack notification is best-effort — order is already persisted above,
|
||||
# so we return success to the user even if this invocation fails.
|
||||
try:
|
||||
_lambda.invoke(
|
||||
FunctionName=os.environ["SLACK_NOTIFIER_ARN"],
|
||||
InvocationType="Event",
|
||||
Payload=json.dumps(
|
||||
{
|
||||
"event": "order_confirmed",
|
||||
"employee_name": name,
|
||||
"employee_email": email,
|
||||
"items": filtered_items,
|
||||
"total": order_data["total"],
|
||||
"week": week,
|
||||
},
|
||||
default=float,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Slack notifier invocation failed (order already saved): %s", exc)
|
||||
|
||||
return response(
|
||||
200,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -41,12 +41,17 @@ def load_orders(week: str) -> list[dict]:
|
|||
|
||||
def generate_order_summary(orders: list[dict]) -> dict:
|
||||
"""Aggregate all meals across employees into a single order for Redefine."""
|
||||
meal_totals = defaultdict(lambda: {"quantity": 0, "unit_price": 0})
|
||||
meal_totals = defaultdict(
|
||||
lambda: {"quantity": 0, "bulk_price": 0, "employee_price": 0}
|
||||
)
|
||||
for order in orders:
|
||||
for item in order.get("items", []):
|
||||
name = item["name"]
|
||||
meal_totals[name]["quantity"] += item.get("quantity", 0)
|
||||
meal_totals[name]["unit_price"] = item.get("price", 0)
|
||||
meal_totals[name]["bulk_price"] = item.get(
|
||||
"bulk_price", item.get("price", 0)
|
||||
)
|
||||
meal_totals[name]["employee_price"] = item.get("price", 0)
|
||||
|
||||
summary = []
|
||||
for name, data in sorted(meal_totals.items()):
|
||||
|
|
@ -54,12 +59,17 @@ def generate_order_summary(orders: list[dict]) -> dict:
|
|||
{
|
||||
"meal": name,
|
||||
"quantity": data["quantity"],
|
||||
"unit_price": data["unit_price"],
|
||||
"line_total": round(data["unit_price"] * data["quantity"], 2),
|
||||
"unit_price": data["bulk_price"],
|
||||
"employee_unit_price": data["employee_price"],
|
||||
"line_total": round(data["bulk_price"] * data["quantity"], 2),
|
||||
"employee_line_total": round(
|
||||
data["employee_price"] * data["quantity"], 2
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
grand_total = sum(s["line_total"] for s in summary)
|
||||
employee_total = sum(s["employee_line_total"] for s in summary)
|
||||
total_meals = sum(s["quantity"] for s in summary)
|
||||
|
||||
return {
|
||||
|
|
@ -68,6 +78,7 @@ def generate_order_summary(orders: list[dict]) -> dict:
|
|||
"total_employees": len(orders),
|
||||
"total_meals": total_meals,
|
||||
"grand_total": round(grand_total, 2),
|
||||
"employee_total": round(employee_total, 2),
|
||||
"meals": summary,
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +161,13 @@ def main():
|
|||
print(f"{meal['meal']:<45} {meal['quantity']:>4} ${meal['line_total']:>7.2f}")
|
||||
print("-" * 60)
|
||||
print(f"{'TOTAL':<45} {summary['total_meals']:>4} ${summary['grand_total']:>7.2f}")
|
||||
if (
|
||||
summary.get("employee_total") is not None
|
||||
and summary["employee_total"] != summary["grand_total"]
|
||||
):
|
||||
print(f"{'PAYROLL DEDUCTIONS':<45} ${summary['employee_total']:>7.2f}")
|
||||
subsidy = round(summary["grand_total"] - summary["employee_total"], 2)
|
||||
print(f"{'COMPANY SUBSIDY':<45} ${subsidy:>7.2f}")
|
||||
print(f"\n{summary['total_employees']} employees ordered")
|
||||
|
||||
print("\nFiles generated:")
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@ Orders are saved as JSON files in the orders directory, one per employee per wee
|
|||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from pathlib import Path
|
||||
|
||||
import boto3
|
||||
from flask import Flask, jsonify, request, send_file
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
|
@ -58,14 +62,79 @@ def 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"])
|
||||
def submit_order():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No data received"}), 400
|
||||
|
||||
name = data.get("employee_name", "").strip()
|
||||
email = data.get("employee_email", "").strip()
|
||||
client_id = _get_google_client_id()
|
||||
google_token = data.get("google_id_token")
|
||||
|
||||
if client_id:
|
||||
if not google_token:
|
||||
return jsonify({"error": "Google authentication is required"}), 403
|
||||
user_info = _verify_google_token(google_token, client_id)
|
||||
if not user_info:
|
||||
return jsonify({"error": "Invalid or unauthorized Google account"}), 403
|
||||
name = user_info["name"]
|
||||
email = user_info["email"]
|
||||
else:
|
||||
name = data.get("employee_name", "").strip()
|
||||
email = data.get("employee_email", "").strip()
|
||||
|
||||
items = data.get("items", [])
|
||||
|
||||
if not name:
|
||||
|
|
@ -75,29 +144,51 @@ def submit_order():
|
|||
if not items or not any(i.get("quantity", 0) > 0 for i in items):
|
||||
return jsonify({"error": "Please select at least one meal"}), 400
|
||||
|
||||
config = load_config()
|
||||
TWO_PLACES = Decimal("0.01")
|
||||
bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0)))
|
||||
subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0)))
|
||||
bulk_pct = max(Decimal("0"), min(Decimal("100"), bulk_pct))
|
||||
subsidy_pct = max(Decimal("0"), min(Decimal("100"), subsidy_pct))
|
||||
bulk_mult = Decimal("1") - (bulk_pct / Decimal("100"))
|
||||
subsidy_mult = Decimal("1") - (subsidy_pct / Decimal("100"))
|
||||
|
||||
filtered = [i for i in items if i.get("quantity", 0) > 0]
|
||||
for item in filtered:
|
||||
retail = Decimal(str(item.get("retail_price", item.get("price", 0)) or 0))
|
||||
qty = Decimal(str(item.get("quantity", 0)))
|
||||
bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
||||
emp_price = (bulk_price * subsidy_mult).quantize(
|
||||
TWO_PLACES, rounding=ROUND_HALF_UP
|
||||
)
|
||||
subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
||||
item["retail_price"] = float(retail)
|
||||
item["bulk_price"] = float(bulk_price)
|
||||
item["price"] = float(emp_price)
|
||||
item["subtotal"] = float(subtotal)
|
||||
|
||||
week = current_week()
|
||||
week_dir = ORDERS_DIR / week
|
||||
week_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
safe_name = (
|
||||
"".join(c if c.isalnum() or c in "-_ " else "" for c in name)
|
||||
.strip()
|
||||
.replace(" ", "-")
|
||||
.lower()
|
||||
# Match Lambda: one order file per employee email (not display name).
|
||||
slug = email.strip().lower()
|
||||
slug_safe = slug.replace("/", "_").replace("\\", "_")
|
||||
order_file = week_dir / f"{slug_safe}.json"
|
||||
|
||||
total = float(
|
||||
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
|
||||
TWO_PLACES, rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
order_file = week_dir / f"{safe_name}.json"
|
||||
|
||||
order = {
|
||||
"employee_name": name,
|
||||
"employee_email": email,
|
||||
"week": week,
|
||||
"submitted_at": datetime.now().isoformat(),
|
||||
"items": [i for i in items if i.get("quantity", 0) > 0],
|
||||
"total": sum(
|
||||
(i.get("price", 0) or 0) * i.get("quantity", 0)
|
||||
for i in items
|
||||
if i.get("quantity", 0) > 0
|
||||
),
|
||||
"items": filtered,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
with open(order_file, "w") as f:
|
||||
|
|
@ -108,6 +199,11 @@ def submit_order():
|
|||
)
|
||||
|
||||
|
||||
@app.route("/api/form-status/<week>")
|
||||
def form_status(week: str):
|
||||
return jsonify({"week": week, "status": "open"})
|
||||
|
||||
|
||||
@app.route("/api/orders/<week>")
|
||||
def get_orders(week: str):
|
||||
week_dir = ORDERS_DIR / week
|
||||
|
|
|
|||
|
|
@ -35,17 +35,196 @@ def latest_menu() -> dict:
|
|||
|
||||
|
||||
def generate_form(
|
||||
menu: dict, config: dict, api_url: str = "", api_key: str = ""
|
||||
menu: dict,
|
||||
config: dict,
|
||||
api_url: str = "",
|
||||
api_key: str = "",
|
||||
bulk_discount: float = 0,
|
||||
company_subsidy: float = 0,
|
||||
google_client_id: str = "",
|
||||
) -> str:
|
||||
meals_json = json.dumps(menu["meals"])
|
||||
roster_json = json.dumps(config.get("roster", []))
|
||||
meals_json = json.dumps(menu["meals"]).replace("</", "<\\/")
|
||||
roster_json = json.dumps(config.get("roster", [])).replace("</", "<\\/")
|
||||
deadline = config.get("order_deadline", "Thursday 11:59 PM")
|
||||
week = datetime.now().strftime("%Y-W%U")
|
||||
scraped_at = menu.get("scraped_at", "unknown")
|
||||
submit_url = f"{api_url}/api/submit-order" if api_url else "/api/submit-order"
|
||||
status_url = f"{api_url}/api/form-status/{week}" if api_url else ""
|
||||
status_url = (
|
||||
f"{api_url}/api/form-status/{week}" if api_url else f"/api/form-status/{week}"
|
||||
)
|
||||
roster_url = f"{api_url}/api/roster" if api_url else "/api/roster"
|
||||
api_key_json = json.dumps(api_key)
|
||||
api_key_json = json.dumps(api_key).replace("</", "<\\/")
|
||||
has_discount = bulk_discount > 0 or company_subsidy > 0
|
||||
use_google_auth = bool(google_client_id)
|
||||
google_client_id_json = json.dumps(google_client_id).replace("</", "<\\/")
|
||||
|
||||
if use_google_auth:
|
||||
auth_section_html = """ <div class="employee-info">
|
||||
<div id="user-info">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<img id="user-avatar" style="width:40px;height:40px;border-radius:50%;object-fit:cover;display:none;" alt="">
|
||||
<div style="flex:1;">
|
||||
<div id="user-name" style="font-weight:600;font-size:0.95rem;"></div>
|
||||
<div id="user-email" style="font-size:0.85rem;color:#6b7280;"></div>
|
||||
</div>
|
||||
<button onclick="signOut()" style="background:none;border:1px solid #d1d5db;border-radius:8px;padding:6px 14px;cursor:pointer;font-size:0.8rem;color:#6b7280;">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>"""
|
||||
else:
|
||||
auth_section_html = """ <div class="employee-info">
|
||||
<label for="emp-name">Your Name</label>
|
||||
<select id="emp-name"><option value="">Loading...</option></select>
|
||||
<input type="hidden" id="emp-email">
|
||||
</div>"""
|
||||
|
||||
if use_google_auth:
|
||||
google_auth_js = """
|
||||
function waitForGoogleAuth() {
|
||||
if (typeof google !== 'undefined' && google.accounts && google.accounts.id) {
|
||||
initGoogleAuth();
|
||||
} else {
|
||||
setTimeout(waitForGoogleAuth, 50);
|
||||
}
|
||||
}
|
||||
|
||||
function initGoogleAuth() {
|
||||
google.accounts.id.initialize({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
callback: handleCredentialResponse,
|
||||
hosted_domain: 'seahavenind.com',
|
||||
auto_select: true,
|
||||
});
|
||||
google.accounts.id.renderButton(
|
||||
document.getElementById('g-signin-btn'),
|
||||
{ theme: 'outline', size: 'large', text: 'signin_with', width: 300 }
|
||||
);
|
||||
}
|
||||
|
||||
function handleCredentialResponse(response) {
|
||||
googleCredential = response.credential;
|
||||
const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(atob(b64));
|
||||
googleUser = { name: payload.name, email: payload.email };
|
||||
|
||||
document.getElementById('auth-overlay').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'block';
|
||||
var footer = document.getElementById('sticky-footer');
|
||||
if (footer) footer.style.display = 'block';
|
||||
document.getElementById('user-name').textContent = payload.name;
|
||||
document.getElementById('user-email').textContent = payload.email;
|
||||
if (payload.picture) {
|
||||
const avatar = document.getElementById('user-avatar');
|
||||
avatar.src = payload.picture;
|
||||
avatar.style.display = 'block';
|
||||
}
|
||||
|
||||
updateTotal();
|
||||
checkDuplicateOrder();
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
googleCredential = null;
|
||||
googleUser = null;
|
||||
google.accounts.id.disableAutoSelect();
|
||||
document.getElementById('auth-overlay').style.display = 'flex';
|
||||
document.getElementById('app').style.display = 'none';
|
||||
var footer = document.getElementById('sticky-footer');
|
||||
if (footer) footer.style.display = 'none';
|
||||
}
|
||||
"""
|
||||
else:
|
||||
google_auth_js = ""
|
||||
|
||||
if use_google_auth:
|
||||
submit_order_js = """
|
||||
async function submitOrder() {
|
||||
if (formClosed) { alert('Orders are closed.'); return; }
|
||||
if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; }
|
||||
|
||||
const items = Object.entries(quantities).map(([i, qty]) => ({
|
||||
name: MEALS[i].name,
|
||||
retail_price: MEALS[i].price,
|
||||
quantity: qty,
|
||||
}));
|
||||
|
||||
const btn = document.getElementById('submit-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Submitting...';
|
||||
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (API_KEY) headers['x-api-key'] = API_KEY;
|
||||
const res = await fetch(SUBMIT_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ google_id_token: googleCredential, items }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; }
|
||||
document.getElementById('success').style.display = 'block';
|
||||
document.getElementById('success-detail').textContent = `${googleUser.name} — $${(data.total || 0).toFixed(2)} total. You're all set!`;
|
||||
document.querySelector('.sticky-footer').style.display = 'none';
|
||||
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
|
||||
} else {
|
||||
alert(data.error || 'Something went wrong.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to submit. Check your connection and try again.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}
|
||||
}
|
||||
"""
|
||||
else:
|
||||
submit_order_js = """
|
||||
async function submitOrder() {
|
||||
if (formClosed) { alert('Orders are closed.'); return; }
|
||||
const name = document.getElementById('emp-name').value.trim();
|
||||
const email = document.getElementById('emp-email').value.trim();
|
||||
if (!name) { alert('Please enter your name.'); return; }
|
||||
if (!email) { alert('Please enter your email.'); return; }
|
||||
|
||||
const items = Object.entries(quantities).map(([i, qty]) => ({
|
||||
name: MEALS[i].name,
|
||||
retail_price: MEALS[i].price,
|
||||
quantity: qty,
|
||||
}));
|
||||
|
||||
const btn = document.getElementById('submit-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Submitting...';
|
||||
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (API_KEY) headers['x-api-key'] = API_KEY;
|
||||
const res = await fetch(SUBMIT_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ employee_name: name, employee_email: email, items }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
for (const el of document.getElementById('app').children) { if (el.id !== 'success') el.style.display = 'none'; }
|
||||
document.getElementById('success').style.display = 'block';
|
||||
document.getElementById('success-detail').textContent = `${name} — $${(data.total || 0).toFixed(2)} total. You're all set!`;
|
||||
document.querySelector('.sticky-footer').style.display = 'none';
|
||||
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
|
||||
} else {
|
||||
alert(data.error || 'Something went wrong.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to submit. Check your connection and try again.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
|
@ -53,6 +232,7 @@ def generate_form(
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sea Haven — Meal Order ({week})</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABX0lEQVRYhe3TPWsUURjF8d+dnSxhoxA1xEJnQQsJGbBTrIIIvoEDNqkNiJ9BrPwI4jeY2kmVoKWkSVALK6MgwWLsJIWIBBFZLGaLTTLrJrX3X96X5znnPPcSiUQikf+dcHAhr+qLuIrT2MV77Owv9ycWy6u6i3tnOu5maTg/FUzjOz5gHe/KImsXkFf1ZTzH9ZbaNdbwElvby/0fwzsJFnANN3uJO/00zJ5MxmrcwqOyyD7uE5BX9Q08xh5O4BL+ZXkXvzQp9RKcS4OzaUukh9nDg7LIVkcFGI04r2pDZw+xgrlx1U516KdB93DnGhvYxBd8w08M0MXnssgmCx7O9RZuYzEw30sszATpXCeY2R/3AC/wTMu420gnHbgyHeY1sb3FH9wfc28DT8oiezOx63EE4DeeYqllb4BXGsevj+L4IEd4M6ysf6X5HUu4oEniE9bKIts5dtdIJBKJREb4C8ngTD+C9DtvAAAAAElFTkSuQmCC">
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; color: #1d1d1f; }}
|
||||
|
|
@ -89,27 +269,78 @@ header p {{ font-size: 0.85rem; opacity: 0.8; }}
|
|||
.submit-btn:hover:not(:disabled) {{ opacity: 0.85; }}
|
||||
.success-msg {{ text-align: center; padding: 60px 20px; }}
|
||||
.success-msg h2 {{ color: #15803d; margin-bottom: 8px; }}
|
||||
.back-btn {{ background: transparent; color: #1a1a2e; border: 2px solid #1a1a2e; padding: 12px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; margin-top: 20px; }}
|
||||
.back-btn:hover {{ background: #1a1a2e; color: #fff; }}
|
||||
.duplicate-warning {{ background: #fef3c7; color: #92400e; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; text-align: center; font-weight: 500; border: 1px solid #fcd34d; }}
|
||||
.meal-desc {{ font-size: 0.8rem; color: #6b7280; margin-bottom: 4px; }}
|
||||
.search-bar {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 16px; }}
|
||||
body {{ padding-bottom: 80px; }}
|
||||
.closed-banner {{ background: #fee2e2; color: #991b1b; padding: 16px; border-radius: 8px; margin-bottom: 20px; text-align: center; font-weight: 600; font-size: 1rem; }}
|
||||
@media (min-width: 768px) {{
|
||||
.container {{ max-width: 1100px; }}
|
||||
#meals-list {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }}
|
||||
#meals-list .meal-card {{ margin-bottom: 0; }}
|
||||
}}
|
||||
@media (min-width: 1200px) {{
|
||||
.container {{ max-width: 1400px; }}
|
||||
#meals-list {{ grid-template-columns: repeat(3, 1fr); }}
|
||||
}}
|
||||
.discount-banner {{ background: #dcfce7; color: #15803d; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.85rem; text-align: center; font-weight: 500; }}
|
||||
.price-retail {{ text-decoration: line-through; color: #9ca3af; font-size: 0.75rem; margin-right: 4px; }}
|
||||
.price-employee {{ color: #15803d; font-weight: 600; }}
|
||||
.auth-overlay {{ position: fixed; inset: 0; background: #1a1a2e; display: flex; align-items: center; justify-content: center; z-index: 1000; }}
|
||||
.auth-card {{ background: #fff; border-radius: 16px; padding: 48px 40px; text-align: center; max-width: 400px; width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }}
|
||||
.auth-card h1 {{ font-size: 1.5rem; margin-bottom: 4px; color: #1d1d1f; }}
|
||||
.auth-card p {{ font-size: 0.9rem; color: #6b7280; margin-bottom: 32px; }}
|
||||
.auth-card .logo {{ font-size: 2rem; margin-bottom: 16px; }}
|
||||
.closed-overlay {{ position: fixed; inset: 0; background: #1a1a2e; display: none; align-items: center; justify-content: center; z-index: 2000; }}
|
||||
.closed-card {{ background: #fff; border-radius: 16px; padding: 48px 40px; text-align: center; max-width: 420px; width: 90%; box-shadow: 0 8px 32px rgba(0,0,0,0.3); }}
|
||||
.closed-card h1 {{ font-size: 1.5rem; margin-bottom: 8px; color: #1d1d1f; }}
|
||||
.closed-card p {{ font-size: 0.9rem; color: #6b7280; margin-bottom: 24px; }}
|
||||
.closed-card .logo {{ font-size: 2.5rem; margin-bottom: 16px; }}
|
||||
.countdown {{ font-size: 2rem; font-weight: 700; color: #1a1a2e; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }}
|
||||
.countdown-label {{ font-size: 0.75rem; color: #9ca3af; margin-top: 4px; }}
|
||||
</style>
|
||||
{
|
||||
'<script src="https://accounts.google.com/gsi/client" async defer></script>'
|
||||
if use_google_auth
|
||||
else ""
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container" id="app">
|
||||
<div class="closed-overlay" id="closed-overlay">
|
||||
<div class="closed-card">
|
||||
<div class="logo">🍽</div>
|
||||
<h1>Orders Are Closed</h1>
|
||||
<p>Orders open again Monday at 8:00 AM ET</p>
|
||||
<div class="countdown" id="countdown"></div>
|
||||
<div class="countdown-label">until orders open</div>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
'<div class="auth-overlay" id="auth-overlay"><div class="auth-card"><div class="logo">🍽</div><h1>Sea Haven Meal Order</h1><p>Sign in with your company Google account to place your order.</p><div id="g-signin-btn" style="display:flex;justify-content:center;"></div></div></div>'
|
||||
if use_google_auth
|
||||
else ""
|
||||
}
|
||||
<div class="container" id="app" {'style="display:none;"' if use_google_auth else ""}>
|
||||
<header>
|
||||
<h1>Sea Haven Meal Order</h1>
|
||||
<p>Week of {week} · Menu scraped {scraped_at[:10]}</p>
|
||||
</header>
|
||||
|
||||
<div class="deadline">Order deadline: {deadline}</div>
|
||||
<div class="duplicate-warning" id="duplicate-warning" style="display:none;">You've already submitted an order this week. Submitting again will replace your previous order.</div>
|
||||
{
|
||||
'<div class="discount-banner">Prices reflect employee cost after '
|
||||
+ (f"{bulk_discount:g}% bulk discount" if bulk_discount > 0 else "")
|
||||
+ (" + " if bulk_discount > 0 and company_subsidy > 0 else "")
|
||||
+ (f"{company_subsidy:g}% company subsidy" if company_subsidy > 0 else "")
|
||||
+ "</div>"
|
||||
if has_discount
|
||||
else ""
|
||||
}
|
||||
|
||||
<div class="employee-info">
|
||||
<label for="emp-name">Your Name</label>
|
||||
<select id="emp-name"><option value="">Loading...</option></select>
|
||||
<input type="hidden" id="emp-email">
|
||||
</div>
|
||||
{auth_section_html}
|
||||
|
||||
<input type="text" class="search-bar" id="search" placeholder="Search meals...">
|
||||
|
||||
|
|
@ -117,7 +348,9 @@ body {{ padding-bottom: 80px; }}
|
|||
|
||||
<div id="meals-list"></div>
|
||||
|
||||
<div class="sticky-footer">
|
||||
<div class="sticky-footer" {
|
||||
'style="display:none;" id="sticky-footer"' if use_google_auth else ""
|
||||
}>
|
||||
<div class="inner">
|
||||
<div>
|
||||
<span class="total" id="total-display">$0.00</span>
|
||||
|
|
@ -130,6 +363,7 @@ body {{ padding-bottom: 80px; }}
|
|||
<div id="success" class="success-msg" style="display:none;">
|
||||
<h2>Order submitted!</h2>
|
||||
<p id="success-detail"></p>
|
||||
<button class="back-btn" onclick="location.reload()">Back to Menu</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -141,12 +375,50 @@ const STATUS_URL = '{status_url}';
|
|||
const ROSTER_URL = '{roster_url}';
|
||||
const API_KEY = {api_key_json};
|
||||
const WEEK = '{week}';
|
||||
const BULK_DISCOUNT = {bulk_discount};
|
||||
const COMPANY_SUBSIDY = {company_subsidy};
|
||||
const quantities = {{}};
|
||||
let formClosed = false;
|
||||
{
|
||||
"const GOOGLE_CLIENT_ID = "
|
||||
+ google_client_id_json
|
||||
+ ";\nlet googleCredential = null;\nlet googleUser = null;"
|
||||
if use_google_auth
|
||||
else ""
|
||||
}
|
||||
|
||||
function escapeHtml(str) {{
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}}
|
||||
|
||||
function employeePrice(retail) {{
|
||||
if (!retail) return 0;
|
||||
const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100;
|
||||
const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100;
|
||||
return empPrice;
|
||||
}}
|
||||
{google_auth_js}
|
||||
function checkDuplicateOrder() {{
|
||||
try {{
|
||||
if (localStorage.getItem('lastOrderWeek') === WEEK) {{
|
||||
document.getElementById('duplicate-warning').style.display = 'block';
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
|
||||
function init() {{
|
||||
checkFormStatus();
|
||||
loadRoster();
|
||||
{
|
||||
"waitForGoogleAuth();"
|
||||
if use_google_auth
|
||||
else "loadRoster(); checkDuplicateOrder();"
|
||||
}
|
||||
// Build filter buttons
|
||||
const tags = new Set();
|
||||
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t)));
|
||||
|
|
@ -186,23 +458,29 @@ function renderMeals() {{
|
|||
|
||||
let tagsHtml = '';
|
||||
if (meal.is_new) tagsHtml += '<span class="new">NEW</span>';
|
||||
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{t}}</span>`; }});
|
||||
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{escapeHtml(t)}}</span>`; }});
|
||||
|
||||
const descHtml = meal.description ? `<div class="meal-desc">${{meal.description}}</div>` : '';
|
||||
const price = meal.price ? `$${{meal.price.toFixed(2)}}` : '—';
|
||||
const descHtml = meal.description ? `<div class="meal-desc">${{escapeHtml(meal.description)}}</div>` : '';
|
||||
const hasDiscount = (BULK_DISCOUNT > 0 || COMPANY_SUBSIDY > 0) && meal.price;
|
||||
const empPrice = employeePrice(meal.price);
|
||||
const priceHtml = hasDiscount
|
||||
? `<span class="price-retail">$${{meal.price.toFixed(2)}}</span><span class="price-employee">$${{empPrice.toFixed(2)}}</span>`
|
||||
: (meal.price ? `$${{meal.price.toFixed(2)}}` : '—');
|
||||
|
||||
const safeName = escapeHtml(meal.name);
|
||||
const safeImageUrl = escapeHtml(meal.image_url);
|
||||
card.innerHTML = `
|
||||
${{meal.image_url ? `<img class="meal-img" src="${{meal.image_url}}" alt="${{meal.name}}" loading="lazy">` : ''}}
|
||||
${{meal.image_url ? `<img class="meal-img" src="${{safeImageUrl}}" alt="${{safeName}}" loading="lazy">` : ''}}
|
||||
<div class="meal-info">
|
||||
<div class="meal-name">${{meal.name}}</div>
|
||||
<div class="meal-name">${{safeName}}</div>
|
||||
${{descHtml}}
|
||||
<div class="meal-meta">${{price}} · ${{meal.calories || '?'}} cal · ${{meal.protein || '?'}} protein</div>
|
||||
<div class="meal-meta">${{priceHtml}} · ${{escapeHtml(meal.calories || '?')}} cal · ${{escapeHtml(meal.protein || '?')}} protein</div>
|
||||
<div class="meal-tags">${{tagsHtml}}</div>
|
||||
</div>
|
||||
<div class="qty-control">
|
||||
<button onclick="changeQty(${{i}}, -1)">−</button>
|
||||
<button onclick="changeQty(${{i}}, -1)" ${{formClosed ? 'disabled' : ''}}>−</button>
|
||||
<input class="qty" type="text" value="${{qty}}" readonly>
|
||||
<button onclick="changeQty(${{i}}, 1)">+</button>
|
||||
<button onclick="changeQty(${{i}}, 1)" ${{formClosed ? 'disabled' : ''}}>+</button>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(card);
|
||||
|
|
@ -221,87 +499,52 @@ function changeQty(index, delta) {{
|
|||
function updateTotal() {{
|
||||
let total = 0, count = 0;
|
||||
Object.entries(quantities).forEach(([i, qty]) => {{
|
||||
total += (MEALS[i].price || 0) * qty;
|
||||
total += employeePrice(MEALS[i].price || 0) * qty;
|
||||
count += qty;
|
||||
}});
|
||||
document.getElementById('total-display').textContent = `$${{total.toFixed(2)}}`;
|
||||
document.getElementById('count-display').textContent = `${{count}} meal${{count !== 1 ? 's' : ''}}`;
|
||||
document.getElementById('submit-btn').disabled = count === 0;
|
||||
document.getElementById('submit-btn').disabled = count === 0{
|
||||
" || !googleCredential" if use_google_auth else ""
|
||||
};
|
||||
}}
|
||||
|
||||
async function submitOrder() {{
|
||||
const name = document.getElementById('emp-name').value.trim();
|
||||
const email = document.getElementById('emp-email').value.trim();
|
||||
if (!name) {{ alert('Please enter your name.'); return; }}
|
||||
if (!email) {{ alert('Please enter your email.'); return; }}
|
||||
{submit_order_js}
|
||||
|
||||
const items = Object.entries(quantities).map(([i, qty]) => ({{
|
||||
name: MEALS[i].name,
|
||||
price: MEALS[i].price,
|
||||
quantity: qty,
|
||||
subtotal: (MEALS[i].price || 0) * qty,
|
||||
}}));
|
||||
|
||||
const btn = document.getElementById('submit-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Submitting...';
|
||||
|
||||
try {{
|
||||
const headers = {{ 'Content-Type': 'application/json' }};
|
||||
if (API_KEY) headers['x-api-key'] = API_KEY;
|
||||
const res = await fetch(SUBMIT_URL, {{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({{ employee_name: name, employee_email: email, items }}),
|
||||
}});
|
||||
const data = await res.json();
|
||||
if (res.ok) {{
|
||||
document.getElementById('app').querySelectorAll(':not(#success)').forEach(el => el.style.display = 'none');
|
||||
document.getElementById('success').style.display = 'block';
|
||||
document.getElementById('success-detail').textContent = `${{name}} — $${{data.total.toFixed(2)}} total. You're all set!`;
|
||||
document.querySelector('.sticky-footer').style.display = 'none';
|
||||
}} else {{
|
||||
alert(data.error || 'Something went wrong.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}}
|
||||
}} catch (e) {{
|
||||
alert('Failed to submit. Check your connection and try again.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Submit Order';
|
||||
}}
|
||||
}}
|
||||
|
||||
async function loadRoster() {{
|
||||
try {{
|
||||
{
|
||||
""
|
||||
if use_google_auth
|
||||
else '''async function loadRoster() {
|
||||
try {
|
||||
const res = await fetch(ROSTER_URL);
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('emp-name');
|
||||
select.innerHTML = '<option value="">Select your name</option>';
|
||||
(data.employees || ROSTER).forEach(emp => {{
|
||||
(data.employees || ROSTER).forEach(emp => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = emp.name;
|
||||
opt.dataset.email = emp.email;
|
||||
opt.textContent = emp.name;
|
||||
select.appendChild(opt);
|
||||
}});
|
||||
select.addEventListener('change', () => {{
|
||||
});
|
||||
select.addEventListener('change', () => {
|
||||
const selected = select.options[select.selectedIndex];
|
||||
document.getElementById('emp-email').value = selected?.dataset?.email || '';
|
||||
}});
|
||||
}} catch (e) {{
|
||||
});
|
||||
} catch (e) {
|
||||
const select = document.getElementById('emp-name');
|
||||
select.innerHTML = '<option value="">Select your name</option>';
|
||||
ROSTER.forEach(emp => {{
|
||||
ROSTER.forEach(emp => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = emp.name;
|
||||
opt.dataset.email = emp.email;
|
||||
opt.textContent = emp.name;
|
||||
select.appendChild(opt);
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
'''
|
||||
}
|
||||
async function checkFormStatus() {{
|
||||
if (!STATUS_URL) return;
|
||||
try {{
|
||||
|
|
@ -309,18 +552,56 @@ async function checkFormStatus() {{
|
|||
const data = await res.json();
|
||||
if (data.status === 'closed') {{
|
||||
formClosed = true;
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'closed-banner';
|
||||
banner.textContent = 'Orders are closed for this week.';
|
||||
const deadline = document.querySelector('.deadline');
|
||||
if (deadline) deadline.replaceWith(banner);
|
||||
document.getElementById('submit-btn').disabled = true;
|
||||
document.getElementById('submit-btn').textContent = 'Closed';
|
||||
document.getElementById('closed-overlay').style.display = 'flex';
|
||||
document.querySelectorAll('.qty-control button').forEach(b => b.disabled = true);
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
startCountdown(data);
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
|
||||
let reopenAtMs = null;
|
||||
|
||||
function startCountdown(data) {{
|
||||
if (data && data.reopen_at) {{
|
||||
reopenAtMs = data.reopen_at * 1000;
|
||||
}}
|
||||
updateCountdown();
|
||||
setInterval(updateCountdown, 1000);
|
||||
}}
|
||||
|
||||
function updateCountdown() {{
|
||||
const now = new Date();
|
||||
let target;
|
||||
|
||||
if (reopenAtMs) {{
|
||||
target = new Date(reopenAtMs);
|
||||
}} else {{
|
||||
const etStr = now.toLocaleString('en-US', {{ timeZone: 'America/New_York', hour12: false }});
|
||||
const etDate = new Date(etStr);
|
||||
const day = etDate.getDay();
|
||||
const hour = etDate.getHours();
|
||||
|
||||
let daysUntil = (1 - day + 7) % 7;
|
||||
if (daysUntil === 0 && hour >= 8) daysUntil = 7;
|
||||
|
||||
target = new Date(etDate);
|
||||
target.setDate(target.getDate() + daysUntil);
|
||||
target.setHours(8, 0, 0, 0);
|
||||
}}
|
||||
|
||||
let diff = Math.max(0, Math.floor((target - now) / 1000));
|
||||
const d = Math.floor(diff / 86400); diff %= 86400;
|
||||
const h = Math.floor(diff / 3600); diff %= 3600;
|
||||
const m = Math.floor(diff / 60);
|
||||
const s = diff % 60;
|
||||
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
document.getElementById('countdown').textContent =
|
||||
`${{d}}d ${{pad(h)}}h ${{pad(m)}}m ${{pad(s)}}s`;
|
||||
}}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
|
|
@ -335,11 +616,58 @@ def main():
|
|||
parser.add_argument(
|
||||
"--api-key", default="", help="API key for order submission (cloud mode)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bulk-discount",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Bulk discount percentage (e.g., 10 for 10%% off)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--company-subsidy",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Company subsidy percentage (e.g., 50 for 50%% off after bulk discount)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--google-client-id",
|
||||
default=None,
|
||||
help="Google OAuth Client ID for Sign-In authentication",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config()
|
||||
menu = latest_menu()
|
||||
html = generate_form(menu, config, api_url=args.api_url, api_key=args.api_key)
|
||||
|
||||
bulk_discount = (
|
||||
args.bulk_discount
|
||||
if args.bulk_discount is not None
|
||||
else config.get("bulk_discount_percent", 0)
|
||||
)
|
||||
company_subsidy = (
|
||||
args.company_subsidy
|
||||
if args.company_subsidy is not None
|
||||
else config.get("company_subsidy_percent", 0)
|
||||
)
|
||||
google_client_id = args.google_client_id or config.get("google_client_id", "")
|
||||
if not google_client_id:
|
||||
try:
|
||||
import boto3
|
||||
|
||||
ssm = boto3.client("ssm")
|
||||
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
|
||||
google_client_id = resp["Parameter"]["Value"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
html = generate_form(
|
||||
menu,
|
||||
config,
|
||||
api_url=args.api_url,
|
||||
api_key=args.api_key,
|
||||
bulk_discount=bulk_discount,
|
||||
company_subsidy=company_subsidy,
|
||||
google_client_id=google_client_id,
|
||||
)
|
||||
|
||||
week = datetime.now().strftime("%Y-W%U")
|
||||
output_file = OUTPUT_DIR / f"order-form-{week}.html"
|
||||
|
|
|
|||
|
|
@ -128,3 +128,19 @@ def put_roster(employees: list[dict]):
|
|||
"updated_at": datetime.now(EASTERN).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_settings() -> dict:
|
||||
resp = _get_table().get_item(Key={"PK": "CONFIG", "SK": "SETTINGS"})
|
||||
return resp.get("Item", {})
|
||||
|
||||
|
||||
def put_settings(settings: dict):
|
||||
_get_table().put_item(
|
||||
Item={
|
||||
"PK": "CONFIG",
|
||||
"SK": "SETTINGS",
|
||||
**_to_decimal(settings),
|
||||
"updated_at": datetime.now(EASTERN).isoformat(),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,19 +1,32 @@
|
|||
import time
|
||||
|
||||
import boto3
|
||||
|
||||
_cache = {}
|
||||
_secret_cache: dict[str, str] = {}
|
||||
_parameter_cache: dict[str, tuple[str, float]] = {}
|
||||
_sm = boto3.client("secretsmanager")
|
||||
_ssm = boto3.client("ssm")
|
||||
|
||||
# SSM reads use a TTL so callers (e.g. submit_order Google client ID) can refresh
|
||||
# on the same cadence as their own caches. Secrets stay cached for the process lifetime.
|
||||
PARAM_CACHE_TTL_SECONDS = 300.0
|
||||
|
||||
|
||||
def get_secret(secret_id: str) -> str:
|
||||
if secret_id not in _cache:
|
||||
if secret_id not in _secret_cache:
|
||||
resp = _sm.get_secret_value(SecretId=secret_id)
|
||||
_cache[secret_id] = resp["SecretString"]
|
||||
return _cache[secret_id]
|
||||
_secret_cache[secret_id] = resp["SecretString"]
|
||||
return _secret_cache[secret_id]
|
||||
|
||||
|
||||
def get_parameter(name: str, decrypt: bool = True) -> str:
|
||||
if name not in _cache:
|
||||
resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt)
|
||||
_cache[name] = resp["Parameter"]["Value"]
|
||||
return _cache[name]
|
||||
now = time.monotonic()
|
||||
entry = _parameter_cache.get(name)
|
||||
if entry is not None:
|
||||
value, cached_at = entry
|
||||
if now - cached_at < PARAM_CACHE_TTL_SECONDS:
|
||||
return value
|
||||
resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt)
|
||||
value = resp["Parameter"]["Value"]
|
||||
_parameter_cache[name] = (value, now)
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -202,6 +202,8 @@ Resources:
|
|||
Type: AWS::Serverless::HttpApi
|
||||
Properties:
|
||||
StageName: $default
|
||||
# CORS only allows the production domain. For local development, use the
|
||||
# Flask dev server (app.py) which proxies API requests and doesn't enforce CORS.
|
||||
CorsConfiguration:
|
||||
AllowOrigins:
|
||||
- !If
|
||||
|
|
@ -230,6 +232,8 @@ Resources:
|
|||
Environment:
|
||||
Variables:
|
||||
FORM_API_KEY_SECRET: meal-order-manager/form-api-key
|
||||
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
|
||||
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
|
||||
Policies:
|
||||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref OrdersTable
|
||||
|
|
@ -237,6 +241,12 @@ Resources:
|
|||
- Effect: Allow
|
||||
Action: secretsmanager:GetSecretValue
|
||||
Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*'
|
||||
- Effect: Allow
|
||||
Action: lambda:InvokeFunction
|
||||
Resource: !GetAtt SlackNotifierFunction.Arn
|
||||
- Effect: Allow
|
||||
Action: ssm:GetParameter
|
||||
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*'
|
||||
Events:
|
||||
SubmitOrder:
|
||||
Type: HttpApi
|
||||
|
|
@ -275,18 +285,20 @@ Resources:
|
|||
Environment:
|
||||
Variables:
|
||||
AGGREGATE_FUNCTION_ARN: !GetAtt AggregateOrdersFunction.Arn
|
||||
# Both EST and EDT schedules fire every week year-round. The handler is
|
||||
# idempotent, so the "wrong timezone" firing is a harmless no-op.
|
||||
Events:
|
||||
CloseEST:
|
||||
Type: Schedule
|
||||
Properties:
|
||||
Schedule: cron(0 23 ? * THU *)
|
||||
Description: 'Close form Thursday 6pm EST (23:00 UTC)'
|
||||
Schedule: cron(59 4 ? * FRI *)
|
||||
Description: 'Close form Thursday 11:59pm EST (04:59 UTC Friday)'
|
||||
Enabled: true
|
||||
CloseEDT:
|
||||
Type: Schedule
|
||||
Properties:
|
||||
Schedule: cron(0 22 ? * THU *)
|
||||
Description: 'Close form Thursday 6pm EDT (22:00 UTC)'
|
||||
Schedule: cron(59 3 ? * FRI *)
|
||||
Description: 'Close form Thursday 11:59pm EDT (03:59 UTC Friday)'
|
||||
Enabled: true
|
||||
|
||||
AggregateOrdersFunction:
|
||||
|
|
@ -460,6 +472,11 @@ Resources:
|
|||
Value: CHANGE_ME
|
||||
Description: Slack channel ID for meal order notifications
|
||||
|
||||
# GoogleClientIdParam (/meal-order-manager/google-client-id) is managed
|
||||
# manually via AWS CLI since it varies per environment. Create it with:
|
||||
# aws ssm put-parameter --name /meal-order-manager/google-client-id \
|
||||
# --type String --value "<YOUR_GOOGLE_CLIENT_ID>"
|
||||
|
||||
Outputs:
|
||||
ApiUrl:
|
||||
Description: API Gateway endpoint URL
|
||||
|
|
|
|||
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""Pytest configuration — add shared layer source to sys.path so handler imports resolve."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the shared layer source directory so `from shared.db import ...` works
|
||||
# without requiring a real Lambda layer or .aws-sam build.
|
||||
_shared_layer_dir = os.path.join(os.path.dirname(__file__), os.pardir, "src", "shared")
|
||||
sys.path.insert(0, os.path.abspath(_shared_layer_dir))
|
||||
503
tests/test_aggregate_orders.py
Normal file
503
tests/test_aggregate_orders.py
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
"""Unit tests for functions/aggregate_orders/handler.py."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — reusable order builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_order(
|
||||
name: str, email: str, items: list[dict], total: float | None = None
|
||||
) -> dict:
|
||||
"""Build a minimal order dict matching DynamoDB shape."""
|
||||
if total is None:
|
||||
total = sum(
|
||||
float(
|
||||
i.get("subtotal", float(i.get("price", 0)) * int(i.get("quantity", 0)))
|
||||
)
|
||||
for i in items
|
||||
)
|
||||
return {
|
||||
"employee_name": name,
|
||||
"employee_email": email,
|
||||
"items": items,
|
||||
"total": Decimal(str(total)),
|
||||
}
|
||||
|
||||
|
||||
def _make_item(
|
||||
name: str,
|
||||
quantity: int = 1,
|
||||
price: float = 10.0,
|
||||
bulk_price: float | None = None,
|
||||
subtotal: float | None = None,
|
||||
) -> dict:
|
||||
"""Build a minimal item dict."""
|
||||
item = {"name": name, "quantity": quantity, "price": price}
|
||||
if bulk_price is not None:
|
||||
item["bulk_price"] = bulk_price
|
||||
if subtotal is not None:
|
||||
item["subtotal"] = subtotal
|
||||
return item
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import handler AFTER patching boto3 + env vars so module-level clients
|
||||
# don't try to hit real AWS.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_env(monkeypatch):
|
||||
monkeypatch.setenv("TABLE_NAME", "test-table")
|
||||
monkeypatch.setenv("REPORTS_BUCKET", "test-bucket")
|
||||
monkeypatch.setenv(
|
||||
"SLACK_NOTIFIER_ARN",
|
||||
"arn:aws:lambda:us-east-1:123456789012:function:test-notifier",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def handler_module():
|
||||
"""Import the handler with boto3 patched at module level."""
|
||||
with patch("boto3.client") as mock_client, patch("boto3.resource"):
|
||||
mock_s3 = MagicMock()
|
||||
mock_lambda = MagicMock()
|
||||
mock_client.side_effect = lambda svc, **kw: {
|
||||
"s3": mock_s3,
|
||||
"lambda": mock_lambda,
|
||||
}[svc]
|
||||
|
||||
import importlib
|
||||
import functions.aggregate_orders.handler as mod
|
||||
|
||||
importlib.reload(mod)
|
||||
|
||||
# Inject mocked clients so tests can assert on them
|
||||
mod._s3 = mock_s3
|
||||
mod._lambda = mock_lambda
|
||||
yield mod
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Summary Building (Critical + High)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestBuildSummarySingleOrder:
|
||||
"""test_build_summary_single_order — one order with 2 items."""
|
||||
|
||||
def test_build_summary_single_order(self, handler_module):
|
||||
orders = [
|
||||
_make_order(
|
||||
"Alice Smith",
|
||||
"alice@example.com",
|
||||
[
|
||||
_make_item(
|
||||
"Chicken Parm", quantity=1, price=12.00, bulk_price=10.00
|
||||
),
|
||||
_make_item("Caesar Salad", quantity=1, price=8.00, bulk_price=6.50),
|
||||
],
|
||||
),
|
||||
]
|
||||
summary = handler_module.build_summary(orders, "2026-W20")
|
||||
|
||||
assert summary["week"] == "2026-W20", "Week should be passed through"
|
||||
assert summary["total_employees"] == 1, "Should count 1 employee"
|
||||
assert summary["total_meals"] == 2, "Should count 2 total meals"
|
||||
|
||||
meals_by_name = {m["meal"]: m for m in summary["meals"]}
|
||||
assert "Chicken Parm" in meals_by_name, "Chicken Parm should appear"
|
||||
assert "Caesar Salad" in meals_by_name, "Caesar Salad should appear"
|
||||
|
||||
chicken = meals_by_name["Chicken Parm"]
|
||||
assert chicken["quantity"] == 1
|
||||
assert chicken["unit_price"] == 10.00, "unit_price should use bulk_price"
|
||||
assert chicken["employee_unit_price"] == 12.00, (
|
||||
"employee_unit_price should use price"
|
||||
)
|
||||
assert chicken["line_total"] == 10.00, "line_total = bulk_price * qty"
|
||||
assert chicken["employee_line_total"] == 12.00, (
|
||||
"employee_line_total = price * qty"
|
||||
)
|
||||
|
||||
assert summary["grand_total"] == 16.50, (
|
||||
"grand_total should sum bulk line totals"
|
||||
)
|
||||
assert summary["employee_total"] == 20.00, (
|
||||
"employee_total should sum employee line totals"
|
||||
)
|
||||
|
||||
|
||||
class TestBuildSummaryMultipleOrdersSameMeal:
|
||||
"""test_build_summary_multiple_orders_same_meal — 3 employees order the same meal."""
|
||||
|
||||
def test_build_summary_multiple_orders_same_meal(self, handler_module):
|
||||
orders = [
|
||||
_make_order(
|
||||
"Alice",
|
||||
"a@x.com",
|
||||
[_make_item("Burger", quantity=1, price=10.00, bulk_price=8.00)],
|
||||
),
|
||||
_make_order(
|
||||
"Bob",
|
||||
"b@x.com",
|
||||
[_make_item("Burger", quantity=2, price=10.00, bulk_price=8.00)],
|
||||
),
|
||||
_make_order(
|
||||
"Carol",
|
||||
"c@x.com",
|
||||
[_make_item("Burger", quantity=1, price=10.00, bulk_price=8.00)],
|
||||
),
|
||||
]
|
||||
summary = handler_module.build_summary(orders, "2026-W20")
|
||||
|
||||
assert len(summary["meals"]) == 1, (
|
||||
"All three ordered the same meal — should aggregate to 1 entry"
|
||||
)
|
||||
burger = summary["meals"][0]
|
||||
assert burger["quantity"] == 4, "Total quantity should be 1 + 2 + 1 = 4"
|
||||
assert burger["line_total"] == 32.00, "line_total = 8.00 * 4"
|
||||
assert burger["employee_line_total"] == 40.00, "employee_line_total = 10.00 * 4"
|
||||
|
||||
|
||||
class TestBuildSummarySortedAlphabetically:
|
||||
"""test_build_summary_sorted_alphabetically — meals appear in alphabetical order."""
|
||||
|
||||
def test_build_summary_sorted_alphabetically(self, handler_module):
|
||||
orders = [
|
||||
_make_order(
|
||||
"Alice",
|
||||
"a@x.com",
|
||||
[
|
||||
_make_item("Ziti", quantity=1, price=10.00),
|
||||
_make_item("Apple Pie", quantity=1, price=5.00),
|
||||
_make_item("Meatloaf", quantity=1, price=12.00),
|
||||
],
|
||||
),
|
||||
]
|
||||
summary = handler_module.build_summary(orders, "2026-W20")
|
||||
|
||||
meal_names = [m["meal"] for m in summary["meals"]]
|
||||
assert meal_names == ["Apple Pie", "Meatloaf", "Ziti"], (
|
||||
"Meals should be sorted alphabetically"
|
||||
)
|
||||
|
||||
|
||||
class TestBuildSummaryGrandTotalVsEmployeeTotal:
|
||||
"""test_build_summary_grand_total_vs_employee_total — grand_total uses bulk_price, employee_total uses price."""
|
||||
|
||||
def test_build_summary_grand_total_vs_employee_total(self, handler_module):
|
||||
orders = [
|
||||
_make_order(
|
||||
"Alice",
|
||||
"a@x.com",
|
||||
[
|
||||
_make_item("Steak", quantity=2, price=15.00, bulk_price=11.00),
|
||||
],
|
||||
),
|
||||
_make_order(
|
||||
"Bob",
|
||||
"b@x.com",
|
||||
[
|
||||
_make_item("Pasta", quantity=1, price=9.00, bulk_price=7.00),
|
||||
],
|
||||
),
|
||||
]
|
||||
summary = handler_module.build_summary(orders, "2026-W20")
|
||||
|
||||
# Steak: bulk 11*2=22, employee 15*2=30
|
||||
# Pasta: bulk 7*1=7, employee 9*1=9
|
||||
assert summary["grand_total"] == 29.00, (
|
||||
"grand_total should use bulk_price (22 + 7)"
|
||||
)
|
||||
assert summary["employee_total"] == 39.00, (
|
||||
"employee_total should use employee_price (30 + 9)"
|
||||
)
|
||||
assert summary["grand_total"] != summary["employee_total"], (
|
||||
"grand_total and employee_total must differ when bulk != employee price"
|
||||
)
|
||||
|
||||
|
||||
class TestBuildSummaryRounding:
|
||||
"""test_build_summary_rounding — totals rounded to 2 decimal places."""
|
||||
|
||||
def test_build_summary_rounding(self, handler_module):
|
||||
# Use prices that produce repeating decimals when multiplied
|
||||
orders = [
|
||||
_make_order(
|
||||
"Alice",
|
||||
"a@x.com",
|
||||
[
|
||||
_make_item("Soup", quantity=3, price=3.33, bulk_price=2.77),
|
||||
],
|
||||
),
|
||||
]
|
||||
summary = handler_module.build_summary(orders, "2026-W20")
|
||||
|
||||
soup = summary["meals"][0]
|
||||
# 2.77 * 3 = 8.31 (rounded)
|
||||
assert soup["line_total"] == 8.31, "line_total should be rounded to 2 decimals"
|
||||
# 3.33 * 3 = 9.99
|
||||
assert soup["employee_line_total"] == 9.99, (
|
||||
"employee_line_total should be rounded to 2 decimals"
|
||||
)
|
||||
assert summary["grand_total"] == 8.31
|
||||
assert summary["employee_total"] == 9.99
|
||||
|
||||
# Verify they are actually rounded (no extra decimal digits)
|
||||
assert summary["grand_total"] == round(summary["grand_total"], 2)
|
||||
assert summary["employee_total"] == round(summary["employee_total"], 2)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# CSV Generation (High)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestBuildOrderSummaryCsv:
|
||||
"""test_build_order_summary_csv — correct header, meal rows, total row format."""
|
||||
|
||||
def test_build_order_summary_csv(self, handler_module):
|
||||
orders = [
|
||||
_make_order(
|
||||
"Alice",
|
||||
"a@x.com",
|
||||
[
|
||||
_make_item("Burger", quantity=2, price=10.00, bulk_price=8.00),
|
||||
_make_item("Fries", quantity=1, price=5.00, bulk_price=4.00),
|
||||
],
|
||||
),
|
||||
]
|
||||
summary = handler_module.build_summary(orders, "2026-W20")
|
||||
csv_str = handler_module.build_order_summary_csv(summary)
|
||||
|
||||
reader = csv.reader(io.StringIO(csv_str))
|
||||
rows = list(reader)
|
||||
|
||||
# Header
|
||||
assert rows[0] == ["Meal", "Quantity", "Unit Price", "Line Total"], (
|
||||
"First row should be the header"
|
||||
)
|
||||
|
||||
# Meal rows (alphabetical: Burger, Fries)
|
||||
assert rows[1][0] == "Burger"
|
||||
assert rows[1][1] == "2"
|
||||
assert rows[1][2] == "$8.00"
|
||||
assert rows[1][3] == "$16.00"
|
||||
|
||||
assert rows[2][0] == "Fries"
|
||||
assert rows[2][1] == "1"
|
||||
assert rows[2][2] == "$4.00"
|
||||
assert rows[2][3] == "$4.00"
|
||||
|
||||
# Empty separator row then total row
|
||||
assert rows[3] == [], "Separator should be an empty row"
|
||||
assert rows[4][0] == "TOTAL"
|
||||
assert rows[4][1] == "3", "Total quantity should be 3"
|
||||
assert rows[4][3] == "$20.00", "Total should be grand_total"
|
||||
|
||||
|
||||
class TestBuildPayrollCsv:
|
||||
"""test_build_payroll_csv — correct header, sorted employees, item format, total deduction."""
|
||||
|
||||
def test_build_payroll_csv(self, handler_module):
|
||||
orders = [
|
||||
_make_order(
|
||||
"Zara Adams",
|
||||
"zara@x.com",
|
||||
[
|
||||
_make_item("Pasta", quantity=2, price=9.00, subtotal=18.00),
|
||||
],
|
||||
total=18.00,
|
||||
),
|
||||
_make_order(
|
||||
"Alice Brown",
|
||||
"alice@x.com",
|
||||
[
|
||||
_make_item("Burger", quantity=1, price=10.00, subtotal=10.00),
|
||||
_make_item("Fries", quantity=2, price=5.00, subtotal=10.00),
|
||||
],
|
||||
total=20.00,
|
||||
),
|
||||
]
|
||||
csv_str = handler_module.build_payroll_csv(orders)
|
||||
|
||||
reader = csv.reader(io.StringIO(csv_str))
|
||||
rows = list(reader)
|
||||
|
||||
# Header
|
||||
assert rows[0] == [
|
||||
"Employee Name",
|
||||
"Employee Email",
|
||||
"Items Ordered",
|
||||
"Total Deduction",
|
||||
]
|
||||
|
||||
# Sorted alphabetically by employee_name: Alice Brown before Zara Adams
|
||||
assert rows[1][0] == "Alice Brown", "Employees should be sorted by name"
|
||||
assert rows[1][1] == "alice@x.com"
|
||||
assert "Burger x1 ($10.00)" in rows[1][2]
|
||||
assert "Fries x2 ($10.00)" in rows[1][2]
|
||||
assert "; " in rows[1][2], "Items should be separated by '; '"
|
||||
assert rows[1][3] == "$20.00"
|
||||
|
||||
assert rows[2][0] == "Zara Adams"
|
||||
assert rows[2][1] == "zara@x.com"
|
||||
assert "Pasta x2 ($18.00)" in rows[2][2]
|
||||
assert rows[2][3] == "$18.00"
|
||||
|
||||
|
||||
class TestBuildPayrollCsvSubtotalFallback:
|
||||
"""test_build_payroll_csv_subtotal_fallback — when item lacks 'subtotal', falls back to price*quantity."""
|
||||
|
||||
def test_build_payroll_csv_subtotal_fallback(self, handler_module):
|
||||
orders = [
|
||||
_make_order(
|
||||
"Alice Brown",
|
||||
"alice@x.com",
|
||||
[
|
||||
_make_item("Burger", quantity=3, price=10.00), # no subtotal key
|
||||
],
|
||||
total=30.00,
|
||||
),
|
||||
]
|
||||
csv_str = handler_module.build_payroll_csv(orders)
|
||||
|
||||
reader = csv.reader(io.StringIO(csv_str))
|
||||
rows = list(reader)
|
||||
|
||||
# price(10) * quantity(3) = 30.00
|
||||
assert "Burger x3 ($30.00)" in rows[1][2], (
|
||||
"Without 'subtotal' key, should fall back to price * quantity"
|
||||
)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Lambda Handler Flow (High)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestAggregateAlreadyAggregated:
|
||||
"""test_aggregate_already_aggregated — summary exists, returns early, no S3 upload."""
|
||||
|
||||
@patch("functions.aggregate_orders.handler.get_summary")
|
||||
@patch("functions.aggregate_orders.handler.get_orders")
|
||||
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
|
||||
def test_aggregate_already_aggregated(
|
||||
self, mock_week, mock_orders, mock_summary, handler_module
|
||||
):
|
||||
mock_summary.return_value = {"week": "2026-W20", "meals": []}
|
||||
|
||||
result = handler_module.lambda_handler({}, None)
|
||||
|
||||
assert result["status"] == "already_aggregated", (
|
||||
"Should return already_aggregated status"
|
||||
)
|
||||
assert result["week"] == "2026-W20"
|
||||
handler_module._s3.put_object.assert_not_called()
|
||||
mock_orders.assert_not_called()
|
||||
|
||||
|
||||
class TestAggregateNoOrders:
|
||||
"""test_aggregate_no_orders — no orders returns no_orders status."""
|
||||
|
||||
@patch("functions.aggregate_orders.handler.get_summary")
|
||||
@patch("functions.aggregate_orders.handler.get_orders")
|
||||
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
|
||||
def test_aggregate_no_orders(
|
||||
self, mock_week, mock_orders, mock_summary, handler_module
|
||||
):
|
||||
mock_summary.return_value = None
|
||||
mock_orders.return_value = []
|
||||
|
||||
result = handler_module.lambda_handler({}, None)
|
||||
|
||||
assert result["status"] == "no_orders", (
|
||||
"Should return no_orders when order list is empty"
|
||||
)
|
||||
assert result["week"] == "2026-W20"
|
||||
handler_module._s3.put_object.assert_not_called()
|
||||
|
||||
|
||||
class TestAggregateHappyPath:
|
||||
"""test_aggregate_happy_path — orders exist, builds summary, uploads CSVs, saves, triggers Slack."""
|
||||
|
||||
@patch("functions.aggregate_orders.handler.put_summary")
|
||||
@patch("functions.aggregate_orders.handler.get_summary")
|
||||
@patch("functions.aggregate_orders.handler.get_orders")
|
||||
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
|
||||
def test_aggregate_happy_path(
|
||||
self, mock_week, mock_orders, mock_get_summary, mock_put_summary, handler_module
|
||||
):
|
||||
mock_get_summary.return_value = None
|
||||
mock_orders.return_value = [
|
||||
_make_order(
|
||||
"Alice",
|
||||
"alice@x.com",
|
||||
[
|
||||
_make_item(
|
||||
"Burger",
|
||||
quantity=1,
|
||||
price=10.00,
|
||||
bulk_price=8.00,
|
||||
subtotal=10.00,
|
||||
),
|
||||
],
|
||||
total=10.00,
|
||||
),
|
||||
_make_order(
|
||||
"Bob",
|
||||
"bob@x.com",
|
||||
[
|
||||
_make_item(
|
||||
"Pasta", quantity=2, price=9.00, bulk_price=7.00, subtotal=18.00
|
||||
),
|
||||
],
|
||||
total=18.00,
|
||||
),
|
||||
]
|
||||
|
||||
result = handler_module.lambda_handler({}, None)
|
||||
|
||||
# Verify return
|
||||
assert result["status"] == "aggregated"
|
||||
assert result["week"] == "2026-W20"
|
||||
assert result["total_employees"] == 2
|
||||
|
||||
# Verify 2 S3 uploads (order summary CSV + payroll CSV)
|
||||
s3_calls = handler_module._s3.put_object.call_args_list
|
||||
assert len(s3_calls) == 2, "Should upload exactly 2 CSVs to S3"
|
||||
|
||||
s3_keys = [call.kwargs["Key"] for call in s3_calls]
|
||||
assert "reports/2026-W20/order-summary.csv" in s3_keys
|
||||
assert "reports/2026-W20/payroll-deductions.csv" in s3_keys
|
||||
|
||||
for call in s3_calls:
|
||||
assert call.kwargs["Bucket"] == "test-bucket"
|
||||
assert call.kwargs["ContentType"] == "text/csv"
|
||||
|
||||
# Verify summary saved to DynamoDB
|
||||
mock_put_summary.assert_called_once()
|
||||
saved_summary = mock_put_summary.call_args[0][1]
|
||||
assert saved_summary["week"] == "2026-W20"
|
||||
assert "order_csv_s3_key" in saved_summary
|
||||
assert "payroll_csv_s3_key" in saved_summary
|
||||
|
||||
# Verify Slack notifier Lambda invoked asynchronously
|
||||
handler_module._lambda.invoke.assert_called_once()
|
||||
invoke_kwargs = handler_module._lambda.invoke.call_args.kwargs
|
||||
assert invoke_kwargs["FunctionName"] == os.environ["SLACK_NOTIFIER_ARN"]
|
||||
assert invoke_kwargs["InvocationType"] == "Event"
|
||||
payload = json.loads(invoke_kwargs["Payload"])
|
||||
assert payload["event"] == "orders_aggregated"
|
||||
assert payload["week"] == "2026-W20"
|
||||
117
tests/test_close_form.py
Normal file
117
tests/test_close_form.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Unit tests for functions/close_form/handler.py — wall-clock guard."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
os.environ.setdefault(
|
||||
"AGGREGATE_FUNCTION_ARN",
|
||||
"arn:aws:lambda:us-east-1:000000000000:function:test-aggregate",
|
||||
)
|
||||
|
||||
import importlib.util
|
||||
|
||||
_handler_path = os.path.join(
|
||||
os.path.dirname(__file__), os.pardir, "functions", "close_form", "handler.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"close_form_handler", os.path.abspath(_handler_path)
|
||||
)
|
||||
close_form_handler = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["close_form_handler"] = close_form_handler
|
||||
_spec.loader.exec_module(close_form_handler)
|
||||
|
||||
ET = ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
def _make_datetime(year, month, day, hour, minute=0):
|
||||
return datetime(year, month, day, hour, minute, tzinfo=ET)
|
||||
|
||||
|
||||
class TestCloseFormGuard:
|
||||
@patch("close_form_handler.datetime")
|
||||
def test_skipped_on_wednesday(self, mock_dt):
|
||||
"""Wednesday 11pm ET -> skipped (not Thursday)."""
|
||||
mock_dt.now.return_value = _make_datetime(2026, 5, 13, 23) # Wednesday
|
||||
|
||||
result = close_form_handler.lambda_handler({}, None)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
|
||||
@patch("close_form_handler.datetime")
|
||||
def test_skipped_on_friday_after_catchup_window(self, mock_dt):
|
||||
"""Friday 4am ET -> skipped (past Thu 23 / Fri 00–03 catch-up window)."""
|
||||
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 4) # Friday 4am
|
||||
|
||||
result = close_form_handler.lambda_handler({}, None)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
|
||||
@patch("close_form_handler.datetime")
|
||||
def test_skipped_thursday_before_11pm(self, mock_dt):
|
||||
"""Thursday 10pm ET -> skipped (too early)."""
|
||||
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 22) # Thursday 10pm
|
||||
|
||||
result = close_form_handler.lambda_handler({}, None)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
|
||||
@patch("close_form_handler.set_form_status")
|
||||
@patch("close_form_handler.get_form_status", return_value="open")
|
||||
@patch("close_form_handler.current_week", return_value="2026-W19")
|
||||
@patch("close_form_handler._lambda")
|
||||
@patch("close_form_handler.datetime")
|
||||
def test_runs_thursday_at_11pm(
|
||||
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
|
||||
):
|
||||
"""Thursday 11pm ET -> proceeds to close."""
|
||||
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23) # Thursday 11pm
|
||||
|
||||
result = close_form_handler.lambda_handler({}, None)
|
||||
|
||||
assert result["status"] == "closed"
|
||||
mock_set.assert_called_once()
|
||||
|
||||
@patch("close_form_handler.set_form_status")
|
||||
@patch("close_form_handler.get_form_status", return_value="open")
|
||||
@patch("close_form_handler.current_week", return_value="2026-W19")
|
||||
@patch("close_form_handler._lambda")
|
||||
@patch("close_form_handler.datetime")
|
||||
def test_runs_thursday_at_1159pm(
|
||||
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
|
||||
):
|
||||
"""Thursday 11:59pm ET -> proceeds to close."""
|
||||
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23, 59)
|
||||
|
||||
result = close_form_handler.lambda_handler({}, None)
|
||||
|
||||
assert result["status"] == "closed"
|
||||
|
||||
@patch("close_form_handler.set_form_status")
|
||||
@patch("close_form_handler.get_form_status", return_value="open")
|
||||
@patch("close_form_handler.current_week", return_value="2026-W19")
|
||||
@patch("close_form_handler._lambda")
|
||||
@patch("close_form_handler.datetime")
|
||||
def test_runs_friday_just_after_midnight(
|
||||
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
|
||||
):
|
||||
"""Friday 12:30am ET -> proceeds (delayed EventBridge past Thu 23:59)."""
|
||||
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 0, 30)
|
||||
|
||||
result = close_form_handler.lambda_handler({}, None)
|
||||
|
||||
assert result["status"] == "closed"
|
||||
mock_set.assert_called_once()
|
||||
|
||||
@patch("close_form_handler.get_form_status", return_value="closed")
|
||||
@patch("close_form_handler.current_week", return_value="2026-W19")
|
||||
@patch("close_form_handler.datetime")
|
||||
def test_already_closed(self, mock_dt, mock_week, mock_status):
|
||||
"""Thursday 11pm but already closed -> returns already_closed."""
|
||||
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23)
|
||||
|
||||
result = close_form_handler.lambda_handler({}, None)
|
||||
|
||||
assert result["status"] == "already_closed"
|
||||
50
tests/test_secrets.py
Normal file
50
tests/test_secrets.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Tests for shared.secrets caching."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_get_parameter_refetches_after_ttl():
|
||||
"""SSM parameter values expire so callers can observe rotations."""
|
||||
from shared import secrets
|
||||
|
||||
secrets._secret_cache.clear()
|
||||
secrets._parameter_cache.clear()
|
||||
|
||||
mock_ssm = MagicMock()
|
||||
mock_ssm.get_parameter.side_effect = [
|
||||
{"Parameter": {"Value": "first"}},
|
||||
{"Parameter": {"Value": "second"}},
|
||||
]
|
||||
|
||||
with patch.object(secrets, "_ssm", mock_ssm):
|
||||
with patch.object(secrets, "PARAM_CACHE_TTL_SECONDS", 10.0):
|
||||
with patch(
|
||||
"shared.secrets.time.monotonic",
|
||||
side_effect=[0.0, 5.0, 15.0],
|
||||
):
|
||||
assert secrets.get_parameter("/test/param") == "first"
|
||||
assert secrets.get_parameter("/test/param") == "first"
|
||||
assert secrets.get_parameter("/test/param") == "second"
|
||||
|
||||
assert mock_ssm.get_parameter.call_count == 2
|
||||
|
||||
|
||||
def test_get_secret_stays_cached():
|
||||
"""Secrets Manager values remain cached (no TTL)."""
|
||||
from shared import secrets
|
||||
|
||||
secrets._secret_cache.clear()
|
||||
secrets._parameter_cache.clear()
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.get_secret_value.side_effect = [
|
||||
{"SecretString": "a"},
|
||||
{"SecretString": "b"},
|
||||
]
|
||||
|
||||
with patch.object(secrets, "_sm", mock_sm):
|
||||
with patch("shared.secrets.time.monotonic", side_effect=[0.0, 5000.0]):
|
||||
assert secrets.get_secret("arn:aws:secret") == "a"
|
||||
assert secrets.get_secret("arn:aws:secret") == "a"
|
||||
|
||||
assert mock_sm.get_secret_value.call_count == 1
|
||||
375
tests/test_slack_notifier.py
Normal file
375
tests/test_slack_notifier.py
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
"""Unit tests for the slack_notifier handler."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure the handler module can be imported. The shared layer lives under
|
||||
# src/shared/ and the handler lives under functions/slack_notifier/.
|
||||
# ---------------------------------------------------------------------------
|
||||
import importlib.util
|
||||
|
||||
_repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(_repo, "src", "shared"))
|
||||
|
||||
_handler_path = os.path.join(_repo, "functions", "slack_notifier", "handler.py")
|
||||
_spec = importlib.util.spec_from_file_location("slack_notifier_handler", _handler_path)
|
||||
handler = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["slack_notifier_handler"] = handler
|
||||
_spec.loader.exec_module(handler)
|
||||
|
||||
ET = ZoneInfo("America/New_York")
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_datetime(year, month, day, hour, minute=0):
|
||||
"""Return a timezone-aware datetime in America/New_York."""
|
||||
return datetime(year, month, day, hour, minute, tzinfo=ET)
|
||||
|
||||
|
||||
def _thursday_10am():
|
||||
"""2026-05-14 is a Thursday."""
|
||||
return _make_datetime(2026, 5, 14, 10)
|
||||
|
||||
|
||||
def _thursday_11am():
|
||||
return _make_datetime(2026, 5, 14, 11)
|
||||
|
||||
|
||||
def _wednesday_10am():
|
||||
"""2026-05-13 is a Wednesday."""
|
||||
return _make_datetime(2026, 5, 13, 10)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Reminder Dedup Guard (Critical)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestReminderDedupGuard:
|
||||
@patch("slack_notifier_handler.datetime")
|
||||
def test_reminder_skipped_wrong_hour(self, mock_dt):
|
||||
"""Invoked at 11am Thursday ET -> returns skipped."""
|
||||
mock_dt.now.return_value = _thursday_11am()
|
||||
|
||||
result = handler.handle_reminder({"event": "reminder"})
|
||||
|
||||
assert result["status"] == "skipped", "Should skip when hour is not 10"
|
||||
assert "outside reminder window" in result["reason"]
|
||||
|
||||
@patch("slack_notifier_handler.datetime")
|
||||
def test_reminder_skipped_wrong_day(self, mock_dt):
|
||||
"""Invoked at 10am Wednesday ET -> returns skipped."""
|
||||
mock_dt.now.return_value = _wednesday_10am()
|
||||
|
||||
result = handler.handle_reminder({"event": "reminder"})
|
||||
|
||||
assert result["status"] == "skipped", "Should skip when day is not Thursday"
|
||||
assert "outside reminder window" in result["reason"]
|
||||
|
||||
@patch("slack_notifier_handler.send_dm")
|
||||
@patch("slack_notifier_handler.get_orders", return_value=[])
|
||||
@patch("slack_notifier_handler.get_roster", return_value=[])
|
||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||
@patch("slack_notifier_handler.datetime")
|
||||
def test_reminder_runs_at_correct_time(
|
||||
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
|
||||
):
|
||||
"""Invoked at 10am Thursday ET -> proceeds (does not skip)."""
|
||||
mock_dt.now.return_value = _thursday_10am()
|
||||
|
||||
result = handler.handle_reminder({"event": "reminder"})
|
||||
|
||||
assert result["status"] != "skipped", "Should not skip at 10am Thursday"
|
||||
|
||||
@patch("slack_notifier_handler.datetime")
|
||||
def test_lambda_handler_reminder_guard(self, mock_dt):
|
||||
"""lambda_handler lines 32-34 also return skipped for wrong time."""
|
||||
mock_dt.now.return_value = _thursday_11am()
|
||||
|
||||
result = handler.lambda_handler({"event": "reminder"}, None)
|
||||
|
||||
assert result["status"] == "skipped", (
|
||||
"lambda_handler should short-circuit before calling handle_reminder"
|
||||
)
|
||||
assert "outside reminder window" in result["reason"]
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Reminder DMs (High)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestReminderDMs:
|
||||
@patch("slack_notifier_handler.send_dm")
|
||||
@patch("slack_notifier_handler.get_orders")
|
||||
@patch("slack_notifier_handler.get_roster")
|
||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||
@patch("slack_notifier_handler.datetime")
|
||||
def test_reminder_sends_to_non_ordered(
|
||||
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
|
||||
):
|
||||
"""Roster of 3, 1 has ordered -> DMs sent to 2 others."""
|
||||
mock_dt.now.return_value = _thursday_10am()
|
||||
mock_roster.return_value = [
|
||||
{"email": "alice@x.com", "name": "Alice A", "slack_user_id": "U001"},
|
||||
{"email": "bob@x.com", "name": "Bob B", "slack_user_id": "U002"},
|
||||
{"email": "carol@x.com", "name": "Carol C", "slack_user_id": "U003"},
|
||||
]
|
||||
mock_orders.return_value = [
|
||||
{"employee_email": "alice@x.com"},
|
||||
]
|
||||
|
||||
result = handler.handle_reminder({"event": "reminder"})
|
||||
|
||||
assert result["dm_count"] == 2, "Should DM the 2 employees who haven't ordered"
|
||||
assert result["missing_count"] == 2
|
||||
assert mock_dm.call_count == 2
|
||||
|
||||
dm_user_ids = {call.args[0] for call in mock_dm.call_args_list}
|
||||
assert dm_user_ids == {"U002", "U003"}, "Should DM Bob and Carol, not Alice"
|
||||
|
||||
@patch("slack_notifier_handler.send_dm")
|
||||
@patch("slack_notifier_handler.get_orders", return_value=[])
|
||||
@patch("slack_notifier_handler.get_roster")
|
||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||
@patch("slack_notifier_handler.datetime")
|
||||
def test_reminder_skips_no_slack_id(
|
||||
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
|
||||
):
|
||||
"""Employee without slack_user_id is counted as missing but not DM'd."""
|
||||
mock_dt.now.return_value = _thursday_10am()
|
||||
mock_roster.return_value = [
|
||||
{"email": "dave@x.com", "name": "Dave D"}, # no slack_user_id
|
||||
{"email": "eve@x.com", "name": "Eve E", "slack_user_id": "U005"},
|
||||
]
|
||||
|
||||
result = handler.handle_reminder({"event": "reminder"})
|
||||
|
||||
assert result["missing_count"] == 2, "Both are missing (no orders at all)"
|
||||
assert result["dm_count"] == 1, "Only Eve should be DM'd (Dave has no Slack ID)"
|
||||
mock_dm.assert_called_once()
|
||||
assert mock_dm.call_args.args[0] == "U005"
|
||||
|
||||
@patch("slack_notifier_handler.send_dm")
|
||||
@patch("slack_notifier_handler.get_orders")
|
||||
@patch("slack_notifier_handler.get_roster")
|
||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||
@patch("slack_notifier_handler.datetime")
|
||||
def test_reminder_case_insensitive_email(
|
||||
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
|
||||
):
|
||||
"""'Adam@x.com' in roster matches 'adam@x.com' in orders."""
|
||||
mock_dt.now.return_value = _thursday_10am()
|
||||
mock_roster.return_value = [
|
||||
{"email": "Adam@x.com", "name": "Adam M", "slack_user_id": "U010"},
|
||||
]
|
||||
mock_orders.return_value = [
|
||||
{"employee_email": "adam@x.com"},
|
||||
]
|
||||
|
||||
result = handler.handle_reminder({"event": "reminder"})
|
||||
|
||||
assert result["dm_count"] == 0, (
|
||||
"Adam already ordered (case-insensitive match) — no DM expected"
|
||||
)
|
||||
mock_dm.assert_not_called()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Order Confirmed (High)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestOrderConfirmed:
|
||||
@patch("slack_notifier_handler.send_dm")
|
||||
@patch("slack_notifier_handler.get_roster")
|
||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||
def test_order_confirmed_dm_format(self, mock_week, mock_roster, mock_dm):
|
||||
"""DM contains item breakdown with 'your cost' labels and payroll total."""
|
||||
mock_roster.return_value = [
|
||||
{"email": "alice@x.com", "name": "Alice Adams", "slack_user_id": "U001"},
|
||||
]
|
||||
|
||||
event = {
|
||||
"event": "order_confirmed",
|
||||
"employee_email": "alice@x.com",
|
||||
"employee_name": "Alice Adams",
|
||||
"items": [
|
||||
{"name": "Grilled Chicken", "quantity": 2, "price": 8.50},
|
||||
{"name": "Caesar Salad", "quantity": 1, "price": 6.00},
|
||||
],
|
||||
"total": 23.00,
|
||||
"week": "2026-W19",
|
||||
}
|
||||
|
||||
handler.handle_order_confirmed(event)
|
||||
|
||||
mock_dm.assert_called_once()
|
||||
call_kwargs = mock_dm.call_args
|
||||
blocks = call_kwargs.kwargs.get("blocks") or call_kwargs[1].get("blocks")
|
||||
body_text = blocks[1]["text"]["text"]
|
||||
|
||||
assert "your cost: $17.00" in body_text, "Should show Grilled Chicken x2 cost"
|
||||
assert "your cost: $6.00" in body_text, "Should show Caesar Salad x1 cost"
|
||||
assert "$23.00" in body_text, "Should show payroll deduction total"
|
||||
assert "payroll deduction" in body_text.lower()
|
||||
|
||||
@patch("slack_notifier_handler.send_dm")
|
||||
@patch("slack_notifier_handler.get_roster")
|
||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||
def test_order_confirmed_no_slack_id(self, mock_week, mock_roster, mock_dm):
|
||||
"""Employee not in roster -> returns {'status': 'no_slack_id'}."""
|
||||
mock_roster.return_value = [] # empty roster
|
||||
|
||||
event = {
|
||||
"event": "order_confirmed",
|
||||
"employee_email": "nobody@x.com",
|
||||
"employee_name": "Nobody",
|
||||
"items": [],
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
result = handler.handle_order_confirmed(event)
|
||||
|
||||
assert result["status"] == "no_slack_id"
|
||||
mock_dm.assert_not_called()
|
||||
|
||||
@patch("slack_notifier_handler.send_dm")
|
||||
@patch("slack_notifier_handler.get_roster")
|
||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||
def test_order_confirmed_empty_name(self, mock_week, mock_roster, mock_dm):
|
||||
"""Empty name -> greeting says 'Hey there!' not crash."""
|
||||
mock_roster.return_value = [
|
||||
{"email": "anon@x.com", "name": "", "slack_user_id": "U099"},
|
||||
]
|
||||
|
||||
event = {
|
||||
"event": "order_confirmed",
|
||||
"employee_email": "anon@x.com",
|
||||
"employee_name": "",
|
||||
"items": [{"name": "Soup", "quantity": 1, "price": 5.00}],
|
||||
"total": 5.00,
|
||||
}
|
||||
|
||||
result = handler.handle_order_confirmed(event)
|
||||
|
||||
assert result["status"] == "confirmed", "Should not crash on empty name"
|
||||
|
||||
blocks = mock_dm.call_args.kwargs.get("blocks") or mock_dm.call_args[1].get(
|
||||
"blocks"
|
||||
)
|
||||
body_text = blocks[1]["text"]["text"]
|
||||
assert "Hey there!" in body_text, (
|
||||
"Should greet with 'Hey there!' when name is empty"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Orders Aggregated (High)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestOrdersAggregated:
|
||||
@patch("slack_notifier_handler.post_channel_message")
|
||||
@patch("slack_notifier_handler.get_summary")
|
||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||
def test_orders_aggregated_with_subsidy(self, mock_week, mock_summary, mock_post):
|
||||
"""employee_total < grand_total -> message includes company subsidy line."""
|
||||
mock_summary.return_value = {
|
||||
"total_employees": 5,
|
||||
"total_meals": 12,
|
||||
"grand_total": Decimal("150.00"),
|
||||
"employee_total": Decimal("120.00"),
|
||||
"meals": [
|
||||
{"meal": "Grilled Chicken", "quantity": Decimal("7")},
|
||||
{"meal": "Veggie Bowl", "quantity": Decimal("5")},
|
||||
],
|
||||
}
|
||||
|
||||
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
|
||||
|
||||
assert result["status"] == "notified"
|
||||
mock_post.assert_called_once()
|
||||
|
||||
blocks = mock_post.call_args.args[1]
|
||||
section_text = blocks[1]["text"]["text"]
|
||||
|
||||
assert "$150.00" in section_text, "Should show grand total"
|
||||
assert "$120.00" in section_text, "Should show employee payroll deductions"
|
||||
assert "$30.00" in section_text, "Should show company subsidy amount"
|
||||
assert "company subsidy" in section_text.lower()
|
||||
|
||||
@patch("slack_notifier_handler.post_channel_message")
|
||||
@patch("slack_notifier_handler.get_summary")
|
||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||
def test_orders_aggregated_without_subsidy(
|
||||
self, mock_week, mock_summary, mock_post
|
||||
):
|
||||
"""Equal totals -> no subsidy line."""
|
||||
mock_summary.return_value = {
|
||||
"total_employees": 3,
|
||||
"total_meals": 6,
|
||||
"grand_total": Decimal("90.00"),
|
||||
"employee_total": Decimal("90.00"),
|
||||
"meals": [
|
||||
{"meal": "Pasta Primavera", "quantity": Decimal("6")},
|
||||
],
|
||||
}
|
||||
|
||||
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
|
||||
|
||||
assert result["status"] == "notified"
|
||||
blocks = mock_post.call_args.args[1]
|
||||
section_text = blocks[1]["text"]["text"]
|
||||
|
||||
assert "company subsidy" not in section_text.lower(), (
|
||||
"Should not mention subsidy when employee_total == grand_total"
|
||||
)
|
||||
|
||||
@patch("slack_notifier_handler.post_channel_message")
|
||||
@patch("slack_notifier_handler.get_summary")
|
||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||
def test_orders_aggregated_no_summary(self, mock_week, mock_summary, mock_post):
|
||||
"""No summary in DB -> returns {'status': 'no_summary'}."""
|
||||
mock_summary.return_value = None
|
||||
|
||||
result = handler.handle_orders_aggregated({"event": "orders_aggregated"})
|
||||
|
||||
assert result["status"] == "no_summary"
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Escaping (Low)
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEscaping:
|
||||
def test_escape_mrkdwn(self):
|
||||
"""'A & B <C>' -> 'A & B <C>'."""
|
||||
assert handler._escape_mrkdwn("A & B <C>") == "A & B <C>"
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Routing
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRouting:
|
||||
def test_unknown_event_type(self):
|
||||
"""Unknown event type returns error message."""
|
||||
result = handler.lambda_handler({"event": "bogus_event"}, None)
|
||||
|
||||
assert "error" in result, "Should return an error key for unknown event type"
|
||||
assert "bogus_event" in result["error"], (
|
||||
"Error message should include the unrecognised event type"
|
||||
)
|
||||
1372
tests/test_submit_order.py
Normal file
1372
tests/test_submit_order.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue