mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
Compare commits
No commits in common. "7e73bd2bfe52ad20d5e996e00d9977acb75fd0bb" and "adf175daefd03033516dd9c2bae5b66202d68044" have entirely different histories.
7e73bd2bfe
...
adf175daef
26 changed files with 1012 additions and 2226 deletions
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
18
.github/workflows/weekly-menu.yml
vendored
18
.github/workflows/weekly-menu.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -7,4 +7,3 @@ output/
|
|||
.DS_Store
|
||||
.aws-sam/
|
||||
samconfig.toml
|
||||
node_modules/
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.60
|
||||
boto3==1.43.56
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.60
|
||||
boto3==1.43.56
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.60
|
||||
boto3==1.43.56
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.60
|
||||
boto3==1.43.56
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.60
|
||||
boto3==1.43.56
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.60
|
||||
boto3==1.43.56
|
||||
|
|
|
|||
934
package-lock.json
generated
934
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
package.json
17
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
playwright==1.61.0
|
||||
flask==3.1.3
|
||||
boto3==1.43.60
|
||||
boto3==1.43.58
|
||||
jinja2==3.1.6
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
boto3==1.43.60
|
||||
boto3==1.43.56
|
||||
fpdf2==2.8.7
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue