meal-order-manager/functions/slack_notifier/handler.py
Adam Moussa 1aa28b38fd
Add admin form bypass and send order summary to admin DMs (#15)
Admins (by email in settings) can now view and submit orders even
after the form closes. The orders-aggregated Slack summary is sent
as a DM to each admin instead of posting to the channel.
2026-05-22 12:24:21 -04:00

235 lines
7.5 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_settings, 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}",
},
},
]
settings = get_settings()
admin_emails = [e.lower() for e in settings.get("admin_emails", [])]
roster = get_roster()
dm_count = 0
for email in admin_emails:
employee = next((e for e in roster if e["email"].lower() == email), None)
if not employee or not employee.get("slack_user_id"):
continue
send_dm(employee["slack_user_id"], text, blocks)
dm_count += 1
return {
"status": "notified",
"event": "orders_aggregated",
"week": week,
"admin_dm_count": dm_count,
}
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),
}