mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-06 17:01:58 +00:00
Initial commit: meal ordering automation system (#1)
Playwright-based menu scraper for Redefine Meals, self-contained HTML order form with S3/CloudFront hosting, DynamoDB-backed order submission via API Gateway, and automated payroll deduction reports via SES.
This commit is contained in:
parent
cd36ed8018
commit
d332affd56
33 changed files with 2604 additions and 0 deletions
9
.github/workflows/ci.yml
vendored
Normal file
9
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
|
||||||
13
.github/workflows/deploy.yml
vendored
Normal file
13
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@main
|
||||||
|
with:
|
||||||
|
stack-name: meal-order-manager
|
||||||
|
secrets:
|
||||||
|
cfn-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
||||||
102
.github/workflows/weekly-menu.yml
vendored
Normal file
102
.github/workflows/weekly-menu.yml
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
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: Generate order form
|
||||||
|
run: |
|
||||||
|
python3 src/server/generate_form.py \
|
||||||
|
--api-url "${{ steps.stack.outputs.api_url }}" \
|
||||||
|
--api-key "${{ steps.apikey.outputs.api_key }}"
|
||||||
|
|
||||||
|
- 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 }}"
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
orders/
|
||||||
|
output/
|
||||||
|
.DS_Store
|
||||||
|
.aws-sam/
|
||||||
|
samconfig.toml
|
||||||
124
README.md
124
README.md
|
|
@ -1 +1,125 @@
|
||||||
# meal-order-manager
|
# meal-order-manager
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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 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.seahavenind.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.seahavenind.com`
|
||||||
|
- **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config)
|
||||||
|
- **API Gateway** — HttpApi for order submission
|
||||||
|
- **Lambda** — 5 functions: submit-order, close-form, aggregate-orders, slack-notifier, 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy to AWS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp samconfig.toml.example samconfig.toml
|
||||||
|
# Edit samconfig.toml with your certificate ARN, API key, 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.seahavenind.com` → CloudFront distribution domain
|
||||||
|
6. Populate employee roster: edit `config.json` and run `python3 scripts/seed_roster.py`
|
||||||
|
|
||||||
|
## Local Workflow (no AWS)
|
||||||
|
|
||||||
|
The scraper, form generator, Flask server, and aggregator still work locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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/python/shared/ # Lambda layer (db, secrets, slack helpers)
|
||||||
|
├── functions/ # Lambda handlers
|
||||||
|
│ ├── submit_order/
|
||||||
|
│ ├── close_form/
|
||||||
|
│ ├── aggregate_orders/
|
||||||
|
│ ├── slack_notifier/
|
||||||
|
│ └── email_report/
|
||||||
|
├── scripts/ # CI/CD helper scripts
|
||||||
|
│ ├── upload_menu.py
|
||||||
|
│ ├── notify_slack.py
|
||||||
|
│ └── seed_roster.py
|
||||||
|
├── template.yaml # SAM template
|
||||||
|
├── samconfig.toml.example
|
||||||
|
└── config.json
|
||||||
|
```
|
||||||
|
|
|
||||||
9
config.json
Normal file
9
config.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"menu_url": "https://redefinemeals.com/menu",
|
||||||
|
"order_deadline": "Wednesday 11:59 PM",
|
||||||
|
"output_dir": "output",
|
||||||
|
"orders_dir": "orders",
|
||||||
|
"roster": [
|
||||||
|
{"name": "Example Employee", "email": "example@seahavenind.com"}
|
||||||
|
]
|
||||||
|
}
|
||||||
115
functions/aggregate_orders/handler.py
Normal file
115
functions/aggregate_orders/handler.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from shared.db import current_week, get_orders, get_summary, put_summary
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
_s3 = boto3.client("s3")
|
||||||
|
_lambda = boto3.client("lambda")
|
||||||
|
|
||||||
|
|
||||||
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
|
def default(self, o):
|
||||||
|
if isinstance(o, Decimal):
|
||||||
|
return float(o)
|
||||||
|
return super().default(o)
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
week = event.get("week", current_week())
|
||||||
|
|
||||||
|
existing = get_summary(week)
|
||||||
|
if existing:
|
||||||
|
return {"status": "already_aggregated", "week": week}
|
||||||
|
|
||||||
|
orders = get_orders(week)
|
||||||
|
if not orders:
|
||||||
|
return {"status": "no_orders", "week": week}
|
||||||
|
|
||||||
|
summary = build_summary(orders, week)
|
||||||
|
order_csv = build_order_summary_csv(summary)
|
||||||
|
payroll_csv = build_payroll_csv(orders)
|
||||||
|
|
||||||
|
bucket = os.environ["REPORTS_BUCKET"]
|
||||||
|
order_csv_key = f"reports/{week}/order-summary.csv"
|
||||||
|
payroll_csv_key = f"reports/{week}/payroll-deductions.csv"
|
||||||
|
|
||||||
|
_s3.put_object(Bucket=bucket, Key=order_csv_key, Body=order_csv, ContentType="text/csv")
|
||||||
|
_s3.put_object(Bucket=bucket, Key=payroll_csv_key, Body=payroll_csv, ContentType="text/csv")
|
||||||
|
|
||||||
|
summary["order_csv_s3_key"] = order_csv_key
|
||||||
|
summary["payroll_csv_s3_key"] = payroll_csv_key
|
||||||
|
put_summary(week, summary)
|
||||||
|
|
||||||
|
_lambda.invoke(
|
||||||
|
FunctionName=os.environ["SLACK_NOTIFIER_ARN"],
|
||||||
|
InvocationType="Event",
|
||||||
|
Payload=json.dumps({"event": "orders_aggregated", "week": week}, cls=DecimalEncoder),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "aggregated", "week": week, "total_employees": len(orders)}
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary(orders: list[dict], week: str) -> dict:
|
||||||
|
meal_totals = defaultdict(lambda: {"quantity": 0, "unit_price": 0})
|
||||||
|
for order in orders:
|
||||||
|
for item in order.get("items", []):
|
||||||
|
name = item["name"]
|
||||||
|
qty = int(item.get("quantity", 0))
|
||||||
|
meal_totals[name]["quantity"] += qty
|
||||||
|
meal_totals[name]["unit_price"] = float(item.get("price", 0))
|
||||||
|
|
||||||
|
meals = []
|
||||||
|
for name, data in sorted(meal_totals.items()):
|
||||||
|
meals.append({
|
||||||
|
"meal": name,
|
||||||
|
"quantity": data["quantity"],
|
||||||
|
"unit_price": data["unit_price"],
|
||||||
|
"line_total": round(data["unit_price"] * data["quantity"], 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"week": week,
|
||||||
|
"generated_at": datetime.now(EASTERN).isoformat(),
|
||||||
|
"total_employees": len(orders),
|
||||||
|
"total_meals": sum(m["quantity"] for m in meals),
|
||||||
|
"grand_total": round(sum(m["line_total"] for m in meals), 2),
|
||||||
|
"meals": meals,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_order_summary_csv(summary: dict) -> str:
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(["Meal", "Quantity", "Unit Price", "Line Total"])
|
||||||
|
for meal in summary["meals"]:
|
||||||
|
writer.writerow([meal["meal"], meal["quantity"], f"${meal['unit_price']:.2f}", f"${meal['line_total']:.2f}"])
|
||||||
|
writer.writerow([])
|
||||||
|
writer.writerow(["TOTAL", summary["total_meals"], "", f"${summary['grand_total']:.2f}"])
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def build_payroll_csv(orders: list[dict]) -> str:
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(["Employee Name", "Employee Email", "Items Ordered", "Total Deduction"])
|
||||||
|
for order in sorted(orders, key=lambda o: o["employee_name"]):
|
||||||
|
items_str = "; ".join(
|
||||||
|
f"{item['name']} x{int(item['quantity'])} (${float(item.get('subtotal', float(item.get('price', 0)) * int(item.get('quantity', 0)))):.2f})"
|
||||||
|
for item in order.get("items", [])
|
||||||
|
)
|
||||||
|
writer.writerow([
|
||||||
|
order["employee_name"],
|
||||||
|
order["employee_email"],
|
||||||
|
items_str,
|
||||||
|
f"${float(order['total']):.2f}",
|
||||||
|
])
|
||||||
|
return buf.getvalue()
|
||||||
1
functions/aggregate_orders/requirements.txt
Normal file
1
functions/aggregate_orders/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
boto3
|
||||||
26
functions/close_form/handler.py
Normal file
26
functions/close_form/handler.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from shared.db import current_week, get_form_status, set_form_status
|
||||||
|
|
||||||
|
_lambda = boto3.client("lambda")
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
week = event.get("week", current_week())
|
||||||
|
|
||||||
|
status = get_form_status(week)
|
||||||
|
if status == "closed":
|
||||||
|
return {"status": "already_closed", "week": week}
|
||||||
|
|
||||||
|
set_form_status(week, "closed")
|
||||||
|
|
||||||
|
_lambda.invoke(
|
||||||
|
FunctionName=os.environ["AGGREGATE_FUNCTION_ARN"],
|
||||||
|
InvocationType="Event",
|
||||||
|
Payload=json.dumps({"week": week}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "closed", "week": week, "aggregate_triggered": True}
|
||||||
1
functions/close_form/requirements.txt
Normal file
1
functions/close_form/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
boto3
|
||||||
56
functions/email_report/handler.py
Normal file
56
functions/email_report/handler.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import os
|
||||||
|
from email.mime.application import MIMEApplication
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from shared.db import get_summary, previous_week
|
||||||
|
|
||||||
|
_ses = boto3.client("ses")
|
||||||
|
_s3 = boto3.client("s3")
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
week = event.get("week", previous_week())
|
||||||
|
|
||||||
|
summary = get_summary(week)
|
||||||
|
if not summary:
|
||||||
|
return {"status": "no_summary", "week": week}
|
||||||
|
|
||||||
|
payroll_key = summary.get("payroll_csv_s3_key")
|
||||||
|
if not payroll_key:
|
||||||
|
return {"status": "no_payroll_csv", "week": week}
|
||||||
|
|
||||||
|
bucket = os.environ["REPORTS_BUCKET"]
|
||||||
|
csv_obj = _s3.get_object(Bucket=bucket, Key=payroll_key)
|
||||||
|
csv_content = csv_obj["Body"].read()
|
||||||
|
|
||||||
|
total_employees = int(summary.get("total_employees", 0))
|
||||||
|
grand_total = float(summary.get("grand_total", 0))
|
||||||
|
|
||||||
|
msg = MIMEMultipart("mixed")
|
||||||
|
msg["Subject"] = f"Meal Order Payroll Deductions — {week}"
|
||||||
|
msg["From"] = os.environ["SENDER_EMAIL"]
|
||||||
|
msg["To"] = os.environ["PAYROLL_EMAIL"]
|
||||||
|
|
||||||
|
body = MIMEText(
|
||||||
|
f"Attached is the meal order payroll deduction report for {week}.\n\n"
|
||||||
|
f"Employees: {total_employees}\n"
|
||||||
|
f"Total deductions: ${grand_total:.2f}\n\n"
|
||||||
|
f"Please apply these deductions in the next pay period.\n",
|
||||||
|
"plain",
|
||||||
|
)
|
||||||
|
msg.attach(body)
|
||||||
|
|
||||||
|
attachment = MIMEApplication(csv_content)
|
||||||
|
attachment.add_header("Content-Disposition", "attachment", filename=f"payroll-deductions-{week}.csv")
|
||||||
|
msg.attach(attachment)
|
||||||
|
|
||||||
|
_ses.send_raw_email(
|
||||||
|
Source=os.environ["SENDER_EMAIL"],
|
||||||
|
Destinations=[os.environ["PAYROLL_EMAIL"]],
|
||||||
|
RawMessage={"Data": msg.as_string()},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "sent", "week": week, "to": os.environ["PAYROLL_EMAIL"]}
|
||||||
1
functions/email_report/requirements.txt
Normal file
1
functions/email_report/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
boto3
|
||||||
137
functions/slack_notifier/handler.py
Normal file
137
functions/slack_notifier/handler.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from shared.db import current_week, get_orders, get_roster, get_summary
|
||||||
|
from shared.slack import post_channel_message, send_dm
|
||||||
|
|
||||||
|
|
||||||
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
|
def default(self, o):
|
||||||
|
if isinstance(o, Decimal):
|
||||||
|
return float(o)
|
||||||
|
return super().default(o)
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
event_type = event.get("event", "")
|
||||||
|
|
||||||
|
if event_type == "menu_published":
|
||||||
|
return handle_menu_published(event)
|
||||||
|
elif event_type == "orders_aggregated":
|
||||||
|
return handle_orders_aggregated(event)
|
||||||
|
elif event_type == "reminder":
|
||||||
|
return handle_reminder(event)
|
||||||
|
|
||||||
|
return {"error": f"Unknown event type: {event_type}"}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_menu_published(event):
|
||||||
|
form_url = os.environ.get("FORM_URL", "")
|
||||||
|
week = event.get("week", current_week())
|
||||||
|
meal_count = event.get("meal_count", "")
|
||||||
|
|
||||||
|
text = f"This week's meal order is open! Deadline: Thursday 6pm."
|
||||||
|
blocks = [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"text": {"type": "plain_text", "text": "Meal Order Open"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f"*<{form_url}|Place your order>*\n\n"
|
||||||
|
f"*Deadline:* Thursday 6pm\n"
|
||||||
|
f"*Menu:* {meal_count} meals available"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
post_channel_message(text, blocks)
|
||||||
|
return {"status": "notified", "event": "menu_published", "week": week}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_orders_aggregated(event):
|
||||||
|
week = event.get("week", current_week())
|
||||||
|
summary = get_summary(week)
|
||||||
|
if not summary:
|
||||||
|
return {"status": "no_summary", "week": week}
|
||||||
|
|
||||||
|
total_employees = int(summary.get("total_employees", 0))
|
||||||
|
total_meals = int(summary.get("total_meals", 0))
|
||||||
|
grand_total = float(summary.get("grand_total", 0))
|
||||||
|
|
||||||
|
meal_lines = []
|
||||||
|
for m in summary.get("meals", []):
|
||||||
|
meal_lines.append(f"{m['meal']}: *{int(m['quantity'])}*")
|
||||||
|
meal_list = "\n".join(meal_lines)
|
||||||
|
|
||||||
|
text = f"Meal orders closed for {week}. {total_employees} employees, {total_meals} meals, ${grand_total:.2f} total."
|
||||||
|
blocks = [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"text": {"type": "plain_text", "text": f"Meal Orders Closed — {week}"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f"*{total_employees}* employees ordered\n"
|
||||||
|
f"*{total_meals}* total meals\n"
|
||||||
|
f"*${grand_total:.2f}* grand total"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"type": "divider"},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": f"*Order for Redefine Meals:*\n{meal_list}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
post_channel_message(text, blocks)
|
||||||
|
return {"status": "notified", "event": "orders_aggregated", "week": week}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_reminder(event):
|
||||||
|
week = event.get("week", current_week())
|
||||||
|
form_url = os.environ.get("FORM_URL", "")
|
||||||
|
|
||||||
|
roster = get_roster()
|
||||||
|
if not roster:
|
||||||
|
return {"status": "no_roster"}
|
||||||
|
|
||||||
|
orders = get_orders(week)
|
||||||
|
ordered_emails = {o["employee_email"].lower() for o in orders}
|
||||||
|
|
||||||
|
missing = [emp for emp in roster if emp["email"].lower() not in ordered_emails]
|
||||||
|
|
||||||
|
dm_count = 0
|
||||||
|
for emp in missing:
|
||||||
|
slack_id = emp.get("slack_user_id")
|
||||||
|
if not slack_id:
|
||||||
|
continue
|
||||||
|
send_dm(
|
||||||
|
slack_id,
|
||||||
|
f"Meal orders close at 6pm today. Place your order: {form_url}",
|
||||||
|
blocks=[{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f"Hey {emp['name'].split()[0]}! Meal orders close at *6pm today*.\n\n"
|
||||||
|
f"*<{form_url}|Place your order>*"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
dm_count += 1
|
||||||
|
|
||||||
|
return {"status": "reminders_sent", "week": week, "dm_count": dm_count, "missing_count": len(missing)}
|
||||||
1
functions/slack_notifier/requirements.txt
Normal file
1
functions/slack_notifier/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
boto3
|
||||||
82
functions/submit_order/handler.py
Normal file
82
functions/submit_order/handler.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from shared.db import current_week, get_form_status, put_order
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
API_KEY = os.environ.get("FORM_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
|
||||||
|
path = event.get("rawPath", "")
|
||||||
|
|
||||||
|
if "/form-status/" in path:
|
||||||
|
return handle_form_status(event)
|
||||||
|
|
||||||
|
if method == "POST":
|
||||||
|
return handle_submit(event)
|
||||||
|
|
||||||
|
return response(405, {"error": "Method not allowed"})
|
||||||
|
|
||||||
|
|
||||||
|
def handle_form_status(event):
|
||||||
|
week = event.get("pathParameters", {}).get("week", current_week())
|
||||||
|
status = get_form_status(week)
|
||||||
|
return response(200, {"week": week, "status": status})
|
||||||
|
|
||||||
|
|
||||||
|
def handle_submit(event):
|
||||||
|
api_key = event.get("headers", {}).get("x-api-key", "")
|
||||||
|
if API_KEY and api_key != 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"})
|
||||||
|
|
||||||
|
name = body.get("employee_name", "").strip()
|
||||||
|
email = body.get("employee_email", "").strip()
|
||||||
|
items = body.get("items", [])
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return response(400, {"error": "Employee name is required"})
|
||||||
|
if not email:
|
||||||
|
return response(400, {"error": "Employee email is required"})
|
||||||
|
if not items or not any(i.get("quantity", 0) > 0 for i in items):
|
||||||
|
return response(400, {"error": "Please select at least one meal"})
|
||||||
|
|
||||||
|
week = current_week()
|
||||||
|
status = get_form_status(week)
|
||||||
|
if status == "closed":
|
||||||
|
return response(410, {"error": "Orders are closed for this week"})
|
||||||
|
if status == "not_found":
|
||||||
|
return response(404, {"error": "No menu available for this week"})
|
||||||
|
|
||||||
|
filtered_items = [i for i in items if i.get("quantity", 0) > 0]
|
||||||
|
total = sum((i.get("price", 0) or 0) * i.get("quantity", 0) for i in filtered_items)
|
||||||
|
|
||||||
|
slug = "".join(c if c.isalnum() or c in "- " else "" for c in name).strip().replace(" ", "-").lower()
|
||||||
|
|
||||||
|
order_data = {
|
||||||
|
"employee_name": name,
|
||||||
|
"employee_email": email,
|
||||||
|
"submitted_at": datetime.now(EASTERN).isoformat(),
|
||||||
|
"items": filtered_items,
|
||||||
|
"total": round(total, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
put_order(week, slug, order_data)
|
||||||
|
|
||||||
|
return response(200, {"status": "ok", "message": f"Order saved for {name}", "total": order_data["total"]})
|
||||||
|
|
||||||
|
|
||||||
|
def response(status_code: int, body: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"statusCode": status_code,
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": json.dumps(body),
|
||||||
|
}
|
||||||
1
functions/submit_order/requirements.txt
Normal file
1
functions/submit_order/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
boto3
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
playwright>=1.40
|
||||||
|
flask>=3.0
|
||||||
10
samconfig.toml.example
Normal file
10
samconfig.toml.example
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
version = 0.1
|
||||||
|
|
||||||
|
[default.deploy.parameters]
|
||||||
|
stack_name = "meal-order-manager"
|
||||||
|
resolve_s3 = true
|
||||||
|
s3_prefix = "meal-order-manager"
|
||||||
|
region = "us-east-1"
|
||||||
|
confirm_changeset = true
|
||||||
|
capabilities = "CAPABILITY_IAM"
|
||||||
|
parameter_overrides = "CustomDomain=orders.seahavenind.com CertificateArn=arn:aws:acm:us-east-1:328440206208:certificate/CHANGE-ME FormApiKey=CHANGE-ME PayrollEmail=payroll@seahavenind.com SenderEmail=adam@seahavenind.com"
|
||||||
74
scripts/notify_slack.py
Normal file
74
scripts/notify_slack.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""
|
||||||
|
Posts the weekly menu notification to Slack.
|
||||||
|
Used by GitHub Actions after uploading the form to S3.
|
||||||
|
|
||||||
|
Usage: python3 scripts/notify_slack.py <form_url>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
SLACK_BOT_TOKEN_SECRET = os.environ.get("SLACK_BOT_TOKEN_SECRET", "meal-order-manager/slack-bot-token")
|
||||||
|
SLACK_CHANNEL_PARAM = os.environ.get("SLACK_CHANNEL_PARAM", "/meal-order-manager/slack-channel-id")
|
||||||
|
|
||||||
|
|
||||||
|
def get_secret(secret_id):
|
||||||
|
import boto3
|
||||||
|
return boto3.client("secretsmanager").get_secret_value(SecretId=secret_id)["SecretString"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_parameter(name):
|
||||||
|
import boto3
|
||||||
|
return boto3.client("ssm").get_parameter(Name=name, WithDecryption=True)["Parameter"]["Value"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: notify_slack.py <form_url>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
form_url = sys.argv[1]
|
||||||
|
token = get_secret(SLACK_BOT_TOKEN_SECRET)
|
||||||
|
channel = get_parameter(SLACK_CHANNEL_PARAM)
|
||||||
|
|
||||||
|
text = f"This week's meal order is open! Deadline: Thursday 6pm."
|
||||||
|
blocks = [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"text": {"type": "plain_text", "text": "Meal Order Open"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f"*<{form_url}|Place your order>*\n\n"
|
||||||
|
f"*Deadline:* Thursday 6pm\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
payload = json.dumps({"channel": channel, "text": text, "blocks": blocks}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://slack.com/api/chat.postMessage",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
|
||||||
|
if result.get("ok"):
|
||||||
|
print(f"Slack notification sent to channel {channel}")
|
||||||
|
else:
|
||||||
|
print(f"Slack API error: {result.get('error')}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
55
scripts/seed_roster.py
Normal file
55
scripts/seed_roster.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""
|
||||||
|
Seeds the employee roster into DynamoDB from config.json.
|
||||||
|
|
||||||
|
Usage: python3 scripts/seed_roster.py
|
||||||
|
|
||||||
|
Each employee needs: name, email, and slack_user_id (for DM reminders).
|
||||||
|
To find Slack user IDs, use the Slack API:
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"https://slack.com/api/users.lookupByEmail?email=name@seahavenind.com" \
|
||||||
|
| jq .user.id
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CONFIG_PATH = PROJECT_ROOT / "config.json"
|
||||||
|
TABLE_NAME = os.environ.get("TABLE_NAME", "meal-order-manager-orders")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
roster = config.get("roster", [])
|
||||||
|
if not roster:
|
||||||
|
print("No roster found in config.json", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
for emp in roster:
|
||||||
|
if "slack_user_id" not in emp:
|
||||||
|
emp["slack_user_id"] = ""
|
||||||
|
|
||||||
|
table = boto3.resource("dynamodb").Table(TABLE_NAME)
|
||||||
|
from datetime import datetime
|
||||||
|
table.put_item(Item={
|
||||||
|
"PK": "CONFIG",
|
||||||
|
"SK": "ROSTER",
|
||||||
|
"employees": roster,
|
||||||
|
"updated_at": datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"Seeded {len(roster)} employees to DynamoDB")
|
||||||
|
for emp in roster:
|
||||||
|
sid = emp.get("slack_user_id", "")
|
||||||
|
status = "ready" if sid else "MISSING slack_user_id"
|
||||||
|
print(f" {emp['name']} ({emp['email']}) — {status}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
59
scripts/upload_menu.py
Normal file
59
scripts/upload_menu.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""
|
||||||
|
Uploads the latest scraped menu JSON to DynamoDB.
|
||||||
|
Used by the GitHub Actions weekly workflow after scraping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
with open(files[0]) as f:
|
||||||
|
menu = json.load(f)
|
||||||
|
|
||||||
|
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)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
152
src/aggregator/aggregate.py
Normal file
152
src/aggregator/aggregate.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""
|
||||||
|
Order aggregation script.
|
||||||
|
|
||||||
|
Reads all submitted orders for a given week and produces:
|
||||||
|
1. Order summary — total quantity of each meal (for submitting to Redefine Meals)
|
||||||
|
2. Payroll deduction report — CSV with employee name, email, itemized meals, total amount
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CONFIG_PATH = PROJECT_ROOT / "config.json"
|
||||||
|
ORDERS_DIR = PROJECT_ROOT / "orders"
|
||||||
|
OUTPUT_DIR = PROJECT_ROOT / "output"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def current_week() -> str:
|
||||||
|
return datetime.now().strftime("%Y-W%U")
|
||||||
|
|
||||||
|
|
||||||
|
def load_orders(week: str) -> list[dict]:
|
||||||
|
week_dir = ORDERS_DIR / week
|
||||||
|
if not week_dir.exists():
|
||||||
|
return []
|
||||||
|
orders = []
|
||||||
|
for f in sorted(week_dir.glob("*.json")):
|
||||||
|
with open(f) as fh:
|
||||||
|
orders.append(json.load(fh))
|
||||||
|
return orders
|
||||||
|
|
||||||
|
|
||||||
|
def generate_order_summary(orders: list[dict]) -> dict:
|
||||||
|
"""Aggregate all meals across employees into a single order for Redefine."""
|
||||||
|
meal_totals = defaultdict(lambda: {"quantity": 0, "unit_price": 0})
|
||||||
|
for order in orders:
|
||||||
|
for item in order.get("items", []):
|
||||||
|
name = item["name"]
|
||||||
|
meal_totals[name]["quantity"] += item.get("quantity", 0)
|
||||||
|
meal_totals[name]["unit_price"] = item.get("price", 0)
|
||||||
|
|
||||||
|
summary = []
|
||||||
|
for name, data in sorted(meal_totals.items()):
|
||||||
|
summary.append({
|
||||||
|
"meal": name,
|
||||||
|
"quantity": data["quantity"],
|
||||||
|
"unit_price": data["unit_price"],
|
||||||
|
"line_total": round(data["unit_price"] * data["quantity"], 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
grand_total = sum(s["line_total"] for s in summary)
|
||||||
|
total_meals = sum(s["quantity"] for s in summary)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"week": orders[0]["week"] if orders else "",
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
"total_employees": len(orders),
|
||||||
|
"total_meals": total_meals,
|
||||||
|
"grand_total": round(grand_total, 2),
|
||||||
|
"meals": summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_payroll_csv(orders: list[dict], output_path: Path):
|
||||||
|
"""Write a CSV for payroll with one row per employee."""
|
||||||
|
with open(output_path, "w", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(["Employee Name", "Employee Email", "Items Ordered", "Total Deduction"])
|
||||||
|
for order in sorted(orders, key=lambda o: o["employee_name"]):
|
||||||
|
items_str = "; ".join(
|
||||||
|
f"{item['name']} x{item['quantity']} (${item['subtotal']:.2f})"
|
||||||
|
for item in order.get("items", [])
|
||||||
|
)
|
||||||
|
writer.writerow([
|
||||||
|
order["employee_name"],
|
||||||
|
order["employee_email"],
|
||||||
|
items_str,
|
||||||
|
f"${order['total']:.2f}",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def generate_order_summary_csv(summary: dict, output_path: Path):
|
||||||
|
"""Write a CSV of the aggregated order for submitting to Redefine."""
|
||||||
|
with open(output_path, "w", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(["Meal", "Quantity", "Unit Price", "Line Total"])
|
||||||
|
for meal in summary["meals"]:
|
||||||
|
writer.writerow([
|
||||||
|
meal["meal"],
|
||||||
|
meal["quantity"],
|
||||||
|
f"${meal['unit_price']:.2f}",
|
||||||
|
f"${meal['line_total']:.2f}",
|
||||||
|
])
|
||||||
|
writer.writerow([])
|
||||||
|
writer.writerow(["TOTAL", summary["total_meals"], "", f"${summary['grand_total']:.2f}"])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
week = sys.argv[1] if len(sys.argv) > 1 else current_week()
|
||||||
|
orders = load_orders(week)
|
||||||
|
|
||||||
|
if not orders:
|
||||||
|
print(f"No orders found for week {week}")
|
||||||
|
print(f" Expected directory: {ORDERS_DIR / week}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Processing {len(orders)} orders for week {week}")
|
||||||
|
|
||||||
|
summary = generate_order_summary(orders)
|
||||||
|
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Save order summary
|
||||||
|
summary_json = OUTPUT_DIR / f"order-summary-{week}.json"
|
||||||
|
with open(summary_json, "w") as f:
|
||||||
|
json.dump(summary, f, indent=2)
|
||||||
|
|
||||||
|
summary_csv = OUTPUT_DIR / f"order-summary-{week}.csv"
|
||||||
|
generate_order_summary_csv(summary, summary_csv)
|
||||||
|
|
||||||
|
# Save payroll report
|
||||||
|
payroll_csv = OUTPUT_DIR / f"payroll-deductions-{week}.csv"
|
||||||
|
generate_payroll_csv(orders, payroll_csv)
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"ORDER SUMMARY — Week {week}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"{'Meal':<45} {'Qty':>4} {'Total':>8}")
|
||||||
|
print("-" * 60)
|
||||||
|
for meal in summary["meals"]:
|
||||||
|
print(f"{meal['meal']:<45} {meal['quantity']:>4} ${meal['line_total']:>7.2f}")
|
||||||
|
print("-" * 60)
|
||||||
|
print(f"{'TOTAL':<45} {summary['total_meals']:>4} ${summary['grand_total']:>7.2f}")
|
||||||
|
print(f"\n{summary['total_employees']} employees ordered")
|
||||||
|
|
||||||
|
print(f"\nFiles generated:")
|
||||||
|
print(f" Order summary: {summary_csv}")
|
||||||
|
print(f" Payroll report: {payroll_csv}")
|
||||||
|
print(f" Raw JSON: {summary_json}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
122
src/scraper/recon.py
Normal file
122
src/scraper/recon.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""
|
||||||
|
Reconnaissance script: loads redefinemeals.com/menu in a real browser,
|
||||||
|
intercepts all network requests, and dumps the page structure.
|
||||||
|
Run this once to understand the site before building the production scraper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
def run_recon():
|
||||||
|
api_responses = []
|
||||||
|
all_requests = []
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context(
|
||||||
|
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
def handle_response(response):
|
||||||
|
url = response.url
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
all_requests.append({
|
||||||
|
"url": url,
|
||||||
|
"status": response.status,
|
||||||
|
"content_type": content_type,
|
||||||
|
})
|
||||||
|
if "json" in content_type or "graphql" in url.lower():
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
api_responses.append({
|
||||||
|
"url": url,
|
||||||
|
"status": response.status,
|
||||||
|
"body_preview": json.dumps(body)[:2000],
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif url.endswith(".json") or "/api/" in url:
|
||||||
|
try:
|
||||||
|
body = response.text()
|
||||||
|
api_responses.append({
|
||||||
|
"url": url,
|
||||||
|
"status": response.status,
|
||||||
|
"body_preview": body[:2000],
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
page.on("response", handle_response)
|
||||||
|
|
||||||
|
print("Navigating to https://redefinemeals.com/menu ...")
|
||||||
|
page.goto("https://redefinemeals.com/menu", wait_until="networkidle", timeout=60000)
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print("PAGE TITLE:", page.title())
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
print(f"\n--- All network requests ({len(all_requests)} total) ---")
|
||||||
|
for req in all_requests:
|
||||||
|
if any(ext in req["url"] for ext in [".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".css"]):
|
||||||
|
continue
|
||||||
|
print(f" [{req['status']}] {req['content_type'][:30]:30s} {req['url'][:120]}")
|
||||||
|
|
||||||
|
print(f"\n--- JSON/API responses ({len(api_responses)} found) ---")
|
||||||
|
for resp in api_responses:
|
||||||
|
print(f"\n URL: {resp['url']}")
|
||||||
|
print(f" Status: {resp['status']}")
|
||||||
|
print(f" Body preview:\n {resp['body_preview'][:500]}")
|
||||||
|
|
||||||
|
print(f"\n--- Page structure (meal-related elements) ---")
|
||||||
|
for selector in [
|
||||||
|
"[class*='meal']", "[class*='menu']", "[class*='product']",
|
||||||
|
"[class*='item']", "[class*='card']", "[data-product]",
|
||||||
|
"[data-item]", ".grid > div", "article",
|
||||||
|
]:
|
||||||
|
elements = page.query_selector_all(selector)
|
||||||
|
if elements:
|
||||||
|
print(f"\n Selector '{selector}': {len(elements)} elements")
|
||||||
|
if elements:
|
||||||
|
first = elements[0]
|
||||||
|
print(f" Tag: {first.evaluate('el => el.tagName')}")
|
||||||
|
print(f" Classes: {first.evaluate('el => el.className')}")
|
||||||
|
inner = first.inner_text()
|
||||||
|
print(f" Text preview: {inner[:200]}")
|
||||||
|
|
||||||
|
# Also dump outer HTML of likely meal containers
|
||||||
|
print(f"\n--- Raw HTML sample (first product/card element) ---")
|
||||||
|
for selector in ["[class*='product']", "[class*='card']", "[class*='meal']", "[class*='menu-item']"]:
|
||||||
|
els = page.query_selector_all(selector)
|
||||||
|
if els:
|
||||||
|
html = els[0].evaluate("el => el.outerHTML")
|
||||||
|
print(f"\n Selector: {selector}")
|
||||||
|
print(f" Count: {len(els)}")
|
||||||
|
print(f" First element HTML:\n{html[:1500]}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for Shopify, WooCommerce, or other known platforms
|
||||||
|
print(f"\n--- Platform detection ---")
|
||||||
|
platform_checks = {
|
||||||
|
"Shopify": "Shopify" in page.content(),
|
||||||
|
"WooCommerce": "woocommerce" in page.content().lower(),
|
||||||
|
"Squarespace": "squarespace" in page.content().lower(),
|
||||||
|
"Wix": "wix" in page.content().lower(),
|
||||||
|
}
|
||||||
|
for name, found in platform_checks.items():
|
||||||
|
if found:
|
||||||
|
print(f" Detected: {name}")
|
||||||
|
|
||||||
|
# Try to get the full page URL after any redirects
|
||||||
|
print(f"\n Final URL: {page.url}")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
return api_responses
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_recon()
|
||||||
106
src/scraper/recon_deep.py
Normal file
106
src/scraper/recon_deep.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""
|
||||||
|
Deep recon: extract the full structure of a single meal card
|
||||||
|
and check for menu-related API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
api_hits = []
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context(
|
||||||
|
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
def capture_response(response):
|
||||||
|
ct = response.headers.get("content-type", "")
|
||||||
|
if "json" in ct or "/api/" in response.url:
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
api_hits.append({"url": response.url, "body": body})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
page.on("response", capture_response)
|
||||||
|
|
||||||
|
page.goto("https://www.redefinemeals.com/menu", wait_until="networkidle", timeout=60000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
# Extract detailed structure from first few article cards
|
||||||
|
articles = page.query_selector_all("article.editorial_card")
|
||||||
|
print(f"Found {len(articles)} article.editorial_card elements\n")
|
||||||
|
|
||||||
|
for i, article in enumerate(articles[:3]):
|
||||||
|
html = article.evaluate("el => el.outerHTML")
|
||||||
|
text = article.inner_text()
|
||||||
|
print(f"--- Article {i} ---")
|
||||||
|
print(f"Text:\n{text}\n")
|
||||||
|
print(f"HTML:\n{html[:3000]}\n")
|
||||||
|
|
||||||
|
# Check for dietary tag elements
|
||||||
|
print("\n--- Dietary/tag elements ---")
|
||||||
|
for sel in ["[class*='tag']", "[class*='diet']", "[class*='label']", "[class*='badge']", "[class*='filter']"]:
|
||||||
|
els = page.query_selector_all(sel)
|
||||||
|
if els:
|
||||||
|
texts = [e.inner_text().strip() for e in els[:10] if e.inner_text().strip()]
|
||||||
|
print(f" {sel}: {len(els)} elements, samples: {texts}")
|
||||||
|
|
||||||
|
# Check for filter/category buttons
|
||||||
|
print("\n--- Filter/category buttons ---")
|
||||||
|
for sel in ["button", "[class*='filter']", "[class*='category']", "[class*='tab']"]:
|
||||||
|
els = page.query_selector_all(sel)
|
||||||
|
if els:
|
||||||
|
texts = [e.inner_text().strip() for e in els[:20] if e.inner_text().strip()]
|
||||||
|
print(f" {sel}: {texts}")
|
||||||
|
|
||||||
|
# Try known API patterns
|
||||||
|
print("\n--- Trying API endpoints ---")
|
||||||
|
for path in ["/api/menu", "/api/products", "/api/meals", "/api/items", "/api/categories"]:
|
||||||
|
try:
|
||||||
|
resp = page.evaluate(f"""
|
||||||
|
async () => {{
|
||||||
|
const r = await fetch('{path}');
|
||||||
|
if (r.ok) return await r.text();
|
||||||
|
return `${{r.status}}`;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
if resp and resp not in ["404", "500", "403"]:
|
||||||
|
print(f" {path}: {resp[:500]}")
|
||||||
|
else:
|
||||||
|
print(f" {path}: {resp}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {path}: error - {e}")
|
||||||
|
|
||||||
|
# Check for Quick View modal data
|
||||||
|
print("\n--- Quick View data (click first meal) ---")
|
||||||
|
quick_view_btns = page.query_selector_all("[class*='quick']")
|
||||||
|
if quick_view_btns:
|
||||||
|
print(f" Found {len(quick_view_btns)} Quick View buttons")
|
||||||
|
try:
|
||||||
|
quick_view_btns[0].click()
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
# Look for modal content
|
||||||
|
for sel in [".modal", "[class*='modal']", "[class*='popup']", "[class*='quick-view']", "[class*='quickview']"]:
|
||||||
|
modal = page.query_selector(sel)
|
||||||
|
if modal and modal.is_visible():
|
||||||
|
print(f" Modal selector: {sel}")
|
||||||
|
print(f" Modal text:\n{modal.inner_text()[:1000]}")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Quick View click failed: {e}")
|
||||||
|
|
||||||
|
print(f"\n--- API calls captured ---")
|
||||||
|
for hit in api_hits:
|
||||||
|
print(f" {hit['url']}")
|
||||||
|
print(f" {json.dumps(hit['body'])[:500]}\n")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
259
src/scraper/scrape_menu.py
Normal file
259
src/scraper/scrape_menu.py
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
"""
|
||||||
|
Redefine Meals menu scraper.
|
||||||
|
|
||||||
|
Navigates to the menu page using a headless browser, waits for the
|
||||||
|
Vue.js SPA to render, and extracts structured meal data from the DOM.
|
||||||
|
Also intercepts network requests to detect any JSON API that could
|
||||||
|
replace the browser scrape in the future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from playwright.sync_api import sync_playwright, TimeoutError as PwTimeout
|
||||||
|
|
||||||
|
CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_menu(url: str, *, headless: bool = True, timeout_ms: int = 60_000) -> dict:
|
||||||
|
"""
|
||||||
|
Returns {
|
||||||
|
"scraped_at": ISO timestamp,
|
||||||
|
"menu_url": str,
|
||||||
|
"api_endpoints_found": [str],
|
||||||
|
"meals": [ { name, price, calories, protein, dietary_tags,
|
||||||
|
image_url, is_new, description } ]
|
||||||
|
}
|
||||||
|
Raises RuntimeError if the page fails to load or no meals are found.
|
||||||
|
"""
|
||||||
|
api_endpoints = []
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=headless)
|
||||||
|
context = browser.new_context(
|
||||||
|
user_agent=(
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
def on_response(response):
|
||||||
|
ct = response.headers.get("content-type", "")
|
||||||
|
if "json" in ct and "/api/" in response.url and "cart" not in response.url:
|
||||||
|
api_endpoints.append(response.url)
|
||||||
|
|
||||||
|
page.on("response", on_response)
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.goto(url, wait_until="networkidle", timeout=timeout_ms)
|
||||||
|
except PwTimeout:
|
||||||
|
browser.close()
|
||||||
|
raise RuntimeError(f"Timed out loading {url}")
|
||||||
|
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
articles = page.query_selector_all("article.editorial_card")
|
||||||
|
if not articles:
|
||||||
|
browser.close()
|
||||||
|
raise RuntimeError(
|
||||||
|
"No meal cards found on page. The site layout may have changed. "
|
||||||
|
"Run recon.py to inspect the current structure."
|
||||||
|
)
|
||||||
|
|
||||||
|
meals = []
|
||||||
|
for article in articles:
|
||||||
|
meal = _extract_card(article)
|
||||||
|
if meal:
|
||||||
|
meals.append(meal)
|
||||||
|
|
||||||
|
# Try to get descriptions via Quick View modals
|
||||||
|
_enrich_with_descriptions(page, articles, meals)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
if not meals:
|
||||||
|
raise RuntimeError("Scraped 0 meals — extraction selectors are likely broken.")
|
||||||
|
|
||||||
|
for meal in meals:
|
||||||
|
meal["dietary_tags"] = _clean_tags(meal["dietary_tags"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scraped_at": datetime.now().isoformat(),
|
||||||
|
"menu_url": url,
|
||||||
|
"api_endpoints_found": api_endpoints,
|
||||||
|
"meal_count": len(meals),
|
||||||
|
"meals": meals,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_card(article) -> dict | None:
|
||||||
|
try:
|
||||||
|
name_el = article.query_selector("h2.meal_title")
|
||||||
|
if not name_el:
|
||||||
|
return None
|
||||||
|
name = name_el.inner_text().strip()
|
||||||
|
|
||||||
|
price_el = article.query_selector(".meal_price")
|
||||||
|
price_text = price_el.inner_text().strip() if price_el else ""
|
||||||
|
price = _parse_price(price_text)
|
||||||
|
|
||||||
|
cal_el = article.query_selector(".card_macros_brief")
|
||||||
|
calories = None
|
||||||
|
protein = None
|
||||||
|
if cal_el:
|
||||||
|
macros_text = cal_el.inner_text()
|
||||||
|
calories, protein = _parse_macros(macros_text)
|
||||||
|
|
||||||
|
tag_els = article.query_selector_all(".diet_mini_tag")
|
||||||
|
dietary_tags = [t.inner_text().strip().title() for t in tag_els if t.inner_text().strip()]
|
||||||
|
|
||||||
|
img_el = article.query_selector("img.main_meal_img")
|
||||||
|
image_url = img_el.get_attribute("src") if img_el else None
|
||||||
|
|
||||||
|
is_new = article.query_selector(".new_badge_pulse") is not None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"price": price,
|
||||||
|
"calories": calories,
|
||||||
|
"protein": protein,
|
||||||
|
"dietary_tags": dietary_tags,
|
||||||
|
"image_url": image_url,
|
||||||
|
"is_new": is_new,
|
||||||
|
"description": None,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: failed to extract a card: {e}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_with_descriptions(page, articles, meals):
|
||||||
|
"""Click each meal's Quick View overlay to grab the description."""
|
||||||
|
for i, article in enumerate(articles):
|
||||||
|
if i >= len(meals):
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
overlay = article.query_selector(".card_overlay")
|
||||||
|
if not overlay:
|
||||||
|
continue
|
||||||
|
article.query_selector(".card_media_wrap").click()
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
modal = page.query_selector(".modal.show, [class*='modal'][class*='show'], [class*='quickview']")
|
||||||
|
if not modal:
|
||||||
|
# Try broader selector
|
||||||
|
modal = page.query_selector("[class*='modal']:not([style*='display: none'])")
|
||||||
|
if modal and modal.is_visible():
|
||||||
|
desc_el = modal.query_selector("[class*='description'], [class*='desc'], .meal_description")
|
||||||
|
if desc_el:
|
||||||
|
desc_text = desc_el.inner_text().strip()
|
||||||
|
# Filter out price strings and very short text
|
||||||
|
if desc_text and len(desc_text) > 10 and not desc_text.startswith("$"):
|
||||||
|
meals[i]["description"] = desc_text
|
||||||
|
|
||||||
|
# Grab full macro details if available
|
||||||
|
detail_tags = modal.query_selector_all(".diet_mini_tag, [class*='lifestyle'] span")
|
||||||
|
for tag_el in detail_tags:
|
||||||
|
tag_text = tag_el.inner_text().strip().title()
|
||||||
|
if tag_text and tag_text not in meals[i]["dietary_tags"]:
|
||||||
|
meals[i]["dietary_tags"].append(tag_text)
|
||||||
|
|
||||||
|
# Close modal
|
||||||
|
close_btn = modal.query_selector("button[class*='close'], [aria-label='Close'], .btn-close")
|
||||||
|
if close_btn:
|
||||||
|
close_btn.click()
|
||||||
|
else:
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: Quick View failed for meal {i} ({meals[i]['name']}): {e}", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
KNOWN_TAGS = ["Gluten Free", "Dairy Free", "Grass-Fed", "Low Carb", "Keto", "Vegan", "Vegetarian", "Nut Free"]
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_tags(raw_tags: list[str]) -> list[str]:
|
||||||
|
"""Split concatenated tags and deduplicate."""
|
||||||
|
import re
|
||||||
|
cleaned = set()
|
||||||
|
for raw in raw_tags:
|
||||||
|
# Split on known tag boundaries (e.g., "Gluten Freedairy Free" → "Gluten Free", "Dairy Free")
|
||||||
|
remaining = raw
|
||||||
|
for known in KNOWN_TAGS:
|
||||||
|
if known.lower() in remaining.lower():
|
||||||
|
cleaned.add(known)
|
||||||
|
remaining = re.sub(re.escape(known), "", remaining, flags=re.IGNORECASE).strip()
|
||||||
|
if remaining and len(remaining) > 2:
|
||||||
|
cleaned.add(remaining.strip().title())
|
||||||
|
return sorted(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_price(text: str) -> float | None:
|
||||||
|
text = text.replace("$", "").replace(",", "").strip()
|
||||||
|
try:
|
||||||
|
return float(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_macros(text: str) -> tuple[int | None, str | None]:
|
||||||
|
"""Parse '570cal • 39gP' into (570, '39g')."""
|
||||||
|
import re
|
||||||
|
cal_match = re.search(r"(\d+)\s*cal", text, re.IGNORECASE)
|
||||||
|
prot_match = re.search(r"(\d+g?)\s*P", text)
|
||||||
|
calories = int(cal_match.group(1)) if cal_match else None
|
||||||
|
protein = prot_match.group(1) if prot_match else None
|
||||||
|
if protein and not protein.endswith("g"):
|
||||||
|
protein += "g"
|
||||||
|
return calories, protein
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
config = load_config()
|
||||||
|
url = config.get("menu_url", "https://www.redefinemeals.com/menu")
|
||||||
|
output_dir = Path(__file__).resolve().parents[2] / config.get("output_dir", "output")
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Scraping menu from {url} ...")
|
||||||
|
result = scrape_menu(url)
|
||||||
|
|
||||||
|
week_str = datetime.now().strftime("%Y-W%U")
|
||||||
|
output_file = output_dir / f"menu-{week_str}.json"
|
||||||
|
with open(output_file, "w") as f:
|
||||||
|
json.dump(result, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\nScraped {result['meal_count']} meals")
|
||||||
|
if result["api_endpoints_found"]:
|
||||||
|
print(f"API endpoints detected (potential future shortcut):")
|
||||||
|
for ep in result["api_endpoints_found"]:
|
||||||
|
print(f" {ep}")
|
||||||
|
print(f"Output saved to {output_file}")
|
||||||
|
|
||||||
|
# Print summary table
|
||||||
|
print(f"\n{'Name':<40} {'Price':>7} {'Cal':>5} {'Prot':>5} {'Tags'}")
|
||||||
|
print("-" * 90)
|
||||||
|
for m in result["meals"]:
|
||||||
|
tags = ", ".join(m["dietary_tags"]) if m["dietary_tags"] else ""
|
||||||
|
new = " *NEW*" if m["is_new"] else ""
|
||||||
|
price = f"${m['price']:.2f}" if m["price"] else "?"
|
||||||
|
cal = str(m["calories"]) if m["calories"] else "?"
|
||||||
|
prot = m["protein"] or "?"
|
||||||
|
print(f"{(m['name'] + new):<40} {price:>7} {cal:>5} {prot:>5} {tags}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
121
src/server/app.py
Normal file
121
src/server/app.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""
|
||||||
|
Lightweight Flask server for the meal order form.
|
||||||
|
|
||||||
|
Serves the generated HTML form and handles order submissions.
|
||||||
|
Orders are saved as JSON files in the orders directory, one per employee per week.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request, send_file
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CONFIG_PATH = PROJECT_ROOT / "config.json"
|
||||||
|
OUTPUT_DIR = PROJECT_ROOT / "output"
|
||||||
|
ORDERS_DIR = PROJECT_ROOT / "orders"
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def current_week() -> str:
|
||||||
|
return datetime.now().strftime("%Y-W%U")
|
||||||
|
|
||||||
|
|
||||||
|
def latest_menu_file() -> Path | None:
|
||||||
|
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
|
||||||
|
return files[0] if files else None
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
form_file = OUTPUT_DIR / f"order-form-{current_week()}.html"
|
||||||
|
if not form_file.exists():
|
||||||
|
return "No order form generated for this week. Run generate_form.py first.", 404
|
||||||
|
return send_file(form_file)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/menu")
|
||||||
|
def get_menu():
|
||||||
|
menu_file = latest_menu_file()
|
||||||
|
if not menu_file:
|
||||||
|
return jsonify({"error": "No menu data available. Run scrape_menu.py first."}), 404
|
||||||
|
with open(menu_file) as f:
|
||||||
|
return jsonify(json.load(f))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/roster")
|
||||||
|
def get_roster():
|
||||||
|
config = load_config()
|
||||||
|
return jsonify(config.get("roster", []))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/submit-order", methods=["POST"])
|
||||||
|
def submit_order():
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "No data received"}), 400
|
||||||
|
|
||||||
|
name = data.get("employee_name", "").strip()
|
||||||
|
email = data.get("employee_email", "").strip()
|
||||||
|
items = data.get("items", [])
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "Employee name is required"}), 400
|
||||||
|
if not email:
|
||||||
|
return jsonify({"error": "Employee email is required"}), 400
|
||||||
|
if not items or not any(i.get("quantity", 0) > 0 for i in items):
|
||||||
|
return jsonify({"error": "Please select at least one meal"}), 400
|
||||||
|
|
||||||
|
week = current_week()
|
||||||
|
week_dir = ORDERS_DIR / week
|
||||||
|
week_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
safe_name = "".join(c if c.isalnum() or c in "-_ " else "" for c in name).strip().replace(" ", "-").lower()
|
||||||
|
order_file = week_dir / f"{safe_name}.json"
|
||||||
|
|
||||||
|
order = {
|
||||||
|
"employee_name": name,
|
||||||
|
"employee_email": email,
|
||||||
|
"week": week,
|
||||||
|
"submitted_at": datetime.now().isoformat(),
|
||||||
|
"items": [i for i in items if i.get("quantity", 0) > 0],
|
||||||
|
"total": sum(
|
||||||
|
(i.get("price", 0) or 0) * i.get("quantity", 0)
|
||||||
|
for i in items
|
||||||
|
if i.get("quantity", 0) > 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(order_file, "w") as f:
|
||||||
|
json.dump(order, f, indent=2)
|
||||||
|
|
||||||
|
return jsonify({"status": "ok", "message": f"Order saved for {name}", "total": order["total"]})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/orders/<week>")
|
||||||
|
def get_orders(week: str):
|
||||||
|
week_dir = ORDERS_DIR / week
|
||||||
|
if not week_dir.exists():
|
||||||
|
return jsonify({"orders": [], "week": week})
|
||||||
|
|
||||||
|
orders = []
|
||||||
|
for f in sorted(week_dir.glob("*.json")):
|
||||||
|
with open(f) as fh:
|
||||||
|
orders.append(json.load(fh))
|
||||||
|
|
||||||
|
return jsonify({"orders": orders, "week": week})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ORDERS_DIR.mkdir(exist_ok=True)
|
||||||
|
print(f"Menu file: {latest_menu_file()}")
|
||||||
|
print(f"Orders dir: {ORDERS_DIR}")
|
||||||
|
app.run(host="0.0.0.0", port=5050, debug=True)
|
||||||
321
src/server/generate_form.py
Normal file
321
src/server/generate_form.py
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
"""
|
||||||
|
Generates a self-contained HTML order form from the latest scraped menu.
|
||||||
|
|
||||||
|
The form shows this week's meals with quantity selectors, a running total,
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
OUTPUT_DIR = PROJECT_ROOT / "output"
|
||||||
|
CONFIG_PATH = PROJECT_ROOT / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_menu() -> dict:
|
||||||
|
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)
|
||||||
|
with open(files[0]) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_form(menu: dict, config: dict, api_url: str = "", api_key: str = "") -> str:
|
||||||
|
meals_json = json.dumps(menu["meals"])
|
||||||
|
roster_json = json.dumps(config.get("roster", []))
|
||||||
|
deadline = config.get("order_deadline", "Thursday 6:00 PM")
|
||||||
|
week = datetime.now().strftime("%Y-W%U")
|
||||||
|
scraped_at = menu.get("scraped_at", "unknown")
|
||||||
|
submit_url = f"{api_url}/api/submit-order" if api_url else "/api/submit-order"
|
||||||
|
status_url = f"{api_url}/api/form-status/{week}" if api_url else ""
|
||||||
|
api_key_json = json.dumps(api_key)
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sea Haven — Meal Order ({week})</title>
|
||||||
|
<style>
|
||||||
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; color: #1d1d1f; }}
|
||||||
|
.container {{ max-width: 800px; margin: 0 auto; padding: 16px; }}
|
||||||
|
header {{ background: #1a1a2e; color: #fff; padding: 24px 16px; text-align: center; margin-bottom: 24px; border-radius: 12px; }}
|
||||||
|
header h1 {{ font-size: 1.5rem; margin-bottom: 4px; }}
|
||||||
|
header p {{ font-size: 0.85rem; opacity: 0.8; }}
|
||||||
|
.deadline {{ background: #fef3c7; color: #92400e; padding: 10px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 0.9rem; text-align: center; font-weight: 500; }}
|
||||||
|
.employee-info {{ background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
|
||||||
|
.employee-info label {{ display: block; font-weight: 600; margin-bottom: 4px; font-size: 0.85rem; }}
|
||||||
|
.employee-info input, .employee-info select {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 12px; }}
|
||||||
|
.filters {{ display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }}
|
||||||
|
.filters button {{ padding: 6px 14px; border: 1px solid #d1d5db; border-radius: 20px; background: #fff; font-size: 0.8rem; cursor: pointer; transition: all 0.2s; }}
|
||||||
|
.filters button.active {{ background: #1a1a2e; color: #fff; border-color: #1a1a2e; }}
|
||||||
|
.meal-card {{ background: #fff; border-radius: 12px; padding: 12px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); display: flex; gap: 12px; align-items: center; }}
|
||||||
|
.meal-img {{ width: 80px; height: 80px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }}
|
||||||
|
.meal-info {{ flex: 1; min-width: 0; }}
|
||||||
|
.meal-name {{ font-weight: 600; font-size: 0.95rem; margin-bottom: 2px; }}
|
||||||
|
.meal-meta {{ font-size: 0.8rem; color: #6b7280; margin-bottom: 4px; }}
|
||||||
|
.meal-tags {{ display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }}
|
||||||
|
.meal-tags span {{ font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: #e0f2fe; color: #0369a1; font-weight: 500; }}
|
||||||
|
.meal-tags span.new {{ background: #dcfce7; color: #15803d; }}
|
||||||
|
.qty-control {{ display: flex; align-items: center; gap: 0; flex-shrink: 0; }}
|
||||||
|
.qty-control button {{ width: 32px; height: 32px; border: 1px solid #d1d5db; background: #f9fafb; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }}
|
||||||
|
.qty-control button:first-child {{ border-radius: 8px 0 0 8px; }}
|
||||||
|
.qty-control button:last-child {{ border-radius: 0 8px 8px 0; }}
|
||||||
|
.qty-control .qty {{ width: 36px; height: 32px; text-align: center; border: 1px solid #d1d5db; border-left: 0; border-right: 0; font-size: 0.95rem; font-weight: 600; }}
|
||||||
|
.meal-card.selected {{ border: 2px solid #1a1a2e; }}
|
||||||
|
.sticky-footer {{ position: fixed; bottom: 0; left: 0; right: 0; background: #fff; border-top: 1px solid #e5e7eb; padding: 12px 16px; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); z-index: 100; }}
|
||||||
|
.sticky-footer .inner {{ max-width: 800px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; }}
|
||||||
|
.total {{ font-size: 1.1rem; font-weight: 700; }}
|
||||||
|
.submit-btn {{ background: #1a1a2e; color: #fff; border: none; padding: 12px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }}
|
||||||
|
.submit-btn:disabled {{ opacity: 0.4; cursor: not-allowed; }}
|
||||||
|
.submit-btn:hover:not(:disabled) {{ opacity: 0.85; }}
|
||||||
|
.success-msg {{ text-align: center; padding: 60px 20px; }}
|
||||||
|
.success-msg h2 {{ color: #15803d; margin-bottom: 8px; }}
|
||||||
|
.meal-desc {{ font-size: 0.8rem; color: #6b7280; margin-bottom: 4px; }}
|
||||||
|
.search-bar {{ width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; margin-bottom: 16px; }}
|
||||||
|
body {{ padding-bottom: 80px; }}
|
||||||
|
.closed-banner {{ background: #fee2e2; color: #991b1b; padding: 16px; border-radius: 8px; margin-bottom: 20px; text-align: center; font-weight: 600; font-size: 1rem; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container" id="app">
|
||||||
|
<header>
|
||||||
|
<h1>Sea Haven Meal Order</h1>
|
||||||
|
<p>Week of {week} · Menu scraped {scraped_at[:10]}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="deadline">Order deadline: {deadline}</div>
|
||||||
|
|
||||||
|
<div class="employee-info">
|
||||||
|
<label for="emp-name">Your Name</label>
|
||||||
|
<input type="text" id="emp-name" placeholder="Full name" autocomplete="name">
|
||||||
|
<label for="emp-email">Your Email</label>
|
||||||
|
<input type="email" id="emp-email" placeholder="you@seahavenind.com" autocomplete="email">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="text" class="search-bar" id="search" placeholder="Search meals...">
|
||||||
|
|
||||||
|
<div class="filters" id="filters"></div>
|
||||||
|
|
||||||
|
<div id="meals-list"></div>
|
||||||
|
|
||||||
|
<div class="sticky-footer">
|
||||||
|
<div class="inner">
|
||||||
|
<div>
|
||||||
|
<span class="total" id="total-display">$0.00</span>
|
||||||
|
<span style="font-size:0.8rem;color:#6b7280;margin-left:8px;" id="count-display">0 meals</span>
|
||||||
|
</div>
|
||||||
|
<button class="submit-btn" id="submit-btn" disabled>Submit Order</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="success" class="success-msg" style="display:none;">
|
||||||
|
<h2>Order submitted!</h2>
|
||||||
|
<p id="success-detail"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const MEALS = {meals_json};
|
||||||
|
const ROSTER = {roster_json};
|
||||||
|
const SUBMIT_URL = '{submit_url}';
|
||||||
|
const STATUS_URL = '{status_url}';
|
||||||
|
const API_KEY = {api_key_json};
|
||||||
|
const WEEK = '{week}';
|
||||||
|
const quantities = {{}};
|
||||||
|
let formClosed = false;
|
||||||
|
|
||||||
|
function init() {{
|
||||||
|
checkFormStatus();
|
||||||
|
// Build filter buttons
|
||||||
|
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.textContent = cat;
|
||||||
|
if (cat === 'All Meals') btn.classList.add('active');
|
||||||
|
btn.onclick = () => {{
|
||||||
|
filtersEl.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
renderMeals();
|
||||||
|
}};
|
||||||
|
filtersEl.appendChild(btn);
|
||||||
|
}});
|
||||||
|
|
||||||
|
document.getElementById('search').addEventListener('input', renderMeals);
|
||||||
|
document.getElementById('submit-btn').addEventListener('click', submitOrder);
|
||||||
|
|
||||||
|
renderMeals();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function renderMeals() {{
|
||||||
|
const activeFilter = document.querySelector('.filters button.active')?.textContent || 'All Meals';
|
||||||
|
const search = document.getElementById('search').value.toLowerCase();
|
||||||
|
const list = document.getElementById('meals-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
MEALS.forEach((meal, i) => {{
|
||||||
|
if (activeFilter !== 'All Meals' && !(meal.dietary_tags || []).includes(activeFilter)) return;
|
||||||
|
if (search && !meal.name.toLowerCase().includes(search)) return;
|
||||||
|
|
||||||
|
const qty = quantities[i] || 0;
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'meal-card' + (qty > 0 ? ' selected' : '');
|
||||||
|
|
||||||
|
let tagsHtml = '';
|
||||||
|
if (meal.is_new) tagsHtml += '<span class="new">NEW</span>';
|
||||||
|
(meal.dietary_tags || []).forEach(t => {{ tagsHtml += `<span>${{t}}</span>`; }});
|
||||||
|
|
||||||
|
const descHtml = meal.description ? `<div class="meal-desc">${{meal.description}}</div>` : '';
|
||||||
|
const price = meal.price ? `$${{meal.price.toFixed(2)}}` : '—';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
${{meal.image_url ? `<img class="meal-img" src="${{meal.image_url}}" alt="${{meal.name}}" loading="lazy">` : ''}}
|
||||||
|
<div class="meal-info">
|
||||||
|
<div class="meal-name">${{meal.name}}</div>
|
||||||
|
${{descHtml}}
|
||||||
|
<div class="meal-meta">${{price}} · ${{meal.calories || '?'}} cal · ${{meal.protein || '?'}} protein</div>
|
||||||
|
<div class="meal-tags">${{tagsHtml}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="qty-control">
|
||||||
|
<button onclick="changeQty(${{i}}, -1)">−</button>
|
||||||
|
<input class="qty" type="text" value="${{qty}}" readonly>
|
||||||
|
<button onclick="changeQty(${{i}}, 1)">+</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
list.appendChild(card);
|
||||||
|
}});
|
||||||
|
|
||||||
|
updateTotal();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function changeQty(index, delta) {{
|
||||||
|
const current = quantities[index] || 0;
|
||||||
|
const next = Math.max(0, current + delta);
|
||||||
|
if (next === 0) delete quantities[index]; else quantities[index] = next;
|
||||||
|
renderMeals();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function updateTotal() {{
|
||||||
|
let total = 0, count = 0;
|
||||||
|
Object.entries(quantities).forEach(([i, qty]) => {{
|
||||||
|
total += (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' : ''}}`;
|
||||||
|
document.getElementById('submit-btn').disabled = count === 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function submitOrder() {{
|
||||||
|
const name = document.getElementById('emp-name').value.trim();
|
||||||
|
const email = document.getElementById('emp-email').value.trim();
|
||||||
|
if (!name) {{ alert('Please enter your name.'); return; }}
|
||||||
|
if (!email) {{ alert('Please enter your email.'); return; }}
|
||||||
|
|
||||||
|
const items = Object.entries(quantities).map(([i, qty]) => ({{
|
||||||
|
name: MEALS[i].name,
|
||||||
|
price: MEALS[i].price,
|
||||||
|
quantity: qty,
|
||||||
|
subtotal: (MEALS[i].price || 0) * qty,
|
||||||
|
}}));
|
||||||
|
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
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,
|
||||||
|
body: JSON.stringify({{ employee_name: name, employee_email: email, items }}),
|
||||||
|
}});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {{
|
||||||
|
document.getElementById('app').querySelectorAll(':not(#success)').forEach(el => el.style.display = 'none');
|
||||||
|
document.getElementById('success').style.display = 'block';
|
||||||
|
document.getElementById('success-detail').textContent = `${{name}} — $${{data.total.toFixed(2)}} total. You're all set!`;
|
||||||
|
document.querySelector('.sticky-footer').style.display = 'none';
|
||||||
|
}} else {{
|
||||||
|
alert(data.error || 'Something went wrong.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Submit Order';
|
||||||
|
}}
|
||||||
|
}} catch (e) {{
|
||||||
|
alert('Failed to submit. Check your connection and try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Submit Order';
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function checkFormStatus() {{
|
||||||
|
if (!STATUS_URL) return;
|
||||||
|
try {{
|
||||||
|
const res = await fetch(STATUS_URL);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'closed') {{
|
||||||
|
formClosed = true;
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.className = 'closed-banner';
|
||||||
|
banner.textContent = 'Orders are closed for this week.';
|
||||||
|
const deadline = document.querySelector('.deadline');
|
||||||
|
if (deadline) deadline.replaceWith(banner);
|
||||||
|
document.getElementById('submit-btn').disabled = true;
|
||||||
|
document.getElementById('submit-btn').textContent = 'Closed';
|
||||||
|
document.querySelectorAll('.qty-control button').forEach(b => b.disabled = true);
|
||||||
|
}}
|
||||||
|
}} catch (e) {{}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Generate meal order form HTML")
|
||||||
|
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)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
menu = latest_menu()
|
||||||
|
html = generate_form(menu, config, api_url=args.api_url, api_key=args.api_key)
|
||||||
|
|
||||||
|
week = datetime.now().strftime("%Y-W%U")
|
||||||
|
output_file = OUTPUT_DIR / f"order-form-{week}.html"
|
||||||
|
with open(output_file, "w") as f:
|
||||||
|
f.write(html)
|
||||||
|
|
||||||
|
mode = "cloud" if args.api_url else "local"
|
||||||
|
print(f"Generated order form ({mode} mode): {output_file}")
|
||||||
|
print(f" {menu['meal_count']} meals from menu scraped {menu['scraped_at'][:10]}")
|
||||||
|
if mode == "local":
|
||||||
|
print(f" Start the server with: python3 src/server/app.py")
|
||||||
|
else:
|
||||||
|
print(f" API endpoint: {args.api_url}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
src/shared/python/shared/__init__.py
Normal file
0
src/shared/python/shared/__init__.py
Normal file
112
src/shared/python/shared/db.py
Normal file
112
src/shared/python/shared/db.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from boto3.dynamodb.conditions import Key
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
|
_table = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_table():
|
||||||
|
global _table
|
||||||
|
if _table is None:
|
||||||
|
_table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
|
||||||
|
return _table
|
||||||
|
|
||||||
|
|
||||||
|
def current_week() -> str:
|
||||||
|
return datetime.now(EASTERN).strftime("%Y-W%U")
|
||||||
|
|
||||||
|
|
||||||
|
def previous_week() -> str:
|
||||||
|
now = datetime.now(EASTERN)
|
||||||
|
prev = now - __import__("datetime").timedelta(weeks=1)
|
||||||
|
return prev.strftime("%Y-W%U")
|
||||||
|
|
||||||
|
|
||||||
|
def ttl_days(days: int) -> int:
|
||||||
|
return int(time.time()) + (days * 86400)
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_menu(week: str) -> dict | None:
|
||||||
|
resp = _get_table().get_item(Key={"PK": f"WEEK#{week}", "SK": "MENU"})
|
||||||
|
return resp.get("Item")
|
||||||
|
|
||||||
|
|
||||||
|
def set_form_status(week: str, status: str):
|
||||||
|
_get_table().update_item(
|
||||||
|
Key={"PK": f"WEEK#{week}", "SK": "MENU"},
|
||||||
|
UpdateExpression="SET form_status = :s",
|
||||||
|
ExpressionAttributeValues={":s": status},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_form_status(week: str) -> str:
|
||||||
|
menu = get_menu(week)
|
||||||
|
if not menu:
|
||||||
|
return "not_found"
|
||||||
|
return menu.get("form_status", "closed")
|
||||||
|
|
||||||
|
|
||||||
|
def put_order(week: str, employee_slug: str, order_data: dict):
|
||||||
|
_get_table().put_item(Item={
|
||||||
|
"PK": f"WEEK#{week}",
|
||||||
|
"SK": f"ORDER#{employee_slug}",
|
||||||
|
"employee_name": order_data["employee_name"],
|
||||||
|
"employee_email": order_data["employee_email"],
|
||||||
|
"submitted_at": order_data["submitted_at"],
|
||||||
|
"items": order_data["items"],
|
||||||
|
"total": order_data["total"],
|
||||||
|
"ttl": ttl_days(90),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_orders(week: str) -> list[dict]:
|
||||||
|
resp = _get_table().query(
|
||||||
|
KeyConditionExpression=Key("PK").eq(f"WEEK#{week}") & Key("SK").begins_with("ORDER#"),
|
||||||
|
)
|
||||||
|
return resp.get("Items", [])
|
||||||
|
|
||||||
|
|
||||||
|
def put_summary(week: str, summary: dict):
|
||||||
|
_get_table().put_item(Item={
|
||||||
|
"PK": f"WEEK#{week}",
|
||||||
|
"SK": "SUMMARY",
|
||||||
|
"ttl": ttl_days(90),
|
||||||
|
**summary,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_summary(week: str) -> dict | None:
|
||||||
|
resp = _get_table().get_item(Key={"PK": f"WEEK#{week}", "SK": "SUMMARY"})
|
||||||
|
return resp.get("Item")
|
||||||
|
|
||||||
|
|
||||||
|
def get_roster() -> list[dict]:
|
||||||
|
resp = _get_table().get_item(Key={"PK": "CONFIG", "SK": "ROSTER"})
|
||||||
|
item = resp.get("Item")
|
||||||
|
return item.get("employees", []) if item else []
|
||||||
|
|
||||||
|
|
||||||
|
def put_roster(employees: list[dict]):
|
||||||
|
_get_table().put_item(Item={
|
||||||
|
"PK": "CONFIG",
|
||||||
|
"SK": "ROSTER",
|
||||||
|
"employees": employees,
|
||||||
|
"updated_at": datetime.now(EASTERN).isoformat(),
|
||||||
|
})
|
||||||
20
src/shared/python/shared/secrets.py
Normal file
20
src/shared/python/shared/secrets.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import os
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
_cache = {}
|
||||||
|
_sm = boto3.client("secretsmanager")
|
||||||
|
_ssm = boto3.client("ssm")
|
||||||
|
|
||||||
|
|
||||||
|
def get_secret(secret_id: str) -> str:
|
||||||
|
if secret_id not in _cache:
|
||||||
|
resp = _sm.get_secret_value(SecretId=secret_id)
|
||||||
|
_cache[secret_id] = resp["SecretString"]
|
||||||
|
return _cache[secret_id]
|
||||||
|
|
||||||
|
|
||||||
|
def get_parameter(name: str, decrypt: bool = True) -> str:
|
||||||
|
if name not in _cache:
|
||||||
|
resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt)
|
||||||
|
_cache[name] = resp["Parameter"]["Value"]
|
||||||
|
return _cache[name]
|
||||||
62
src/shared/python/shared/slack.py
Normal file
62
src/shared/python/shared/slack.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from shared.secrets import get_secret, get_parameter
|
||||||
|
|
||||||
|
_token = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_token() -> str:
|
||||||
|
global _token
|
||||||
|
if _token is None:
|
||||||
|
_token = get_secret(os.environ["SLACK_BOT_TOKEN_SECRET"])
|
||||||
|
return _token
|
||||||
|
|
||||||
|
|
||||||
|
def _get_channel() -> str:
|
||||||
|
return get_parameter(os.environ["SLACK_CHANNEL_PARAM"])
|
||||||
|
|
||||||
|
|
||||||
|
def _api_call(method: str, payload: dict) -> dict:
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://slack.com/api/{method}",
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {_get_token()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read())
|
||||||
|
|
||||||
|
|
||||||
|
def post_channel_message(text: str, blocks: list[dict] | None = None):
|
||||||
|
payload = {"channel": _get_channel(), "text": text}
|
||||||
|
if blocks:
|
||||||
|
payload["blocks"] = blocks
|
||||||
|
return _api_call("chat.postMessage", payload)
|
||||||
|
|
||||||
|
|
||||||
|
def send_dm(user_id: str, text: str, blocks: list[dict] | None = None):
|
||||||
|
open_resp = _api_call("conversations.open", {"users": user_id})
|
||||||
|
if not open_resp.get("ok"):
|
||||||
|
return open_resp
|
||||||
|
channel = open_resp["channel"]["id"]
|
||||||
|
payload = {"channel": channel, "text": text}
|
||||||
|
if blocks:
|
||||||
|
payload["blocks"] = blocks
|
||||||
|
return _api_call("chat.postMessage", payload)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(channel: str | None, filename: str, content: str, title: str = ""):
|
||||||
|
if channel is None:
|
||||||
|
channel = _get_channel()
|
||||||
|
payload = {
|
||||||
|
"channels": channel,
|
||||||
|
"content": content,
|
||||||
|
"filename": filename,
|
||||||
|
"title": title or filename,
|
||||||
|
}
|
||||||
|
return _api_call("files.upload", payload)
|
||||||
1
src/shared/requirements.txt
Normal file
1
src/shared/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
boto3
|
||||||
441
template.yaml
Normal file
441
template.yaml
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
AWSTemplateFormatVersion: '2010-09-09'
|
||||||
|
Transform: AWS::Serverless-2016-10-31
|
||||||
|
Description: >
|
||||||
|
meal-order-manager — automated weekly meal ordering from Redefine Meals
|
||||||
|
with employee order collection, Slack notifications, and payroll deduction reports.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
CustomDomain:
|
||||||
|
Type: String
|
||||||
|
Default: orders.seahavenind.com
|
||||||
|
Description: Custom domain for the order form (requires ACM cert)
|
||||||
|
CertificateArn:
|
||||||
|
Type: String
|
||||||
|
Default: ''
|
||||||
|
Description: ACM certificate ARN for the custom domain (us-east-1)
|
||||||
|
PayrollEmail:
|
||||||
|
Type: String
|
||||||
|
Default: payroll@seahavenind.com
|
||||||
|
Description: Email address for payroll deduction reports
|
||||||
|
SenderEmail:
|
||||||
|
Type: String
|
||||||
|
Default: adam@seahavenind.com
|
||||||
|
Description: SES verified sender email for payroll reports
|
||||||
|
FormApiKey:
|
||||||
|
Type: String
|
||||||
|
NoEcho: true
|
||||||
|
Description: API key embedded in the order form for submission auth
|
||||||
|
|
||||||
|
Conditions:
|
||||||
|
HasCustomDomain: !Not [!Equals [!Ref CertificateArn, '']]
|
||||||
|
|
||||||
|
Globals:
|
||||||
|
Function:
|
||||||
|
Runtime: python3.12
|
||||||
|
Architectures:
|
||||||
|
- arm64
|
||||||
|
Timeout: 30
|
||||||
|
MemorySize: 256
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
TABLE_NAME: !Ref OrdersTable
|
||||||
|
REPORTS_BUCKET: !Ref ReportsBucket
|
||||||
|
SLACK_BOT_TOKEN_SECRET: meal-order-manager/slack-bot-token
|
||||||
|
SLACK_CHANNEL_PARAM: /meal-order-manager/slack-channel-id
|
||||||
|
FORM_URL: !If
|
||||||
|
- HasCustomDomain
|
||||||
|
- !Sub 'https://${CustomDomain}'
|
||||||
|
- !Sub 'https://${FormDistribution.DomainName}'
|
||||||
|
Layers:
|
||||||
|
- !Ref SharedLayer
|
||||||
|
|
||||||
|
Resources:
|
||||||
|
|
||||||
|
# ─── Shared Layer ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
SharedLayer:
|
||||||
|
Type: AWS::Serverless::LayerVersion
|
||||||
|
Properties:
|
||||||
|
LayerName: meal-order-manager-shared
|
||||||
|
ContentUri: src/shared/
|
||||||
|
CompatibleRuntimes:
|
||||||
|
- python3.12
|
||||||
|
CompatibleArchitectures:
|
||||||
|
- arm64
|
||||||
|
Metadata:
|
||||||
|
BuildMethod: python3.12
|
||||||
|
BuildArchitecture: arm64
|
||||||
|
|
||||||
|
# ─── DynamoDB ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
OrdersTable:
|
||||||
|
Type: AWS::DynamoDB::Table
|
||||||
|
Properties:
|
||||||
|
TableName: meal-order-manager-orders
|
||||||
|
BillingMode: PAY_PER_REQUEST
|
||||||
|
AttributeDefinitions:
|
||||||
|
- AttributeName: PK
|
||||||
|
AttributeType: S
|
||||||
|
- AttributeName: SK
|
||||||
|
AttributeType: S
|
||||||
|
KeySchema:
|
||||||
|
- AttributeName: PK
|
||||||
|
KeyType: HASH
|
||||||
|
- AttributeName: SK
|
||||||
|
KeyType: RANGE
|
||||||
|
TimeToLiveSpecification:
|
||||||
|
AttributeName: ttl
|
||||||
|
Enabled: true
|
||||||
|
|
||||||
|
# ─── S3 Buckets ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
FormBucket:
|
||||||
|
Type: AWS::S3::Bucket
|
||||||
|
Properties:
|
||||||
|
BucketName: !Sub 'meal-order-manager-form-${AWS::AccountId}'
|
||||||
|
PublicAccessBlockConfiguration:
|
||||||
|
BlockPublicAcls: true
|
||||||
|
BlockPublicPolicy: true
|
||||||
|
IgnorePublicAcls: true
|
||||||
|
RestrictPublicBuckets: true
|
||||||
|
LifecycleConfiguration:
|
||||||
|
Rules:
|
||||||
|
- Id: delete-old-archives
|
||||||
|
Prefix: archive/
|
||||||
|
Status: Enabled
|
||||||
|
ExpirationInDays: 90
|
||||||
|
Tags:
|
||||||
|
- Key: Purpose
|
||||||
|
Value: meal-order-form-hosting
|
||||||
|
- Key: ManagedBy
|
||||||
|
Value: meal-order-manager
|
||||||
|
|
||||||
|
FormBucketPolicy:
|
||||||
|
Type: AWS::S3::BucketPolicy
|
||||||
|
Properties:
|
||||||
|
Bucket: !Ref FormBucket
|
||||||
|
PolicyDocument:
|
||||||
|
Version: '2012-10-17'
|
||||||
|
Statement:
|
||||||
|
- Sid: AllowCloudFrontOAC
|
||||||
|
Effect: Allow
|
||||||
|
Principal:
|
||||||
|
Service: cloudfront.amazonaws.com
|
||||||
|
Action: s3:GetObject
|
||||||
|
Resource: !Sub '${FormBucket.Arn}/*'
|
||||||
|
Condition:
|
||||||
|
StringEquals:
|
||||||
|
AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${FormDistribution}'
|
||||||
|
|
||||||
|
ReportsBucket:
|
||||||
|
Type: AWS::S3::Bucket
|
||||||
|
Properties:
|
||||||
|
BucketName: !Sub 'meal-order-manager-reports-${AWS::AccountId}'
|
||||||
|
PublicAccessBlockConfiguration:
|
||||||
|
BlockPublicAcls: true
|
||||||
|
BlockPublicPolicy: true
|
||||||
|
IgnorePublicAcls: true
|
||||||
|
RestrictPublicBuckets: true
|
||||||
|
LifecycleConfiguration:
|
||||||
|
Rules:
|
||||||
|
- Id: archive-old-reports
|
||||||
|
Status: Enabled
|
||||||
|
Transitions:
|
||||||
|
- StorageClass: GLACIER_IR
|
||||||
|
TransitionInDays: 90
|
||||||
|
Tags:
|
||||||
|
- Key: Purpose
|
||||||
|
Value: meal-order-reports
|
||||||
|
- Key: ManagedBy
|
||||||
|
Value: meal-order-manager
|
||||||
|
|
||||||
|
# ─── CloudFront ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
FormOAC:
|
||||||
|
Type: AWS::CloudFront::OriginAccessControl
|
||||||
|
Properties:
|
||||||
|
OriginAccessControlConfig:
|
||||||
|
Name: meal-order-manager-oac
|
||||||
|
OriginAccessControlOriginType: s3
|
||||||
|
SigningBehavior: always
|
||||||
|
SigningProtocol: sigv4
|
||||||
|
|
||||||
|
FormDistribution:
|
||||||
|
Type: AWS::CloudFront::Distribution
|
||||||
|
Properties:
|
||||||
|
DistributionConfig:
|
||||||
|
Enabled: true
|
||||||
|
DefaultRootObject: index.html
|
||||||
|
Comment: meal-order-manager form hosting
|
||||||
|
PriceClass: PriceClass_100
|
||||||
|
HttpVersion: http2and3
|
||||||
|
Aliases: !If
|
||||||
|
- HasCustomDomain
|
||||||
|
- [!Ref CustomDomain]
|
||||||
|
- !Ref AWS::NoValue
|
||||||
|
ViewerCertificate: !If
|
||||||
|
- HasCustomDomain
|
||||||
|
- AcmCertificateArn: !Ref CertificateArn
|
||||||
|
SslSupportMethod: sni-only
|
||||||
|
MinimumProtocolVersion: TLSv1.2_2021
|
||||||
|
- CloudFrontDefaultCertificate: true
|
||||||
|
Origins:
|
||||||
|
- Id: S3FormOrigin
|
||||||
|
DomainName: !GetAtt FormBucket.RegionalDomainName
|
||||||
|
OriginAccessControlId: !Ref FormOAC
|
||||||
|
S3OriginConfig:
|
||||||
|
OriginAccessIdentity: ''
|
||||||
|
DefaultCacheBehavior:
|
||||||
|
TargetOriginId: S3FormOrigin
|
||||||
|
ViewerProtocolPolicy: redirect-to-https
|
||||||
|
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
|
||||||
|
Compress: true
|
||||||
|
AllowedMethods:
|
||||||
|
- GET
|
||||||
|
- HEAD
|
||||||
|
CachedMethods:
|
||||||
|
- GET
|
||||||
|
- HEAD
|
||||||
|
CustomErrorResponses:
|
||||||
|
- ErrorCode: 403
|
||||||
|
ResponseCode: 200
|
||||||
|
ResponsePagePath: /index.html
|
||||||
|
|
||||||
|
# ─── API Gateway ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
OrderApi:
|
||||||
|
Type: AWS::Serverless::HttpApi
|
||||||
|
Properties:
|
||||||
|
StageName: $default
|
||||||
|
CorsConfiguration:
|
||||||
|
AllowOrigins:
|
||||||
|
- !If
|
||||||
|
- HasCustomDomain
|
||||||
|
- !Sub 'https://${CustomDomain}'
|
||||||
|
- !Sub 'https://${FormDistribution.DomainName}'
|
||||||
|
AllowMethods:
|
||||||
|
- GET
|
||||||
|
- POST
|
||||||
|
- OPTIONS
|
||||||
|
AllowHeaders:
|
||||||
|
- Content-Type
|
||||||
|
- x-api-key
|
||||||
|
MaxAge: 3600
|
||||||
|
|
||||||
|
# ─── Lambda Functions ──────────────────────────────────────────
|
||||||
|
|
||||||
|
SubmitOrderFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
FunctionName: meal-order-manager-submit-order
|
||||||
|
Handler: handler.lambda_handler
|
||||||
|
CodeUri: functions/submit_order/
|
||||||
|
MemorySize: 128
|
||||||
|
Timeout: 10
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
FORM_API_KEY: !Ref FormApiKey
|
||||||
|
Policies:
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref OrdersTable
|
||||||
|
Events:
|
||||||
|
SubmitOrder:
|
||||||
|
Type: HttpApi
|
||||||
|
Properties:
|
||||||
|
ApiId: !Ref OrderApi
|
||||||
|
Path: /api/submit-order
|
||||||
|
Method: POST
|
||||||
|
FormStatus:
|
||||||
|
Type: HttpApi
|
||||||
|
Properties:
|
||||||
|
ApiId: !Ref OrderApi
|
||||||
|
Path: /api/form-status/{week}
|
||||||
|
Method: GET
|
||||||
|
|
||||||
|
CloseFormFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
FunctionName: meal-order-manager-close-form
|
||||||
|
Handler: handler.lambda_handler
|
||||||
|
CodeUri: functions/close_form/
|
||||||
|
MemorySize: 128
|
||||||
|
Timeout: 30
|
||||||
|
Policies:
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref OrdersTable
|
||||||
|
- Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action: lambda:InvokeFunction
|
||||||
|
Resource: !GetAtt AggregateOrdersFunction.Arn
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
AGGREGATE_FUNCTION_ARN: !GetAtt AggregateOrdersFunction.Arn
|
||||||
|
Events:
|
||||||
|
CloseEST:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 23 ? * THU *)
|
||||||
|
Description: 'Close form Thursday 6pm EST (23:00 UTC)'
|
||||||
|
Enabled: true
|
||||||
|
CloseEDT:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 22 ? * THU *)
|
||||||
|
Description: 'Close form Thursday 6pm EDT (22:00 UTC)'
|
||||||
|
Enabled: true
|
||||||
|
|
||||||
|
AggregateOrdersFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
FunctionName: meal-order-manager-aggregate-orders
|
||||||
|
Handler: handler.lambda_handler
|
||||||
|
CodeUri: functions/aggregate_orders/
|
||||||
|
MemorySize: 256
|
||||||
|
Timeout: 60
|
||||||
|
Policies:
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref OrdersTable
|
||||||
|
- S3CrudPolicy:
|
||||||
|
BucketName: !Ref ReportsBucket
|
||||||
|
- Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action: lambda:InvokeFunction
|
||||||
|
Resource: !GetAtt SlackNotifierFunction.Arn
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
|
||||||
|
|
||||||
|
SlackNotifierFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
FunctionName: meal-order-manager-slack-notifier
|
||||||
|
Handler: handler.lambda_handler
|
||||||
|
CodeUri: functions/slack_notifier/
|
||||||
|
MemorySize: 128
|
||||||
|
Timeout: 30
|
||||||
|
Policies:
|
||||||
|
- DynamoDBReadPolicy:
|
||||||
|
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: ssm:GetParameter
|
||||||
|
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*'
|
||||||
|
Events:
|
||||||
|
ReminderEST:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 15 ? * THU *)
|
||||||
|
Description: 'DM reminders Thursday 10am EST (15:00 UTC)'
|
||||||
|
Enabled: true
|
||||||
|
Input: '{"event": "reminder"}'
|
||||||
|
ReminderEDT:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 14 ? * THU *)
|
||||||
|
Description: 'DM reminders Thursday 10am EDT (14:00 UTC)'
|
||||||
|
Enabled: true
|
||||||
|
Input: '{"event": "reminder"}'
|
||||||
|
|
||||||
|
EmailReportFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
Properties:
|
||||||
|
FunctionName: meal-order-manager-email-report
|
||||||
|
Handler: handler.lambda_handler
|
||||||
|
CodeUri: functions/email_report/
|
||||||
|
MemorySize: 128
|
||||||
|
Timeout: 30
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
PAYROLL_EMAIL: !Ref PayrollEmail
|
||||||
|
SENDER_EMAIL: !Ref SenderEmail
|
||||||
|
Policies:
|
||||||
|
- DynamoDBReadPolicy:
|
||||||
|
TableName: !Ref OrdersTable
|
||||||
|
- S3ReadPolicy:
|
||||||
|
BucketName: !Ref ReportsBucket
|
||||||
|
- Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- ses:SendRawEmail
|
||||||
|
Resource: '*'
|
||||||
|
Events:
|
||||||
|
PayrollEmailEST:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 12 ? * MON *)
|
||||||
|
Description: 'Email payroll deductions Monday 7am EST (12:00 UTC)'
|
||||||
|
Enabled: true
|
||||||
|
PayrollEmailEDT:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: cron(0 11 ? * MON *)
|
||||||
|
Description: 'Email payroll deductions Monday 7am EDT (11:00 UTC)'
|
||||||
|
Enabled: true
|
||||||
|
|
||||||
|
# ─── CloudWatch Log Groups (60-day retention) ──────────────────
|
||||||
|
|
||||||
|
SubmitOrderLogGroup:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
LogGroupName: !Sub '/aws/lambda/${SubmitOrderFunction}'
|
||||||
|
RetentionInDays: 60
|
||||||
|
|
||||||
|
CloseFormLogGroup:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
LogGroupName: !Sub '/aws/lambda/${CloseFormFunction}'
|
||||||
|
RetentionInDays: 60
|
||||||
|
|
||||||
|
AggregateOrdersLogGroup:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
LogGroupName: !Sub '/aws/lambda/${AggregateOrdersFunction}'
|
||||||
|
RetentionInDays: 60
|
||||||
|
|
||||||
|
SlackNotifierLogGroup:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
LogGroupName: !Sub '/aws/lambda/${SlackNotifierFunction}'
|
||||||
|
RetentionInDays: 60
|
||||||
|
|
||||||
|
EmailReportLogGroup:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
LogGroupName: !Sub '/aws/lambda/${EmailReportFunction}'
|
||||||
|
RetentionInDays: 60
|
||||||
|
|
||||||
|
# ─── SSM Parameters ────────────────────────────────────────────
|
||||||
|
|
||||||
|
SlackChannelParam:
|
||||||
|
Type: AWS::SSM::Parameter
|
||||||
|
Properties:
|
||||||
|
Name: /meal-order-manager/slack-channel-id
|
||||||
|
Type: String
|
||||||
|
Value: CHANGE_ME
|
||||||
|
Description: Slack channel ID for meal order notifications
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
ApiUrl:
|
||||||
|
Description: API Gateway endpoint URL
|
||||||
|
Value: !Sub 'https://${OrderApi}.execute-api.${AWS::Region}.amazonaws.com'
|
||||||
|
FormUrl:
|
||||||
|
Description: Order form URL
|
||||||
|
Value: !If
|
||||||
|
- HasCustomDomain
|
||||||
|
- !Sub 'https://${CustomDomain}'
|
||||||
|
- !Sub 'https://${FormDistribution.DomainName}'
|
||||||
|
DistributionId:
|
||||||
|
Description: CloudFront distribution ID (for cache invalidation)
|
||||||
|
Value: !Ref FormDistribution
|
||||||
|
FormBucketName:
|
||||||
|
Description: S3 bucket for form HTML
|
||||||
|
Value: !Ref FormBucket
|
||||||
|
ReportsBucketName:
|
||||||
|
Description: S3 bucket for CSV reports
|
||||||
|
Value: !Ref ReportsBucket
|
||||||
|
OrdersTableName:
|
||||||
|
Description: DynamoDB table name
|
||||||
|
Value: !Ref OrdersTable
|
||||||
Loading…
Add table
Reference in a new issue