Compare commits

..

No commits in common. "7e73bd2bfe52ad20d5e996e00d9977acb75fd0bb" and "adf175daefd03033516dd9c2bae5b66202d68044" have entirely different histories.

26 changed files with 1012 additions and 2226 deletions

View file

@ -8,29 +8,10 @@ permissions:
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2
with:
run-tests: false
template-js:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.x
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: "24"
cache: npm
- name: Install JavaScript tooling
run: npm ci
- name: Check template JavaScript
run: npm run check:templates
test:
runs-on: ubuntu-latest
timeout-minutes: 15

View file

@ -7,4 +7,4 @@ permissions:
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2

View file

@ -13,7 +13,7 @@ concurrency:
jobs:
deploy:
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2
with:
stack-name: meal-order-manager
cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role

View file

@ -10,4 +10,4 @@ permissions:
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@3f746774229d41770727e2e4fd63ed5f5555a8b3 # v1.0.3
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@0170a57c0d99b542cfafd1f3e1d369c32643f486 # v1.0.2

View file

@ -88,6 +88,16 @@ jobs:
echo "dist_id=$DIST_ID" >> $GITHUB_OUTPUT
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 \
--secret-id meal-order-manager/form-api-key \
--query 'SecretString' --output text)
echo "::add-mask::$API_KEY"
echo "api_key=$API_KEY" >> $GITHUB_OUTPUT
- name: Get discount settings
if: env.SKIP_RUN != 'true'
id: discount
@ -116,10 +126,9 @@ jobs:
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
--name /meal-order-manager/google-client-id \
--query 'Parameter.Value' \
--output text)
--output text 2>/dev/null || echo "")
if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then
echo "Google client ID is required for cloud form generation" >&2
exit 1
GOOGLE_CLIENT_ID=""
fi
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
@ -128,9 +137,10 @@ jobs:
run: |
python3 src/server/generate_form.py \
--api-url "${{ steps.stack.outputs.api_url }}" \
--api-key "${{ steps.apikey.outputs.api_key }}" \
--bulk-discount "${{ steps.discount.outputs.bulk_discount }}" \
--company-subsidy "${{ steps.discount.outputs.company_subsidy }}" \
--google-client-id "${{ 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
if: env.SKIP_RUN != 'true'

1
.gitignore vendored
View file

@ -7,4 +7,3 @@ output/
.DS_Store
.aws-sam/
samconfig.toml
node_modules/

View file

@ -2,7 +2,7 @@
"suppressions": [
{
"id": "gitleaks-generic-api-key-45",
"justification": "False positive. tests/test_submit_order.py defines a synthetic Google OAuth audience used only for mocked token verification. OAuth client IDs are public identifiers, this fixture is not a credential, and the tests make no real Google or AWS calls."
"justification": "False positive. tests/test_submit_order.py:45 defines a low-entropy dummy test constant used as the mocked get_secret return value and injected as the x-api-key header in the test harness (the module makes no real AWS calls). Not a live credential; the real key lives in Secrets Manager (FORM_APIKEY_SM_NAME). Verified proof-or-kill 2026-07-13. Moved from machine-level to repo-local so the Open SWE daily-report automation resolves it."
}
]
}

View file

@ -30,15 +30,6 @@ reminders to employees └──────
who haven't ordered
```
### Form frontend decisions
- **Theme:** Retain the current order-form palette and typography. There is no shared Sea Haven web design system to adopt, and changing the theme without one would be an isolated visual redesign. Future theme changes should remap the existing CSS custom properties instead of adding scattered color values or inline styles.
- **Deployment:** Keep generating and publishing one self-contained HTML file. Inlining the template CSS and JavaScript at build time preserves the current atomic S3 upload and avoids introducing asset versioning, cache coordination, and additional CloudFront invalidation paths. Revisit a multi-file deploy only when asset size, cross-page sharing, or independent caching provides a concrete benefit.
- **Visual regression:** Do not add visual-regression CI now. Structural and Playwright coverage remain the active safeguards. Revisit snapshots, computed-style assertions, or a hosted service only after a layout or token regression reaches production without those checks catching it.
ESLint and Prettier check the JavaScript template sources in CI. They do not lint
the generated HTML, and the generated deployment artifact remains self-contained.
## Weekly Flow
| When | What | How |
@ -68,8 +59,7 @@ Stack name: `meal-order-manager` (us-east-1)
- **API Gateway** — HttpApi for order submission and admin operations
- **Lambda** — 7 functions: submit-order, admin-authorizer, 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
- Migration note: the existing `meal-order-manager/form-api-key` secret remains until this change is deployed and verified, then must be deleted during post-deploy cleanup.
- **Secrets Manager** — Slack bot token, form API key
- **SES** — payroll deduction emails
- **CloudWatch Alarms** — coverage across the stack, all notifying the shared `site-alerts` SNS topic (see Monitoring)
@ -90,11 +80,11 @@ sit in ALARM between runs). Alarm names follow `meal-order-manager-<fn>-<signal>
at the table-only dimension, so those are intentionally not alarmed.
- **API Gateway (OrderApi, HTTP API v2)** — 5xx (threshold 0), 4xx (threshold 20,
3/2 datapoints to absorb routine 401s from the token authorizer), and p99 Latency
(~3000ms). The submit route is limited to 5 requests/second with a burst of 10.
(~3000ms).
## Authentication
Google Identity Services (OAuth) with tokeninfo endpoint verification. Accepts both `seahavenind.com` and `seahaven.com` Google Workspace domains. Cloud form generation and Lambda order submission fail closed unless `/meal-order-manager/google-client-id` is configured. The local Flask workflow can still use manual name and email entry when Google auth is not configured.
Google Identity Services (OAuth) with tokeninfo endpoint verification. Accepts both `seahavenind.com` and `seahaven.com` Google Workspace domains.
## Admin Panel
@ -147,7 +137,7 @@ sam deploy
### Post-deploy
1. Create the Slack bot token secret: `aws secretsmanager create-secret --name meal-order-manager/slack-bot-token --secret-string "xoxb-..."`
2. Set the Google OAuth client ID: `aws ssm put-parameter --name /meal-order-manager/google-client-id --type String --value "<YOUR_GOOGLE_CLIENT_ID>" --overwrite`
2. Create the form API key secret: `aws secretsmanager create-secret --name meal-order-manager/form-api-key --secret-string "$(openssl rand -hex 32)"`
3. Update the Slack channel SSM parameter: `aws ssm put-parameter --name /meal-order-manager/slack-channel-id --value "C0XXXXXXX" --overwrite`
4. Verify SES sender identity for `adam@seahavenind.com`
5. Set up DNS: CNAME `orders.seahaven.com` → CloudFront distribution domain
@ -170,7 +160,6 @@ python3 src/aggregator/aggregate.py # generate CSV reports
- `menu_url` — Redefine Meals menu URL
- `order_deadline` — displayed on the form
- `roster` — employee list (name, email, slack_user_id)
- `google_client_id` — optional locally; required for cloud generation
- `output_dir` / `orders_dir` — local output paths
## Project Structure

View file

