Automated weekly meal ordering from Redefine Meals — scraper, order form, payroll deductions
Find a file
Adam Moussa a752c24e0f
Some checks failed
Deploy / deploy (push) Has been cancelled
Add discount pricing, Google auth, and order hardening (#10)
* 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>
2026-05-13 18:00:21 -04:00
.github/workflows Add discount pricing, Google auth, and order hardening (#10) 2026-05-13 18:00:21 -04:00
functions Add discount pricing, Google auth, and order hardening (#10) 2026-05-13 18:00:21 -04:00
scripts Fix ruff lint and format violations, update README (#5) 2026-05-12 19:31:27 -04:00
src Add discount pricing, Google auth, and order hardening (#10) 2026-05-13 18:00:21 -04:00
tests Add discount pricing, Google auth, and order hardening (#10) 2026-05-13 18:00:21 -04:00
.gitignore Initial commit: meal ordering automation system (#1) 2026-05-12 18:25:15 -04:00
config.json Add discount pricing, Google auth, and order hardening (#10) 2026-05-13 18:00:21 -04:00
README.md Switch to orders.seahaven.com, add roster dropdown, fix deadline (#9) 2026-05-12 20:16:46 -04:00
requirements.txt Initial commit: meal ordering automation system (#1) 2026-05-12 18:25:15 -04:00
samconfig.toml.example Switch to orders.seahaven.com, add roster dropdown, fix deadline (#9) 2026-05-12 20:16:46 -04:00
slack-app-manifest.yml Fix Slack manifest long_description to meet 174-char minimum (#4) 2026-05-12 19:22:46 -04:00
template.yaml Add discount pricing, Google auth, and order hardening (#10) 2026-05-13 18:00:21 -04:00

meal-order-manager

Automates weekly meal ordering from Redefine Meals for Sea Haven Industries employees. Scrapes the menu, generates an order form, collects individual orders, and produces payroll deduction reports.

Architecture

Monday 8am ET                        Employees (Mon–Thu)               Thursday 6pm ET
┌─────────────────┐                  ┌──────────────────┐              ┌──────────────────┐
│  GitHub Actions  │                 │  orders.seahaven │              │  EventBridge      │
│  - Scrape menu   │────S3 upload───▶│  ind.com         │              │  - Close form     │
│  - Generate form │  + DynamoDB     │  (CloudFront+S3) │──POST───┐    │  - Aggregate      │
│  - Slack notify  │                 └──────────────────┘         │    │  - Slack summary  │
└─────────────────┘                                               ▼    └──────────────────┘
                                                          ┌──────────┐
Monday 7am ET                                             │ API GW + │
┌──────────────────┐                                      │ Lambda   │
│  EventBridge     │                                      │ submit   │
│  - Email payroll │                                      └────┬─────┘
│    deductions    │                                           ▼
└──────────────────┘                                      ┌──────────┐
                                                          │ DynamoDB │
Thu 10am: Slack DM                                        │ orders   │
reminders to employees                                    └──────────┘
who haven't ordered

Weekly Flow

When What How
Monday 6:55am ET Sync employee roster from Slack channel membership EventBridge → Lambda → DynamoDB
Monday 7am ET Email previous week's payroll deductions to payroll@ EventBridge → Lambda → SES
Monday 8am ET Scrape menu, generate form, upload to S3, post link to Slack GitHub Actions cron
Mon–Thu Employees visit orders.seahaven.com and submit orders S3 static form → API Gateway → Lambda → DynamoDB
Thursday 10am ET DM employees who haven't ordered yet EventBridge → Lambda → Slack DM
Thursday 6pm ET Close form, aggregate orders, post Redefine order summary to Slack EventBridge → Lambda chain

AWS Resources

Stack name: meal-order-manager (us-east-1)

  • S3 — meal-order-manager-form-* (static form hosting), meal-order-manager-reports-* (CSV reports)
  • CloudFront — HTTPS distribution with custom domain orders.seahaven.com
  • DynamoDB — meal-order-manager-orders (orders, menu, roster, config)
  • API Gateway — HttpApi for order submission
  • Lambda — 6 functions: submit-order, close-form, aggregate-orders, slack-notifier, sync-roster, email-report
  • EventBridge — scheduled rules (dual EST/EDT) for close, reminders, payroll email
  • Secrets Manager — Slack bot token, form API key
  • SES — payroll deduction emails

Setup

Local development

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
playwright install chromium

Deploy to AWS

cp samconfig.toml.example samconfig.toml
# Edit samconfig.toml with your certificate ARN, etc.
sam build
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)"
  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
  6. Roster syncs automatically from Slack channel members (runs Monday 6:55am ET), or seed manually: python3 scripts/seed_roster.py

Local Workflow (no AWS)

The scraper, form generator, Flask server, and aggregator still work locally:

python3 src/scraper/scrape_menu.py        # scrape menu
python3 src/server/generate_form.py       # generate form (local mode)
python3 src/server/app.py                 # serve on localhost:5050
python3 src/aggregator/aggregate.py       # generate CSV reports

Configuration

config.json (local dev):

  • menu_url — Redefine Meals menu URL
  • order_deadline — displayed on the form
  • roster — employee list (name, email, slack_user_id)
  • output_dir / orders_dir — local output paths

Project Structure

meal-order-manager/
├── .github/workflows/
│   ├── weekly-menu.yml          # Monday cron: scrape + publish + notify
│   ├── ci.yml                   # PR checks
│   └── deploy.yml               # Push to main: sam deploy
├── src/
│   ├── scraper/                 # Playwright menu scraper
│   ├── server/                  # Form generator + local Flask server
│   ├── aggregator/              # Order aggregation + CSV reports
│   └── shared/shared/           # Lambda layer (db, secrets, slack helpers)
├── functions/                   # Lambda handlers
│   ├── submit_order/
│   ├── close_form/
│   ├── aggregate_orders/
│   ├── slack_notifier/
│   ├── sync_roster/
│   └── email_report/
├── scripts/                     # CI/CD helper scripts
│   ├── upload_menu.py
│   ├── notify_slack.py
│   └── seed_roster.py
├── template.yaml                # SAM template
├── samconfig.toml.example
├── slack-app-manifest.yml       # Slack app manifest (paste into api.slack.com)
└── config.json