Automated weekly meal ordering from Redefine Meals — scraper, order form, payroll deductions
Find a file
Adam Moussa dce33918b7
feat(cart): add a local Redefine cart filler
Match the admin order-list CSV to the live menu and add each meal to a guest cart so the weekly bulk order does not have to be typed in by hand.
2026-09-25 19:50:12 -04:00
.github/workflows feat(menu): publish the weekly menu from the job worker (PLAT-229) (#219) 2026-09-25 22:10:24 +00:00
.security-review fix(iam): drop githubdeploy workflow_ref OIDC condition (PLAT-222) (#215) 2026-09-22 21:30:25 +00:00
scripts feat(cart): add a local Redefine cart filler 2026-09-25 19:50:12 -04:00
src feat(cart): add a local Redefine cart filler 2026-09-25 19:50:12 -04:00
terraform feat(menu): publish the weekly menu from the job worker (PLAT-229) (#219) 2026-09-25 22:10:24 +00:00
tests feat(cart): add a local Redefine cart filler 2026-09-25 19:50:12 -04:00
.dockerignore feat(api): serve meals on ECS Fargate instead of Lambda (PLAT-215) (#199) 2026-09-21 19:34:24 +00:00
.gitignore feat(infra): migrate meal-order-manager to HCP Terraform 2026-08-07 19:19:51 -04:00
.redocly.yaml feat(api): add OpenAPI Redocly contract and VPC outputs (DEV-289) (#206) 2026-09-22 00:33:48 +00:00
AGENTS.md ci: add org PR policy caller (PLAT-62) (#108) 2026-08-04 15:57:37 +00:00
config.json Add discount pricing, Google auth, and order hardening (#10) 2026-05-13 18:00:21 -04:00
Dockerfile chore(deps): pin python docker tag to 2f17fc0 (#207) 2026-09-22 14:24:50 +00:00
eslint.config.mjs chore(form): add template JavaScript checks (#92) 2026-08-03 14:15:25 -04:00
openapi.yaml feat(api): add OpenAPI Redocly contract and VPC outputs (DEV-289) (#206) 2026-09-22 00:33:48 +00:00
package-lock.json chore(deps): update npm minor and patch (#210) 2026-09-25 20:20:05 +00:00
package.json chore(deps): update npm minor and patch (#210) 2026-09-25 20:20:05 +00:00
README.md feat(menu): publish the weekly menu from the job worker (PLAT-229) (#219) 2026-09-25 22:10:24 +00:00
requirements-api.txt chore(deps): update dependency gunicorn to v26 (#212) 2026-09-25 20:25:51 +00:00
requirements.txt chore(deps): update dependency boto3 to v1.43.98 (#208) 2026-09-22 14:31:22 +00:00
slack-app-manifest.yml Fix Slack manifest long_description to meet 174-char minimum (#4) 2026-05-12 19:22:46 -04:00

meal-order-manager

Python AWS Slack CI

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 plus a per-person weekly summary PDF.

Architecture

EventBridge Scheduler (America/New_York)          Employees
┌──────────────────────────────────────┐          ┌────────────────────┐
│ Mon 6:55 roster, Mon 7:30 menu       │          │ orders.seahaven.com│
│ Thu 10:00 reminder, Thu 23:59 close  │          │ CloudFront + S3    │
└──────────────────┬───────────────────┘          └─────────┬──────────┘
                   │ jobs SQS                               │ POST /api
                   ▼                                        ▼
             ┌──────────────────────────────────────────────────┐
             │ Fargate: Flask + SQS worker                      │
             │ menu → DynamoDB                                  │
             │ form HTML → S3, then invalidate /index.html      │
             └──────────────────────────────────────────────────┘

The production HTTP app is src/server/app.py (gunicorn). Menu publish, close, aggregate/PDF, Slack reminder, and roster sync run in the same task from a dedicated SQS consumer (src/server/worker.py). Menu publish fetches the Redefine HTML catalog. It does not run a browser.

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
Monday 6:55am ET Sync employee roster from Slack channel membership EventBridge Scheduler → jobs SQS → Fargate
Monday 7:30am ET Fetch menu, generate form, write menu, upload form to S3, invalidate CloudFront, post link to Slack EventBridge Scheduler → jobs SQS
Mon–Thu Employees visit orders.seahaven.com and submit orders S3 form → CloudFront /api/* → ALB → Flask → DynamoDB
Thursday 10am ET DM employees who haven't ordered yet EventBridge Scheduler → jobs SQS
Thursday 11:59pm ET Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack EventBridge Scheduler → jobs SQS

Weekly menu publication

Monday 7:30am Eastern, EventBridge Scheduler enqueues publish_menu. The Fargate worker fetches the Redefine menu HTML, parses the embedded catalog, writes that Eastern-time week's menu to DynamoDB, renders the form, uploads it to the form bucket, invalidates /index.html, and posts to Slack.

scripts/upload_menu.py remains a manual HMAC fallback for one production Monday:

  • 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.

Publish routes are omitted from CloudFront. The generated form uses relative /api/... paths so it stays same-origin on orders.seahaven.com.

Reports (written to meal-order-manager-reports-* at Thursday close)

Key Contents
reports/{week}/order-summary.csv Meal-level aggregate (meal, qty, unit price, line total) for the Redefine order
reports/{week}/payroll-deductions.csv Per-employee payroll deduction totals
reports/{week}/weekly-summary-{week}.pdf Per-person summary (employee → item → quantity, no pricing); downloadable from the admin panel via presigned URL

AWS Resources

Workspace: meal-order-manager-prod / meal-order-manager-dev (us-east-1)

  • ECS Fargate — Flask + gunicorn + SQS job consumer. Desired count 2 in prod, 1 in dev.
  • VPC — Prod attaches to the After Hours VPC (existing_vpc_id / existing_public_subnet_ids from afterhours-shift-manager outputs). The 10.60 CIDR is unused fallback.
  • HTTP contract — openapi.yaml, linted in CI with npm run openapi:lint (Redocly extends: recommended, same as internal-portal and afterhours-shift-manager).
  • ALB — origin for CloudFront /api behaviors and weekly-menu HMAC publish. Idle timeout 120s.
  • ECR — API image. GitHub Actions deploy-api.yaml owns the image; Terraform ignores container_definitions.
  • S3 — meal-order-manager-form-* (static form hosting), meal-order-manager-reports-* (CSV reports + weekly summary PDF)
  • CloudFront — HTTPS distribution with custom domain orders.seahaven.com. API origin is the ALB (HTTP-only).
  • DynamoDB — meal-order-manager-orders (orders, menu, roster, config)
  • SQS — meal-order-manager-jobs (+ DLQ). EventBridge Scheduler in America/New_York enqueues menu publish, close, reminder, and roster sync.
  • Secrets Manager — Slack bot token
  • CloudWatch Alarms — ALB 5xx, ECS CPU, jobs DLQ, DynamoDB throttles, all notifying site-alerts
  • HCP Terraform — workspace meal-order-manager-<env> in project seahaven-<env>. Working directory terraform/. VCS file triggers should be terraform/** only after the image deploy workflow owns src/. Do not terraform apply locally to prod.

GitHub Environments dev and prod need DEPLOY_ROLE_ARN (the githubdeploy-meal-order-manager output). Prod requires reviewers and branch policy main plus v*.

Monitoring

CloudWatch alarms are defined in terraform/alarms.tf. Prod alarms send to the shared site-alerts SNS topic, have no OKActions, and treat missing data as not breaching.

  • ALB target 5xx — Sum over 5 min, threshold 0.
  • ECS CPU — Average over 5 min above 80 percent, 2 evaluation periods.
  • Jobs DLQ — visible messages, threshold 0.
  • DynamoDB orders table — ReadThrottleEvents and WriteThrottleEvents.
  • Submit is throttled in the Flask process at 5 requests/second per worker.

Authentication

Google Identity Services and portal Cognito ID tokens coexist until portal cutover. Google tokens use the tokeninfo endpoint. Portal tokens are verified locally against the configured Cognito issuer, audience, signature, expiry, token use, and email domain. Both paths accept only seahavenind.com and seahaven.com identities and fail closed when their SSM configuration is unavailable. The local Flask workflow can still use manual name and email entry when Google auth is not configured.

Set the portal_cognito_issuer and portal_cognito_audience HCP Terraform workspace variables from one internal-portal stage. Add the other stage to portal_cognito_extra_trust so portal-dev and portal-prod tokens both work against this single meals API. Google federated portal users are accepted without an email_verified=true claim; identity still has to be a seahavenind.com or seahaven.com email from a trusted pool.

Admin Panel

Admins (configured in DynamoDB CONFIG/SETTINGS → admin_emails list) get an "Admin" button after Google sign-in. The panel provides:

  • View all orders by week with totals
  • Edit order quantities, add new menu items, remove items
  • Delete orders entirely
  • Download order list — a CSV rollup of item → total quantity across all employees (no per-employee breakdown, no prices) to drive the bulk Redefine order. Generated client-side from the loaded week, so it works for open weeks too.
  • Download summary PDF — fetches a short-lived presigned URL for the week's per-person summary PDF (generated at Thursday close) and opens it. Returns 404 for weeks that haven't closed yet.

All admin operations enforce server-side price recalculation from the menu.

API routes (all require Google auth + admin email):

Method Path Description
GET /api/admin/orders List weeks with order counts
GET /api/admin/orders?week=YYYY-WNN Get all orders for a week
PUT /api/admin/orders Update an order (recalculates prices)
DELETE /api/admin/orders?week=...&email=... Delete an order
GET /api/admin/summary-pdf?week=YYYY-WNN Presigned URL for the week's summary PDF (404 if week not closed)

Documentation

The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This project's meal-order-manager stack is represented there as a Mermaid subgraph.

Setup

Local development

Local :5050 is the same Flask app as production. DynamoDB is used when AWS credentials are present. Portal VITE_MEALS_API_BASE can keep pointing at http://127.0.0.1:5050.

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt -r requirements-api.txt
PYTHONPATH=src:src/shared python3 -m server.app

Form tests and src/scraper/recon.py need playwright install chromium. Menu publish does not.

Deploy to AWS

Image deploys are GitHub Actions deploy-api.yaml (push to main → dev, GitHub Release → prod). Infrastructure applies through HCP Terraform. First apply of the new tf-managed IAM policies needs the hcptf-bootstrap window.

Rollback is workflow_dispatch of deploy-api.yaml at the previous tag. Terraform does not revert the image.

Post-deploy

  1. Create the Slack bot token secret: aws secretsmanager create-secret --name meal-order-manager/slack-bot-token --secret-string "xoxb-..."
  2. Set the Google OAuth client ID: aws ssm put-parameter --name /meal-order-manager/google-client-id --type String --value "<YOUR_GOOGLE_CLIENT_ID>" --overwrite
  3. Update the Slack channel SSM parameter: aws ssm put-parameter --name /meal-order-manager/slack-channel-id --value "C0XXXXXXX" --overwrite
  4. Set up DNS: CNAME orders.seahaven.com → CloudFront distribution domain
  5. 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. Order persistence in this app is DynamoDB; file-backed orders are not the happy path.

python3 src/scraper/scrape_menu.py        # scrape menu
python3 src/server/generate_form.py       # generate form (local mode)
PYTHONPATH=src:src/shared python3 -m server.app   # 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)
  • google_client_id — optional locally; required for cloud generation
  • output_dir / orders_dir — local output paths

Project Structure

meal-order-manager/
├── .github/workflows/
│   ├── deploy-api.yaml          # Image CD to Fargate
│   └── ci.yml                   # PR checks (lint, pytest, template JS, terraform, ci-complete)
├── terraform/                   # HCP Terraform (cluster, ALB, ECR, jobs queue)
├── openapi.yaml                 # Employee HTTP contract (Redocly recommended)
├── .redocly.yaml
├── Dockerfile
├── src/
│   ├── scraper/                 # HTML menu parser (recon scripts still use Playwright)
│   ├── server/                  # Flask API, form generator, job handlers
│   ├── aggregator/              # Order aggregation + CSV reports
│   └── shared/shared/           # db, secrets, slack, pdf helpers
├── scripts/                     # CI/CD helper scripts
│   ├── upload_menu.py
│   ├── notify_slack.py
│   └── seed_roster.py
├── slack-app-manifest.yml       # Slack app manifest (paste into api.slack.com)
└── config.json