@ -1,20 +0,0 @@
import eslint from '@eslint/js';
import globals from 'globals';
export default [
{
files: ['src/server/templates/*.js'],
...eslint.configs.recommended,
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'script',
globals: {
...globals.browser,
google: 'readonly',
},
},
rules: {
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
];

View file

@ -1 +1 @@
boto3==1.43.60
boto3==1.43.56

View file

@ -1 +1 @@
boto3==1.43.60
boto3==1.43.56

View file

@ -1 +1 @@
boto3==1.43.60
boto3==1.43.56

View file

@ -1 +1 @@
boto3==1.43.60
boto3==1.43.56

View file

@ -1 +1 @@
boto3==1.43.60
boto3==1.43.56

View file

@ -26,7 +26,7 @@ from shared.db import (
list_weeks,
put_order,
)
from shared.secrets import get_parameter
from shared.secrets import get_parameter, get_secret
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
@ -44,6 +44,7 @@ def _eastern_now() -> _dt.datetime:
return _dt.datetime.now(EASTERN)
_api_key = None
_settings = None
_settings_ts = 0.0
_google_client_id = None
@ -52,6 +53,13 @@ _lambda = boto3.client("lambda")
_s3 = boto3.client("s3")
def _get_api_key() -> str:
global _api_key
if _api_key is None:
_api_key = get_secret(os.environ["FORM_APIKEY_SM_NAME"])
return _api_key
def _get_discount_settings() -> tuple[Decimal, Decimal]:
global _settings, _settings_ts
now = time.monotonic()
@ -65,10 +73,6 @@ def _get_discount_settings() -> tuple[Decimal, Decimal]:
return _settings
def _get_admin_emails() -> set[str]:
return {email.lower() for email in get_settings().get("admin_emails", [])}
def _google_auth_configured() -> bool:
return bool(os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""))
@ -151,10 +155,6 @@ def _verify_google_token(token: str) -> tuple[dict | None, str]:
return None, "invalid"
def _authentication_service_unavailable() -> dict:
return response(503, {"error": "Authentication service temporarily unavailable"})
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():
@ -171,11 +171,14 @@ def _verify_admin(event) -> tuple[dict | None, dict | None]:
user_info, status = _verify_google_token(token)
if status == "unavailable":
return None, _authentication_service_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"})
if user_info["email"].lower() not in _get_admin_emails():
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
@ -414,32 +417,48 @@ def handle_roster():
def handle_submit(event):
api_key = event.get("headers", {}).get("x-api-key", "")
if api_key != _get_api_key():
return response(403, {"error": "Invalid API key"})
try:
body = json.loads(event.get("body", "{}"))
except json.JSONDecodeError:
return response(400, {"error": "Invalid JSON"})
# The Lambda is the cloud submission path, so Google authentication is always
# required. Local/manual submissions are handled only by src/server/app.py.
# --- Authentication ---
# If Google auth is configured (SSM param contains a client ID), require a valid
# google_id_token. Manual fallback is only allowed when auth is NOT configured.
# If SSM fetch fails for any other reason, fail closed (503).
google_token = body.get("google_id_token")
try:
client_id = _get_google_client_id()
except Exception as exc:
logger.error("SSM fetch failed for Google client ID: %s", exc)
return _authentication_service_unavailable()
if not client_id:
logger.error("Google client ID is unavailable")
return _authentication_service_unavailable()
if not google_token:
return response(403, {"error": "Google authentication is required"})
user_info, verify_status = _verify_google_token(google_token)
if verify_status == "unavailable":
return _authentication_service_unavailable()
if user_info is None:
return response(403, {"error": "Invalid or unauthorized Google account"})
name = user_info["name"]
email = user_info["email"]
if _google_auth_configured():
try:
client_id = _get_google_client_id()
except Exception as exc:
logger.error("SSM fetch failed for Google client ID: %s", exc)
return response(
503, {"error": "Authentication service temporarily unavailable"}
)
if not client_id:
logger.error("Google auth configured but client ID is empty")
return response(
503, {"error": "Authentication service temporarily unavailable"}
)
if not google_token:
return response(403, {"error": "Google authentication is required"})
user_info, verify_status = _verify_google_token(google_token)
if verify_status == "unavailable":
return response(
503, {"error": "Authentication service temporarily unavailable"}
)
if user_info is None:
return response(403, {"error": "Invalid or unauthorized Google account"})
name = user_info["name"]
email = user_info["email"]
else:
name = body.get("employee_name", "").strip()
email = body.get("employee_email", "").strip()
items = body.get("items", [])
@ -452,7 +471,10 @@ def handle_submit(event):
week = current_week()
status = get_form_status(week)
is_admin_user = email.lower() in _get_admin_emails()
is_admin_user = False
if _google_auth_configured():
admin_emails = {e.lower() for e in get_settings().get("admin_emails", [])}
is_admin_user = email.lower() in admin_emails
if status == "closed" and not is_admin_user:
return response(410, {"error": "Orders are closed for this week"})
if status == "not_found":

View file

@ -1 +1 @@
boto3==1.43.60
boto3==1.43.56

934
package-lock.json generated
View file

@ -1,934 +0,0 @@
{
"name": "meal-order-manager",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meal-order-manager",
"version": "1.0.0",
"devDependencies": {
"@eslint/js": "10.0.1",
"eslint": "10.8.0",
"globals": "17.8.0",
"prettier": "3.9.6"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
"integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.5",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.5",
"debug": "^4.3.1",
"minimatch": "^10.2.4"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
"integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/core": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/js": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"eslint": "^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/@eslint/object-schema": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
"integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.1",
"levn": "^0.4.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.22"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@humanwhocodes/retry": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint": {
"version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"dev": true,
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.2",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.14.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1",
"espree": "^11.2.0",
"esquery": "^1.7.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"jiti": "*"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
}
}
},
"node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"estraverse": "^5.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"estraverse": "^5.2.0"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"flat-cache": "^4.0.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"flatted": "^3.2.9",
"keyv": "^4.5.4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/flatted": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
"dev": true,
"license": "ISC"
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/globals": {
"version": "17.8.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
"integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true,
"license": "MIT"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
"levn": "^0.4.1",
"prelude-ls": "^1.2.1",
"type-check": "^0.4.0",
"word-wrap": "^1.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dev": true,
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}

View file

@ -1,17 +0,0 @@
{
"name": "meal-order-manager",
"version": "1.0.0",
"private": true,
"scripts": {
"check:templates": "npm run lint:templates && npm run format:templates",
"format:templates": "prettier --check \"src/server/templates/*.js\"",
"format:templates:write": "prettier --write \"src/server/templates/*.js\"",
"lint:templates": "eslint \"src/server/templates/*.js\""
},
"devDependencies": {
"@eslint/js": "10.0.1",
"eslint": "10.8.0",
"globals": "17.8.0",
"prettier": "3.9.6"
}
}

View file

@ -1,4 +1,4 @@
playwright==1.61.0
flask==3.1.3
boto3==1.43.60
boto3==1.43.58
jinja2==3.1.6

View file

