mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 04:13:12 +00:00
Some checks failed
Deploy / deploy (push) Has been cancelled
* Add discount settings and two-tier pricing to order aggregation Settings CRUD (get_settings/put_settings) for DynamoDB CONFIG#SETTINGS item. Aggregation now tracks bulk_price and employee_price separately, with grand_total (company cost) and employee_total (payroll deductions). * Add Google OAuth, server-side discounts, and Slack order confirmations Submit order Lambda now verifies Google ID tokens via tokeninfo endpoint, calculates two-tier discount pricing server-side, and async-invokes the Slack notifier for per-employee order confirmation DMs. Deadlines updated to Thursday 11:59pm across all Slack messages. * Update SAM template for Google auth, Slack invocation, and deadline change Add SLACK_NOTIFIER_ARN and GOOGLE_CLIENT_ID_PARAM env vars to submit order function with lambda:InvokeFunction policy. Move close-form schedule to Thursday 11:59pm EST/EDT. * Update order form UI and CI workflow for new features Form now shows discount pricing, responsive grid layout, Google Sign-In overlay, and closed-orders page with countdown timer. CI workflow fetches discount settings from DynamoDB and Google Client ID from SSM. * Add SSM GetParameter permission to submit order Lambda Required for reading the Google Client ID from Parameter Store at /meal-order-manager/google-client-id. * Harden auth, pricing, and reliability in order handlers Enforce Google auth when configured (reject missing tokens with 403), return 503 on token verification outages, switch to Decimal with ROUND_HALF_UP for financial precision, clamp discount bounds 0-100, use email-based slugs, add 5-min cache TTL with time.monotonic(), wrap Slack invocation in try/except, add reopen_at timestamp to closed form status, add reminder dedup guards for dual EST/EDT crons, escape Slack mrkdwn special characters, and handle empty employee names. * Fix XSS risks and add closed-form UX to order page Add escapeHtml() for all scraped content in innerHTML, fix script injection via </script> in JSON, fix JWT base64url decoding, match backend two-step rounding in JS employeePrice(), disable qty buttons and submit when form is closed, add server-driven countdown from reopen_at, add duplicate order warning via localStorage, add back button after submission, embed favicon, use :g format for fractional discounts, and exclude dead loadRoster code when Google auth enabled. * Document CORS, cron idempotency, and SSM config in template Add comments explaining CORS dev server strategy, dual EST/EDT cron idempotency, and manual SSM parameter creation for Google Client ID. * Add unit tests for submit, notify, and aggregate handlers 50 tests covering pricing pipeline (Decimal rounding, clamping, totals), Google auth (enforcement, bypass prevention, audience/domain validation, 503 on outage), email slug generation, form status with reopen_at, input validation, Slack failure resilience, reminder dedup guards, order confirmation DMs, aggregated summaries, CSV generation, and mrkdwn escaping. * Use full email as order slug for defense-in-depth Replace email-prefix slug with full lowercase email to eliminate any possibility of cross-domain collisions, per senior review sign-off. * Remove unused imports flagged by ruff * Apply ruff formatting * Fix PR review findings: auth, rounding, and close-form guard - Remove dead elif branch in submit_order auth (always returned 403) - Catch HTTPError before URLError so expired tokens return 403 not 503 - Wrap SSM get_parameter in try/except for fresh deployments - Add wall-clock guard to close_form handler (Friday >= 11 PM ET) - Add epsilon nudge to JS employeePrice for IEEE 754 boundary match - Switch Flask dev server from round() to Decimal ROUND_HALF_UP - Add tests for HTTPError handling and close_form guard (6 new tests) * Fix close-form weekday guard and SSM auth fail-open - Close form guard: check weekday == 3 (Thursday), not 4 (Friday) — the crons fire at Thursday 11:59 PM ET, when weekday() is 3 - SSM fail-closed: separate _google_auth_configured() (checks env var) from _get_google_client_id() (fetches value). If auth is configured but the SSM fetch fails, return 503 instead of silently falling back to manual auth - Update close_form tests to use Thursday dates - Add test_ssm_failure_fails_closed * Harden Flask dev server auth and escaping - Add hosted domain check to _verify_google_token (mirror Lambda) - Gate auth on config (client_id presence), not request body — prevents bypass by omitting google_id_token when auth is configured - Add discount percentage clamping to match Lambda handler - Add </script> escaping to google_client_id_json * fix: Email order filenames, SSM param TTL, DST-safe reopen_at - Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo) - shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes - form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta) - Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com> * Apply ruff formatting to submit_order handler * fix(server): retry SSM for Google client id after TTL on failure Transient SSM errors no longer cache empty client id for the process lifetime; matches Lambda handler refresh behavior (300s TTL). Co-authored-by: Cursor <cursoragent@cursor.com> * style(server): ruff-format Google client id cache helper Co-authored-by: Cursor <cursoragent@cursor.com> * fix(close-form): accept Fri 00–03 ET catch-up after Thu close cron EventBridge can deliver past midnight ET; widen the wall-clock guard so a delayed Thursday 23:59 UTC cron still closes the form. Idempotent when already closed. Adds test for early Friday; past-window skip now starts Fri 04:00 ET. Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com> * fix(submit-order): bill from Dynamo menu retail, not client JSON Load authoritative meal prices from get_menu(week); reject unknown meal names and return 503 when the menu has no priced meals. Use meal_name in the pricing loop to avoid shadowing the employee name. Adds regression tests for tampering, unknown meals, and empty menu meals. Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com> * fix: use single braces in loadRoster JS nested string Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Eastern fallback countdown * Fix pricing validation and JWT display decoding * Fix optional Google auth detection * Format app.py line length for ruff compliance * Fix auth config check and URL escaping in form - _google_auth_configured() now checks env var presence (intent), not the fetched SSM value — prevents silent auth bypass if SSM param is deleted - Add </script> escaping to URL values in generate_form.py for consistency with other injected values --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
137 lines
4.9 KiB
YAML
137 lines
4.9 KiB
YAML
name: Weekly Menu Scrape & Publish
|
|
|
|
on:
|
|
schedule:
|
|
# Monday 8am EST = 13:00 UTC
|
|
- cron: '0 13 * * 1'
|
|
# Monday 8am EDT = 12:00 UTC
|
|
- cron: '0 12 * * 1'
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
id-token: write
|
|
contents: read
|
|
|
|
jobs:
|
|
scrape-and-publish:
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
AWS_REGION: us-east-1
|
|
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.12'
|
|
|
|
- name: Install dependencies
|
|
run: |
|
|
pip install playwright boto3
|
|
playwright install chromium --with-deps
|
|
|
|
- name: Configure AWS credentials
|
|
uses: aws-actions/configure-aws-credentials@v4
|
|
with:
|
|
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
|
aws-region: us-east-1
|
|
|
|
- name: Scrape menu
|
|
run: python3 src/scraper/scrape_menu.py
|
|
|
|
- name: Get stack outputs
|
|
id: stack
|
|
run: |
|
|
API_URL=$(aws cloudformation describe-stacks \
|
|
--stack-name meal-order-manager \
|
|
--query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \
|
|
--output text)
|
|
FORM_BUCKET=$(aws cloudformation describe-stacks \
|
|
--stack-name meal-order-manager \
|
|
--query 'Stacks[0].Outputs[?OutputKey==`FormBucketName`].OutputValue' \
|
|
--output text)
|
|
DIST_ID=$(aws cloudformation describe-stacks \
|
|
--stack-name meal-order-manager \
|
|
--query 'Stacks[0].Outputs[?OutputKey==`DistributionId`].OutputValue' \
|
|
--output text)
|
|
FORM_URL=$(aws cloudformation describe-stacks \
|
|
--stack-name meal-order-manager \
|
|
--query 'Stacks[0].Outputs[?OutputKey==`FormUrl`].OutputValue' \
|
|
--output text)
|
|
echo "api_url=$API_URL" >> $GITHUB_OUTPUT
|
|
echo "form_bucket=$FORM_BUCKET" >> $GITHUB_OUTPUT
|
|
echo "dist_id=$DIST_ID" >> $GITHUB_OUTPUT
|
|
echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT
|
|
|
|
- name: Get API key
|
|
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
|
|
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
|
|
|
|
- name: Get Google Client ID
|
|
id: google
|
|
run: |
|
|
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
|
|
--name /meal-order-manager/google-client-id \
|
|
--query 'Parameter.Value' \
|
|
--output text 2>/dev/null || echo "")
|
|
if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then
|
|
GOOGLE_CLIENT_ID=""
|
|
fi
|
|
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
|
|
|
|
- name: Generate order form
|
|
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) || '' }}
|
|
|
|
- name: Upload menu to DynamoDB
|
|
run: python3 scripts/upload_menu.py
|
|
|
|
- name: Upload form to S3
|
|
run: |
|
|
WEEK=$(date +%Y-W%U)
|
|
aws s3 cp "output/order-form-$WEEK.html" \
|
|
"s3://${{ steps.stack.outputs.form_bucket }}/index.html" \
|
|
--content-type "text/html" \
|
|
--cache-control "no-cache"
|
|
aws s3 cp "output/order-form-$WEEK.html" \
|
|
"s3://${{ steps.stack.outputs.form_bucket }}/archive/$WEEK.html" \
|
|
--content-type "text/html"
|
|
|
|
- name: Invalidate CloudFront cache
|
|
run: |
|
|
aws cloudfront create-invalidation \
|
|
--distribution-id "${{ steps.stack.outputs.dist_id }}" \
|
|
--paths "/index.html"
|
|
|
|
- name: Notify Slack
|
|
run: python3 scripts/notify_slack.py "${{ steps.stack.outputs.form_url }}"
|