# meal-order-manager ![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white) ![AWS](https://img.shields.io/badge/AWS-ECS-FF9900?logo=amazonaws&logoColor=white) ![Slack](https://img.shields.io/badge/Slack-integration-4A154B?logo=slack&logoColor=white) ![CI](https://github.com/Sea-Haven-Industries/meal-order-manager/actions/workflows/ci.yml/badge.svg) Automates weekly meal ordering from [Redefine Meals](https://www.redefinemeals.com) 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 ``` Monday 7:30am ET Employees (Mon–Thu) Thursday 11:59pm ET ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ GitHub Actions │ │ orders.seahaven │ │ EventBridge │ │ - Scrape menu │────S3 upload───▶│ .com │ │ Scheduler (ET) │ │ - HMAC publish │ │ (CloudFront+S3) │──POST───┐ │ → jobs SQS │ │ - Slack notify │ └──────────────────┘ │ └─────────┬────────┘ └─────────────────┘ ▼ │ ┌──────────┐ │ Thu 10am: Slack DM │ ALB + │◀──────────┘ reminders to employees │ Fargate │ who haven't ordered │ Flask │ └────┬─────┘ ▼ ┌──────────┐ │ DynamoDB │ │ orders │ └──────────┘ ``` The production HTTP app is `src/server/app.py` (gunicorn). Close, aggregate/PDF, Slack reminder, and roster sync run in the same task from a dedicated SQS consumer (`src/server/worker.py`). Playwright scrape stays in GitHub Actions. ### 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 | Scrape menu, generate form, HMAC-publish menu, upload form to S3, post link to Slack | GitHub Actions cron | | 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 boundary The scheduled GitHub workflow has no DynamoDB permissions. It sends two HMAC requests with `X-Meals-Publish-Key` from Parameter Store to the ALB: - `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. - **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 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-` in project `seahaven-`. 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. - **[AWS Architecture Map](https://seahaven.atlassian.net/wiki/spaces/IT/pages/1540098)** (Confluence, IT space, page 1540098) ## 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`. ```bash python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -r requirements-api.txt playwright install chromium PYTHONPATH=src:src/shared python3 -m server.app ``` ### 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 "" --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. ```bash 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/ │ ├── weekly-menu.yml # Monday cron: scrape + HMAC publish + notify │ ├── deploy-api.yaml # Image CD to Fargate │ ├── ci.yml # PR checks │ └── ci-terraform.yaml # terraform fmt / validate ├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue) ├── Dockerfile ├── src/ │ ├── scraper/ # Playwright menu scraper │ ├── 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 ```