@ -6,7 +6,7 @@ and submits orders to the backend (local Flask or cloud API Gateway).
Usage:
python3 generate_form.py # local mode
python3 generate_form.py --api-url URL --google-client-id ID # cloud mode
python3 generate_form.py --api-url URL --api-key KEY # cloud mode
"""
import argparse
@ -51,14 +51,11 @@ def generate_form(
menu: dict,
config: dict,
api_url: str = "",
api_key: str = "",
bulk_discount: float = 0,
company_subsidy: float = 0,
google_client_id: str = "",
) -> str:
google_client_id = google_client_id.strip()
if api_url and not google_client_id:
raise ValueError("Google client ID is required in cloud mode")
week = datetime.now().strftime("%Y-W%U")
scraped_at = menu.get("scraped_at", "unknown")
deadline = config.get("order_deadline", "Thursday 11:59 PM")
@ -82,6 +79,7 @@ def generate_form(
"adminPdfUrl": (
f"{api_url}/api/admin/summary-pdf" if api_url else "/api/admin/summary-pdf"
),
"apiKey": api_key,
"bulkDiscount": bulk_discount,
"companySubsidy": company_subsidy,
"googleClientId": google_client_id if use_google_auth else "",
@ -118,6 +116,9 @@ def main():
parser.add_argument(
"--api-url", default="", help="API Gateway base URL (cloud mode)"
)
parser.add_argument(
"--api-key", default="", help="API key for order submission (cloud mode)"
)
parser.add_argument(
"--bulk-discount",
type=float,
@ -150,28 +151,22 @@ def main():
if args.company_subsidy is not None
else config.get("company_subsidy_percent", 0)
)
google_client_id = (
args.google_client_id or config.get("google_client_id", "")
).strip()
if args.api_url and not google_client_id:
google_client_id = args.google_client_id or config.get("google_client_id", "")
if not google_client_id:
try:
import boto3
ssm = boto3.client("ssm")
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
google_client_id = resp["Parameter"]["Value"].strip()
except Exception as exc:
parser.error(
"Google auth is required in cloud mode, but the Google client ID "
f"could not be loaded from SSM: {exc}"
)
if args.api_url and not google_client_id:
parser.error("Google auth is required in cloud mode")
google_client_id = resp["Parameter"]["Value"]
except Exception:
pass
html = generate_form(
menu,
config,
api_url=args.api_url,
api_key=args.api_key,
bulk_discount=bulk_discount,
company_subsidy=company_subsidy,
google_client_id=google_client_id,

File diff suppressed because it is too large Load diff

View file

@ -7,10 +7,11 @@ const STATUS_URL = CONFIG.statusUrl;
const ROSTER_URL = CONFIG.rosterUrl;
const ADMIN_URL = CONFIG.adminUrl;
const ADMIN_PDF_URL = CONFIG.adminPdfUrl;
const API_KEY = CONFIG.apiKey;
const WEEK = CONFIG.week;
const BULK_DISCOUNT = CONFIG.bulkDiscount;
const COMPANY_SUBSIDY = CONFIG.companySubsidy;
const GOOGLE_CLIENT_ID = CONFIG.googleClientId || "";
const GOOGLE_CLIENT_ID = CONFIG.googleClientId || '';
window.MEALS = MEALS;
window.WEEK = WEEK;
@ -34,35 +35,33 @@ window.googleCredential = null;
function employeePrice(retail) {
if (!retail) return 0;
const bulkPrice =
Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100;
const empPrice =
Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100;
const bulkPrice = Math.round(retail * (1 - BULK_DISCOUNT / 100) * 100 + 1e-8) / 100;
const empPrice = Math.round(bulkPrice * (1 - COMPANY_SUBSIDY / 100) * 100 + 1e-8) / 100;
return empPrice;
}
function clearStatus(scope) {
const prefix = scope === "admin" ? "admin-status" : "form-status";
const alertEl = document.getElementById(prefix + "-alert");
const infoEl = document.getElementById(prefix + "-info");
if (alertEl) alertEl.textContent = "";
if (infoEl) infoEl.textContent = "";
const prefix = scope === 'admin' ? 'admin-status' : 'form-status';
const alertEl = document.getElementById(prefix + '-alert');
const infoEl = document.getElementById(prefix + '-info');
if (alertEl) alertEl.textContent = '';
if (infoEl) infoEl.textContent = '';
}
function showStatus(message, options) {
const opts = options || {};
const tone = opts.tone || "error";
const scope = opts.scope || "form";
const prefix = scope === "admin" ? "admin-status" : "form-status";
const alertEl = document.getElementById(prefix + "-alert");
const infoEl = document.getElementById(prefix + "-info");
const tone = opts.tone || 'error';
const scope = opts.scope || 'form';
const prefix = scope === 'admin' ? 'admin-status' : 'form-status';
const alertEl = document.getElementById(prefix + '-alert');
const infoEl = document.getElementById(prefix + '-info');
if (!alertEl || !infoEl) return;
if (tone === "info") {
alertEl.textContent = "";
infoEl.textContent = message || "";
if (tone === 'info') {
alertEl.textContent = '';
infoEl.textContent = message || '';
} else {
infoEl.textContent = "";
alertEl.textContent = message || "";
infoEl.textContent = '';
alertEl.textContent = message || '';
}
}
@ -81,17 +80,16 @@ function buildItems() {
}
function waitForGoogleAuth(attempt = 0) {
if (typeof google !== "undefined" && google.accounts && google.accounts.id) {
if (typeof google !== 'undefined' && google.accounts && google.accounts.id) {
initGoogleAuth();
} else if (attempt < GOOGLE_AUTH_MAX_ATTEMPTS) {
setTimeout(() => waitForGoogleAuth(attempt + 1), 50);
} else {
const signIn = document.getElementById("g-signin-btn");
const signIn = document.getElementById('g-signin-btn');
if (signIn) {
signIn.textContent =
"Google sign-in failed to load. Refresh the page to try again.";
signIn.textContent = 'Google sign-in failed to load. Refresh the page to try again.';
}
console.error("Google Identity Services failed to load.");
console.error('Google Identity Services failed to load.');
}
}
@ -101,35 +99,30 @@ function initGoogleAuth() {
callback: handleCredentialResponse,
auto_select: true,
});
google.accounts.id.renderButton(document.getElementById("g-signin-btn"), {
theme: "outline",
size: "large",
text: "signin_with",
width: 300,
});
google.accounts.id.renderButton(
document.getElementById('g-signin-btn'),
{ theme: 'outline', size: 'large', text: 'signin_with', width: 300 }
);
}
function handleCredentialResponse(response) {
googleCredential = response.credential;
syncGoogleCredential();
const b64 = response.credential
.split(".")[1]
.replace(/-/g, "+")
.replace(/_/g, "/");
const payloadBytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const b64 = response.credential.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payloadBytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
googleUser = { name: payload.name, email: payload.email };
document.getElementById("auth-overlay").classList.add("is-hidden");
document.getElementById("app").classList.remove("is-hidden");
const footer = document.getElementById("sticky-footer");
if (footer) footer.classList.remove("is-hidden");
document.getElementById("user-name").textContent = payload.name;
document.getElementById("user-email").textContent = payload.email;
document.getElementById('auth-overlay').classList.add('is-hidden');
document.getElementById('app').classList.remove('is-hidden');
const footer = document.getElementById('sticky-footer');
if (footer) footer.classList.remove('is-hidden');
document.getElementById('user-name').textContent = payload.name;
document.getElementById('user-email').textContent = payload.email;
if (payload.picture) {
const avatar = document.getElementById("user-avatar");
const avatar = document.getElementById('user-avatar');
avatar.src = payload.picture;
avatar.classList.remove("is-hidden");
avatar.classList.remove('is-hidden');
}
updateTotal();
@ -141,49 +134,37 @@ function handleCredentialResponse(response) {
function signOut() {
isAdmin = false;
const ab = document.getElementById("admin-btn");
if (ab) ab.classList.add("is-hidden");
const banner = document.getElementById("admin-closed-banner");
const ab = document.getElementById('admin-btn');
if (ab) ab.classList.add('is-hidden');
const banner = document.getElementById('admin-closed-banner');
if (banner) banner.remove();
googleCredential = null;
googleUser = null;
syncGoogleCredential();
if (
CONFIG.authMode === "google" &&
typeof google !== "undefined" &&
google.accounts
) {
if (CONFIG.authMode === 'google' && typeof google !== 'undefined' && google.accounts) {
google.accounts.id.disableAutoSelect();
}
document.getElementById("auth-overlay").classList.remove("is-hidden");
document.getElementById("app").classList.add("is-hidden");
const footer = document.getElementById("sticky-footer");
if (footer) footer.classList.add("is-hidden");
document.getElementById('auth-overlay').classList.remove('is-hidden');
document.getElementById('app').classList.add('is-hidden');
const footer = document.getElementById('sticky-footer');
if (footer) footer.classList.add('is-hidden');
if (formClosed) showClosedOverlay();
}
function checkDuplicateOrder() {
try {
if (localStorage.getItem("lastOrderWeek") === WEEK) {
document
.getElementById("duplicate-warning")
.classList.remove("is-hidden");
if (localStorage.getItem('lastOrderWeek') === WEEK) {
document.getElementById('duplicate-warning').classList.remove('is-hidden');
}
} catch (e) {}
}
function getActiveFilter() {
return (
document.querySelector(".filters button.active")?.textContent || "All Meals"
);
return document.querySelector('.filters button.active')?.textContent || 'All Meals';
}
function mealMatchesFilter(meal, activeFilter, search) {
if (
activeFilter !== "All Meals" &&
!(meal.dietary_tags || []).includes(activeFilter)
)
return false;
if (activeFilter !== 'All Meals' && !(meal.dietary_tags || []).includes(activeFilter)) return false;
if (search && !meal.name.toLowerCase().includes(search)) return false;
return true;
}
@ -191,45 +172,37 @@ function mealMatchesFilter(meal, activeFilter, search) {
function buildMealMetaHtml(meal) {
const hasDiscount = CONFIG.hasDiscount && meal.price;
const empPrice = employeePrice(meal.price);
const meta = document.createElement("div");
meta.className = "meal-meta";
const meta = document.createElement('div');
meta.className = 'meal-meta';
if (hasDiscount) {
const retail = document.createElement("span");
retail.className = "price-retail";
retail.textContent = "$" + meal.price.toFixed(2);
const retail = document.createElement('span');
retail.className = 'price-retail';
retail.textContent = '$' + meal.price.toFixed(2);
meta.appendChild(retail);
const employee = document.createElement("span");
employee.className = "price-employee";
employee.textContent = "$" + empPrice.toFixed(2);
const employee = document.createElement('span');
employee.className = 'price-employee';
employee.textContent = '$' + empPrice.toFixed(2);
meta.appendChild(employee);
} else if (meal.price) {
meta.appendChild(document.createTextNode("$" + meal.price.toFixed(2)));
meta.appendChild(document.createTextNode('$' + meal.price.toFixed(2)));
} else {
meta.appendChild(document.createTextNode("—"));
meta.appendChild(document.createTextNode('—'));
}
meta.appendChild(
document.createTextNode(
" · " +
(meal.calories || "?") +
" cal · " +
(meal.protein || "?") +
" protein",
),
);
meta.appendChild(document.createTextNode(' · ' + (meal.calories || '?') + ' cal · ' + (meal.protein || '?') + ' protein'));
return meta;
}
function buildMealTagsEl(meal) {
const tagsEl = document.createElement("div");
tagsEl.className = "meal-tags";
const tagsEl = document.createElement('div');
tagsEl.className = 'meal-tags';
if (meal.is_new) {
const span = document.createElement("span");
span.className = "new";
span.textContent = "NEW";
const span = document.createElement('span');
span.className = 'new';
span.textContent = 'NEW';
tagsEl.appendChild(span);
}
(meal.dietary_tags || []).forEach((t) => {
const span = document.createElement("span");
(meal.dietary_tags || []).forEach(t => {
const span = document.createElement('span');
span.textContent = t;
tagsEl.appendChild(span);
});
@ -237,25 +210,25 @@ function buildMealTagsEl(meal) {
}
function removeDescToggle(card) {
const existing = card.querySelector(".meal-desc-toggle");
const existing = card.querySelector('.meal-desc-toggle');
if (existing) existing.remove();
}
function attachDescToggleIfOverflow(card) {
const descEl = card.querySelector(".meal-desc");
if (!descEl || descEl.classList.contains("is-hidden")) return;
const descEl = card.querySelector('.meal-desc');
if (!descEl || descEl.classList.contains('is-hidden')) return;
removeDescToggle(card);
if (descEl.scrollHeight <= descEl.clientHeight + 1) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "meal-desc-toggle";
btn.textContent = "More";
btn.setAttribute("aria-expanded", "false");
btn.addEventListener("click", () => {
const expanded = descEl.classList.toggle("is-expanded");
btn.textContent = expanded ? "Less" : "More";
btn.setAttribute("aria-expanded", expanded ? "true" : "false");
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'meal-desc-toggle';
btn.textContent = 'More';
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', () => {
const expanded = descEl.classList.toggle('is-expanded');
btn.textContent = expanded ? 'Less' : 'More';
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
descEl.after(btn);
}
@ -269,59 +242,57 @@ function scheduleDescToggle(card) {
function updateMealCard(card, meal, i) {
const qty = quantities[i] || 0;
card.dataset.mealIndex = String(i);
card.classList.toggle("selected", qty > 0);
card.classList.toggle('selected', qty > 0);
const img = card.querySelector(".meal-img");
const img = card.querySelector('.meal-img');
if (meal.image_url && /^https?:\/\//i.test(meal.image_url)) {
img.src = meal.image_url;
img.alt = meal.name;
img.classList.remove("is-hidden");
img.classList.remove('is-hidden');
} else {
img.removeAttribute("src");
img.alt = "";
img.classList.add("is-hidden");
img.removeAttribute('src');
img.alt = '';
img.classList.add('is-hidden');
}
card.querySelector(".meal-name").textContent = meal.name;
card.querySelector('.meal-name').textContent = meal.name;
const descEl = card.querySelector(".meal-desc");
const descEl = card.querySelector('.meal-desc');
removeDescToggle(card);
descEl.classList.remove("is-expanded");
descEl.classList.remove('is-expanded');
if (meal.description) {
descEl.textContent = meal.description;
descEl.classList.remove("is-hidden");
descEl.classList.remove('is-hidden');
scheduleDescToggle(card);
} else {
descEl.textContent = "";
descEl.classList.add("is-hidden");
descEl.textContent = '';
descEl.classList.add('is-hidden');
}
const metaEl = card.querySelector(".meal-meta");
const metaEl = card.querySelector('.meal-meta');
const newMeta = buildMealMetaHtml(meal);
metaEl.replaceWith(newMeta);
const tagsEl = card.querySelector(".meal-tags");
const tagsEl = card.querySelector('.meal-tags');
const newTags = buildMealTagsEl(meal);
tagsEl.replaceWith(newTags);
const qtyInput = card.querySelector(".qty");
const qtyInput = card.querySelector('.qty');
qtyInput.value = qty;
qtyInput.setAttribute("aria-label", "Quantity of " + meal.name);
qtyInput.setAttribute('aria-label', 'Quantity of ' + meal.name);
const minusBtn = card.querySelector('[data-qty-delta="-1"]');
const plusBtn = card.querySelector('[data-qty-delta="1"]');
if (minusBtn) minusBtn.setAttribute("aria-label", "Remove one " + meal.name);
if (plusBtn) plusBtn.setAttribute("aria-label", "Add one " + meal.name);
if (minusBtn) minusBtn.setAttribute('aria-label', 'Remove one ' + meal.name);
if (plusBtn) plusBtn.setAttribute('aria-label', 'Add one ' + meal.name);
const disabled = formClosed && !isAdmin;
card.querySelectorAll("[data-qty-delta]").forEach((btn) => {
btn.disabled = disabled;
});
card.querySelectorAll('[data-qty-delta]').forEach(btn => { btn.disabled = disabled; });
qtyInput.disabled = disabled;
}
function createMealCard(meal, i) {
const tpl = document.getElementById("meal-card-tpl");
const tpl = document.getElementById('meal-card-tpl');
const card = tpl.content.firstElementChild.cloneNode(true);
updateMealCard(card, meal, i);
return card;
@ -329,9 +300,9 @@ function createMealCard(meal, i) {
function renderMeals() {
const activeFilter = getActiveFilter();
const search = document.getElementById("search").value.toLowerCase();
const list = document.getElementById("meals-list");
list.innerHTML = "";
const search = document.getElementById('search').value.toLowerCase();
const list = document.getElementById('meals-list');
list.innerHTML = '';
MEALS.forEach((meal, i) => {
if (!mealMatchesFilter(meal, activeFilter, search)) return;
@ -349,8 +320,8 @@ function updateMealCardQty(index) {
const card = findMealCard(index);
if (!card) return;
const qty = quantities[index] || 0;
card.classList.toggle("selected", qty > 0);
const qtyInput = card.querySelector(".qty");
card.classList.toggle('selected', qty > 0);
const qtyInput = card.querySelector('.qty');
if (qtyInput && qtyInput !== document.activeElement) qtyInput.value = qty;
}
@ -374,29 +345,29 @@ function changeQty(index, delta) {
function bindMealsListDelegation() {
if (mealsListDelegationBound) return;
mealsListDelegationBound = true;
const list = document.getElementById("meals-list");
list.addEventListener("click", (e) => {
const btn = e.target.closest("[data-qty-delta]");
const list = document.getElementById('meals-list');
list.addEventListener('click', (e) => {
const btn = e.target.closest('[data-qty-delta]');
if (!btn) return;
const card = btn.closest("[data-meal-index]");
const card = btn.closest('[data-meal-index]');
if (!card) return;
const index = parseInt(card.dataset.mealIndex, 10);
const delta = parseInt(btn.dataset.qtyDelta, 10);
changeQty(index, delta);
});
list.addEventListener("input", (e) => {
const input = e.target.closest(".qty");
list.addEventListener('input', (e) => {
const input = e.target.closest('.qty');
if (!input) return;
const card = input.closest("[data-meal-index]");
const card = input.closest('[data-meal-index]');
if (!card) return;
const index = parseInt(card.dataset.mealIndex, 10);
setQtyState(index, input.value);
updateTotal();
});
list.addEventListener("focusout", (e) => {
const input = e.target.closest(".qty");
list.addEventListener('focusout', (e) => {
const input = e.target.closest('.qty');
if (!input) return;
const card = input.closest("[data-meal-index]");
const card = input.closest('[data-meal-index]');
if (!card) return;
updateMealCardQty(parseInt(card.dataset.mealIndex, 10));
});
@ -409,118 +380,100 @@ function updateTotal() {
total += employeePrice(MEALS[i].price || 0) * qty;
count += qty;
});
document.getElementById("total-display").textContent = "$" + total.toFixed(2);
document.getElementById("count-display").textContent =
count + " meal" + (count !== 1 ? "s" : "");
const submitBtn = document.getElementById("submit-btn");
submitBtn.disabled =
count === 0 || (CONFIG.authMode === "google" && !googleCredential);
document.getElementById('total-display').textContent = '$' + total.toFixed(2);
document.getElementById('count-display').textContent = count + ' meal' + (count !== 1 ? 's' : '');
const submitBtn = document.getElementById('submit-btn');
submitBtn.disabled = count === 0 || (CONFIG.authMode === 'google' && !googleCredential);
}
async function submitOrder() {
clearStatus("form");
clearStatus('form');
if (formClosed && !isAdmin) {
showStatus("Orders are closed.", { tone: "error", scope: "form" });
showStatus('Orders are closed.', { tone: 'error', scope: 'form' });
return;
}
let payload;
let successName;
if (CONFIG.authMode === "google") {
if (CONFIG.authMode === 'google') {
if (!googleCredential || !googleUser) {
showStatus("Please sign in with Google first.", {
tone: "error",
scope: "form",
});
showStatus('Please sign in with Google first.', { tone: 'error', scope: 'form' });
return;
}
payload = { google_id_token: googleCredential, items: buildItems() };
successName = googleUser.name;
} else {
const nameEl = document.getElementById("emp-name");
const emailEl = document.getElementById("emp-email");
const nameEl = document.getElementById('emp-name');
const emailEl = document.getElementById('emp-email');
const name = nameEl.value.trim();
const email = emailEl.value.trim();
if (!name) {
showStatus("Please enter your name.", { tone: "error", scope: "form" });
showStatus('Please enter your name.', { tone: 'error', scope: 'form' });
nameEl.focus();
return;
}
if (!email) {
showStatus("Please enter your email.", { tone: "error", scope: "form" });
showStatus('Please enter your email.', { tone: 'error', scope: 'form' });
emailEl.focus();
return;
}
payload = {
employee_name: name,
employee_email: email,
items: buildItems(),
};
payload = { employee_name: name, employee_email: email, items: buildItems() };
successName = name;
}
const btn = document.getElementById("submit-btn");
const btn = document.getElementById('submit-btn');
btn.disabled = true;
btn.textContent = "Submitting...";
btn.textContent = 'Submitting...';
try {
const headers = { 'Content-Type': 'application/json' };
if (API_KEY) headers['x-api-key'] = API_KEY;
const res = await fetch(SUBMIT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
method: 'POST',
headers,
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
for (const el of document.getElementById("app").children) {
if (el.id !== "success") el.classList.add("is-hidden");
for (const el of document.getElementById('app').children) {
if (el.id !== 'success') el.classList.add('is-hidden');
}
document.getElementById("success").classList.remove("is-hidden");
document.getElementById("success-detail").textContent =
successName +
" — $" +
(data.total || 0).toFixed(2) +
" total. You're all set!";
const footer = document.querySelector(".sticky-footer");
if (footer) footer.classList.add("is-hidden");
try {
localStorage.setItem("lastOrderWeek", WEEK);
} catch (e) {}
const successHeading = document.getElementById("success-heading");
document.getElementById('success').classList.remove('is-hidden');
document.getElementById('success-detail').textContent =
successName + ' — $' + (data.total || 0).toFixed(2) + ' total. You\'re all set!';
const footer = document.querySelector('.sticky-footer');
if (footer) footer.classList.add('is-hidden');
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
const successHeading = document.getElementById('success-heading');
if (successHeading) successHeading.focus();
} else {
showStatus(data.error || "Something went wrong.", {
tone: "error",
scope: "form",
});
showStatus(data.error || 'Something went wrong.', { tone: 'error', scope: 'form' });
btn.disabled = false;
btn.textContent = "Submit Order";
btn.textContent = 'Submit Order';
}
} catch (e) {
showStatus("Failed to submit. Check your connection and try again.", {
tone: "error",
scope: "form",
});
showStatus('Failed to submit. Check your connection and try again.', { tone: 'error', scope: 'form' });
btn.disabled = false;
btn.textContent = "Submit Order";
btn.textContent = 'Submit Order';
}
}
function bindEmpNameChange(select) {
if (select.dataset.boundChange === "1") return;
select.dataset.boundChange = "1";
select.addEventListener("change", () => {
if (select.dataset.boundChange === '1') return;
select.dataset.boundChange = '1';
select.addEventListener('change', () => {
const selected = select.options[select.selectedIndex];
document.getElementById("emp-email").value = selected?.dataset?.email || "";
clearStatus("form");
document.getElementById('emp-email').value = selected?.dataset?.email || '';
clearStatus('form');
});
}
function populateEmpSelect(select, employees) {
select.innerHTML = '<option value="">Select your name</option>';
employees.forEach((emp) => {
const opt = document.createElement("option");
employees.forEach(emp => {
const opt = document.createElement('option');
opt.value = emp.name;
opt.dataset.email = emp.email;
opt.textContent = emp.name;
@ -535,55 +488,43 @@ async function loadRoster() {
const res = await fetch(ROSTER_URL);
const data = await res.json();
const candidate = data.employees;
if (
Array.isArray(candidate) &&
candidate.every(
(e) => typeof e.name === "string" && typeof e.email === "string",
)
) {
if (Array.isArray(candidate) && candidate.every(e => typeof e.name === 'string' && typeof e.email === 'string')) {
employees = candidate;
} else {
employees = ROSTER;
}
} catch (e) {}
const select = document.getElementById("emp-name");
const select = document.getElementById('emp-name');
populateEmpSelect(select, employees);
}
function showClosedOverlay() {
const overlay = document.getElementById("closed-overlay");
overlay.classList.add("is-visible");
overlay.style.display = "flex";
document
.querySelectorAll(".qty-control button, .qty-control .qty")
.forEach((el) => {
el.disabled = true;
});
const submitBtn = document.getElementById("submit-btn");
const overlay = document.getElementById('closed-overlay');
overlay.classList.add('is-visible');
overlay.style.display = 'flex';
document.querySelectorAll('.qty-control button, .qty-control .qty').forEach(el => { el.disabled = true; });
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) submitBtn.disabled = true;
if (formStatusData) startCountdown(formStatusData);
}
function adminBypassClosed() {
const overlay = document.getElementById("closed-overlay");
overlay.classList.remove("is-visible");
overlay.style.display = "none";
document.querySelectorAll(".qty-control button").forEach((b) => {
b.disabled = false;
});
const submitBtn = document.getElementById("submit-btn");
const overlay = document.getElementById('closed-overlay');
overlay.classList.remove('is-visible');
overlay.style.display = 'none';
document.querySelectorAll('.qty-control button').forEach(b => { b.disabled = false; });
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) submitBtn.disabled = Object.keys(quantities).length === 0;
if (!document.getElementById("admin-closed-banner")) {
const banner = document.createElement("div");
banner.id = "admin-closed-banner";
banner.className = "closed-banner";
banner.textContent = "Form is closed — admin override active";
const deadline = document.querySelector(".deadline");
if (deadline)
deadline.parentNode.insertBefore(banner, deadline.nextSibling);
if (!document.getElementById('admin-closed-banner')) {
const banner = document.createElement('div');
banner.id = 'admin-closed-banner';
banner.className = 'closed-banner';
banner.textContent = 'Form is closed — admin override active';
const deadline = document.querySelector('.deadline');
if (deadline) deadline.parentNode.insertBefore(banner, deadline.nextSibling);
}
document.querySelectorAll("[data-meal-index]").forEach((card) => {
document.querySelectorAll('[data-meal-index]').forEach(card => {
const index = parseInt(card.dataset.mealIndex, 10);
const meal = MEALS[index];
if (meal) updateMealCard(card, meal, index);
@ -594,17 +535,17 @@ function adminBypassClosed() {
function checkAdmin() {
if (!ADMIN_URL) return Promise.resolve();
const headers = {};
if (googleCredential) headers["Authorization"] = "Bearer " + googleCredential;
if (googleCredential) headers['Authorization'] = 'Bearer ' + googleCredential;
return fetch(ADMIN_URL, { headers })
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data && data.weeks) {
isAdmin = true;
window.isAdmin = true;
adminWeeks = data.weeks;
window.adminWeeks = adminWeeks;
const ab = document.getElementById("admin-btn");
if (ab) ab.classList.remove("is-hidden");
const ab = document.getElementById('admin-btn');
if (ab) ab.classList.remove('is-hidden');
if (formClosed) adminBypassClosed();
} else if (formClosed) {
showClosedOverlay();
@ -616,71 +557,62 @@ function checkAdmin() {
}
function showAdmin() {
document.querySelectorAll("#app > :not(#admin-panel)").forEach((el) => {
if (el.id !== "admin-panel") {
el.dataset.prevHidden = el.classList.contains("is-hidden") ? "1" : "";
el.classList.add("is-hidden");
document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => {
if (el.id !== 'admin-panel') {
el.dataset.prevHidden = el.classList.contains('is-hidden') ? '1' : '';
el.classList.add('is-hidden');
}
});
document.getElementById("admin-panel").classList.remove("is-hidden");
const footer = document.querySelector(".sticky-footer");
if (footer) footer.classList.add("is-hidden");
document.getElementById('admin-panel').classList.remove('is-hidden');
const footer = document.querySelector('.sticky-footer');
if (footer) footer.classList.add('is-hidden');
const select = document.getElementById("admin-week");
select.innerHTML = "";
adminWeeks.forEach((w) => {
const opt = document.createElement("option");
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)" : "");
opt.textContent = w.week + (w.form_status === 'open' ? ' (open)' : '');
select.appendChild(opt);
});
if (adminWeeks.length > 0 && typeof loadWeekOrders === "function") {
if (adminWeeks.length > 0 && typeof loadWeekOrders === 'function') {
loadWeekOrders(adminWeeks[0].week);
}
}
function hideAdmin() {
document.getElementById("admin-panel").classList.add("is-hidden");
document.querySelectorAll("#app > :not(#admin-panel)").forEach((el) => {
if (el.dataset.prevHidden === "1") el.classList.add("is-hidden");
else el.classList.remove("is-hidden");
document.getElementById('admin-panel').classList.add('is-hidden');
document.querySelectorAll('#app > :not(#admin-panel)').forEach(el => {
if (el.dataset.prevHidden === '1') el.classList.add('is-hidden');
else el.classList.remove('is-hidden');
delete el.dataset.prevHidden;
});
const footer = document.querySelector(".sticky-footer");
if (footer) footer.classList.remove("is-hidden");
const footer = document.querySelector('.sticky-footer');
if (footer) footer.classList.remove('is-hidden');
}
const easternFormatter = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
const easternFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
function getEasternParts(date) {
const values = {};
easternFormatter.formatToParts(date).forEach((part) => {
if (part.type !== "literal") values[part.type] = Number(part.value);
easternFormatter.formatToParts(date).forEach(part => {
if (part.type !== 'literal') values[part.type] = Number(part.value);
});
return values;
}
function getEasternOffsetMs(date) {
const parts = getEasternParts(date);
return (
Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
) - date.getTime()
);
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - date.getTime();
}
function startCountdown(data) {
@ -698,36 +630,24 @@ function updateCountdown() {
target = new Date(reopenAtMs);
} else {
const etNow = getEasternParts(now);
const day = new Date(
Date.UTC(etNow.year, etNow.month - 1, etNow.day),
).getUTCDay();
const day = new Date(Date.UTC(etNow.year, etNow.month - 1, etNow.day)).getUTCDay();
const hour = etNow.hour;
let daysUntil = (1 - day + 7) % 7;
if (daysUntil === 0 && hour >= 8) daysUntil = 7;
const targetEtMs = Date.UTC(
etNow.year,
etNow.month - 1,
etNow.day + daysUntil,
8,
0,
0,
);
const targetEtMs = Date.UTC(etNow.year, etNow.month - 1, etNow.day + daysUntil, 8, 0, 0);
target = new Date(targetEtMs - getEasternOffsetMs(new Date(targetEtMs)));
}
let diff = Math.max(0, Math.floor((target - now) / 1000));
const d = Math.floor(diff / 86400);
diff %= 86400;
const h = Math.floor(diff / 3600);
diff %= 3600;
const d = Math.floor(diff / 86400); diff %= 86400;
const h = Math.floor(diff / 3600); diff %= 3600;
const m = Math.floor(diff / 60);
const s = diff % 60;
const pad = (n) => String(n).padStart(2, "0");
document.getElementById("countdown").textContent =
d + "d " + pad(h) + "h " + pad(m) + "m " + pad(s) + "s";
const pad = n => String(n).padStart(2, '0');
document.getElementById('countdown').textContent = d + 'd ' + pad(h) + 'h ' + pad(m) + 'm ' + pad(s) + 's';
}
async function checkFormStatus() {
@ -735,12 +655,12 @@ async function checkFormStatus() {
try {
const res = await fetch(STATUS_URL);
const data = await res.json();
if (data.status === "closed") {
if (data.status === 'closed') {
formClosed = true;
formStatusData = data;
if (isAdmin) {
adminBypassClosed();
} else if (CONFIG.authMode !== "google" || googleCredential) {
} else if (CONFIG.authMode !== 'google' || googleCredential) {
showClosedOverlay();
}
}
@ -749,7 +669,7 @@ async function checkFormStatus() {
function init() {
checkFormStatus();
if (CONFIG.authMode === "google") {
if (CONFIG.authMode === 'google') {
waitForGoogleAuth();
} else {
loadRoster();
@ -757,40 +677,39 @@ function init() {
}
const tags = new Set();
MEALS.forEach((m) => (m.dietary_tags || []).forEach((t) => tags.add(t)));
const cats = ["All Meals", ...Array.from(tags).sort()];
const filtersEl = document.getElementById("filters");
cats.forEach((cat) => {
const btn = document.createElement("button");
btn.type = "button";
const isActive = cat === "All Meals";
btn.className = "btn btn-chip" + (isActive ? " active" : "");
btn.setAttribute("aria-pressed", isActive ? "true" : "false");
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t)));
const cats = ['All Meals', ...Array.from(tags).sort()];
const filtersEl = document.getElementById('filters');
cats.forEach(cat => {
const btn = document.createElement('button');
btn.type = 'button';
const isActive = cat === 'All Meals';
btn.className = 'btn btn-chip' + (isActive ? ' active' : '');
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
btn.textContent = cat;
btn.addEventListener("click", () => {
filtersEl.querySelectorAll("button").forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
btn.addEventListener('click', () => {
filtersEl.querySelectorAll('button').forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-pressed', 'false');
});
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
btn.classList.add('active');
btn.setAttribute('aria-pressed', 'true');
renderMeals();
});
filtersEl.appendChild(btn);
});
document.getElementById("search").addEventListener("input", renderMeals);
document.getElementById("submit-btn").addEventListener("click", submitOrder);
document.getElementById('search').addEventListener('input', renderMeals);
document.getElementById('submit-btn').addEventListener('click', submitOrder);
const adminBtn = document.getElementById("admin-btn");
if (adminBtn) adminBtn.addEventListener("click", showAdmin);
const adminBtn = document.getElementById('admin-btn');
if (adminBtn) adminBtn.addEventListener('click', showAdmin);
const signOutBtn = document.getElementById("sign-out-btn");
if (signOutBtn) signOutBtn.addEventListener("click", signOut);
const signOutBtn = document.getElementById('sign-out-btn');
if (signOutBtn) signOutBtn.addEventListener('click', signOut);
const successBackBtn = document.getElementById("success-back-btn");
if (successBackBtn)
successBackBtn.addEventListener("click", () => location.reload());
const successBackBtn = document.getElementById('success-back-btn');
if (successBackBtn) successBackBtn.addEventListener('click', () => location.reload());
bindMealsListDelegation();
renderMeals();

View file

@ -1,2 +1,2 @@
boto3==1.43.60
boto3==1.43.56
fpdf2==2.8.7

View file

@ -244,10 +244,6 @@ Resources:
DefaultRouteSettings:
ThrottlingBurstLimit: 50
ThrottlingRateLimit: 100
RouteSettings:
'POST /api/submit-order':
ThrottlingBurstLimit: 10
ThrottlingRateLimit: 5
# CORS only allows the production domain. For local development, use the
# Flask dev server (app.py) which proxies API requests and doesn't enforce CORS.
CorsConfiguration:
@ -264,6 +260,7 @@ Resources:
- OPTIONS
AllowHeaders:
- Content-Type
- x-api-key
- Authorization
MaxAge: 3600
@ -279,6 +276,7 @@ Resources:
Timeout: 10
Environment:
Variables:
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
REPORTS_BUCKET: !Ref ReportsBucket
@ -286,6 +284,9 @@ Resources:
- DynamoDBCrudPolicy:
TableName: !Ref OrdersTable
- Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*'
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt SlackNotifierFunction.Arn

View file

@ -7,7 +7,6 @@ import json
import re
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
@ -17,9 +16,7 @@ FIXTURES = Path(__file__).resolve().parent / "fixtures"
sys.path.insert(0, str(SERVER_DIR))
import generate_form as generate_form_module # noqa: E402
generate_form = generate_form_module.generate_form
from generate_form import generate_form # noqa: E402
EXPECTED_CONFIG_KEYS = {
"authMode",
@ -31,6 +28,7 @@ EXPECTED_CONFIG_KEYS = {
"rosterUrl",
"adminUrl",
"adminPdfUrl",
"apiKey",
"bulkDiscount",
"companySubsidy",
"googleClientId",
@ -56,25 +54,19 @@ def _extract_config(html: str) -> dict:
return json.loads(match.group(1))
def _render(*, google: bool = False) -> str:
def _render(*, google: bool = False, api_key: str = "test-key") -> str:
menu, config = _load_fixtures()
return generate_form(
menu,
config,
api_url="https://api.example.com" if google else "",
api_url="https://api.example.com",
api_key=api_key,
bulk_discount=10,
company_subsidy=50,
google_client_id="google-client.apps.googleusercontent.com" if google else "",
)
def _render_for_browser(*, google: bool = False) -> str:
html = _render(google=google)
if not google:
html = html.replace("<head>", '<head><base href="https://api.example.com/">', 1)
return html
def _google_credential() -> str:
payload = json.dumps(
{
@ -120,64 +112,6 @@ def _mock_form_routes(page):
class TestGenerateFormStructural:
def test_cloud_mode_requires_google_auth(self):
menu, config = _load_fixtures()
for client_id in ("", " "):
with pytest.raises(ValueError, match="Google client ID is required"):
generate_form(
menu,
config,
api_url="https://api.example.com",
google_client_id=client_id,
)
def test_cloud_cli_rejects_whitespace_ssm_client_id(self, monkeypatch, capsys):
menu, config = _load_fixtures()
ssm = SimpleNamespace(get_parameter=lambda **_: {"Parameter": {"Value": " "}})
monkeypatch.setitem(sys.modules, "boto3", SimpleNamespace(client=lambda _: ssm))
monkeypatch.setattr(generate_form_module, "load_config", lambda: config)
monkeypatch.setattr(generate_form_module, "latest_menu", lambda: menu)
monkeypatch.setattr(
sys,
"argv",
["generate_form.py", "--api-url", "https://api.example.com"],
)
with pytest.raises(SystemExit) as exc_info:
generate_form_module.main()
assert exc_info.value.code == 2
assert "Google auth is required in cloud mode" in capsys.readouterr().err
def test_generated_form_contains_no_api_key_handling(self):
html = _render(google=True)
assert "apiKey" not in _extract_config(html)
assert "API_KEY" not in html
assert "x-api-key" not in html
def test_cloud_configuration_has_no_form_api_key(self):
template = (REPO_ROOT / "template.yaml").read_text()
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
readme = (REPO_ROOT / "README.md").read_text()
for text in (template, workflow):
assert "FORM_APIKEY" not in text
assert "form-api-key" not in text
assert "x-api-key" not in text
assert "--api-key" not in text
assert (
"`meal-order-manager/form-api-key` secret remains until this change "
"is deployed and verified"
) in readme
def test_submit_route_has_explicit_throttling(self):
template = (REPO_ROOT / "template.yaml").read_text()
assert "'POST /api/submit-order':" in template
submit_settings = template.split("'POST /api/submit-order':", 1)[1].split(
"CorsConfiguration:", 1
)[0]
assert "ThrottlingBurstLimit: 10" in submit_settings
assert "ThrottlingRateLimit: 5" in submit_settings
def test_local_and_google_render(self):
local = _render(google=False)
google = _render(google=True)
@ -357,7 +291,7 @@ class TestGenerateFormPlaywright:
sync_api = pytest.importorskip("playwright.sync_api")
sync_playwright = sync_api.sync_playwright
html = _render_for_browser(google=False)
html = _render(google=False, api_key="")
# Stub status/roster so the page does not hang on network; roster fallback
# in loadRoster still applies if fetch fails — intercept to be deterministic.
out = tmp_path_factory.mktemp("form") / "order-form.html"
@ -387,7 +321,7 @@ class TestGenerateFormPlaywright:
sync_playwright = sync_api.sync_playwright
html_path = tmp_path / f"order-form-{'google' if google else 'local'}.html"
html_path.write_text(_render_for_browser(google=google))
html_path.write_text(_render(google=google, api_key=""))
with sync_playwright() as p:
try:
@ -666,7 +600,7 @@ class TestGenerateFormPlaywright:
@pytest.fixture
def mobile_admin_page(self, tmp_path, browser_page):
html = _render_for_browser(google=False)
html = _render(google=False, api_key="")
out = tmp_path / "admin-order-form.html"
out.write_text(html)
hostile_name = """O'Reilly "<b>Ops</b>\""""
@ -799,9 +733,6 @@ class TestGenerateFormPlaywright:
assert "hostile@example.com" in card.inner_text()
assert "Chicken Bowl ×1" in card.inner_text() # noqa: RUF001
assert "$12.00" in card.inner_text()
submitted_time = card.locator(".admin-card-time")
assert submitted_time.is_visible()
assert submitted_time.inner_text() == "Jul 30, 10:15 AM"
assert page.evaluate(
"() => document.documentElement.scrollWidth === "
"document.documentElement.clientWidth"
@ -830,12 +761,10 @@ class TestGenerateFormPlaywright:
'.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"][data-delta="1"]'
).click()
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
saved_card = page.locator(
".admin-order-card",
has_text="Luis' Lomo Saltado ×2", # noqa: RUF001
page.wait_for_function(
"() => document.querySelector('.admin-order-card')"
'.textContent.includes("Luis\' Lomo Saltado ×2")' # noqa: RUF001
)
saved_card.wait_for(state="visible")
assert "Luis' Lomo Saltado ×2" in saved_card.inner_text() # noqa: RUF001
assert state["orders"][0]["employee_name"] == hostile_name
assert state["orders"][0]["total"] == 41.0
@ -916,7 +845,7 @@ class TestGenerateFormGooglePlaywright:
) from exc
out = tmp_path_factory.mktemp("google-form") / "order-form.html"
out.write_text(_render_for_browser(google=True))
out.write_text(_render(google=True, api_key=""))
with sync_playwright() as p:
try:

