fcefd0978f
- Add UserNotificationTarget, UserNotificationPreference, InAppNotification models - Add migration 025_add_user_notifications (tables + indexes) - Add app/utils/user_notification.py dispatch service - Add app/api/notifications.py REST endpoints (inbox, targets, preferences) - Add app/views/notifications.py view route - Add frontend/templates/notifications_dashboard.html Alpine.js dashboard - Add bell icon with unread badge in base.html nav (desktop + mobile) - Register routers in app/api/__init__.py and app/views/__init__.py - Add 32 unit tests in tests/test_notifications_api.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
485 lines
16 KiB
Python
485 lines
16 KiB
Python
"""API endpoints for per-user notification targets, preferences, and in-app inbox.
|
|
|
|
Users can define notification targets (email via SMTP, webhook via HTTP POST)
|
|
and configure which document events trigger which targets. In-app notifications
|
|
are always created and surfaced via the bell icon / inbox endpoints.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget
|
|
from app.utils.user_notification import USER_EVENT_LABELS
|
|
from app.utils.user_scope import get_current_owner_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/user-notifications", tags=["user-notifications"])
|
|
|
|
DbSession = Annotated[Session, Depends(get_db)]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth helper (mirrors api_tokens.py pattern)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_owner_id(request: Request) -> str:
|
|
"""Return the current user's owner ID, raising 401 if unauthenticated."""
|
|
owner_id = get_current_owner_id(request)
|
|
if not owner_id:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
|
return owner_id
|
|
|
|
|
|
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
VALID_CHANNEL_TYPES = {"email", "webhook"}
|
|
VALID_EVENT_TYPES = set(USER_EVENT_LABELS.keys())
|
|
|
|
|
|
class NotificationTargetCreate(BaseModel):
|
|
"""Schema for creating a new notification target."""
|
|
|
|
channel_type: str = Field(..., pattern="^(email|webhook)$")
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
is_active: bool = True
|
|
|
|
|
|
class NotificationTargetUpdate(BaseModel):
|
|
"""Schema for updating an existing notification target."""
|
|
|
|
name: str | None = Field(None, min_length=1, max_length=255)
|
|
config: dict[str, Any] | None = None
|
|
is_active: bool | None = None
|
|
|
|
|
|
class PreferenceItem(BaseModel):
|
|
"""A single preference toggle for one event+channel combination."""
|
|
|
|
is_enabled: bool
|
|
target_id: int | None = None
|
|
|
|
|
|
class PreferenceItemFull(BaseModel):
|
|
"""Full preference item including event and channel type (used in bulk update)."""
|
|
|
|
event_type: str
|
|
channel_type: str
|
|
is_enabled: bool
|
|
target_id: int | None = None
|
|
|
|
|
|
class PreferencesUpdate(BaseModel):
|
|
"""Bulk preferences update payload — a flat list of preference items."""
|
|
|
|
preferences: list[PreferenceItemFull]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _mask_email_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return a copy of an email config dict with the password masked."""
|
|
masked = dict(config)
|
|
if masked.get("smtp_password"):
|
|
masked["smtp_password"] = "****"
|
|
return masked
|
|
|
|
|
|
def _target_to_dict(target: UserNotificationTarget) -> dict[str, Any]:
|
|
"""Serialize a UserNotificationTarget to a response dict, masking secrets."""
|
|
config: dict[str, Any] = {}
|
|
if target.config:
|
|
try:
|
|
config = json.loads(target.config)
|
|
except (json.JSONDecodeError, ValueError):
|
|
config = {}
|
|
|
|
if target.channel_type == "email":
|
|
config = _mask_email_config(config)
|
|
|
|
return {
|
|
"id": target.id,
|
|
"channel_type": target.channel_type,
|
|
"name": target.name,
|
|
"config": config,
|
|
"is_active": target.is_active,
|
|
"created_at": target.created_at,
|
|
"updated_at": target.updated_at,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inbox endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/inbox")
|
|
async def list_inbox(
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
skip: int = 0,
|
|
limit: int = 50,
|
|
) -> list[dict[str, Any]]:
|
|
"""List in-app notifications for the authenticated user, newest first."""
|
|
notifications = (
|
|
db.query(InAppNotification)
|
|
.filter(InAppNotification.owner_id == owner_id)
|
|
.order_by(InAppNotification.created_at.desc())
|
|
.offset(skip)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
return [
|
|
{
|
|
"id": n.id,
|
|
"event_type": n.event_type,
|
|
"title": n.title,
|
|
"message": n.message,
|
|
"is_read": n.is_read,
|
|
"file_id": n.file_id,
|
|
"created_at": n.created_at,
|
|
}
|
|
for n in notifications
|
|
]
|
|
|
|
|
|
@router.get("/inbox/unread-count")
|
|
async def unread_count(
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, int]:
|
|
"""Return the number of unread in-app notifications."""
|
|
count = (
|
|
db.query(InAppNotification)
|
|
.filter(InAppNotification.owner_id == owner_id, InAppNotification.is_read == False) # noqa: E712
|
|
.count()
|
|
)
|
|
return {"count": count}
|
|
|
|
|
|
@router.post("/inbox/{notification_id}/read", status_code=status.HTTP_200_OK)
|
|
async def mark_read(
|
|
notification_id: int,
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, str]:
|
|
"""Mark a single in-app notification as read."""
|
|
notif = (
|
|
db.query(InAppNotification)
|
|
.filter(InAppNotification.id == notification_id, InAppNotification.owner_id == owner_id)
|
|
.first()
|
|
)
|
|
if not notif:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
|
|
try:
|
|
notif.is_read = True
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
return {"detail": "Marked as read"}
|
|
|
|
|
|
@router.post("/inbox/read-all", status_code=status.HTTP_200_OK)
|
|
async def mark_all_read(
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, str]:
|
|
"""Mark all in-app notifications as read for the authenticated user."""
|
|
try:
|
|
db.query(InAppNotification).filter(
|
|
InAppNotification.owner_id == owner_id,
|
|
InAppNotification.is_read == False, # noqa: E712
|
|
).update({"is_read": True})
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
return {"detail": "All notifications marked as read"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notification target endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/targets")
|
|
async def list_targets(
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> list[dict[str, Any]]:
|
|
"""List all notification targets for the authenticated user."""
|
|
targets = (
|
|
db.query(UserNotificationTarget)
|
|
.filter(UserNotificationTarget.owner_id == owner_id)
|
|
.order_by(UserNotificationTarget.created_at.desc())
|
|
.all()
|
|
)
|
|
return [_target_to_dict(t) for t in targets]
|
|
|
|
|
|
@router.post("/targets", status_code=status.HTTP_201_CREATED)
|
|
async def create_target(
|
|
body: NotificationTargetCreate,
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, Any]:
|
|
"""Create a new notification target (email or webhook)."""
|
|
target = UserNotificationTarget(
|
|
owner_id=owner_id,
|
|
channel_type=body.channel_type,
|
|
name=body.name,
|
|
config=json.dumps(body.config),
|
|
is_active=body.is_active,
|
|
)
|
|
try:
|
|
db.add(target)
|
|
db.commit()
|
|
db.refresh(target)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info("Notification target created: id=%s owner=%s type=%s", target.id, owner_id, body.channel_type)
|
|
return _target_to_dict(target)
|
|
|
|
|
|
@router.put("/targets/{target_id}", status_code=status.HTTP_200_OK)
|
|
async def update_target(
|
|
target_id: int,
|
|
body: NotificationTargetUpdate,
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, Any]:
|
|
"""Update an existing notification target."""
|
|
target = (
|
|
db.query(UserNotificationTarget)
|
|
.filter(UserNotificationTarget.id == target_id, UserNotificationTarget.owner_id == owner_id)
|
|
.first()
|
|
)
|
|
if not target:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target not found")
|
|
|
|
try:
|
|
if body.name is not None:
|
|
target.name = body.name
|
|
if body.config is not None:
|
|
# Merge new config over existing, preserving masked password field if unchanged
|
|
existing_config: dict[str, Any] = {}
|
|
if target.config:
|
|
try:
|
|
existing_config = json.loads(target.config)
|
|
except (json.JSONDecodeError, ValueError):
|
|
existing_config = {}
|
|
merged = dict(existing_config)
|
|
for k, v in body.config.items():
|
|
# Skip writing back a masked password placeholder
|
|
if k == "smtp_password" and v == "****":
|
|
continue
|
|
merged[k] = v
|
|
target.config = json.dumps(merged)
|
|
if body.is_active is not None:
|
|
target.is_active = body.is_active
|
|
db.commit()
|
|
db.refresh(target)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info("Notification target updated: id=%s owner=%s", target_id, owner_id)
|
|
return _target_to_dict(target)
|
|
|
|
|
|
@router.delete("/targets/{target_id}", status_code=status.HTTP_200_OK)
|
|
async def delete_target(
|
|
target_id: int,
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, str]:
|
|
"""Delete a notification target and its associated preferences."""
|
|
target = (
|
|
db.query(UserNotificationTarget)
|
|
.filter(UserNotificationTarget.id == target_id, UserNotificationTarget.owner_id == owner_id)
|
|
.first()
|
|
)
|
|
if not target:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target not found")
|
|
|
|
try:
|
|
# Remove any preferences that reference this target
|
|
db.query(UserNotificationPreference).filter(
|
|
UserNotificationPreference.owner_id == owner_id,
|
|
UserNotificationPreference.target_id == target_id,
|
|
).delete()
|
|
db.delete(target)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info("Notification target deleted: id=%s owner=%s", target_id, owner_id)
|
|
return {"detail": "Target deleted"}
|
|
|
|
|
|
@router.post("/targets/{target_id}/test", status_code=status.HTTP_200_OK)
|
|
async def test_target(
|
|
target_id: int,
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, str]:
|
|
"""Send a test notification to the specified target."""
|
|
target = (
|
|
db.query(UserNotificationTarget)
|
|
.filter(UserNotificationTarget.id == target_id, UserNotificationTarget.owner_id == owner_id)
|
|
.first()
|
|
)
|
|
if not target:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target not found")
|
|
|
|
config: dict[str, Any] = {}
|
|
if target.config:
|
|
try:
|
|
config = json.loads(target.config)
|
|
except (json.JSONDecodeError, ValueError):
|
|
config = {}
|
|
|
|
title = "DocuElevate Test Notification"
|
|
message = f"This is a test notification from DocuElevate for target '{target.name}'."
|
|
|
|
if target.channel_type == "email":
|
|
from app.utils.user_notification import _send_email_notification
|
|
|
|
ok = _send_email_notification(config, title, message)
|
|
elif target.channel_type == "webhook":
|
|
from app.utils.user_notification import _send_webhook_notification
|
|
|
|
ok = _send_webhook_notification(config, "test", title, message)
|
|
else:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown channel type")
|
|
|
|
if not ok:
|
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Failed to send test notification")
|
|
|
|
return {"detail": "Test notification sent"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preferences endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/preferences")
|
|
async def get_preferences(
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, Any]:
|
|
"""Return all notification preferences for the authenticated user.
|
|
|
|
Response structure:
|
|
{
|
|
"event_types": ["document.processed", "document.failed"],
|
|
"event_labels": {"document.processed": "Document Processed", ...},
|
|
"preferences": {
|
|
"document.processed": {
|
|
"in_app": {"is_enabled": true, "target_id": null},
|
|
"email": {"is_enabled": false, "target_id": 1},
|
|
...
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
prefs = db.query(UserNotificationPreference).filter(UserNotificationPreference.owner_id == owner_id).all()
|
|
|
|
# Build nested dict: event_type -> channel_type -> {is_enabled, target_id}
|
|
result: dict[str, dict[str, dict[str, Any]]] = {}
|
|
for pref in prefs:
|
|
result.setdefault(pref.event_type, {})[pref.channel_type] = {
|
|
"is_enabled": pref.is_enabled,
|
|
"target_id": pref.target_id,
|
|
}
|
|
|
|
return {
|
|
"event_types": list(USER_EVENT_LABELS.keys()),
|
|
"event_labels": USER_EVENT_LABELS,
|
|
"preferences": result,
|
|
}
|
|
|
|
|
|
@router.put("/preferences", status_code=status.HTTP_200_OK)
|
|
async def update_preferences(
|
|
body: PreferencesUpdate,
|
|
owner_id: CurrentOwner,
|
|
db: DbSession,
|
|
) -> dict[str, str]:
|
|
"""Bulk upsert notification preferences for the authenticated user.
|
|
|
|
Validates that any referenced target_id belongs to the requesting user.
|
|
"""
|
|
# Collect all target IDs referenced in the payload for ownership validation
|
|
referenced_target_ids: set[int] = set()
|
|
for item in body.preferences:
|
|
if item.target_id is not None:
|
|
referenced_target_ids.add(item.target_id)
|
|
|
|
if referenced_target_ids:
|
|
owned_ids = {
|
|
row.id
|
|
for row in db.query(UserNotificationTarget.id)
|
|
.filter(
|
|
UserNotificationTarget.owner_id == owner_id,
|
|
UserNotificationTarget.id.in_(referenced_target_ids),
|
|
)
|
|
.all()
|
|
}
|
|
invalid = referenced_target_ids - owned_ids
|
|
if invalid:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid or inaccessible target_id(s): {sorted(invalid)}",
|
|
)
|
|
|
|
try:
|
|
for item in body.preferences:
|
|
existing = (
|
|
db.query(UserNotificationPreference)
|
|
.filter(
|
|
UserNotificationPreference.owner_id == owner_id,
|
|
UserNotificationPreference.event_type == item.event_type,
|
|
UserNotificationPreference.channel_type == item.channel_type,
|
|
UserNotificationPreference.target_id == item.target_id,
|
|
)
|
|
.first()
|
|
)
|
|
if existing:
|
|
existing.is_enabled = item.is_enabled
|
|
else:
|
|
db.add(
|
|
UserNotificationPreference(
|
|
owner_id=owner_id,
|
|
event_type=item.event_type,
|
|
channel_type=item.channel_type,
|
|
target_id=item.target_id,
|
|
is_enabled=item.is_enabled,
|
|
)
|
|
)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
logger.info("Notification preferences updated for owner=%s", owner_id)
|
|
return {"detail": "Preferences updated"}
|