meal-order-manager/functions/slack_notifier/handler.py
Adam Moussa bcce15b9e6 Harden auth, pricing, and reliability in order handlers
Enforce Google auth when configured (reject missing tokens with 403),
return 503 on token verification outages, switch to Decimal with
ROUND_HALF_UP for financial precision, clamp discount bounds 0-100,
use email-based slugs, add 5-min cache TTL with time.monotonic(),
wrap Slack invocation in try/except, add reopen_at timestamp to
closed form status, add reminder dedup guards for dual EST/EDT crons,
escape Slack mrkdwn special characters, and handle empty employee names.
2026-05-13 12:58:56 -04:00

214 lines
6.9 KiB
Python

import json
import os
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo
from shared.db import current_week, get_orders, get_roster, get_summary
from shared.slack import post_channel_message, send_dm
def _escape_mrkdwn(text: str) -> str:
"""Escape Slack mrkdwn special characters."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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":
# Guard against duplicate triggers from dual EST/EDT schedules
now_et = datetime.now(ZoneInfo("America/New_York"))
if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday
return {"status": "skipped", "reason": "outside reminder window"}
return handle_reminder(event)
elif event_type == "order_confirmed":
return handle_order_confirmed(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 = "This week's meal order is open! Deadline: Thursday at 11:59pm."
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 at 11:59pm\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))
employee_total = float(summary.get("employee_total", grand_total))
meal_lines = []
for m in summary.get("meals", []):
meal_lines.append(f"{_escape_mrkdwn(m['meal'])}: *{int(m['quantity'])}*")
meal_list = "\n".join(meal_lines)
has_subsidy = employee_total < grand_total
totals_text = (
f"*${grand_total:.2f}* order total (bulk rate)\n"
f"*${employee_total:.2f}* payroll deductions"
+ (f"\n*${grand_total - employee_total:.2f}* company subsidy" if has_subsidy else "")
)
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"{totals_text}"
),
},
},
{"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_order_confirmed(event):
email = event.get("employee_email", "").lower()
name = event.get("employee_name", "")
items = event.get("items", [])
total = float(event.get("total", 0))
week = event.get("week", current_week())
roster = get_roster()
employee = next((e for e in roster if e["email"].lower() == email), None)
if not employee or not employee.get("slack_user_id"):
return {"status": "no_slack_id", "email": email}
item_lines = []
for item in items:
qty = int(item.get("quantity", 0))
price = float(item.get("price", 0))
item_lines.append(f"{_escape_mrkdwn(item['name'])} x{qty} — your cost: ${price * qty:.2f}")
item_list = "\n".join(item_lines)
send_dm(
employee["slack_user_id"],
f"Order confirmed for {week}: ${total:.2f} total.",
blocks=[
{
"type": "header",
"text": {"type": "plain_text", "text": f"Order Confirmed — {week}"},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"Hey {name.split()[0] if name.strip() else 'there'}! Your meal order has been submitted.\n\n"
f"{item_list}\n\n"
f"*Your total: ${total:.2f}* (payroll deduction)"
),
},
},
],
)
return {"status": "confirmed", "week": week, "employee": name}
def handle_reminder(event):
# Guard against duplicate triggers from dual EST/EDT schedules
now_et = datetime.now(ZoneInfo("America/New_York"))
if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday
return {"status": "skipped", "reason": "outside reminder window"}
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 today at 11:59pm. Place your order: {form_url}",
blocks=[
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"Hey {emp['name'].split()[0] if emp['name'].strip() else 'there'}! Meal orders close today at *11:59pm*.\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),
}