diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c2151d --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..dfdd1ca --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 }} diff --git a/.github/workflows/weekly-menu.yml b/.github/workflows/weekly-menu.yml new file mode 100644 index 0000000..3c03083 --- /dev/null +++ b/.github/workflows/weekly-menu.yml @@ -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 }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31bd40a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +.env +orders/ +output/ +.DS_Store +.aws-sam/ +samconfig.toml diff --git a/README.md b/README.md index b94a4a4..f854eb0 100644 --- a/README.md +++ b/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 +``` diff --git a/config.json b/config.json new file mode 100644 index 0000000..6904a34 --- /dev/null +++ b/config.json @@ -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"} + ] +} diff --git a/functions/aggregate_orders/handler.py b/functions/aggregate_orders/handler.py new file mode 100644 index 0000000..e2c9534 --- /dev/null +++ b/functions/aggregate_orders/handler.py @@ -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() diff --git a/functions/aggregate_orders/requirements.txt b/functions/aggregate_orders/requirements.txt new file mode 100644 index 0000000..30ddf82 --- /dev/null +++ b/functions/aggregate_orders/requirements.txt @@ -0,0 +1 @@ +boto3 diff --git a/functions/close_form/handler.py b/functions/close_form/handler.py new file mode 100644 index 0000000..14e1ee3 --- /dev/null +++ b/functions/close_form/handler.py @@ -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} diff --git a/functions/close_form/requirements.txt b/functions/close_form/requirements.txt new file mode 100644 index 0000000..30ddf82 --- /dev/null +++ b/functions/close_form/requirements.txt @@ -0,0 +1 @@ +boto3 diff --git a/functions/email_report/handler.py b/functions/email_report/handler.py new file mode 100644 index 0000000..e30ab36 --- /dev/null +++ b/functions/email_report/handler.py @@ -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"]} diff --git a/functions/email_report/requirements.txt b/functions/email_report/requirements.txt new file mode 100644 index 0000000..30ddf82 --- /dev/null +++ b/functions/email_report/requirements.txt @@ -0,0 +1 @@ +boto3 diff --git a/functions/slack_notifier/handler.py b/functions/slack_notifier/handler.py new file mode 100644 index 0000000..94e5513 --- /dev/null +++ b/functions/slack_notifier/handler.py @@ -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)} diff --git a/functions/slack_notifier/requirements.txt b/functions/slack_notifier/requirements.txt new file mode 100644 index 0000000..30ddf82 --- /dev/null +++ b/functions/slack_notifier/requirements.txt @@ -0,0 +1 @@ +boto3 diff --git a/functions/submit_order/handler.py b/functions/submit_order/handler.py new file mode 100644 index 0000000..21ecf00 --- /dev/null +++ b/functions/submit_order/handler.py @@ -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), + } diff --git a/functions/submit_order/requirements.txt b/functions/submit_order/requirements.txt new file mode 100644 index 0000000..30ddf82 --- /dev/null +++ b/functions/submit_order/requirements.txt @@ -0,0 +1 @@ +boto3 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f31897d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +playwright>=1.40 +flask>=3.0 diff --git a/samconfig.toml.example b/samconfig.toml.example new file mode 100644 index 0000000..b54e806 --- /dev/null +++ b/samconfig.toml.example @@ -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" diff --git a/scripts/notify_slack.py b/scripts/notify_slack.py new file mode 100644 index 0000000..654afb6 --- /dev/null +++ b/scripts/notify_slack.py @@ -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 +""" + +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 ", 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() diff --git a/scripts/seed_roster.py b/scripts/seed_roster.py new file mode 100644 index 0000000..3a69a59 --- /dev/null +++ b/scripts/seed_roster.py @@ -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() diff --git a/scripts/upload_menu.py b/scripts/upload_menu.py new file mode 100644 index 0000000..4336ec2 --- /dev/null +++ b/scripts/upload_menu.py @@ -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() diff --git a/src/aggregator/aggregate.py b/src/aggregator/aggregate.py new file mode 100644 index 0000000..e2ed507 --- /dev/null +++ b/src/aggregator/aggregate.py @@ -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() diff --git a/src/scraper/recon.py b/src/scraper/recon.py new file mode 100644 index 0000000..4326c2b --- /dev/null +++ b/src/scraper/recon.py @@ -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() diff --git a/src/scraper/recon_deep.py b/src/scraper/recon_deep.py new file mode 100644 index 0000000..2a29979 --- /dev/null +++ b/src/scraper/recon_deep.py @@ -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() diff --git a/src/scraper/scrape_menu.py b/src/scraper/scrape_menu.py new file mode 100644 index 0000000..be4f79e --- /dev/null +++ b/src/scraper/scrape_menu.py @@ -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() diff --git a/src/server/app.py b/src/server/app.py new file mode 100644 index 0000000..7697a62 --- /dev/null +++ b/src/server/app.py @@ -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/") +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) diff --git a/src/server/generate_form.py b/src/server/generate_form.py new file mode 100644 index 0000000..fa8d45b --- /dev/null +++ b/src/server/generate_form.py @@ -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""" + + + + +Sea Haven — Meal Order ({week}) + + + + +
+
+

Sea Haven Meal Order

+

Week of {week} · Menu scraped {scraped_at[:10]}

+
+ +
Order deadline: {deadline}
+ +
+ + + + +
+ + + +
+ +
+ + + + +
+ + + +""" + + +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() diff --git a/src/shared/python/shared/__init__.py b/src/shared/python/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shared/python/shared/db.py b/src/shared/python/shared/db.py new file mode 100644 index 0000000..6129801 --- /dev/null +++ b/src/shared/python/shared/db.py @@ -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(), + }) diff --git a/src/shared/python/shared/secrets.py b/src/shared/python/shared/secrets.py new file mode 100644 index 0000000..3a3813e --- /dev/null +++ b/src/shared/python/shared/secrets.py @@ -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] diff --git a/src/shared/python/shared/slack.py b/src/shared/python/shared/slack.py new file mode 100644 index 0000000..7b824f1 --- /dev/null +++ b/src/shared/python/shared/slack.py @@ -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) diff --git a/src/shared/requirements.txt b/src/shared/requirements.txt new file mode 100644 index 0000000..30ddf82 --- /dev/null +++ b/src/shared/requirements.txt @@ -0,0 +1 @@ +boto3 diff --git a/template.yaml b/template.yaml new file mode 100644 index 0000000..34ca11c --- /dev/null +++ b/template.yaml @@ -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