mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 05:23:13 +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:
|
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 }}"
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 · <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() {{
|
async function checkFormStatus() {{
|
||||||
if (!STATUS_URL) return;
|
if (!STATUS_URL) return;
|
||||||
try {{
|
try {{
|
||||||
|
|
|
||||||
|
|
@ -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", {})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue