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:
schedule:
# Monday 8am EST = 13:00 UTC
- cron: '0 13 * * 1'
# Monday 8am EDT = 12:00 UTC
- cron: '0 12 * * 1'
# Monday 7:30am EST = 12:30 UTC
- cron: '30 12 * * 1'
# Monday 7:30am EDT = 11:30 UTC
- cron: '30 11 * * 1'
workflow_dispatch:
permissions:
@ -19,27 +19,45 @@ jobs:
AWS_REGION: us-east-1
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
if: env.SKIP_RUN != 'true'
- uses: actions/setup-python@v5
if: env.SKIP_RUN != 'true'
with:
python-version: '3.12'
- name: Install dependencies
if: env.SKIP_RUN != 'true'
run: |
pip install playwright boto3
playwright install chromium --with-deps
- name: Configure AWS credentials
if: env.SKIP_RUN != 'true'
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- name: Scrape menu
if: env.SKIP_RUN != 'true'
run: python3 src/scraper/scrape_menu.py
- name: Get stack outputs
if: env.SKIP_RUN != 'true'
id: stack
run: |
API_URL=$(aws cloudformation describe-stacks \
@ -64,6 +82,7 @@ jobs:
echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT
- name: Get API key
if: env.SKIP_RUN != 'true'
id: apikey
run: |
API_KEY=$(aws secretsmanager get-secret-value \
@ -73,6 +92,7 @@ jobs:
echo "api_key=$API_KEY" >> $GITHUB_OUTPUT
- name: Get discount settings
if: env.SKIP_RUN != 'true'
id: discount
run: |
RESULT=$(aws dynamodb get-item \
@ -93,6 +113,7 @@ jobs:
echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT
- name: Get Google Client ID
if: env.SKIP_RUN != 'true'
id: google
run: |
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
@ -105,6 +126,7 @@ jobs:
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
- name: Generate order form
if: env.SKIP_RUN != 'true'
run: |
python3 src/server/generate_form.py \
--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) || '' }}
- name: Upload menu to DynamoDB
if: env.SKIP_RUN != 'true'
run: python3 scripts/upload_menu.py
- name: Upload form to S3
if: env.SKIP_RUN != 'true'
run: |
WEEK=$(date +%Y-W%U)
aws s3 cp "output/order-form-$WEEK.html" \
@ -128,10 +152,12 @@ jobs:
--content-type "text/html"
- name: Invalidate CloudFront cache
if: env.SKIP_RUN != 'true'
run: |
aws cloudfront create-invalidation \
--distribution-id "${{ steps.stack.outputs.dist_id }}" \
--paths "/index.html"
- name: Notify Slack
if: env.SKIP_RUN != 'true'
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
```
Monday 8am ET Employees (Mon–Thu) Thursday 6pm ET
Monday 7:30am ET Employees (Mon–Thu) Thursday 6pm ET
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ GitHub Actions │ │ orders.seahaven │ │ EventBridge │
│ - 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 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 |
| 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 |
@ -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)
- **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com`
- **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
- **EventBridge** — scheduled rules (dual EST/EDT) for close, reminders, payroll email
- **Secrets Manager** — Slack bot token, form API key
- **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
### Local development

View file

@ -14,10 +14,14 @@ import boto3
from shared.db import (
current_week,
delete_order,
get_form_status,
get_menu,
get_order,
get_orders,
get_roster,
get_settings,
list_weeks,
put_order,
)
from shared.secrets import get_parameter, get_secret
@ -29,6 +33,7 @@ if not logger.handlers:
EASTERN = ZoneInfo("America/New_York")
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
def _eastern_now() -> _dt.datetime:
@ -47,7 +52,7 @@ _lambda = boto3.client("lambda")
def _get_api_key() -> str:
global _api_key
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
@ -131,7 +136,7 @@ def _verify_google_token(token: str) -> tuple[dict | None, str]:
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":
if data.get("hd") not in ALLOWED_DOMAINS:
logger.warning("Google token domain mismatch: got %s", data.get("hd"))
return None, "invalid"
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"
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):
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
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:
return handle_form_status(event)
@ -162,6 +203,151 @@ def lambda_handler(event, context):
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):
week = event.get("pathParameters", {}).get("week", current_week())
status = get_form_status(week)

View file

@ -10,8 +10,8 @@ import os
import sys
import urllib.request
SLACK_BOT_TOKEN_SECRET = os.environ.get(
"SLACK_BOT_TOKEN_SECRET", "meal-order-manager/slack-bot-token"
SLACK_BOT_SM_NAME = os.environ.get(
"SLACK_BOT_SM_NAME", "meal-order-manager/slack-bot-token"
)
SLACK_CHANNEL_PARAM = os.environ.get(
"SLACK_CHANNEL_PARAM", "/meal-order-manager/slack-channel-id"
@ -40,7 +40,7 @@ def main():
sys.exit(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)
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
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
def _verify_google_token(token: str, client_id: str) -> dict | None:
if not client_id:
return None
@ -122,7 +125,7 @@ def _verify_google_token(token: str, client_id: str) -> dict | None:
data = json.loads(resp.read())
if data.get("aud") != client_id:
return None
if data.get("hd") != "seahavenind.com":
if data.get("hd") not in ALLOWED_DOMAINS:
return None
return {"name": data.get("name", ""), "email": data.get("email", "")}
except Exception:
@ -230,6 +233,129 @@ def form_status(week: str):
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>")
def get_orders(week: str):
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(
"</", "<\\/"
)
admin_url = (
f"{api_url}/api/admin/orders" if api_url else "/api/admin/orders"
).replace("</", "<\\/")
api_key_json = json.dumps(api_key).replace("</", "<\\/")
has_discount = bulk_discount > 0 or company_subsidy > 0
use_google_auth = bool(google_client_id)
@ -71,6 +74,7 @@ def generate_form(
<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 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>
</div>
</div>
@ -96,7 +100,6 @@ function initGoogleAuth() {
google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID,
callback: handleCredentialResponse,
hosted_domain: 'seahavenind.com',
auto_select: true,
});
google.accounts.id.renderButton(
@ -126,9 +129,13 @@ function handleCredentialResponse(response) {
updateTotal();
checkDuplicateOrder();
checkAdmin();
}
function signOut() {
isAdmin = false;
var ab = document.getElementById('admin-btn');
if (ab) ab.style.display = 'none';
googleCredential = null;
googleUser = null;
google.accounts.id.disableAutoSelect();
@ -305,6 +312,40 @@ body {{ padding-bottom: 80px; }}
.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; }}
.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>
{
'<script src="https://accounts.google.com/gsi/client" async defer></script>'
@ -365,6 +406,34 @@ body {{ padding-bottom: 80px; }}
</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;">
<h2>Order submitted!</h2>
<p id="success-detail"></p>
@ -378,12 +447,15 @@ const ROSTER = {roster_json};
const SUBMIT_URL = '{submit_url}';
const STATUS_URL = '{status_url}';
const ROSTER_URL = '{roster_url}';
const ADMIN_URL = '{admin_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;
let isAdmin = false;
let adminWeeks = [];
{
"const GOOGLE_CLIENT_ID = "
+ 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() {{
if (!STATUS_URL) return;
try {{

View file

@ -4,7 +4,7 @@ import time
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo
from boto3.dynamodb.conditions import Key
from boto3.dynamodb.conditions import Attr, Key
import boto3
EASTERN = ZoneInfo("America/New_York")
@ -97,6 +97,17 @@ def get_orders(week: str) -> list[dict]:
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):
_get_table().put_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:
resp = _get_table().get_item(Key={"PK": "CONFIG", "SK": "SETTINGS"})
return resp.get("Item", {})

View file

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

View file

@ -35,12 +35,12 @@ Globals:
Variables:
TABLE_NAME: !Ref OrdersTable
REPORTS_BUCKET: !Ref ReportsBucket
SLACK_BOT_TOKEN_SECRET: meal-order-manager/slack-bot-token
SLACK_CHANNEL_PARAM: /meal-order-manager/slack-channel-id
FORM_URL: !If
- HasCustomDomain
- !Sub 'https://${CustomDomain}'
- !Sub 'https://${FormDistribution.DomainName}'
SLACK_BOT_SM_NAME: meal-order-manager/slack-bot-token
Layers:
- !Ref SharedLayer
@ -213,10 +213,13 @@ Resources:
AllowMethods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
AllowHeaders:
- Content-Type
- x-api-key
- Authorization
MaxAge: 3600
# ─── Lambda Functions ──────────────────────────────────────────
@ -231,7 +234,7 @@ Resources:
Timeout: 10
Environment:
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
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
Policies:
@ -266,6 +269,24 @@ Resources:
ApiId: !Ref OrderApi
Path: /api/roster
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:
Type: AWS::Serverless::Function

View file

@ -18,7 +18,7 @@ import pytest
# Environment variables required by the handler at import time
# ---------------------------------------------------------------------------
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(
"SLACK_NOTIFIER_ARN",
"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()
@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.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")