View file

@ -1,6 +1,6 @@
"""Unit tests for functions/submit_order/handler.py
All external dependencies (DynamoDB, SSM, Google tokeninfo, Lambda invoke) are
All external dependencies (DynamoDB, SSM, Secrets Manager, Lambda invoke) are
mocked — no real AWS calls are made.
"""
@ -18,6 +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_APIKEY_SM_NAME", "test/form-api-key")
os.environ.setdefault(
"SLACK_NOTIFIER_ARN",
"arn:aws:lambda:us-east-1:000000000000:function:test-slack-notifier",
@ -41,7 +42,8 @@ _spec.loader.exec_module(submit_order_handler)
# Helpers
# ---------------------------------------------------------------------------
EASTERN = ZoneInfo("America/New_York")
VALID_GOOGLE_CLIENT_ID = "test-google-client-id"
TEST_API_KEY = "test-api-key-12345"
VALID_GOOGLE_CLIENT_ID = "123456789.apps.googleusercontent.com"
def _make_event(
@ -69,9 +71,9 @@ def _make_event(
def _submit_event(
items,
api_key=TEST_API_KEY,
employee_name="Test User",
employee_email="test.user@seahavenind.com",
google_token="valid-google-token",
extra_body=None,
):
"""Shortcut for a typical POST /submit event with items."""
@ -80,14 +82,13 @@ def _submit_event(
"employee_email": employee_email,
"items": items,
}
if google_token is not None:
body["google_id_token"] = google_token
if extra_body:
body.update(extra_body)
return _make_event(
method="POST",
path="/submit",
body=body,
headers={"x-api-key": api_key},
)
@ -128,12 +129,13 @@ def _menu_doc_from_retail_pairs(pairs):
# ---------------------------------------------------------------------------
# We need to reset module-level caches between tests to avoid cross-test
# leakage. The handler module caches settings and the Google client ID.
# leakage. The handler module caches _api_key, _settings, _google_client_id.
@pytest.fixture(autouse=True)
def _reset_handler_caches():
"""Reset handler module-level caches before each test."""
submit_order_handler._api_key = None
submit_order_handler._settings = None
submit_order_handler._settings_ts = 0.0
submit_order_handler._google_client_id = None
@ -151,24 +153,6 @@ def _reset_shared_caches():
yield
@pytest.fixture(autouse=True)
def _mock_google_tokeninfo():
"""Return a valid company identity unless a test overrides tokeninfo."""
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(
{
"aud": VALID_GOOGLE_CLIENT_ID,
"hd": "seahavenind.com",
"name": "Test User",
"email": "test.user@seahavenind.com",
}
).encode()
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("submit_order_handler.urllib.request.urlopen", return_value=mock_resp):
yield
# ===========================================================================
# PRICING PIPELINE (Critical)
# ===========================================================================
@ -182,13 +166,13 @@ def _mock_google_tokeninfo():
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 50},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_discount_two_step_rounding(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -233,13 +217,13 @@ def test_discount_two_step_rounding(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 50, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_discount_rounding_half_up_boundary(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -279,13 +263,13 @@ def test_discount_rounding_half_up_boundary(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": -5, "company_subsidy_percent": 150},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_discount_clamping(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -325,13 +309,13 @@ def test_discount_clamping(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_discount_both_zero(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -374,13 +358,13 @@ def test_discount_both_zero(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 25},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_total_summation_multiple_items(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -433,11 +417,13 @@ def test_total_summation_multiple_items(
"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_parameter")
@patch("submit_order_handler.get_menu")
def test_missing_ssm_param_with_env_var_fails_closed(
mock_get_menu,
mock_get_parameter,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -474,6 +460,7 @@ def test_missing_ssm_param_with_env_var_fails_closed(
"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
)
@ -481,6 +468,7 @@ def test_missing_ssm_param_with_env_var_fails_closed(
def test_google_auth_required_when_configured(
mock_gac,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -492,7 +480,7 @@ def test_google_auth_required_when_configured(
items = _make_items([(10.00, 1)])
# Body has name/email but no google_id_token
event = _submit_event(items, google_token=None)
event = _submit_event(items)
result = lambda_handler(event, None)
status, body = _parse_response(result)
@ -511,6 +499,7 @@ def test_google_auth_required_when_configured(
"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
)
@ -518,6 +507,7 @@ def test_google_auth_required_when_configured(
def test_google_auth_bypass_prevention(
mock_gac,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -537,6 +527,7 @@ def test_google_auth_bypass_prevention(
method="POST",
path="/submit",
body=body,
headers={"x-api-key": TEST_API_KEY},
)
result = lambda_handler(event, None)
status, body_resp = _parse_response(result)
@ -553,6 +544,7 @@ def test_google_auth_bypass_prevention(
"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
)
@ -562,6 +554,7 @@ def test_google_token_audience_mismatch(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -602,6 +595,7 @@ def test_google_token_audience_mismatch(
"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
)
@ -611,6 +605,7 @@ def test_google_token_domain_mismatch(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -650,6 +645,7 @@ def test_google_token_domain_mismatch(
"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
)
@ -662,6 +658,7 @@ def test_google_token_service_unavailable(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -691,6 +688,7 @@ def test_google_token_service_unavailable(
"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
)
@ -709,6 +707,7 @@ def test_google_token_http_error_returns_403(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -740,6 +739,7 @@ def test_google_token_http_error_returns_403(
"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",
side_effect=Exception("ParameterNotFound"),
@ -748,6 +748,7 @@ def test_google_token_http_error_returns_403(
def test_ssm_failure_fails_closed(
mock_gac,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -779,6 +780,7 @@ def test_ssm_failure_fails_closed(
"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
)
@ -790,6 +792,7 @@ def test_google_token_valid_seahaven_com_domain(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -834,6 +837,7 @@ def test_google_token_valid_seahaven_com_domain(
"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
)
@ -845,6 +849,7 @@ def test_google_token_valid_success(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -898,18 +903,20 @@ def test_google_token_valid_success(
"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="")
@patch("submit_order_handler.get_menu")
def test_missing_google_client_id_fails_closed(
def test_manual_fallback_when_google_not_configured(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
mock_put,
mock_lam,
):
"""No Google client ID configured -> 503, never manual fallback."""
"""No Google client ID configured -> manual name/email accepted and order saved."""
from submit_order_handler import lambda_handler
items = _make_items([(12.00, 1)])
@ -920,8 +927,15 @@ def test_missing_google_client_id_fails_closed(
result = lambda_handler(event, None)
status, body = _parse_response(result)
assert status == 503, f"Expected fail-closed 503, got {status}: {body}"
mock_put.assert_not_called()
assert status == 200, f"Expected 200, got {status}: {body}"
saved_order = mock_put.call_args[0][2]
assert saved_order["employee_name"] == "Manual User", (
f"Name should be from manual input, got: {saved_order['employee_name']}"
)
assert saved_order["employee_email"] == "manual@seahavenind.com", (
f"Email should be from manual input, got: {saved_order['employee_email']}"
)
@patch("submit_order_handler._lambda")
@ -932,30 +946,24 @@ def test_missing_google_client_id_fails_closed(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu")
def test_submit_requires_no_api_key(
mock_get_menu,
mock_gcid,
mock_settings,
mock_week,
mock_status,
mock_put,
mock_lam,
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
def test_submit_invalid_api_key(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""A Google-authenticated submission succeeds without a shared API key."""
"""Wrong x-api-key -> 403."""
from submit_order_handler import lambda_handler
items = _make_items([(10.00, 1)])
mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
event = _submit_event(items)
event = _submit_event(items, api_key="wrong-api-key")
result = lambda_handler(event, None)
status, body = _parse_response(result)
assert status == 200, f"Expected 200 without API key, got {status}: {body}"
mock_put.assert_called_once()
assert status == 403, f"Expected 403 for invalid API key, got {status}: {body}"
assert "Invalid API key" in body["error"], (
f"Expected 'Invalid API key' in error, got: {body['error']}"
)
mock_put.assert_not_called()
# ===========================================================================
@ -971,21 +979,13 @@ def test_submit_requires_no_api_key(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch(
"submit_order_handler._verify_google_token",
return_value=(
{"name": "Adam Moussa", "email": "Adam.Moussa@seahavenind.com"},
"ok",
),
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_slug_from_email(
mock_get_menu,
mock_verify,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -1020,46 +1020,33 @@ def test_slug_from_email(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler._verify_google_token")
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_slug_edge_cases(
mock_get_menu,
mock_verify,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
mock_put,
mock_lam,
):
"""Slug uses the full lowercase Google email across allowed domains."""
"""Slug uses full lowercase email, preserving uniqueness across domains."""
from submit_order_handler import lambda_handler
mock_verify.side_effect = [
(
{
"name": "First Middle Last",
"email": "First.Middle.Last@seahavenind.com",
},
"ok",
),
({"name": "Bob Smith", "email": "Bob@seahaven.com"}, "ok"),
]
# Test 1: full email preserved
items = _make_items([(10.00, 1)])
mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
event = _submit_event(
items,
employee_name="First Middle Last",
employee_email="ignored@seahavenind.com",
employee_email="First.Middle.Last@x.com",
)
lambda_handler(event, None)
slug_1 = mock_put.call_args[0][1]
assert slug_1 == "first.middle.last@seahavenind.com", (
assert slug_1 == "first.middle.last@x.com", (
f"Slug should be full lowercase email, got '{slug_1}'"
)
@ -1067,11 +1054,11 @@ def test_slug_edge_cases(
mock_put.reset_mock()
mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
event = _submit_event(
items, employee_name="Ignored", employee_email="ignored@seahavenind.com"
items, employee_name="Bob Smith", employee_email="Bob@other.com"
)
lambda_handler(event, None)
slug_2 = mock_put.call_args[0][1]
assert slug_2 == "bob@seahaven.com", (
assert slug_2 == "bob@other.com", (
f"Slug should be full lowercase email, got '{slug_2}'"
)
assert slug_1 != slug_2, "Different emails must produce different slugs"
@ -1204,17 +1191,12 @@ def test_form_status_closed_saturday_before_dst_end(mock_week, mock_status):
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch(
"submit_order_handler._verify_google_token",
return_value=({"name": "", "email": "test@seahavenind.com"}, "ok"),
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
def test_submit_missing_name(
mock_verify, mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""A Google identity without a name -> 400."""
"""Empty employee name -> 400."""
from submit_order_handler import lambda_handler
items = _make_items([(10.00, 1)])
@ -1239,17 +1221,12 @@ def test_submit_missing_name(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch(
"submit_order_handler._verify_google_token",
return_value=({"name": "Test User", "email": ""}, "ok"),
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
def test_submit_missing_email(
mock_verify, mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""A Google identity without an email -> 400."""
"""Empty employee email -> 400."""
from submit_order_handler import lambda_handler
items = _make_items([(10.00, 1)])
@ -1272,11 +1249,10 @@ def test_submit_missing_email(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
def test_submit_zero_quantity_only(
mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""All items with quantity 0 -> 400."""
from submit_order_handler import lambda_handler
@ -1301,11 +1277,10 @@ def test_submit_zero_quantity_only(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
def test_submit_form_closed(
mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""Form closed -> 410."""
from submit_order_handler import lambda_handler
@ -1334,6 +1309,7 @@ def test_submit_form_closed(
"admin_emails": ["adam@seahavenind.com"],
},
)
@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
)
@ -1345,6 +1321,7 @@ def test_admin_can_submit_when_form_closed(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -1389,6 +1366,7 @@ def test_admin_can_submit_when_form_closed(
"admin_emails": ["adam@seahavenind.com"],
},
)
@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
)
@ -1400,6 +1378,7 @@ def test_non_admin_blocked_when_form_closed(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -1440,11 +1419,10 @@ def test_non_admin_blocked_when_form_closed(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
def test_submit_no_menu(
mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""No menu available -> 404."""
from submit_order_handler import lambda_handler
@ -1469,13 +1447,13 @@ def test_submit_no_menu(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_unknown_meal_returns_400(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -1505,13 +1483,13 @@ def test_unknown_meal_returns_400(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_retail_price_from_menu_not_request_body(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -1541,13 +1519,13 @@ def test_retail_price_from_menu_not_request_body(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_menu_missing_priced_meals_returns_503(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -1579,13 +1557,13 @@ def test_menu_missing_priced_meals_returns_503(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
)
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu")
def test_slack_failure_does_not_fail_order(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,