mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 04:13:12 +00:00
Add admin panel, fix dual-domain auth, harden scrape schedule (#14)
Some checks failed
Deploy / deploy (push) Has been cancelled
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:
parent
a752c24e0f
commit
5db95ce9d1
10 changed files with 773 additions and 19 deletions
34
.github/workflows/weekly-menu.yml
vendored
34
.github/workflows/weekly-menu.yml
vendored
|
|
@ -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 }}"
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 · <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)">−</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, '"') + '">' + 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) + ' ×' + 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 {{
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue