mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 06:33:12 +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
|
||||
|
||||
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