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:
Adam Moussa 2026-05-12 18:25:15 -04:00 • committed by GitHub
parent cd36ed8018
commit d332affd56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2604 additions and 0 deletions

9
.github/workflows/ci.yml vendored Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,9 @@
.venv/
__pycache__/
*.pyc
.env
orders/
output/
.DS_Store
.aws-sam/
samconfig.toml

124
README.md
View file

@ -1 +1,125 @@
# 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
View 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"}
]
}

View 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()

View file

@ -0,0 +1 @@
boto3

View 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}

View file

@ -0,0 +1 @@
boto3

View 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"]}

View file

@ -0,0 +1 @@
boto3

View 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)}

View file

@ -0,0 +1 @@
boto3

View 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),
}

View file

@ -0,0 +1 @@
boto3

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
playwright>=1.40
flask>=3.0

10
samconfig.toml.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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} &middot; 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}} &middot; ${{meal.calories || '?'}} cal &middot; ${{meal.protein || '?'}} protein</div>
<div class="meal-tags">${{tagsHtml}}</div>
</div>
<div class="qty-control">
<button onclick="changeQty(${{i}}, -1)">&minus;</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()

View file

View 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(),
})

View 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]

View 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)

View file

@ -0,0 +1 @@
boto3

441
template.yaml Normal file
View 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