Compare commits

...

16 commits

Author SHA1 Message Date
7e4bccc804
style(python): apply Ruff formatting 2026-08-03 14:24:54 -04:00
Adam Moussa
308f46eafd
fix: address review comments 2026-08-03 14:22:00 -04:00
be0cfce943
refactor(workflow): publish weekly menus through API
Use SigV4 requests for settings and menu publication so the scheduled workflow no longer needs direct DynamoDB access.
2026-08-03 14:22:00 -04:00
6ebe9f282c
feat(api): add IAM-authenticated menu publication
Keep weekly menu writes behind Lambda so the GitHub runtime role cannot access the shared DynamoDB table directly.
2026-08-03 14:22:00 -04:00
Adam Moussa
7e73bd2bfe
test(form): stabilize mobile admin assertions (#93)
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-08-03 14:21:10 -04:00
Adam Moussa
bcf255b17f
chore(form): add template JavaScript checks (#92)
* fix(auth): require Google authentication in cloud mode

Remove the public shared-key mechanism and fail closed on Google auth while adding submit-route throttling.

* chore(form): lint template JavaScript in CI

* docs(form): record frontend delivery decisions

* fix: resolve remaining merge conflicts
2026-08-03 14:15:25 -04:00
Adam Moussa
311eab35c0
fix(auth): require Google authentication in cloud mode (#90)
* fix(auth): require Google authentication in cloud mode

Remove the public shared-key mechanism and fail closed on Google auth while adding submit-route throttling.

* fix(auth): address review follow-ups

Fail closed on whitespace-only Google configuration and centralize shared authentication behavior.

* test(auth): use non-secret Google client fixture

Make the public test identifier explicit so secret scanning does not misclassify it as an API key.

* test(auth): avoid OAuth-shaped fixture

Use a format-neutral audience value so secret scanning can distinguish the fixture from a real client identifier.

* chore(security): suppress public OAuth fixture

Document the scanner false positive without suppressing any runtime credential flow.
2026-08-03 13:51:12 -04:00
dependabot[bot]
6968a0299b
chore(deps): bump the minor-and-patch group with 4 updates (#96)
Bumps the minor-and-patch group with 4 updates: [Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml](https://github.com/sea-haven-industries/.github) and [Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml](https://github.com/sea-haven-industries/.github).


Updates `Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 13:39:41 -04:00
dependabot[bot]
a55c56f0ab
chore(deps): bump boto3 in /src/shared in the minor-and-patch group (#104)
Bumps the minor-and-patch group in /src/shared with 1 update: [boto3](https://github.com/boto/boto3).


Updates `boto3` from 1.43.56 to 1.43.60
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.56...1.43.60)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 13:39:40 -04:00
dependabot[bot]
182f612a18
chore(deps): bump boto3 in the minor-and-patch group (#103)
Bumps the minor-and-patch group with 1 update: [boto3](https://github.com/boto/boto3).


Updates `boto3` from 1.43.58 to 1.43.60
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.58...1.43.60)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 13:39:40 -04:00
dependabot[bot]
88373a34ff
chore(deps): bump boto3 (#102)
Bumps the minor-and-patch group in /functions/slack_notifier with 1 update: [boto3](https://github.com/boto/boto3).


Updates `boto3` from 1.43.56 to 1.43.60
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.56...1.43.60)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 13:39:40 -04:00
dependabot[bot]
d96424b11d
chore(deps): bump boto3 (#101)
Bumps the minor-and-patch group in /functions/email_report with 1 update: [boto3](https://github.com/boto/boto3).


Updates `boto3` from 1.43.56 to 1.43.60
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.56...1.43.60)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 13:39:39 -04:00
dependabot[bot]
468d68fa02
chore(deps): bump boto3 (#100)
Bumps the minor-and-patch group in /functions/close_form with 1 update: [boto3](https://github.com/boto/boto3).


Updates `boto3` from 1.43.56 to 1.43.60
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.56...1.43.60)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 13:34:16 -04:00
dependabot[bot]
16d7920ac6
chore(deps): bump boto3 (#98)
Bumps the minor-and-patch group in /functions/submit_order with 1 update: [boto3](https://github.com/boto/boto3).


Updates `boto3` from 1.43.56 to 1.43.60
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.56...1.43.60)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 13:33:05 -04:00
dependabot[bot]
4584b8fe50
chore(deps): bump boto3 (#99)
Bumps the minor-and-patch group in /functions/admin_authorizer with 1 update: [boto3](https://github.com/boto/boto3).


Updates `boto3` from 1.43.56 to 1.43.60
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.56...1.43.60)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 13:28:21 -04:00
dependabot[bot]
243dd7323f
chore(deps): bump boto3 (#97)
Bumps the minor-and-patch group in /functions/aggregate_orders with 1 update: [boto3](https://github.com/boto/boto3).


Updates `boto3` from 1.43.56 to 1.43.60
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.56...1.43.60)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 13:26:26 -04:00
28 changed files with 2553 additions and 1095 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -88,36 +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
run: |
RESULT=$(aws dynamodb get-item \
--table-name meal-order-manager-orders \
--key '{"PK":{"S":"CONFIG"},"SK":{"S":"SETTINGS"}}' \
--output json 2>/dev/null || echo '{}')
BULK=$(echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('Item',{}).get('bulk_discount_percent',{}).get('N','0'))
" 2>/dev/null || echo "0")
SUBSIDY=$(echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('Item',{}).get('company_subsidy_percent',{}).get('N','0'))
" 2>/dev/null || echo "0")
echo "bulk_discount=$BULK" >> $GITHUB_OUTPUT
echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT
SETTINGS=$(python3 scripts/upload_menu.py settings \
--api-url "${{ steps.stack.outputs.api_url }}")
BULK=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["bulk_discount_percent"])' "$SETTINGS")
SUBSIDY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["company_subsidy_percent"])' "$SETTINGS")
echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT"
echo "company_subsidy=$SUBSIDY" >> "$GITHUB_OUTPUT"
- name: Get Google Client ID
if: env.SKIP_RUN != 'true'
@ -126,9 +106,10 @@ jobs:
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
--name /meal-order-manager/google-client-id \
--query 'Parameter.Value' \
--output text 2>/dev/null || echo "")
--output text)
if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then
GOOGLE_CLIENT_ID=""
echo "Google client ID is required for cloud form generation" >&2
exit 1
fi
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
@ -137,14 +118,15 @@ 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 }}" \
${{ steps.google.outputs.client_id && format('--google-client-id "{0}"', steps.google.outputs.client_id) || '' }}
--google-client-id "${{ steps.google.outputs.client_id }}"
- name: Upload menu to DynamoDB
- name: Publish menu through API
if: env.SKIP_RUN != 'true'
run: python3 scripts/upload_menu.py
run: |
python3 scripts/upload_menu.py publish \
--api-url "${{ steps.stack.outputs.api_url }}"
- name: Upload form to S3
if: env.SKIP_RUN != 'true'

1
.gitignore vendored
View file

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

View file

@ -2,7 +2,7 @@
"suppressions": [
{
"id": "gitleaks-generic-api-key-45",
"justification": "False positive. tests/test_submit_order.py: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."
"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."
}
]
}

View file

@ -14,7 +14,7 @@ Monday 7:30am ET Employees (Mon–Thu) Thursda
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ GitHub Actions │ │ orders.seahaven │ │ EventBridge │
│ - Scrape menu │────S3 upload───▶│ ind.com │ │ - Close form │
│ - Generate form │ + DynamoDB │ (CloudFront+S3) │──POST───┐ │ - Aggregate │
│ - Generate form │ + signed API │ (CloudFront+S3) │──POST───┐ │ - Aggregate │
│ - Slack notify │ └──────────────────┘ │ │ - Slack summary │
└─────────────────┘ ▼ └──────────────────┘
┌──────────┐
@ -30,6 +30,15 @@ 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 |
@ -41,6 +50,25 @@ who haven't ordered
| Thursday 10am ET | DM employees who haven't ordered yet | EventBridge → Lambda → Slack DM |
| Thursday 6pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge → Lambda chain |
### Weekly menu publication boundary
The scheduled GitHub workflow has no DynamoDB permissions. It signs two requests
with its short-lived OIDC role credentials:
- `GET /api/publish/settings` returns only the bulk discount and company subsidy.
- `POST /api/publish/menu` validates and writes the current Eastern-time week's menu.
Both routes use API Gateway `AWS_IAM` authorization and invoke the existing
submit-order Lambda. The GitHub role can invoke only these method and path
combinations. Employee orders, the roster, admin configuration, and all direct
DynamoDB actions remain inaccessible to the role.
Deploy the API routes before switching the workflow and IAM policy. No data
migration is required because the Lambda writes the existing `WEEK#...` / `MENU`
record shape. To roll back, restore the previous workflow and its DynamoDB policy
together; restoring only the policy does not make the API-based workflow depend on
DynamoDB access.
### Reports (written to `meal-order-manager-reports-*` at Thursday close)
| Key | Contents |
@ -59,7 +87,8 @@ 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, form API key
- **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.
- **SES** — payroll deduction emails
- **CloudWatch Alarms** — coverage across the stack, all notifying the shared `site-alerts` SNS topic (see Monitoring)
@ -80,11 +109,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).
(~3000ms). The submit route is limited to 5 requests/second with a burst of 10.
## Authentication
Google Identity Services (OAuth) with tokeninfo endpoint verification. Accepts both `seahavenind.com` and `seahaven.com` Google Workspace domains.
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.
## Admin Panel
@ -137,7 +166,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. Create the form API key secret: `aws secretsmanager create-secret --name meal-order-manager/form-api-key --secret-string "$(openssl rand -hex 32)"`
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`
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
@ -160,6 +189,7 @@ 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

20
eslint.config.mjs Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,9 +24,10 @@ from shared.db import (
get_settings,
get_summary,
list_weeks,
put_menu,
put_order,
)
from shared.secrets import get_parameter, get_secret
from shared.secrets import get_parameter
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
@ -44,7 +45,6 @@ def _eastern_now() -> _dt.datetime:
return _dt.datetime.now(EASTERN)
_api_key = None
_settings = None
_settings_ts = 0.0
_google_client_id = None
@ -53,13 +53,6 @@ _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()
@ -73,6 +66,10 @@ 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", ""))
@ -155,6 +152,10 @@ 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,14 +172,11 @@ def _verify_admin(event) -> tuple[dict | None, dict | None]:
user_info, status = _verify_google_token(token)
if status == "unavailable":
return None, response(
503, {"error": "Authentication service temporarily unavailable"}
)
return None, _authentication_service_unavailable()
if user_info is None:
return None, response(403, {"error": "Invalid or unauthorized Google account"})
admin_emails = {e.lower() for e in get_settings().get("admin_emails", [])}
if user_info["email"].lower() not in admin_emails:
if user_info["email"].lower() not in _get_admin_emails():
return None, response(403, {"error": "Admin access required"})
return user_info, None
@ -188,6 +186,12 @@ def lambda_handler(event, context):
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
path = event.get("rawPath", "")
if path == "/api/publish/settings" and method == "GET":
return handle_publish_settings()
if path == "/api/publish/menu" and method == "POST":
return handle_publish_menu(event)
if "/admin/orders" in path:
if method == "DELETE":
return handle_admin_delete(event)
@ -210,6 +214,62 @@ def lambda_handler(event, context):
return response(405, {"error": "Method not allowed"})
def handle_publish_settings():
"""Return only the pricing fields needed by the weekly-menu workflow."""
settings = get_settings()
return response(
200,
{
"bulk_discount_percent": float(settings.get("bulk_discount_percent", 0)),
"company_subsidy_percent": float(
settings.get("company_subsidy_percent", 0)
),
},
)
def handle_publish_menu(event):
"""Persist a scraped menu for the current Eastern-time week."""
try:
body = json.loads(event.get("body", "{}"))
except json.JSONDecodeError:
return response(400, {"error": "Invalid JSON"})
if not isinstance(body, dict):
return response(400, {"error": "Request body must be a JSON object"})
meals = body.get("meals")
if not isinstance(meals, list) or not meals:
return response(400, {"error": "At least one meal is required"})
for meal in meals:
price = meal.get("price") if isinstance(meal, dict) else None
if (
not isinstance(meal, dict)
or not str(meal.get("name", "")).strip()
or isinstance(price, bool)
or not isinstance(price, (int, float))
or price < 0
):
return response(
400, {"error": "Each meal requires a name and non-negative price"}
)
week = current_week()
menu = {
"scraped_at": body.get("scraped_at"),
"menu_url": body.get("menu_url"),
"meal_count": len(meals),
"meals": meals,
}
put_menu(week, menu)
logger.info("Published %s meals for %s", len(meals), week)
return response(
200,
{"status": "published", "week": week, "meal_count": len(meals)},
)
def handle_admin_orders(event):
user, err = _verify_admin(event)
if err:
@ -417,48 +477,32 @@ 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"})
# --- 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).
# The Lambda is the cloud submission path, so Google authentication is always
# required. Local/manual submissions are handled only by src/server/app.py.
google_token = body.get("google_id_token")
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()
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"]
items = body.get("items", [])
@ -471,10 +515,7 @@ def handle_submit(event):
week = current_week()
status = get_form_status(week)
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
is_admin_user = email.lower() in _get_admin_emails()
if status == "closed" and not is_admin_user:
return response(410, {"error": "Orders are closed for this week"})
if status == "not_found":

View file

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

934
package-lock.json generated Normal file
View file

@ -0,0 +1,934 @@
{
"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 Normal file
View file

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

View file

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

View file

@ -1,60 +1,102 @@
"""
Uploads the latest scraped menu JSON to DynamoDB.
Used by the GitHub Actions weekly workflow after scraping.
"""
"""Call the IAM-protected weekly-menu publication API with SigV4."""
import argparse
import json
import os
import sys
from datetime import datetime
from decimal import Decimal
import urllib.error
import urllib.request
from pathlib import Path
import boto3
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
OUTPUT_DIR = PROJECT_ROOT / "output"
TABLE_NAME = os.environ.get("TABLE_NAME", "meal-order-manager-orders")
def convert_floats(obj):
if isinstance(obj, float):
return Decimal(str(obj))
if isinstance(obj, dict):
return {k: convert_floats(v) for k, v in obj.items()}
if isinstance(obj, list):
return [convert_floats(i) for i in obj]
return obj
def signed_request(
api_url: str,
path: str,
method: str = "GET",
body: dict | None = None,
region: str = "us-east-1",
) -> dict:
if not api_url.startswith(("http://", "https://")):
raise ValueError(f"api_url must use http(s) scheme: {api_url!r}")
data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"} if data is not None else {}
request = AWSRequest(
method=method,
url=f"{api_url.rstrip('/')}{path}",
data=data,
headers=headers,
)
credentials = boto3.Session().get_credentials()
if credentials is None:
raise RuntimeError("AWS credentials are required")
SigV4Auth(credentials.get_frozen_credentials(), "execute-api", region).add_auth(
request
)
prepared = request.prepare()
http_request = urllib.request.Request(
prepared.url,
data=prepared.body,
headers=dict(prepared.headers),
method=method,
)
try:
with urllib.request.urlopen(http_request, timeout=30) as response:
return json.loads(response.read())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
raise RuntimeError(
f"Publication API returned HTTP {exc.code}: {detail}"
) from exc
def get_settings(api_url: str, region: str) -> dict:
return signed_request(api_url, "/api/publish/settings", method="GET", region=region)
def publish_menu(api_url: str, region: str) -> dict:
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
if not files:
raise RuntimeError("No menu JSON found. Run scrape_menu.py first.")
with files[0].open() as menu_file:
menu = json.load(menu_file)
return signed_request(
api_url,
"/api/publish/menu",
method="POST",
body=menu,
region=region,
)
def main():
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
if not files:
print("Error: No menu JSON found. Run scrape_menu.py first.", file=sys.stderr)
sys.exit(1)
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=("settings", "publish"))
parser.add_argument("--api-url", required=True)
parser.add_argument("--region", default=os.environ.get("AWS_REGION", "us-east-1"))
args = parser.parse_args()
with open(files[0]) as f:
menu = json.load(f)
try:
result = (
get_settings(args.api_url, args.region)
if args.action == "settings"
else publish_menu(args.api_url, args.region)
)
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
week = datetime.now().strftime("%Y-W%U")
table = boto3.resource("dynamodb").Table(TABLE_NAME)
import time
item = {
"PK": f"WEEK#{week}",
"SK": "MENU",
"form_status": "open",
"scraped_at": menu.get("scraped_at"),
"menu_url": menu.get("menu_url"),
"meal_count": menu.get("meal_count"),
"meals": convert_floats(menu.get("meals", [])),
"ttl": int(time.time()) + (90 * 86400),
}
table.put_item(Item=item)
print(f"Uploaded menu for {week} to DynamoDB ({len(menu.get('meals', []))} meals)")
print(json.dumps(result))
return 0
if __name__ == "__main__":
main()
sys.exit(main())

View file

@ -6,7 +6,7 @@ and submits orders to the backend (local Flask or cloud API Gateway).
Usage:
python3 generate_form.py # local mode
python3 generate_form.py --api-url URL --api-key KEY # cloud mode
python3 generate_form.py --api-url URL --google-client-id ID # cloud mode
"""
import argparse
@ -51,11 +51,14 @@ 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")
@ -79,7 +82,6 @@ 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 "",
@ -116,9 +118,6 @@ 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,
@ -151,22 +150,28 @@ 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", "")
if not google_client_id:
google_client_id = (
args.google_client_id or config.get("google_client_id", "")
).strip()
if args.api_url and 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"]
except Exception:
pass
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")
html = generate_form(
menu,
config,
api_url=args.api_url,
api_key=args.api_key,
bulk_discount=bulk_discount,
company_subsidy=company_subsidy,
google_client_id=google_client_id,

File diff suppressed because it is too large Load diff

View file

@ -7,11 +7,10 @@ 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;
@ -35,33 +34,35 @@ 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 || "";
}
}
@ -80,16 +81,17 @@ 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.");
}
}
@ -99,30 +101,35 @@ 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();
@ -134,37 +141,49 @@ 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;
}
@ -172,37 +191,45 @@ 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);
});
@ -210,25 +237,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);
}
@ -242,57 +269,59 @@ 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;
@ -300,9 +329,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;
@ -320,8 +349,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;
}
@ -345,29 +374,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));
});
@ -380,100 +409,118 @@ 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,
method: "POST",
headers: { "Content-Type": "application/json" },
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;
@ -488,43 +535,55 @@ 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);
@ -535,17 +594,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();
@ -557,62 +616,71 @@ 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) {
@ -630,24 +698,36 @@ 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() {
@ -655,12 +735,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();
}
}
@ -669,7 +749,7 @@ async function checkFormStatus() {
function init() {
checkFormStatus();
if (CONFIG.authMode === 'google') {
if (CONFIG.authMode === "google") {
waitForGoogleAuth();
} else {
loadRoster();
@ -677,39 +757,40 @@ function init() {
}
const tags = new Set();
MEALS.forEach(m => (m.dietary_tags || []).forEach(t => tags.add(t)));
const cats = ['All Meals', ...Array.from(tags).sort()];
const filtersEl = document.getElementById('filters');
cats.forEach(cat => {
const btn = document.createElement('button');
btn.type = 'button';
const isActive = cat === 'All Meals';
btn.className = 'btn btn-chip' + (isActive ? ' active' : '');
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
MEALS.forEach((m) => (m.dietary_tags || []).forEach((t) => tags.add(t)));
const cats = ["All Meals", ...Array.from(tags).sort()];
const filtersEl = document.getElementById("filters");
cats.forEach((cat) => {
const btn = document.createElement("button");
btn.type = "button";
const isActive = cat === "All Meals";
btn.className = "btn btn-chip" + (isActive ? " active" : "");
btn.setAttribute("aria-pressed", isActive ? "true" : "false");
btn.textContent = cat;
btn.addEventListener('click', () => {
filtersEl.querySelectorAll('button').forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-pressed', 'false');
btn.addEventListener("click", () => {
filtersEl.querySelectorAll("button").forEach((b) => {
b.classList.remove("active");
b.setAttribute("aria-pressed", "false");
});
btn.classList.add('active');
btn.setAttribute('aria-pressed', 'true');
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
renderMeals();
});
filtersEl.appendChild(btn);
});
document.getElementById('search').addEventListener('input', renderMeals);
document.getElementById('submit-btn').addEventListener('click', submitOrder);
document.getElementById("search").addEventListener("input", renderMeals);
document.getElementById("submit-btn").addEventListener("click", submitOrder);
const adminBtn = document.getElementById('admin-btn');
if (adminBtn) adminBtn.addEventListener('click', showAdmin);
const adminBtn = document.getElementById("admin-btn");
if (adminBtn) adminBtn.addEventListener("click", showAdmin);
const signOutBtn = document.getElementById('sign-out-btn');
if (signOutBtn) signOutBtn.addEventListener('click', signOut);
const signOutBtn = document.getElementById("sign-out-btn");
if (signOutBtn) signOutBtn.addEventListener("click", signOut);
const successBackBtn = document.getElementById('success-back-btn');
if (successBackBtn) successBackBtn.addEventListener('click', () => location.reload());
const successBackBtn = document.getElementById("success-back-btn");
if (successBackBtn)
successBackBtn.addEventListener("click", () => location.reload());
bindMealsListDelegation();
renderMeals();

View file

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

View file

@ -34,16 +34,18 @@ def ttl_days(days: int) -> int:
def put_menu(week: str, menu_data: dict):
_get_table().put_item(
Item={
"PK": f"WEEK#{week}",
"SK": "MENU",
"form_status": "open",
"scraped_at": menu_data.get("scraped_at"),
"menu_url": menu_data.get("menu_url"),
"meal_count": menu_data.get("meal_count"),
"meals": menu_data.get("meals"),
"ttl": ttl_days(90),
}
Item=_to_decimal(
{
"PK": f"WEEK#{week}",
"SK": "MENU",
"form_status": "open",
"scraped_at": menu_data.get("scraped_at"),
"menu_url": menu_data.get("menu_url"),
"meal_count": menu_data.get("meal_count"),
"meals": menu_data.get("meals"),
"ttl": ttl_days(90),
}
)
)

View file

@ -220,8 +220,9 @@ Resources:
# authorizer validates the same Google ID token (Authorization: Bearer)
# the admin panel already sends, so admin routes are no longer
# AuthorizationType NONE. Public routes (submit-order, form-status,
# roster) stay open — they're explicitly set to NONE on their events.
# roster) stay open, while weekly-menu publication routes opt into IAM.
Auth:
EnableIamAuthorizer: true
Authorizers:
AdminGoogleAuthorizer:
FunctionArn: !GetAtt AdminAuthorizerFunction.Arn
@ -244,6 +245,13 @@ Resources:
DefaultRouteSettings:
ThrottlingBurstLimit: 50
ThrottlingRateLimit: 100
RouteSettings:
'POST /api/submit-order':
ThrottlingBurstLimit: 10
ThrottlingRateLimit: 5
'POST /api/publish/menu':
ThrottlingBurstLimit: 2
ThrottlingRateLimit: 1
# 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:
@ -260,7 +268,6 @@ Resources:
- OPTIONS
AllowHeaders:
- Content-Type
- x-api-key
- Authorization
MaxAge: 3600
@ -276,7 +283,6 @@ 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
@ -284,9 +290,6 @@ 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
@ -318,6 +321,22 @@ Resources:
ApiId: !Ref OrderApi
Path: /api/roster
Method: GET
PublishSettings:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/publish/settings
Method: GET
Auth:
Authorizer: AWS_IAM
PublishMenu:
Type: HttpApi
Properties:
ApiId: !Ref OrderApi
Path: /api/publish/menu
Method: POST
Auth:
Authorizer: AWS_IAM
AdminOrders:
Type: HttpApi
Properties:

View file

@ -7,6 +7,7 @@ import json
import re
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
@ -16,7 +17,9 @@ FIXTURES = Path(__file__).resolve().parent / "fixtures"
sys.path.insert(0, str(SERVER_DIR))
from generate_form import generate_form # noqa: E402
import generate_form as generate_form_module # noqa: E402
generate_form = generate_form_module.generate_form
EXPECTED_CONFIG_KEYS = {
"authMode",
@ -28,7 +31,6 @@ EXPECTED_CONFIG_KEYS = {
"rosterUrl",
"adminUrl",
"adminPdfUrl",
"apiKey",
"bulkDiscount",
"companySubsidy",
"googleClientId",
@ -54,19 +56,25 @@ def _extract_config(html: str) -> dict:
return json.loads(match.group(1))
def _render(*, google: bool = False, api_key: str = "test-key") -> str:
def _render(*, google: bool = False) -> str:
menu, config = _load_fixtures()
return generate_form(
menu,
config,
api_url="https://api.example.com",
api_key=api_key,
api_url="https://api.example.com" if google else "",
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(
{
@ -112,6 +120,86 @@ 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_weekly_menu_uses_iam_protected_api_without_dynamodb(self):
template = (REPO_ROOT / "template.yaml").read_text()
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text()
for route in ("/api/publish/settings", "/api/publish/menu"):
next_event = (
" PublishMenu:"
if route == "/api/publish/settings"
else " AdminOrders:"
)
route_config = template.split(f"Path: {route}", 1)[1].split(next_event, 1)[
0
]
assert "Authorizer: AWS_IAM" in route_config
assert route in script
assert "scripts/upload_menu.py settings" in workflow
assert "scripts/upload_menu.py publish" in workflow
assert "aws dynamodb" not in workflow
assert 'boto3.resource("dynamodb")' not in script
def test_local_and_google_render(self):
local = _render(google=False)
google = _render(google=True)
@ -291,7 +379,7 @@ class TestGenerateFormPlaywright:
sync_api = pytest.importorskip("playwright.sync_api")
sync_playwright = sync_api.sync_playwright
html = _render(google=False, api_key="")
html = _render_for_browser(google=False)
# 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"
@ -321,7 +409,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(google=google, api_key=""))
html_path.write_text(_render_for_browser(google=google))
with sync_playwright() as p:
try:
@ -600,7 +688,7 @@ class TestGenerateFormPlaywright:
@pytest.fixture
def mobile_admin_page(self, tmp_path, browser_page):
html = _render(google=False, api_key="")
html = _render_for_browser(google=False)
out = tmp_path / "admin-order-form.html"
out.write_text(html)
hostile_name = """O'Reilly "<b>Ops</b>\""""
@ -733,6 +821,9 @@ 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"
@ -761,10 +852,12 @@ 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()
page.wait_for_function(
"() => document.querySelector('.admin-order-card')"
'.textContent.includes("Luis\' Lomo Saltado ×2")' # noqa: RUF001
saved_card = page.locator(
".admin-order-card",
has_text="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
@ -845,7 +938,7 @@ class TestGenerateFormGooglePlaywright:
) from exc
out = tmp_path_factory.mktemp("google-form") / "order-form.html"
out.write_text(_render(google=True, api_key=""))
out.write_text(_render_for_browser(google=True))
with sync_playwright() as p:
try:

View file

@ -1,6 +1,6 @@
"""Unit tests for functions/submit_order/handler.py
All external dependencies (DynamoDB, SSM, Secrets Manager, Lambda invoke) are
All external dependencies (DynamoDB, SSM, Google tokeninfo, Lambda invoke) are
mocked — no real AWS calls are made.
"""
@ -18,7 +18,6 @@ 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",
@ -42,8 +41,7 @@ _spec.loader.exec_module(submit_order_handler)
# Helpers
# ---------------------------------------------------------------------------
EASTERN = ZoneInfo("America/New_York")
TEST_API_KEY = "test-api-key-12345"
VALID_GOOGLE_CLIENT_ID = "123456789.apps.googleusercontent.com"
VALID_GOOGLE_CLIENT_ID = "test-google-client-id"
def _make_event(
@ -71,9 +69,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."""
@ -82,13 +80,14 @@ 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},
)
@ -121,6 +120,90 @@ def _menu_doc_from_retail_pairs(pairs):
}
# ===========================================================================
# WEEKLY MENU PUBLICATION
# ===========================================================================
@patch(
"submit_order_handler.get_settings",
return_value={
"bulk_discount_percent": 10,
"company_subsidy_percent": 50,
"admin_emails": ["admin@seahavenind.com"],
},
)
def test_publish_settings_returns_only_pricing(mock_settings):
result = submit_order_handler.lambda_handler(
_make_event(method="GET", path="/api/publish/settings"), None
)
status, body = _parse_response(result)
assert status == 200
assert body == {
"bulk_discount_percent": 10.0,
"company_subsidy_percent": 50.0,
}
@patch("submit_order_handler.put_menu")
@patch("submit_order_handler.current_week", return_value="2026-W30")
def test_publish_menu_uses_current_week_and_server_meal_count(mock_week, mock_put):
menu = {
"scraped_at": "2026-07-27T07:31:00-04:00",
"menu_url": "https://example.com/menu",
"meal_count": 999,
"meals": [{"name": "Chicken Bowl", "price": 12.5}],
}
result = submit_order_handler.lambda_handler(
_make_event(method="POST", path="/api/publish/menu", body=menu), None
)
status, body = _parse_response(result)
assert status == 200
assert body == {"status": "published", "week": "2026-W30", "meal_count": 1}
mock_put.assert_called_once_with(
"2026-W30",
{
"scraped_at": menu["scraped_at"],
"menu_url": menu["menu_url"],
"meal_count": 1,
"meals": menu["meals"],
},
)
@pytest.mark.parametrize(
"body",
[
{},
{"meals": []},
{"meals": [{"name": "", "price": 12.5}]},
{"meals": [{"name": "Chicken Bowl", "price": -1}]},
{"meals": [{"name": "Chicken Bowl", "price": "12.50"}]},
],
)
@patch("submit_order_handler.put_menu")
def test_publish_menu_rejects_invalid_payload(mock_put, body):
result = submit_order_handler.lambda_handler(
_make_event(method="POST", path="/api/publish/menu", body=body), None
)
status, _ = _parse_response(result)
assert status == 400
mock_put.assert_not_called()
@patch("submit_order_handler.put_menu")
def test_publish_menu_rejects_invalid_json(mock_put):
event = _make_event(method="POST", path="/api/publish/menu")
event["body"] = "{"
status, _ = _parse_response(submit_order_handler.lambda_handler(event, None))
assert status == 400
mock_put.assert_not_called()
# ---------------------------------------------------------------------------
# Module-level patches that must be active before the handler is imported
# ---------------------------------------------------------------------------
@ -129,13 +212,12 @@ 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 _api_key, _settings, _google_client_id.
# leakage. The handler module caches settings and the 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
@ -153,6 +235,24 @@ 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)
# ===========================================================================
@ -166,13 +266,13 @@ def _reset_shared_caches():
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 50},
)
@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_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@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,
@ -217,13 +317,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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@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,
@ -263,13 +363,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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu")
def test_discount_clamping(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -309,13 +409,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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu")
def test_discount_both_zero(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -358,13 +458,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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@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,
@ -417,13 +517,11 @@ 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,
@ -460,7 +558,6 @@ 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
)
@ -468,7 +565,6 @@ 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,
@ -480,7 +576,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)
event = _submit_event(items, google_token=None)
result = lambda_handler(event, None)
status, body = _parse_response(result)
@ -499,7 +595,6 @@ 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
)
@ -507,7 +602,6 @@ 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,
@ -527,7 +621,6 @@ 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)
@ -544,7 +637,6 @@ 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
)
@ -554,7 +646,6 @@ def test_google_token_audience_mismatch(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -595,7 +686,6 @@ 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
)
@ -605,7 +695,6 @@ def test_google_token_domain_mismatch(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -645,7 +734,6 @@ 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
)
@ -658,7 +746,6 @@ def test_google_token_service_unavailable(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -688,7 +775,6 @@ 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
)
@ -707,7 +793,6 @@ def test_google_token_http_error_returns_403(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -739,7 +824,6 @@ 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,7 +832,6 @@ 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,
@ -780,7 +863,6 @@ 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
)
@ -792,7 +874,6 @@ def test_google_token_valid_seahaven_com_domain(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -837,7 +918,6 @@ 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
)
@ -849,7 +929,6 @@ def test_google_token_valid_success(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -903,20 +982,18 @@ 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_manual_fallback_when_google_not_configured(
def test_missing_google_client_id_fails_closed(
mock_get_menu,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
mock_put,
mock_lam,
):
"""No Google client ID configured -> manual name/email accepted and order saved."""
"""No Google client ID configured -> 503, never manual fallback."""
from submit_order_handler import lambda_handler
items = _make_items([(12.00, 1)])
@ -927,15 +1004,8 @@ def test_manual_fallback_when_google_not_configured(
result = lambda_handler(event, None)
status, body = _parse_response(result)
assert status == 200, f"Expected 200, got {status}: {body}"
saved_order = mock_put.call_args[0][2]
assert saved_order["employee_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']}"
)
assert status == 503, f"Expected fail-closed 503, got {status}: {body}"
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@ -946,24 +1016,30 @@ def test_manual_fallback_when_google_not_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="")
def test_submit_invalid_api_key(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
@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,
):
"""Wrong x-api-key -> 403."""
"""A Google-authenticated submission succeeds without a shared API key."""
from submit_order_handler import lambda_handler
items = _make_items([(10.00, 1)])
event = _submit_event(items, api_key="wrong-api-key")
mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
event = _submit_event(items)
result = lambda_handler(event, None)
status, body = _parse_response(result)
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()
assert status == 200, f"Expected 200 without API key, got {status}: {body}"
mock_put.assert_called_once()
# ===========================================================================
@ -979,13 +1055,21 @@ def test_submit_invalid_api_key(
"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_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_menu")
def test_slug_from_email(
mock_get_menu,
mock_verify,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -1020,33 +1104,46 @@ 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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@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_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 full lowercase email, preserving uniqueness across domains."""
"""Slug uses the full lowercase Google email across allowed 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="First.Middle.Last@x.com",
employee_email="ignored@seahavenind.com",
)
lambda_handler(event, None)
slug_1 = mock_put.call_args[0][1]
assert slug_1 == "first.middle.last@x.com", (
assert slug_1 == "first.middle.last@seahavenind.com", (
f"Slug should be full lowercase email, got '{slug_1}'"
)
@ -1054,11 +1151,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="Bob Smith", employee_email="Bob@other.com"
items, employee_name="Ignored", employee_email="ignored@seahavenind.com"
)
lambda_handler(event, None)
slug_2 = mock_put.call_args[0][1]
assert slug_2 == "bob@other.com", (
assert slug_2 == "bob@seahaven.com", (
f"Slug should be full lowercase email, got '{slug_2}'"
)
assert slug_1 != slug_2, "Different emails must produce different slugs"
@ -1191,12 +1288,17 @@ 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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@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"),
)
def test_submit_missing_name(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
mock_verify, mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""Empty employee name -> 400."""
"""A Google identity without a name -> 400."""
from submit_order_handler import lambda_handler
items = _make_items([(10.00, 1)])
@ -1221,12 +1323,17 @@ 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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@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"),
)
def test_submit_missing_email(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
mock_verify, mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""Empty employee email -> 400."""
"""A Google identity without an email -> 400."""
from submit_order_handler import lambda_handler
items = _make_items([(10.00, 1)])
@ -1249,10 +1356,11 @@ 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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
def test_submit_zero_quantity_only(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""All items with quantity 0 -> 400."""
from submit_order_handler import lambda_handler
@ -1277,10 +1385,11 @@ 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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
def test_submit_form_closed(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""Form closed -> 410."""
from submit_order_handler import lambda_handler
@ -1309,7 +1418,6 @@ 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
)
@ -1321,7 +1429,6 @@ def test_admin_can_submit_when_form_closed(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -1366,7 +1473,6 @@ 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
)
@ -1378,7 +1484,6 @@ def test_non_admin_blocked_when_form_closed(
mock_gac,
mock_urlopen,
mock_gcid,
mock_secret,
mock_settings,
mock_week,
mock_status,
@ -1419,10 +1524,11 @@ 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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
def test_submit_no_menu(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
):
"""No menu available -> 404."""
from submit_order_handler import lambda_handler
@ -1447,13 +1553,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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@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,
@ -1483,13 +1589,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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@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,
@ -1519,13 +1625,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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@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,
@ -1557,13 +1663,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_secret", return_value=TEST_API_KEY)
@patch("submit_order_handler._get_google_client_id", return_value="")
@patch(
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@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,