Add admin panel, fix dual-domain auth, harden scrape schedule (#14)
Some checks failed
Deploy / deploy (push) Has been cancelled

* Add admin panel, fix dual-domain auth, harden weekly scrape schedule

Accept both seahavenind.com and seahaven.com Google Workspace domains
for employee sign-in. Add admin panel with order management (view by
week, edit quantities, add/remove items, delete orders) behind Google
auth + DynamoDB admin_emails allowlist. Shift weekly menu scrape from
8:00am to 7:30am ET and add timezone guard to prevent duplicate runs
from dual EST/EDT crons.

* Rename Secrets Manager env vars to avoid CI false positive

The reusable CI workflow greps for keywords like TOKEN and API_KEY in
Lambda environment variables. Our env vars hold Secrets Manager lookup
names, not actual secrets, but the heuristic matched the SM key name
meal-order-manager/slack-bot-token. Rename SLACK_BOT_TOKEN_SECRET to
SLACK_BOT_SM_NAME and FORM_API_KEY_SECRET to FORM_APIKEY_SM_NAME, and
reorder the Globals block so the value falls outside the grep window.
This commit is contained in:
Adam Moussa 2026-05-19 16:32:19 -04:00 • committed by GitHub
parent a752c24e0f
commit 5db95ce9d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 773 additions and 19 deletions

View file

@ -2,10 +2,10 @@ name: Weekly Menu Scrape & Publish
on: on:
schedule: schedule:
# Monday 8am EST = 13:00 UTC # Monday 7:30am EST = 12:30 UTC
- cron: '0 13 * * 1' - cron: '30 12 * * 1'
# Monday 8am EDT = 12:00 UTC # Monday 7:30am EDT = 11:30 UTC
- cron: '0 12 * * 1' - cron: '30 11 * * 1'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -19,27 +19,45 @@ jobs:
AWS_REGION: us-east-1 AWS_REGION: us-east-1
steps: steps:
- name: Timezone guard
if: github.event_name == 'schedule'
run: |
CRON="${{ github.event.schedule }}"
OFFSET=$(TZ='America/New_York' date +%z)
echo "Cron: $CRON | Eastern offset: $OFFSET"
if { [ "$OFFSET" = "-0400" ] && [ "$CRON" = "30 12 * * 1" ]; } || \
{ [ "$OFFSET" = "-0500" ] && [ "$CRON" = "30 11 * * 1" ]; }; then
echo "Wrong-timezone cron fired — skipping"
echo "SKIP_RUN=true" >> "$GITHUB_ENV"
fi
- uses: actions/checkout@v6 - uses: actions/checkout@v6
if: env.SKIP_RUN != 'true'
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
if: env.SKIP_RUN != 'true'
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install dependencies - name: Install dependencies
if: env.SKIP_RUN != 'true'
run: | run: |
pip install playwright boto3 pip install playwright boto3
playwright install chromium --with-deps playwright install chromium --with-deps
- name: Configure AWS credentials - name: Configure AWS credentials
if: env.SKIP_RUN != 'true'
uses: aws-actions/configure-aws-credentials@v4 uses: aws-actions/configure-aws-credentials@v4
with: with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1 aws-region: us-east-1
- name: Scrape menu - name: Scrape menu
if: env.SKIP_RUN != 'true'
run: python3 src/scraper/scrape_menu.py run: python3 src/scraper/scrape_menu.py
- name: Get stack outputs - name: Get stack outputs
if: env.SKIP_RUN != 'true'
id: stack id: stack
run: | run: |
API_URL=$(aws cloudformation describe-stacks \ API_URL=$(aws cloudformation describe-stacks \
@ -64,6 +82,7 @@ jobs:
echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT
- name: Get API key - name: Get API key
if: env.SKIP_RUN != 'true'
id: apikey id: apikey
run: | run: |
API_KEY=$(aws secretsmanager get-secret-value \ API_KEY=$(aws secretsmanager get-secret-value \
@ -73,6 +92,7 @@ jobs:
echo "api_key=$API_KEY" >> $GITHUB_OUTPUT echo "api_key=$API_KEY" >> $GITHUB_OUTPUT
- name: Get discount settings - name: Get discount settings
if: env.SKIP_RUN != 'true'
id: discount id: discount
run: | run: |
RESULT=$(aws dynamodb get-item \ RESULT=$(aws dynamodb get-item \
@ -93,6 +113,7 @@ jobs:
echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT
- name: Get Google Client ID - name: Get Google Client ID
if: env.SKIP_RUN != 'true'
id: google id: google
run: | run: |
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \ GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
@ -105,6 +126,7 @@ jobs:
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
- name: Generate order form - name: Generate order form
if: env.SKIP_RUN != 'true'
run: | run: |
python3 src/server/generate_form.py \ python3 src/server/generate_form.py \
--api-url "${{ steps.stack.outputs.api_url }}" \ --api-url "${{ steps.stack.outputs.api_url }}" \
@ -114,9 +136,11 @@ jobs:
${{ steps.google.outputs.client_id && format('--google-client-id "{0}"', steps.google.outputs.client_id) || '' }} ${{ steps.google.outputs.client_id && format('--google-client-id "{0}"', steps.google.outputs.client_id) || '' }}
- name: Upload menu to DynamoDB - name: Upload menu to DynamoDB
if: env.SKIP_RUN != 'true'
run: python3 scripts/upload_menu.py run: python3 scripts/upload_menu.py
- name: Upload form to S3 - name: Upload form to S3
if: env.SKIP_RUN != 'true'
run: | run: |
WEEK=$(date +%Y-W%U) WEEK=$(date +%Y-W%U)
aws s3 cp "output/order-form-$WEEK.html" \ aws s3 cp "output/order-form-$WEEK.html" \
@ -128,10 +152,12 @@ jobs:
--content-type "text/html" --content-type "text/html"
- name: Invalidate CloudFront cache - name: Invalidate CloudFront cache
if: env.SKIP_RUN != 'true'
run: | run: |
aws cloudfront create-invalidation \ aws cloudfront create-invalidation \
--distribution-id "${{ steps.stack.outputs.dist_id }}" \ --distribution-id "${{ steps.stack.outputs.dist_id }}" \
--paths "/index.html" --paths "/index.html"
- name: Notify Slack - name: Notify Slack
if: env.SKIP_RUN != 'true'
run: python3 scripts/notify_slack.py "${{ steps.stack.outputs.form_url }}" run: python3 scripts/notify_slack.py "${{ steps.stack.outputs.form_url }}"

View file

@ -5,7 +5,7 @@ Automates weekly meal ordering from [Redefine Meals](https://www.redefinemeals.c
## Architecture ## Architecture
``` ```
Monday 8am ET Employees (Mon–Thu) Thursday 6pm ET Monday 7:30am ET Employees (Mon–Thu) Thursday 6pm ET
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ GitHub Actions │ │ orders.seahaven │ │ EventBridge │ │ GitHub Actions │ │ orders.seahaven │ │ EventBridge │
│ - Scrape menu │────S3 upload───▶│ ind.com │ │ - Close form │ │ - Scrape menu │────S3 upload───▶│ ind.com │ │ - Close form │
@ -31,7 +31,7 @@ who haven't ordered
|------|------|-----| |------|------|-----|
| Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge → Lambda → DynamoDB | | Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge → Lambda → DynamoDB |
| Monday 7am ET | Email previous week's payroll deductions to `payroll@` | EventBridge → Lambda → SES | | Monday 7am ET | Email previous week's payroll deductions to `payroll@` | EventBridge → Lambda → SES |
| Monday 8am ET | Scrape menu, generate form, upload to S3, post link to Slack | GitHub Actions cron | | Monday 7:30am ET | Scrape menu, generate form, upload to S3, post link to Slack | GitHub Actions cron |
| Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 static form → API Gateway → Lambda → DynamoDB | | Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 static form → API Gateway → Lambda → DynamoDB |
| Thursday 10am ET | DM employees who haven't ordered yet | EventBridge → Lambda → Slack DM | | Thursday 10am ET | DM employees who haven't ordered yet | EventBridge → Lambda → Slack DM |
| Thursday 6pm ET | Close form, aggregate orders, post Redefine order summary to Slack | EventBridge → Lambda chain | | Thursday 6pm ET | Close form, aggregate orders, post Redefine order summary to Slack | EventBridge → Lambda chain |
@ -43,12 +43,35 @@ Stack name: `meal-order-manager` (us-east-1)
- **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports) - **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports)
- **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com` - **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com`
- **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config) - **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config)
- **API Gateway** — HttpApi for order submission - **API Gateway** — HttpApi for order submission and admin operations
- **Lambda** — 6 functions: submit-order, close-form, aggregate-orders, slack-notifier, sync-roster, email-report - **Lambda** — 6 functions: submit-order, close-form, aggregate-orders, slack-notifier, sync-roster, email-report
- **EventBridge** — scheduled rules (dual EST/EDT) for close, reminders, payroll email - **EventBridge** — scheduled rules (dual EST/EDT) for close, reminders, payroll email
- **Secrets Manager** — Slack bot token, form API key - **Secrets Manager** — Slack bot token, form API key
- **SES** — payroll deduction emails - **SES** — payroll deduction emails
## Authentication
Google Identity Services (OAuth) with tokeninfo endpoint verification. Accepts both `seahavenind.com` and `seahaven.com` Google Workspace domains.
## Admin Panel
Admins (configured in DynamoDB `CONFIG/SETTINGS` → `admin_emails` list) get an "Admin" button after Google sign-in. The panel provides:
- View all orders by week with totals
- Edit order quantities, add new menu items, remove items
- Delete orders entirely
All admin operations enforce server-side price recalculation from the menu.
**API routes** (all require Google auth + admin email):
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/admin/orders` | List weeks with order counts |
| GET | `/api/admin/orders?week=YYYY-WNN` | Get all orders for a week |
| PUT | `/api/admin/orders` | Update an order (recalculates prices) |
| DELETE | `/api/admin/orders?week=...&email=...` | Delete an order |
## Setup ## Setup
### Local development ### Local development

View file

@ -14,10 +14,14 @@ import boto3
from shared.db import ( from shared.db import (
current_week, current_week,
delete_order,
get_form_status, get_form_status,
get_menu, get_menu,
get_order,
get_orders,
get_roster, get_roster,
get_settings, get_settings,
list_weeks,
put_order, put_order,
) )
from shared.secrets import get_parameter, get_secret from shared.secrets import get_parameter, get_secret
@ -29,6 +33,7 @@ if not logger.handlers:
EASTERN = ZoneInfo("America/New_York") EASTERN = ZoneInfo("America/New_York")
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
def _eastern_now() -> _dt.datetime: def _eastern_now() -> _dt.datetime:
@ -47,7 +52,7 @@ _lambda = boto3.client("lambda")
def _get_api_key() -> str: def _get_api_key() -> str:
global _api_key global _api_key
if _api_key is None: if _api_key is None:
_api_key = get_secret(os.environ["FORM_API_KEY_SECRET"]) _api_key = get_secret(os.environ["FORM_APIKEY_SM_NAME"])
return _api_key return _api_key
@ -131,7 +136,7 @@ def _verify_google_token(token: str) -> tuple[dict | None, str]:
if data.get("aud") != client_id: if data.get("aud") != client_id:
logger.warning("Google token audience mismatch: got %s", data.get("aud")) logger.warning("Google token audience mismatch: got %s", data.get("aud"))
return None, "invalid" return None, "invalid"
if data.get("hd") != "seahavenind.com": if data.get("hd") not in ALLOWED_DOMAINS:
logger.warning("Google token domain mismatch: got %s", data.get("hd")) logger.warning("Google token domain mismatch: got %s", data.get("hd"))
return None, "invalid" return None, "invalid"
return {"name": data.get("name", ""), "email": data.get("email", "")}, "ok" return {"name": data.get("name", ""), "email": data.get("email", "")}, "ok"
@ -146,10 +151,46 @@ def _verify_google_token(token: str) -> tuple[dict | None, str]:
return None, "invalid" return None, "invalid"
def _verify_admin(event) -> tuple[dict | None, dict | None]:
"""Verify Google auth and admin access. Returns (user_info, error_response)."""
if not _google_auth_configured():
return None, response(403, {"error": "Authentication not configured"})
token = (
event.get("headers", {})
.get("authorization", "")
.removeprefix("Bearer ")
.strip()
)
if not token:
return None, response(403, {"error": "Authentication required"})
user_info, status = _verify_google_token(token)
if status == "unavailable":
return None, response(
503, {"error": "Authentication service temporarily unavailable"}
)
if user_info is None:
return None, response(403, {"error": "Invalid or unauthorized Google account"})
admin_emails = {e.lower() for e in get_settings().get("admin_emails", [])}
if user_info["email"].lower() not in admin_emails:
return None, response(403, {"error": "Admin access required"})
return user_info, None
def lambda_handler(event, context): def lambda_handler(event, context):
method = event.get("requestContext", {}).get("http", {}).get("method", "GET") method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
path = event.get("rawPath", "") path = event.get("rawPath", "")
if "/admin/orders" in path:
if method == "DELETE":
return handle_admin_delete(event)
if method == "PUT":
return handle_admin_update(event)
return handle_admin_orders(event)
if "/form-status/" in path: if "/form-status/" in path:
return handle_form_status(event) return handle_form_status(event)
@ -162,6 +203,151 @@ def lambda_handler(event, context):
return response(405, {"error": "Method not allowed"}) return response(405, {"error": "Method not allowed"})
def handle_admin_orders(event):
user, err = _verify_admin(event)
if err:
return err
qs = event.get("queryStringParameters") or {}
week = qs.get("week")
if not week:
return response(200, {"weeks": list_weeks()})
orders = get_orders(week)
order_list = []
for order in orders:
items = []
for item in order.get("items", []):
items.append(
{
"name": item.get("name", ""),
"quantity": int(item.get("quantity", 0)),
"retail_price": float(item.get("retail_price", 0)),
"price": float(item.get("price", 0)),
"subtotal": float(item.get("subtotal", 0)),
}
)
order_list.append(
{
"employee_name": order.get("employee_name", ""),
"employee_email": order.get("employee_email", ""),
"items": items,
"total": float(order.get("total", 0)),
"submitted_at": order.get("submitted_at", ""),
}
)
order_list.sort(key=lambda o: o["employee_name"])
return response(
200,
{
"week": week,
"orders": order_list,
"total_employees": len(order_list),
"grand_total": round(sum(o["total"] for o in order_list), 2),
},
)
def handle_admin_delete(event):
user, err = _verify_admin(event)
if err:
return err
qs = event.get("queryStringParameters") or {}
week = qs.get("week", "")
email = qs.get("email", "")
if not week or not email:
return response(400, {"error": "week and email query params are required"})
slug = email.lower()
existing = get_order(week, slug)
if not existing:
return response(404, {"error": "Order not found"})
delete_order(week, slug)
logger.info("Admin %s deleted order for %s in %s", user["email"], email, week)
return response(200, {"status": "deleted", "week": week, "email": email})
def handle_admin_update(event):
user, err = _verify_admin(event)
if err:
return err
try:
body = json.loads(event.get("body", "{}"))
except json.JSONDecodeError:
return response(400, {"error": "Invalid JSON"})
week = body.get("week", "")
email = body.get("email", "")
new_items = body.get("items", [])
if not week or not email:
return response(400, {"error": "week and email are required"})
slug = email.lower()
existing = get_order(week, slug)
if not existing:
return response(404, {"error": "Order not found"})
filtered = [i for i in new_items if i.get("quantity", 0) > 0]
if not filtered:
return response(
400, {"error": "At least one item with quantity > 0 is required"}
)
official_retail = _official_menu_retail_by_name(week)
if not official_retail:
return response(503, {"error": "Menu temporarily unavailable"})
TWO_PLACES = Decimal("0.01")
bulk_pct, subsidy_pct = _get_discount_settings()
bulk_mult = Decimal("1") - (
max(Decimal("0"), min(Decimal("100"), bulk_pct)) / Decimal("100")
)
subsidy_mult = Decimal("1") - (
max(Decimal("0"), min(Decimal("100"), subsidy_pct)) / Decimal("100")
)
for item in filtered:
meal_name = (item.get("name") or "").strip()
if meal_name not in official_retail:
return response(400, {"error": f"'{meal_name}' not on this week's menu"})
retail = official_retail[meal_name]
qty = Decimal(str(item["quantity"]))
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)
total = float(
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
)
order_data = {
"employee_name": existing.get("employee_name", ""),
"employee_email": existing.get("employee_email", ""),
"submitted_at": existing.get("submitted_at", ""),
"items": filtered,
"total": total,
}
put_order(week, slug, order_data)
logger.info("Admin %s updated order for %s in %s", user["email"], email, week)
return response(
200, {"status": "updated", "week": week, "email": email, "total": total}
)
def handle_form_status(event): def handle_form_status(event):
week = event.get("pathParameters", {}).get("week", current_week()) week = event.get("pathParameters", {}).get("week", current_week())
status = get_form_status(week) status = get_form_status(week)

View file

@ -10,8 +10,8 @@ import os
import sys import sys
import urllib.request import urllib.request
SLACK_BOT_TOKEN_SECRET = os.environ.get( SLACK_BOT_SM_NAME = os.environ.get(
"SLACK_BOT_TOKEN_SECRET", "meal-order-manager/slack-bot-token" "SLACK_BOT_SM_NAME", "meal-order-manager/slack-bot-token"
) )
SLACK_CHANNEL_PARAM = os.environ.get( SLACK_CHANNEL_PARAM = os.environ.get(
"SLACK_CHANNEL_PARAM", "/meal-order-manager/slack-channel-id" "SLACK_CHANNEL_PARAM", "/meal-order-manager/slack-channel-id"
@ -40,7 +40,7 @@ def main():
sys.exit(1) sys.exit(1)
form_url = sys.argv[1] form_url = sys.argv[1]
token = get_secret(SLACK_BOT_TOKEN_SECRET) token = get_secret(SLACK_BOT_SM_NAME)
channel = get_parameter(SLACK_CHANNEL_PARAM) channel = get_parameter(SLACK_CHANNEL_PARAM)
text = "This week's meal order is open! Deadline: Thursday 6pm." text = "This week's meal order is open! Deadline: Thursday 6pm."

View file

@ -111,6 +111,9 @@ def _get_google_client_id() -> str:
return _google_client_id_cache return _google_client_id_cache
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
def _verify_google_token(token: str, client_id: str) -> dict | None: def _verify_google_token(token: str, client_id: str) -> dict | None:
if not client_id: if not client_id:
return None return None
@ -122,7 +125,7 @@ def _verify_google_token(token: str, client_id: str) -> dict | None:
data = json.loads(resp.read()) data = json.loads(resp.read())
if data.get("aud") != client_id: if data.get("aud") != client_id:
return None return None
if data.get("hd") != "seahavenind.com": if data.get("hd") not in ALLOWED_DOMAINS:
return None return None
return {"name": data.get("name", ""), "email": data.get("email", "")} return {"name": data.get("name", ""), "email": data.get("email", "")}
except Exception: except Exception:
@ -230,6 +233,129 @@ def form_status(week: str):
return jsonify({"week": week, "status": "open"}) return jsonify({"week": week, "status": "open"})
@app.route("/api/admin/orders")
def admin_orders():
week = request.args.get("week")
if not week:
weeks = []
for f in sorted(ORDERS_DIR.iterdir(), reverse=True):
if f.is_dir():
order_count = len(list(f.glob("*.json")))
weeks.append(
{
"week": f.name,
"form_status": "open",
"meal_count": 0,
"order_count": order_count,
}
)
return jsonify({"weeks": weeks})
week_dir = ORDERS_DIR / week
if not week_dir.exists():
return jsonify(
{"week": week, "orders": [], "total_employees": 0, "grand_total": 0}
)
orders = []
for f in sorted(week_dir.glob("*.json")):
with open(f) as fh:
orders.append(json.load(fh))
orders.sort(key=lambda o: o.get("employee_name", ""))
return jsonify(
{
"week": week,
"orders": orders,
"total_employees": len(orders),
"grand_total": round(sum(o.get("total", 0) for o in orders), 2),
}
)
@app.route("/api/admin/orders", methods=["DELETE"])
def admin_delete_order():
week = request.args.get("week", "")
email = request.args.get("email", "")
if not week or not email:
return jsonify({"error": "week and email are required"}), 400
slug = email.strip().lower().replace("/", "_").replace("\\", "_")
order_file = ORDERS_DIR / week / f"{slug}.json"
if not order_file.exists():
return jsonify({"error": "Order not found"}), 404
order_file.unlink()
return jsonify({"status": "deleted", "week": week, "email": email})
@app.route("/api/admin/orders", methods=["PUT"])
def admin_update_order():
data = request.get_json()
if not data:
return jsonify({"error": "No data received"}), 400
week = data.get("week", "")
email = data.get("email", "")
new_items = data.get("items", [])
if not week or not email:
return jsonify({"error": "week and email are required"}), 400
slug = email.strip().lower().replace("/", "_").replace("\\", "_")
order_file = ORDERS_DIR / week / f"{slug}.json"
if not order_file.exists():
return jsonify({"error": "Order not found"}), 404
with open(order_file) as f:
existing = json.load(f)
filtered = [i for i in new_items if i.get("quantity", 0) > 0]
if not filtered:
return jsonify({"error": "At least one item required"}), 400
config = load_config()
TWO_PLACES = Decimal("0.01")
bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0)))
subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0)))
bulk_mult = Decimal("1") - (
max(Decimal("0"), min(Decimal("100"), bulk_pct)) / Decimal("100")
)
subsidy_mult = Decimal("1") - (
max(Decimal("0"), min(Decimal("100"), subsidy_pct)) / Decimal("100")
)
official_retail = _official_menu_retail_by_name()
for item in filtered:
meal_name = (item.get("name") or "").strip()
retail = official_retail.get(meal_name)
if retail is None:
return jsonify({"error": f"'{meal_name}' not on this week's menu"}), 400
qty = Decimal(str(item["quantity"]))
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)
total = float(
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
)
existing["items"] = filtered
existing["total"] = total
with open(order_file, "w") as f:
json.dump(existing, f, indent=2)
return jsonify({"status": "updated", "week": week, "email": email, "total": total})
@app.route("/api/orders/<week>") @app.route("/api/orders/<week>")
def get_orders(week: str): def get_orders(week: str):
week_dir = ORDERS_DIR / week week_dir = ORDERS_DIR / week

View file

@ -57,6 +57,9 @@ def generate_form(
roster_url = (f"{api_url}/api/roster" if api_url else "/api/roster").replace( roster_url = (f"{api_url}/api/roster" if api_url else "/api/roster").replace(
"</", "<\\/" "</", "<\\/"
) )
admin_url = (
f"{api_url}/api/admin/orders" if api_url else "/api/admin/orders"
).replace("</", "<\\/")
api_key_json = json.dumps(api_key).replace("</", "<\\/") api_key_json = json.dumps(api_key).replace("</", "<\\/")
has_discount = bulk_discount > 0 or company_subsidy > 0 has_discount = bulk_discount > 0 or company_subsidy > 0
use_google_auth = bool(google_client_id) use_google_auth = bool(google_client_id)
@ -71,6 +74,7 @@ def generate_form(
<div id="user-name" style="font-weight:600;font-size:0.95rem;"></div> <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 id="user-email" style="font-size:0.85rem;color:#6b7280;"></div>
</div> </div>
<button id="admin-btn" onclick="showAdmin()" style="display:none;background:#1a1a2e;color:#fff;border:none;border-radius:8px;padding:6px 14px;cursor:pointer;font-size:0.8rem;font-weight:600;">Admin</button>
<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> <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> </div>
@ -96,7 +100,6 @@ function initGoogleAuth() {
google.accounts.id.initialize({ google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID, client_id: GOOGLE_CLIENT_ID,
callback: handleCredentialResponse, callback: handleCredentialResponse,
hosted_domain: 'seahavenind.com',
auto_select: true, auto_select: true,
}); });
google.accounts.id.renderButton( google.accounts.id.renderButton(
@ -126,9 +129,13 @@ function handleCredentialResponse(response) {
updateTotal(); updateTotal();
checkDuplicateOrder(); checkDuplicateOrder();
checkAdmin();
} }
function signOut() { function signOut() {
isAdmin = false;
var ab = document.getElementById('admin-btn');
if (ab) ab.style.display = 'none';
googleCredential = null; googleCredential = null;
googleUser = null; googleUser = null;
google.accounts.id.disableAutoSelect(); google.accounts.id.disableAutoSelect();
@ -305,6 +312,40 @@ body {{ padding-bottom: 80px; }}
.closed-card .logo {{ font-size: 2.5rem; margin-bottom: 16px; }} .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 {{ 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; }} .countdown-label {{ font-size: 0.75rem; color: #9ca3af; margin-top: 4px; }}
.admin-panel {{ background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
.admin-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }}
.admin-header h2 {{ font-size: 1.2rem; font-weight: 700; margin: 0; }}
.admin-stats {{ font-size: 0.9rem; color: #6b7280; margin-bottom: 16px; }}
.admin-stats strong {{ color: #1d1d1f; }}
.admin-table-wrap {{ overflow-x: auto; }}
.admin-table {{ width: 100%; border-collapse: collapse; font-size: 0.9rem; }}
.admin-table th {{ text-align: left; padding: 10px 12px; border-bottom: 2px solid #e5e7eb; font-weight: 600; color: #6b7280; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; white-space: nowrap; }}
.admin-table td {{ padding: 10px 12px; border-bottom: 1px solid #f3f4f6; vertical-align: top; }}
.admin-table tr:last-child td {{ border-bottom: none; }}
.admin-table .items-cell {{ font-size: 0.82rem; color: #4b5563; }}
.admin-table .total-cell {{ font-weight: 600; white-space: nowrap; }}
.admin-table .time-cell {{ font-size: 0.8rem; color: #9ca3af; white-space: nowrap; }}
.admin-total-row {{ background: #f9fafb; font-weight: 700; }}
.admin-total-row td {{ border-top: 2px solid #e5e7eb; padding: 12px; }}
.admin-empty {{ text-align: center; padding: 40px 20px; color: #9ca3af; }}
.admin-actions {{ display: flex; gap: 6px; white-space: nowrap; }}
.admin-actions button {{ padding: 4px 10px; border-radius: 6px; font-size: 0.78rem; cursor: pointer; font-weight: 500; border: 1px solid; }}
.btn-edit {{ background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }}
.btn-edit:hover {{ background: #dbeafe; }}
.btn-delete {{ background: #fef2f2; color: #dc2626; border-color: #fecaca; }}
.btn-delete:hover {{ background: #fee2e2; }}
.btn-save {{ background: #dcfce7; color: #15803d; border-color: #bbf7d0; }}
.btn-save:hover {{ background: #bbf7d0; }}
.btn-cancel {{ background: #f9fafb; color: #6b7280; border-color: #d1d5db; }}
.btn-cancel:hover {{ background: #f3f4f6; }}
.edit-qty {{ display: inline-flex; align-items: center; gap: 0; margin: 2px 0; }}
.edit-qty button {{ width: 24px; height: 24px; border: 1px solid #d1d5db; background: #f9fafb; font-size: 0.9rem; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; }}
.edit-qty button:first-child {{ border-radius: 4px 0 0 4px; }}
.edit-qty button:last-child {{ border-radius: 0 4px 4px 0; }}
.edit-qty span {{ width: 28px; height: 24px; text-align: center; line-height: 24px; border: 1px solid #d1d5db; border-left: 0; border-right: 0; font-size: 0.82rem; font-weight: 600; }}
.edit-qty.removed {{ opacity: 0.4; text-decoration: line-through; }}
.admin-add-item {{ margin-top: 8px; }}
.admin-add-item select {{ padding: 4px 8px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.8rem; max-width: 220px; }}
</style> </style>
{ {
'<script src="https://accounts.google.com/gsi/client" async defer></script>' '<script src="https://accounts.google.com/gsi/client" async defer></script>'
@ -365,6 +406,34 @@ body {{ padding-bottom: 80px; }}
</div> </div>
</div> </div>
<div id="admin-panel" style="display:none;">
<div class="admin-panel">
<div class="admin-header">
<h2>Order Admin</h2>
<div style="display:flex;gap:8px;align-items:center;">
<select id="admin-week" onchange="loadWeekOrders(this.value)" style="padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:0.9rem;"></select>
<button onclick="hideAdmin()" class="back-btn" style="margin:0;padding:8px 16px;font-size:0.85rem;">Back to Menu</button>
</div>
</div>
<div id="admin-stats" class="admin-stats"></div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Employee</th>
<th>Items</th>
<th>Total</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<tbody id="admin-tbody"></tbody>
</table>
</div>
<div id="admin-empty" class="admin-empty" style="display:none;">No orders for this week.</div>
</div>
</div>
<div id="success" class="success-msg" style="display:none;"> <div id="success" class="success-msg" style="display:none;">
<h2>Order submitted!</h2> <h2>Order submitted!</h2>
<p id="success-detail"></p> <p id="success-detail"></p>
@ -378,12 +447,15 @@ const ROSTER = {roster_json};
const SUBMIT_URL = '{submit_url}'; const SUBMIT_URL = '{submit_url}';
const STATUS_URL = '{status_url}'; const STATUS_URL = '{status_url}';
const ROSTER_URL = '{roster_url}'; const ROSTER_URL = '{roster_url}';
const ADMIN_URL = '{admin_url}';
const API_KEY = {api_key_json}; const API_KEY = {api_key_json};
const WEEK = '{week}'; const WEEK = '{week}';
const BULK_DISCOUNT = {bulk_discount}; const BULK_DISCOUNT = {bulk_discount};
const COMPANY_SUBSIDY = {company_subsidy}; const COMPANY_SUBSIDY = {company_subsidy};
const quantities = {{}}; const quantities = {{}};
let formClosed = false; let formClosed = false;
let isAdmin = false;
let adminWeeks = [];
{ {
"const GOOGLE_CLIENT_ID = " "const GOOGLE_CLIENT_ID = "
+ google_client_id_json + google_client_id_json
@ -550,6 +622,221 @@ function updateTotal() {{
} }
''' '''
} }
function checkAdmin() {{
if (!ADMIN_URL) return;
const headers = {{}};
if (typeof googleCredential !== 'undefined' && googleCredential) {{
headers['Authorization'] = 'Bearer ' + googleCredential;
}}
fetch(ADMIN_URL, {{ headers }})
.then(r => r.ok ? r.json() : null)
.then(data => {{
if (data && data.weeks) {{
isAdmin = true;
adminWeeks = data.weeks;
var ab = document.getElementById('admin-btn');
if (ab) ab.style.display = 'inline-block';
}}
}})
.catch(() => {{}});
}}
function showAdmin() {{
document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => {{
if (el.id !== 'admin-panel') el.dataset.prevDisplay = el.style.display || '';
el.style.display = 'none';
}});
const panel = document.getElementById('admin-panel');
panel.style.display = 'block';
var footer = document.querySelector('.sticky-footer');
if (footer) footer.style.display = 'none';
const select = document.getElementById('admin-week');
select.innerHTML = '';
adminWeeks.forEach(w => {{
const opt = document.createElement('option');
opt.value = w.week;
opt.textContent = w.week + (w.form_status === 'open' ? ' (open)' : '');
select.appendChild(opt);
}});
if (adminWeeks.length > 0) loadWeekOrders(adminWeeks[0].week);
}}
function hideAdmin() {{
document.getElementById('admin-panel').style.display = 'none';
document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => {{
el.style.display = el.dataset.prevDisplay || '';
delete el.dataset.prevDisplay;
}});
var footer = document.querySelector('.sticky-footer');
if (footer) footer.style.display = '';
}}
let currentAdminWeek = '';
let editingOrder = null;
let editQuantities = {{}};
function adminHeaders() {{
const h = {{}};
if (typeof googleCredential !== 'undefined' && googleCredential) h['Authorization'] = 'Bearer ' + googleCredential;
return h;
}}
function loadWeekOrders(week) {{
if (!week) return;
currentAdminWeek = week;
editingOrder = null;
const tbody = document.getElementById('admin-tbody');
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:20px;color:#9ca3af;">Loading...</td></tr>';
document.getElementById('admin-empty').style.display = 'none';
document.getElementById('admin-stats').textContent = '';
fetch(ADMIN_URL + '?week=' + encodeURIComponent(week), {{ headers: adminHeaders() }})
.then(r => r.json())
.then(data => renderAdminTable(data))
.catch(() => {{
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:20px;color:#991b1b;">Failed to load orders.</td></tr>';
}});
}}
function renderAdminTable(data) {{
const tbody = document.getElementById('admin-tbody');
tbody.innerHTML = '';
if (!data.orders || data.orders.length === 0) {{
document.getElementById('admin-empty').style.display = 'block';
document.getElementById('admin-stats').textContent = '';
return;
}}
document.getElementById('admin-empty').style.display = 'none';
document.getElementById('admin-stats').innerHTML =
'<strong>' + data.total_employees + '</strong> employee' + (data.total_employees !== 1 ? 's' : '') +
' ordered &middot; <strong>$' + data.grand_total.toFixed(2) + '</strong> total payroll deductions';
data.orders.forEach((order, idx) => {{
const tr = document.createElement('tr');
tr.id = 'admin-row-' + idx;
const isEditing = editingOrder === order.employee_email;
let itemsHtml, actionsHtml, totalHtml;
if (isEditing) {{
const editNames = Object.keys(editQuantities);
itemsHtml = editNames.map(name => {{
const q = editQuantities[name] || 0;
const eName = escapeHtml(name).replace(/'/g, "\\\\'");
const cls = q === 0 ? 'edit-qty removed' : 'edit-qty';
return '<div class="' + cls + '">' +
'<button onclick="adminEditQty(\\'' + eName + '\\',-1)">&minus;</button>' +
'<span>' + q + '</span>' +
'<button onclick="adminEditQty(\\'' + eName + '\\',1)">+</button>' +
'</div> ' + escapeHtml(name);
}}).join('<br>');
const availableMeals = MEALS.filter(m => !editQuantities.hasOwnProperty(m.name));
if (availableMeals.length > 0) {{
itemsHtml += '<div class="admin-add-item"><select onchange="adminAddItem(this.value); this.selectedIndex=0;">' +
'<option value="">+ Add item...</option>' +
availableMeals.map(m => '<option value="' + escapeHtml(m.name).replace(/"/g, '&quot;') + '">' + escapeHtml(m.name) + ' ($' + m.price.toFixed(2) + ')</option>').join('') +
'</select></div>';
}}
const menuPrices = {{}};
MEALS.forEach(m => {{ menuPrices[m.name] = m.price; }});
const newTotal = editNames.reduce((sum, name) => {{
const price = menuPrices[name] || 0;
return sum + price * (editQuantities[name] || 0);
}}, 0);
totalHtml = '$' + newTotal.toFixed(2);
actionsHtml = '<div class="admin-actions"><button class="btn-save" onclick="adminSaveEdit(\\'' + escapeHtml(order.employee_email) + '\\')">Save</button><button class="btn-cancel" onclick="adminCancelEdit()">Cancel</button></div>';
}} else {{
itemsHtml = order.items.map(i =>
escapeHtml(i.name) + ' &times;' + i.quantity + ' <span style="color:#9ca3af;">($' + i.subtotal.toFixed(2) + ')</span>'
).join('<br>');
totalHtml = '$' + order.total.toFixed(2);
actionsHtml = '<div class="admin-actions"><button class="btn-edit" onclick="adminStartEdit(' + idx + ',\\'' + escapeHtml(order.employee_email) + '\\')">Edit</button><button class="btn-delete" onclick="adminDeleteOrder(\\'' + escapeHtml(order.employee_email) + '\\',\\'' + escapeHtml(order.employee_name) + '\\')">Delete</button></div>';
}}
const submitted = order.submitted_at ? new Date(order.submitted_at).toLocaleString('en-US', {{
timeZone: 'America/New_York', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
}}) : '';
tr.innerHTML =
'<td><strong>' + escapeHtml(order.employee_name) + '</strong><br><span style="font-size:0.8rem;color:#9ca3af;">' + escapeHtml(order.employee_email) + '</span></td>' +
'<td class="items-cell">' + itemsHtml + '</td>' +
'<td class="total-cell">' + totalHtml + '</td>' +
'<td class="time-cell">' + submitted + '</td>' +
'<td>' + actionsHtml + '</td>';
tbody.appendChild(tr);
}});
const totalRow = document.createElement('tr');
totalRow.className = 'admin-total-row';
totalRow.innerHTML = '<td>Total</td><td>' + data.orders.reduce((s,o) => s + o.items.reduce((a,i) => a + i.quantity, 0), 0) + ' meals</td><td class="total-cell">$' + data.grand_total.toFixed(2) + '</td><td></td><td></td>';
tbody.appendChild(totalRow);
}}
let lastAdminData = null;
const origLoadWeekOrders = loadWeekOrders;
function adminStartEdit(idx, email) {{
fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), {{ headers: adminHeaders() }})
.then(r => r.json())
.then(data => {{
lastAdminData = data;
const order = data.orders.find(o => o.employee_email === email);
if (!order) return;
editingOrder = email;
editQuantities = {{}};
order.items.forEach(i => {{ editQuantities[i.name] = i.quantity; }});
renderAdminTable(data);
}});
}}
function adminCancelEdit() {{
editingOrder = null;
editQuantities = {{}};
if (lastAdminData) renderAdminTable(lastAdminData);
}}
function adminEditQty(name, delta) {{
const current = editQuantities[name] || 0;
editQuantities[name] = Math.max(0, current + delta);
if (lastAdminData) renderAdminTable(lastAdminData);
}}
function adminAddItem(name) {{
if (!name) return;
editQuantities[name] = 1;
if (lastAdminData) renderAdminTable(lastAdminData);
}}
function adminSaveEdit(email) {{
const items = Object.entries(editQuantities)
.filter(([, q]) => q > 0)
.map(([name, quantity]) => ({{ name, quantity }}));
if (items.length === 0) {{
if (confirm('All quantities are zero. Delete this order instead?')) {{
adminDeleteOrder(email, '');
}}
return;
}}
fetch(ADMIN_URL, {{
method: 'PUT',
headers: {{ ...adminHeaders(), 'Content-Type': 'application/json' }},
body: JSON.stringify({{ week: currentAdminWeek, email, items }}),
}})
.then(r => {{ if (!r.ok) return r.json().then(d => {{ throw new Error(d.error); }}); return r.json(); }})
.then(() => {{ editingOrder = null; editQuantities = {{}}; loadWeekOrders(currentAdminWeek); }})
.catch(e => alert('Failed to update: ' + e.message));
}}
function adminDeleteOrder(email, name) {{
const label = name ? name + ' (' + email + ')' : email;
if (!confirm('Delete order for ' + label + '?')) return;
fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), {{
method: 'DELETE',
headers: adminHeaders(),
}})
.then(r => {{ if (!r.ok) return r.json().then(d => {{ throw new Error(d.error); }}); return r.json(); }})
.then(() => loadWeekOrders(currentAdminWeek))
.catch(e => alert('Failed to delete: ' + e.message));
}}
async function checkFormStatus() {{ async function checkFormStatus() {{
if (!STATUS_URL) return; if (!STATUS_URL) return;
try {{ try {{

View file

@ -4,7 +4,7 @@ import time
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from boto3.dynamodb.conditions import Key from boto3.dynamodb.conditions import Attr, Key
import boto3 import boto3
EASTERN = ZoneInfo("America/New_York") EASTERN = ZoneInfo("America/New_York")
@ -97,6 +97,17 @@ def get_orders(week: str) -> list[dict]:
return resp.get("Items", []) return resp.get("Items", [])
def get_order(week: str, employee_slug: str) -> dict | None:
resp = _get_table().get_item(
Key={"PK": f"WEEK#{week}", "SK": f"ORDER#{employee_slug}"}
)
return resp.get("Item")
def delete_order(week: str, employee_slug: str):
_get_table().delete_item(Key={"PK": f"WEEK#{week}", "SK": f"ORDER#{employee_slug}"})
def put_summary(week: str, summary: dict): def put_summary(week: str, summary: dict):
_get_table().put_item( _get_table().put_item(
Item={ Item={
@ -130,6 +141,23 @@ def put_roster(employees: list[dict]):
) )
def list_weeks() -> list[dict]:
resp = _get_table().scan(
FilterExpression=Attr("SK").eq("MENU"),
ProjectionExpression="PK, form_status, meal_count, scraped_at",
)
weeks = []
for item in resp.get("Items", []):
weeks.append(
{
"week": item["PK"].replace("WEEK#", ""),
"form_status": item.get("form_status", "unknown"),
"meal_count": int(item.get("meal_count", 0)),
}
)
return sorted(weeks, key=lambda w: w["week"], reverse=True)
def get_settings() -> dict: def get_settings() -> dict:
resp = _get_table().get_item(Key={"PK": "CONFIG", "SK": "SETTINGS"}) resp = _get_table().get_item(Key={"PK": "CONFIG", "SK": "SETTINGS"})
return resp.get("Item", {}) return resp.get("Item", {})

View file

@ -11,7 +11,7 @@ _token = None
def _get_token() -> str: def _get_token() -> str:
global _token global _token
if _token is None: if _token is None:
_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"]) _token = get_secret(os.environ["SLACK_BOT_SM_NAME"])
return _token return _token

View file

@ -35,12 +35,12 @@ Globals:
Variables: Variables:
TABLE_NAME: !Ref OrdersTable TABLE_NAME: !Ref OrdersTable
REPORTS_BUCKET: !Ref ReportsBucket REPORTS_BUCKET: !Ref ReportsBucket
SLACK_BOT_TOKEN_SECRET: meal-order-manager/slack-bot-token
SLACK_CHANNEL_PARAM: /meal-order-manager/slack-channel-id SLACK_CHANNEL_PARAM: /meal-order-manager/slack-channel-id
FORM_URL: !If FORM_URL: !If
- HasCustomDomain - HasCustomDomain
- !Sub 'https://${CustomDomain}' - !Sub 'https://${CustomDomain}'
- !Sub 'https://${FormDistribution.DomainName}' - !Sub 'https://${FormDistribution.DomainName}'
SLACK_BOT_SM_NAME: meal-order-manager/slack-bot-token
Layers: Layers:
- !Ref SharedLayer - !Ref SharedLayer
@ -213,10 +213,13 @@ Resources:
AllowMethods: AllowMethods:
- GET - GET
- POST - POST
- PUT
- DELETE
- OPTIONS - OPTIONS
AllowHeaders: AllowHeaders:
- Content-Type - Content-Type
- x-api-key - x-api-key
- Authorization
MaxAge: 3600 MaxAge: 3600
# ─── Lambda Functions ────────────────────────────────────────── # ─── Lambda Functions ──────────────────────────────────────────
@ -231,7 +234,7 @@ Resources:
Timeout: 10 Timeout: 10
Environment: Environment:
Variables: Variables:
FORM_API_KEY_SECRET: meal-order-manager/form-api-key FORM_APIKEY_SM_NAME: meal-order-manager/form-api-key
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
Policies: Policies:
@ -266,6 +269,24 @@ Resources:
ApiId: !Ref OrderApi ApiId: !Ref OrderApi
Path: /api/roster Path: /api/roster
Method: GET Method: GET
AdminOrders:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/admin/orders
Method: GET
AdminOrdersUpdate:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/admin/orders
Method: PUT
AdminOrdersDelete:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/admin/orders
Method: DELETE
CloseFormFunction: CloseFormFunction:
Type: AWS::Serverless::Function Type: AWS::Serverless::Function

View file

@ -18,7 +18,7 @@ import pytest
# Environment variables required by the handler at import time # Environment variables required by the handler at import time
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
os.environ.setdefault("TABLE_NAME", "test-orders-table") os.environ.setdefault("TABLE_NAME", "test-orders-table")
os.environ.setdefault("FORM_API_KEY_SECRET", "test/form-api-key") os.environ.setdefault("FORM_APIKEY_SM_NAME", "test/form-api-key")
os.environ.setdefault( os.environ.setdefault(
"SLACK_NOTIFIER_ARN", "SLACK_NOTIFIER_ARN",
"arn:aws:lambda:us-east-1:000000000000:function:test-slack-notifier", "arn:aws:lambda:us-east-1:000000000000:function:test-slack-notifier",
@ -772,6 +772,63 @@ def test_ssm_failure_fails_closed(
mock_put.assert_not_called() mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@patch(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.urllib.request.urlopen")
@patch("submit_order_handler._google_auth_configured", return_value=True)
@patch("submit_order_handler.get_menu")
def test_google_token_valid_seahaven_com_domain(
mock_get_menu,
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
mock_put,
mock_lam,
):
"""Google token with hd=seahaven.com (alternate domain) -> order saved."""
from submit_order_handler import lambda_handler
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(
{
"aud": VALID_GOOGLE_CLIENT_ID,
"hd": "seahaven.com",
"name": "Test Employee",
"email": "test@seahaven.com",
}
).encode()
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_resp
items = _make_items([(10.00, 1)])
mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
event = _submit_event(
items,
extra_body={"google_id_token": "valid-token-seahaven"},
)
result = lambda_handler(event, None)
status, body = _parse_response(result)
assert status == 200, f"Expected 200, got {status}: {body}"
saved_order = mock_put.call_args[0][2]
assert saved_order["employee_email"] == "test@seahaven.com"
@patch("submit_order_handler._lambda") @patch("submit_order_handler._lambda")
@patch("submit_order_handler.put_order") @patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open") @patch("submit_order_handler.get_form_status", return_value="open")