jobhunt-platform/apps/api/app/notify.py

183 lines
No EOL
5.5 KiB
Python

"""Notification channels: LogChannel + WebhookChannel.
Protocol-based: NotificationChannel defines send(kind, text, data) -> bool.
LogChannel writes to notification_log (delivered=true always).
WebhookChannel POSTs to NOTIFY_WEBHOOK_URL (2xx=delivered, else error row).
send_notification(kind, text, data) is the public entry point used by
the scheduler and email watch modules.
"""
from __future__ import annotations
import json
import logging
import os
from typing import Any, Protocol
from app.db import execute, fetch_all
logger = logging.getLogger(__name__)
class NotificationChannel(Protocol):
"""Protocol for notification delivery channels."""
def send(self, kind: str, text: str, data: dict[str, Any]) -> bool:
"""Send a notification. Returns True if delivered, False otherwise."""
...
def _normalize_log_row(row: dict[str, Any]) -> dict[str, Any]:
payload = row.get("payload")
if payload is not None and not isinstance(payload, dict):
payload = json.loads(payload) if isinstance(payload, str) else payload
return {
"id": str(row["id"]),
"channel": row["channel"],
"kind": row["kind"],
"payload": payload or {},
"delivered": row["delivered"],
"error": row.get("error"),
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
}
class LogChannel:
"""Writes notification entries to notification_log with delivered=true."""
channel_name: str = "log"
def send(self, kind: str, text: str, data: dict[str, Any]) -> bool:
row = execute(
"""
INSERT INTO notification_log (channel, kind, payload, delivered, error)
VALUES (%s, %s, %s, true, NULL)
RETURNING *
""",
(self.channel_name, kind, json.dumps({"text": text, **data})),
)
if row is None:
logger.error("LogChannel: insert notification_log failed")
return False
logger.info("LogChannel: delivered kind=%s", kind)
return True
class WebhookChannel:
"""POSTs notification payload to NOTIFY_WEBHOOK_URL.
On 2xx response: writes notification_log with delivered=true.
On non-2xx or exception: writes notification_log with delivered=false and error.
"""
channel_name: str = "webhook"
def __init__(self, url: str | None = None) -> None:
self.url = url or os.environ.get("NOTIFY_WEBHOOK_URL", "")
def send(self, kind: str, text: str, data: dict[str, Any]) -> bool:
payload: dict[str, Any] = {"kind": kind, "text": text, "data": data}
error: str | None = None
delivered = False
if not self.url:
error = "NOTIFY_WEBHOOK_URL not configured"
logger.warning("WebhookChannel: %s", error)
else:
try:
import httpx
resp = httpx.post(self.url, json=payload, timeout=10)
if 200 <= resp.status_code < 300:
delivered = True
else:
error = f"HTTP {resp.status_code}: {resp.text[:200]}"
logger.warning("WebhookChannel: %s", error)
except Exception as exc:
error = str(exc)
logger.warning("WebhookChannel: exception: %s", error)
row = execute(
"""
INSERT INTO notification_log (channel, kind, payload, delivered, error)
VALUES (%s, %s, %s, %s, %s)
RETURNING *
""",
(
self.channel_name,
kind,
json.dumps(payload),
delivered,
error,
),
)
if row is None:
logger.error("WebhookChannel: insert notification_log failed")
return delivered
# --- Channel registry ---
_channels: list[NotificationChannel] | None = None
def get_channels() -> list[NotificationChannel]:
"""Return the list of active notification channels.
LogChannel is always included.
WebhookChannel is included when NOTIFY_WEBHOOK_URL is set.
"""
global _channels
if _channels is not None:
return _channels
channels: list[NotificationChannel] = [LogChannel()]
webhook_url = os.environ.get("NOTIFY_WEBHOOK_URL", "").strip()
if webhook_url:
channels.append(WebhookChannel())
_channels = channels
return channels
def set_channels(channels: list[NotificationChannel] | None) -> None:
"""Override channel list (for testing)."""
global _channels
_channels = channels
def reset_channels() -> None:
"""Reset to default (for testing)."""
global _channels
_channels = None
def send_notification(kind: str, text: str, data: dict[str, Any] | None = None) -> None:
"""Send a notification via all active channels.
kind: e.g. 'daily_digest', 'email_suggestion'
text: human-readable notification text
data: structured payload dict
"""
chs = get_channels()
payload = data or {}
for ch in chs:
try:
ch.send(kind, text, payload)
except Exception:
logger.exception("send_notification: channel %s failed", type(ch).__name__)
# --- Repository helpers ---
def list_notification_log(limit: int = 50) -> list[dict[str, Any]]:
"""Return recent notification_log rows, newest first."""
rows = fetch_all(
"""
SELECT * FROM notification_log
ORDER BY created_at DESC
LIMIT %s
""",
(limit,),
)
return [_normalize_log_row(r) for r in rows]