Add webhook notifications for order events

Order admins can configure webhooks per order (admin-only endpoints, stored
next to the other per-order files and removed with the order). Preset events:
order created/closed/opened, last call and delivery started, with
customizable texts. Admins can define custom notifications and send manual
ones through POST /admin/notifications/send.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon GruberandClaude Sonnet 5.5 committed 2026-11-04 09:33:47 +01:00
1 parent 6a896304de
commit cad2f4bef3
4 files changed
+286 -3

No files matched your search

+130 -3
View File
@@ -3,7 +3,7 @@ import sqlite3
from typing import Any
from uuid import uuid4
from fastapi import Depends, FastAPI, Header, HTTPException, UploadFile, File
from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
@@ -37,17 +37,27 @@ from .services.order_file_service import (
delete_order_config,
delete_order_description,
delete_order_images,
delete_order_notifications,
delete_order_payment,
delete_order_templates,
load_order_config,
load_order_notifications,
load_order_payment,
load_order_templates,
save_order_config,
save_order_description,
save_order_notifications,
save_order_payment,
save_order_templates,
save_uploaded_image,
)
from .services.notification_service import (
EVENT_KEYS,
MANUAL_EVENT_KEYS,
custom_event_key,
is_valid_webhook_url,
send_order_event,
)
from .services.order_service import ensure_order_exists, ensure_order_open
from .services.submission_service import (
build_formatted_submission_string,
@@ -76,10 +86,53 @@ app.add_middleware(
)
class NotificationEventSettings(BaseModel):
enabled: bool = False
text: str = Field(default="", max_length=1000) # Empty falls back to the default text
class Webhook(BaseModel):
id: str = Field(min_length=1, max_length=100)
name: str = Field(default="", max_length=200)
url: str = Field(max_length=2048)
events: dict[str, NotificationEventSettings] = Field(default_factory=dict)
class CustomNotification(BaseModel):
"""Admin-defined message that can be sent manually from the notification center."""
id: str = Field(min_length=1, max_length=100)
label: str = Field(min_length=1, max_length=100)
text: str = Field(min_length=1, max_length=1000)
class OrderNotifications(BaseModel):
"""Admin-only webhook configuration. Webhook URLs are secrets, never expose them to participants."""
webhooks: list[Webhook] = Field(default_factory=list, max_length=20)
custom: list[CustomNotification] = Field(default_factory=list, max_length=20)
class SendNotificationRequest(BaseModel):
event: str = Field(min_length=1, max_length=200)
def validate_notifications(notifications: OrderNotifications) -> dict[str, Any]:
for webhook in notifications.webhooks:
if not is_valid_webhook_url(webhook.url.strip()):
raise HTTPException(status_code=422, detail="Webhook URLs must be valid http(s) URLs")
result = notifications.model_dump()
allowed_events = [*EVENT_KEYS, *(custom_event_key(entry["id"]) for entry in result["custom"])]
for webhook in result["webhooks"]:
webhook["url"] = webhook["url"].strip()
webhook["events"] = {key: webhook["events"][key] for key in allowed_events if key in webhook["events"]}
return result
class OrderCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(default="")
image_url: str | None = Field(default=None, max_length=2048)
notifications: OrderNotifications | None = None
class SubmissionPayload(BaseModel):
@@ -532,7 +585,12 @@ def confirm_account_action(token: str) -> dict[str, Any]:
@app.post("/api/orders")
def create_order(payload: OrderCreateRequest, user_id: str = Depends(get_existing_user_id)) -> dict[str, Any]:
def create_order(
payload: OrderCreateRequest,
background_tasks: BackgroundTasks,
user_id: str = Depends(get_existing_user_id),
) -> dict[str, Any]:
notifications = validate_notifications(payload.notifications) if payload.notifications else None
clean_id = clean_user_id(user_id)
order_id = str(uuid4())
admin_token = str(uuid4())
@@ -550,6 +608,10 @@ def create_order(payload: OrderCreateRequest, user_id: str = Depends(get_existin
if payload.description:
save_order_description(admin_token, payload.description)
if notifications:
save_order_notifications(admin_token, notifications)
background_tasks.add_task(send_order_event, admin_token, order_id, payload.title.strip(), "order_created")
return {
"id": order_id,
"admin_token": admin_token,
@@ -793,6 +855,7 @@ def delete_order(order_id: str, user_id: str = Depends(get_existing_user_id)) ->
delete_order_config(admin_token)
delete_order_templates(admin_token)
delete_order_payment(admin_token)
delete_order_notifications(admin_token)
delete_order_images(admin_token, order["image_url"])
return {"deleted": True}
@@ -1096,12 +1159,14 @@ def admin_update_submission_status(
def admin_update_order_status(
order_id: str,
payload: OrderStatusUpdateRequest,
background_tasks: BackgroundTasks,
user_id: str = Depends(get_existing_user_id),
) -> dict[str, Any]:
clean_id = clean_user_id(user_id)
with get_connection() as conn:
ensure_owner_access(conn, clean_id, order_id)
auth = ensure_owner_access(conn, clean_id, order_id)
previous = conn.execute("SELECT closed FROM group_orders WHERE id = ?", (order_id,)).fetchone()
conn.execute(
"UPDATE group_orders SET closed = ? WHERE id = ?",
@@ -1116,6 +1181,15 @@ def admin_update_order_status(
if not updated:
raise HTTPException(status_code=404, detail="Order not found")
if previous and bool(previous["closed"]) != payload.closed:
background_tasks.add_task(
send_order_event,
auth["admin_token"],
order_id,
updated["title"],
"order_closed" if payload.closed else "order_opened",
)
return {
"id": updated["id"],
"title": updated["title"],
@@ -1272,3 +1346,56 @@ def admin_update_order_payment(
save_order_payment(admin_token, payment_dict)
return payment_dict
@app.get("/api/orders/{order_id}/admin/notifications")
def admin_get_order_notifications(order_id: str, user_id: str = Depends(get_existing_user_id)) -> dict[str, Any]:
"""Get webhook settings. Admin only: webhook URLs are secrets."""
clean_id = clean_user_id(user_id)
with get_connection() as conn:
auth = ensure_owner_access(conn, clean_id, order_id)
return load_order_notifications(auth["admin_token"])
@app.put("/api/orders/{order_id}/admin/notifications")
def admin_update_order_notifications(
order_id: str,
payload: OrderNotifications,
user_id: str = Depends(get_existing_user_id),
) -> dict[str, Any]:
"""Replace webhook settings."""
clean_id = clean_user_id(user_id)
notifications = validate_notifications(payload)
with get_connection() as conn:
auth = ensure_owner_access(conn, clean_id, order_id)
save_order_notifications(auth["admin_token"], notifications)
return notifications
@app.post("/api/orders/{order_id}/admin/notifications/send")
def admin_send_order_notification(
order_id: str,
payload: SendNotificationRequest,
background_tasks: BackgroundTasks,
user_id: str = Depends(get_existing_user_id),
) -> dict[str, bool]:
"""Manually send a preset (last call, delivery started) or custom notification to the configured webhooks."""
clean_id = clean_user_id(user_id)
with get_connection() as conn:
auth = ensure_owner_access(conn, clean_id, order_id)
order = conn.execute("SELECT title FROM group_orders WHERE id = ?", (order_id,)).fetchone()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
custom_events = {custom_event_key(entry.get("id", "")) for entry in load_order_notifications(auth["admin_token"]).get("custom", [])}
if payload.event not in MANUAL_EVENT_KEYS and payload.event not in custom_events:
raise HTTPException(status_code=422, detail="Unknown notification")
background_tasks.add_task(send_order_event, auth["admin_token"], order_id, order["title"], payload.event)
return {"queued": True}
@@ -81,3 +81,10 @@ def get_email_settings() -> dict[str, Any]:
email.get("confirmation_link_base", app.get("public_base_url", "")) or ""
).strip(),
}
def get_public_base_url() -> str:
"""Base URL of the app (app.public_base_url) without trailing slash; empty if not configured."""
data = load_raw_config()
app = data.get("app") if isinstance(data.get("app"), dict) else {}
return str(app.get("public_base_url", "") or "").strip().rstrip("/")
@@ -0,0 +1,115 @@
import json
import logging
import urllib.request
from typing import Any
from urllib.parse import urlparse
from .config_service import get_public_base_url
from .order_file_service import load_order_notifications
logger = logging.getLogger(__name__)
DEFAULT_EVENT_TEXTS: dict[str, str] = {
"order_created": 'New lunch order "{title}" has been created: {url}',
"order_closed": 'Lunch order "{title}" has been closed: {url}',
"order_opened": 'Lunch order "{title}" has been opened: {url}',
"delivery_started": 'Delivery for lunch order "{title}" has started: {url}',
"last_call": 'Last call for lunch order "{title}"! Place your order now: {url}',
}
EVENT_KEYS = tuple(DEFAULT_EVENT_TEXTS)
CUSTOM_EVENT_PREFIX = "custom:"
# Events the admin triggers by hand from the notification center (custom notifications are manual too).
MANUAL_EVENT_KEYS = ("last_call", "delivery_started")
def custom_event_key(custom_id: str) -> str:
return f"{CUSTOM_EVENT_PREFIX}{custom_id}"
WEBHOOK_TIMEOUT_SECONDS = 10
def is_valid_webhook_url(url: str) -> bool:
parsed = urlparse(url)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def build_order_url(order_id: str) -> str:
return f"{get_public_base_url()}/order/{order_id}"
def render_event_text(template: str, default: str, title: str, url: str) -> str:
text = template.strip() or default
return text.replace("{title}", title).replace("{url}", url)
def _post_json(url: str, payload: dict[str, Any]) -> None:
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", "User-Agent": "Lunchtime"},
method="POST",
)
with urllib.request.urlopen(request, timeout=WEBHOOK_TIMEOUT_SECONDS):
pass
def send_order_event(admin_token: str, order_id: str, title: str, event: str) -> None:
"""Posts the event to every webhook that has it enabled. Never raises: delivery is best effort.
The payload carries the rendered message as ``text`` (Slack, Mattermost, Teams) and ``content`` (Discord)
plus structured fields for custom receivers. The webhook URL is never logged since it usually embeds a secret.
"""
notifications = load_order_notifications(admin_token)
webhooks = notifications.get("webhooks", [])
if not isinstance(webhooks, list):
return
custom_text: str | None = None
if event.startswith(CUSTOM_EVENT_PREFIX):
custom = next(
(
entry
for entry in notifications.get("custom", [])
if isinstance(entry, dict) and custom_event_key(str(entry.get("id"))) == event
),
None,
)
if custom is None:
return
custom_text = str(custom.get("text", ""))
elif event not in DEFAULT_EVENT_TEXTS:
return
order_url = build_order_url(order_id)
for webhook in webhooks:
events = webhook.get("events") if isinstance(webhook, dict) else None
config = events.get(event) if isinstance(events, dict) else None
if not isinstance(config, dict):
config = {}
# Custom notifications go to every webhook unless it explicitly opted out; presets must be opted in.
if not config.get("enabled", custom_text is not None):
continue
url = str(webhook.get("url", ""))
if not is_valid_webhook_url(url):
continue
if custom_text is not None:
text = render_event_text(custom_text, custom_text, title, order_url)
else:
text = render_event_text(str(config.get("text", "")), DEFAULT_EVENT_TEXTS[event], title, order_url)
payload = {
"text": text,
"content": text,
"event": event,
"order_id": order_id,
"title": title,
"url": order_url,
}
try:
_post_json(url, payload)
except Exception as exc:
logger.warning(
"Webhook '%s' for event %s failed: %s", webhook.get("name") or webhook.get("id"), event, exc
)
@@ -12,12 +12,14 @@ CONFIGS_DIR = BASE_DIR / "data" / "configs"
DESCRIPTIONS_DIR = BASE_DIR / "data" / "descriptions"
TEMPLATES_DIR = BASE_DIR / "data" / "templates"
PAYMENTS_DIR = BASE_DIR / "data" / "payments"
NOTIFICATIONS_DIR = BASE_DIR / "data" / "notifications"
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
DESCRIPTIONS_DIR.mkdir(parents=True, exist_ok=True)
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
PAYMENTS_DIR.mkdir(parents=True, exist_ok=True)
NOTIFICATIONS_DIR.mkdir(parents=True, exist_ok=True)
def load_order_config(admin_token: str) -> dict[str, Any]:
@@ -187,3 +189,35 @@ def save_uploaded_image(file: UploadFile, order_id: str, admin_token: str) -> st
raise HTTPException(status_code=500, detail=f"Failed to save image: {str(exc)}")
return f"{admin_token}/{new_filename}"
def load_order_notifications(admin_token: str) -> dict[str, Any]:
"""Webhook configuration; contains secrets (webhook URLs) and must only be exposed to the order admin."""
notifications_path = NOTIFICATIONS_DIR / f"{admin_token}.json"
if not notifications_path.exists():
return {}
try:
with notifications_path.open("r", encoding="utf-8") as file:
loaded = json.load(file)
return loaded if isinstance(loaded, dict) else {}
except Exception:
return {}
def save_order_notifications(admin_token: str, notifications: dict[str, Any]) -> None:
notifications_path = NOTIFICATIONS_DIR / f"{admin_token}.json"
try:
with notifications_path.open("w", encoding="utf-8") as file:
json.dump(notifications, file, indent=2, ensure_ascii=False)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to save notification settings: {str(exc)}")
def delete_order_notifications(admin_token: str) -> None:
notifications_path = NOTIFICATIONS_DIR / f"{admin_token}.json"
try:
if notifications_path.exists():
notifications_path.unlink()
except Exception:
pass