From cad2f4bef3e09d6ff1ad376ca60c07ca95b82a86 Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Wed, 4 Nov 2026 09:33:47 +0100 Subject: [PATCH] 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 --- src/backend/app/main.py | 133 +++++++++++++++++- src/backend/app/services/config_service.py | 7 + .../app/services/notification_service.py | 115 +++++++++++++++ .../app/services/order_file_service.py | 34 +++++ 4 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 src/backend/app/services/notification_service.py diff --git a/src/backend/app/main.py b/src/backend/app/main.py index 004c013..2dfa821 100644 --- a/src/backend/app/main.py +++ b/src/backend/app/main.py @@ -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} diff --git a/src/backend/app/services/config_service.py b/src/backend/app/services/config_service.py index 651c21c..66325eb 100644 --- a/src/backend/app/services/config_service.py +++ b/src/backend/app/services/config_service.py @@ -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("/") diff --git a/src/backend/app/services/notification_service.py b/src/backend/app/services/notification_service.py new file mode 100644 index 0000000..9a58b94 --- /dev/null +++ b/src/backend/app/services/notification_service.py @@ -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 + ) diff --git a/src/backend/app/services/order_file_service.py b/src/backend/app/services/order_file_service.py index 3200c49..04bc448 100644 --- a/src/backend/app/services/order_file_service.py +++ b/src/backend/app/services/order_file_service.py @@ -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