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:
1 parent
6a896304de
commit
cad2f4bef3
4 files changed
+286
-3
No files matched your search
+130
-3
@@ -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
|
||||
Reference in new issue
Block a user