Merge pull request #567 from christianlouis/copilot/build-notification-system
feat(notifications): per-user notification system with email, webhook, and in-app inbox
This commit is contained in:
@@ -20,6 +20,7 @@ from app.api.google_drive import router as google_drive_router
|
||||
from app.api.imap_accounts import router as imap_accounts_router
|
||||
from app.api.integrations import router as integrations_router
|
||||
from app.api.logs import router as logs_router
|
||||
from app.api.notifications import router as notifications_router
|
||||
from app.api.onboarding import router as onboarding_router
|
||||
from app.api.onedrive import router as onedrive_router
|
||||
from app.api.openai import router as openai_router
|
||||
@@ -74,3 +75,4 @@ router.include_router(billing_router)
|
||||
router.include_router(pipelines_router)
|
||||
router.include_router(imap_accounts_router)
|
||||
router.include_router(integrations_router)
|
||||
router.include_router(notifications_router)
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
"""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"}
|
||||
@@ -637,3 +637,50 @@ class ApiToken(Base):
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class UserNotificationTarget(Base):
|
||||
"""Per-user notification target (email or webhook channel)."""
|
||||
|
||||
__tablename__ = "user_notification_targets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
owner_id = Column(String, nullable=False, index=True)
|
||||
channel_type = Column(String(20), nullable=False) # "email" or "webhook"
|
||||
name = Column(String(255), nullable=False) # Human-readable label
|
||||
config = Column(Text, nullable=True) # JSON: smtp config or webhook url
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class UserNotificationPreference(Base):
|
||||
"""Mapping: which user events trigger which notification channel."""
|
||||
|
||||
__tablename__ = "user_notification_preferences"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
owner_id = Column(String, nullable=False, index=True)
|
||||
event_type = Column(String(50), nullable=False) # "document.processed", "document.failed"
|
||||
channel_type = Column(String(20), nullable=False) # "in_app", "email", "webhook"
|
||||
target_id = Column(Integer, nullable=True) # NULL = in_app, else UserNotificationTarget.id
|
||||
is_enabled = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
__table_args__ = (UniqueConstraint("owner_id", "event_type", "channel_type", "target_id"),)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class InAppNotification(Base):
|
||||
"""In-app notification record for the bell icon / inbox."""
|
||||
|
||||
__tablename__ = "in_app_notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
owner_id = Column(String, nullable=False, index=True)
|
||||
event_type = Column(String(50), nullable=False) # "document.processed", "document.failed"
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text, nullable=True)
|
||||
is_read = Column(Boolean, nullable=False, default=False, index=True)
|
||||
file_id = Column(Integer, nullable=True) # Optional link to FileRecord
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Per-user notification dispatch service.
|
||||
|
||||
Handles user-centric events (document.processed, document.failed) by:
|
||||
1. Always creating an InAppNotification record
|
||||
2. Sending via configured email/webhook targets (UserNotificationTarget)
|
||||
if the user has enabled that channel/event combination.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Supported user-centric event types
|
||||
EVENT_DOCUMENT_PROCESSED = "document.processed"
|
||||
EVENT_DOCUMENT_FAILED = "document.failed"
|
||||
|
||||
USER_EVENT_LABELS: dict[str, str] = {
|
||||
EVENT_DOCUMENT_PROCESSED: "Document Processed",
|
||||
EVENT_DOCUMENT_FAILED: "Document Processing Failed",
|
||||
}
|
||||
|
||||
|
||||
def create_in_app_notification(
|
||||
owner_id: str,
|
||||
event_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
file_id: int | None = None,
|
||||
) -> InAppNotification | None:
|
||||
"""Persist an InAppNotification record for the given user.
|
||||
|
||||
Returns:
|
||||
The created InAppNotification, or None on error.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
notif = InAppNotification(
|
||||
owner_id=owner_id,
|
||||
event_type=event_type,
|
||||
title=title,
|
||||
message=message,
|
||||
file_id=file_id,
|
||||
)
|
||||
db.add(notif)
|
||||
db.commit()
|
||||
db.refresh(notif)
|
||||
return notif
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to create in-app notification for owner_id=%s", owner_id)
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _send_email_notification(target_config: dict[str, Any], title: str, message: str) -> bool:
|
||||
"""Send an email notification via the configured SMTP target.
|
||||
|
||||
Args:
|
||||
target_config: dict with keys: smtp_host, smtp_port, smtp_username,
|
||||
smtp_password, smtp_use_tls, recipient_email
|
||||
title: Email subject
|
||||
message: Email body text
|
||||
|
||||
Returns:
|
||||
True if the email was sent successfully, False otherwise.
|
||||
"""
|
||||
try:
|
||||
smtp_host = target_config.get("smtp_host", "")
|
||||
smtp_port = int(target_config.get("smtp_port", 587))
|
||||
smtp_username = target_config.get("smtp_username", "")
|
||||
smtp_password = target_config.get("smtp_password", "")
|
||||
smtp_use_tls = bool(target_config.get("smtp_use_tls", True))
|
||||
recipient_email = target_config.get("recipient_email", "")
|
||||
sender_email = target_config.get("sender_email") or smtp_username or "noreply@docuelevate.local"
|
||||
|
||||
if not smtp_host or not recipient_email:
|
||||
logger.warning("Email notification target missing smtp_host or recipient_email")
|
||||
return False
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = title
|
||||
msg["From"] = sender_email
|
||||
msg["To"] = recipient_email
|
||||
msg.attach(MIMEText(message, "plain"))
|
||||
|
||||
with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
|
||||
if smtp_use_tls:
|
||||
server.starttls()
|
||||
if smtp_username and smtp_password:
|
||||
server.login(smtp_username, smtp_password)
|
||||
server.send_message(msg)
|
||||
|
||||
logger.info("Email notification sent to %s", recipient_email)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to send email notification")
|
||||
return False
|
||||
|
||||
|
||||
def _send_webhook_notification(target_config: dict[str, Any], event_type: str, title: str, message: str) -> bool:
|
||||
"""Send a webhook POST notification to the configured URL.
|
||||
|
||||
Args:
|
||||
target_config: dict with keys: url, secret (optional HMAC header value)
|
||||
event_type: The event type string
|
||||
title: Notification title
|
||||
message: Notification body
|
||||
|
||||
Returns:
|
||||
True if the webhook was delivered successfully, False otherwise.
|
||||
"""
|
||||
try:
|
||||
url = target_config.get("url", "")
|
||||
secret = target_config.get("secret", "")
|
||||
|
||||
if not url:
|
||||
logger.warning("Webhook notification target missing url")
|
||||
return False
|
||||
|
||||
payload = {
|
||||
"event": event_type,
|
||||
"title": title,
|
||||
"message": message,
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if secret:
|
||||
headers["X-DocuElevate-Secret"] = secret
|
||||
|
||||
response = httpx.post(url, json=payload, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
logger.info("Webhook notification sent to %s (status %s)", url, response.status_code)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to send webhook notification to %s", target_config.get("url", ""))
|
||||
return False
|
||||
|
||||
|
||||
def dispatch_user_notification(
|
||||
owner_id: str,
|
||||
event_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
file_id: int | None = None,
|
||||
) -> None:
|
||||
"""Dispatch a user notification for the given event.
|
||||
|
||||
Always creates an in-app notification. Also sends via email/webhook
|
||||
targets if the user has configured and enabled them for this event.
|
||||
|
||||
Args:
|
||||
owner_id: The user's stable identifier.
|
||||
event_type: e.g. "document.processed" or "document.failed"
|
||||
title: Short notification title.
|
||||
message: Longer notification body.
|
||||
file_id: Optional FileRecord.id to link.
|
||||
"""
|
||||
# 1. Always create an in-app notification
|
||||
create_in_app_notification(
|
||||
owner_id=owner_id,
|
||||
event_type=event_type,
|
||||
title=title,
|
||||
message=message,
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
# 2. Check for configured email/webhook preferences
|
||||
db = SessionLocal()
|
||||
try:
|
||||
prefs = (
|
||||
db.query(UserNotificationPreference)
|
||||
.filter(
|
||||
UserNotificationPreference.owner_id == owner_id,
|
||||
UserNotificationPreference.event_type == event_type,
|
||||
UserNotificationPreference.is_enabled == True, # noqa: E712
|
||||
UserNotificationPreference.channel_type.in_(["email", "webhook"]),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
for pref in prefs:
|
||||
if not pref.target_id:
|
||||
continue
|
||||
target = db.get(UserNotificationTarget, pref.target_id)
|
||||
if not target or not target.is_active:
|
||||
continue
|
||||
config: dict[str, Any] = {}
|
||||
if target.config:
|
||||
try:
|
||||
config = json.loads(target.config)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
config = {}
|
||||
|
||||
if pref.channel_type == "email":
|
||||
_send_email_notification(config, title, message)
|
||||
elif pref.channel_type == "webhook":
|
||||
_send_webhook_notification(config, event_type, title, message)
|
||||
except Exception:
|
||||
logger.exception("Error dispatching user notification for owner_id=%s event=%s", owner_id, event_type)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def notify_user_document_processed(owner_id: str, filename: str, file_id: int | None = None) -> None:
|
||||
"""Notify a user that their document was successfully processed."""
|
||||
dispatch_user_notification(
|
||||
owner_id=owner_id,
|
||||
event_type=EVENT_DOCUMENT_PROCESSED,
|
||||
title=f"Document processed: {filename}",
|
||||
message=f"Your document '{filename}' has been successfully processed and uploaded.",
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
|
||||
def notify_user_document_failed(owner_id: str, filename: str, error: str, file_id: int | None = None) -> None:
|
||||
"""Notify a user that their document processing failed."""
|
||||
dispatch_user_notification(
|
||||
owner_id=owner_id,
|
||||
event_type=EVENT_DOCUMENT_FAILED,
|
||||
title=f"Document processing failed: {filename}",
|
||||
message=f"Processing of '{filename}' failed: {error}",
|
||||
file_id=file_id,
|
||||
)
|
||||
@@ -18,6 +18,7 @@ from app.views.help import router as help_router # Built-in help / How-To docs
|
||||
from app.views.imap_accounts import router as imap_accounts_router
|
||||
from app.views.integrations import router as integrations_router # Unified integrations dashboard
|
||||
from app.views.license_routes import router as license_router # Add the license router
|
||||
from app.views.notifications import router as notifications_router
|
||||
from app.views.onboarding import router as onboarding_router
|
||||
from app.views.onedrive import router as onedrive_router
|
||||
from app.views.pipelines import router as pipelines_router # Processing pipelines
|
||||
@@ -52,4 +53,5 @@ router.include_router(onboarding_router) # User onboarding wizard
|
||||
router.include_router(pipelines_router) # Processing pipelines
|
||||
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
|
||||
router.include_router(integrations_router) # Unified integrations dashboard
|
||||
router.include_router(notifications_router) # User notification dashboard
|
||||
router.include_router(help_router) # Built-in help / How-To docs
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""View route for the notifications dashboard."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.views.base import APIRouter, require_login, templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/notifications")
|
||||
@require_login
|
||||
async def notifications_dashboard(request: Request):
|
||||
"""Render the notifications dashboard."""
|
||||
return templates.TemplateResponse(
|
||||
"notifications_dashboard.html",
|
||||
{"request": request, "page_title": "Notifications"},
|
||||
)
|
||||
@@ -1070,6 +1070,55 @@ payment processors.
|
||||
|
||||
For detailed setup instructions, see the [Notifications Setup Guide](NotificationsSetup.md).
|
||||
|
||||
#### Per-User Notification System
|
||||
|
||||
In addition to the system-level Apprise notifications, DocuElevate includes a **per-user notification system** that gives each user full control over how they are notified about their own document events.
|
||||
|
||||
**Notification Dashboard** — available at `/notifications` for every logged-in user. It has three tabs:
|
||||
|
||||
| Tab | Description |
|
||||
|-----|-------------|
|
||||
| **Inbox** | In-app bell-icon notification feed. Persisted in the database; shows unread count badge in the navigation bar. Users can mark individual items or all items as read. |
|
||||
| **Targets** | User-defined notification channels: **Email (SMTP)** and **Webhook (HTTP POST)**. Each target can be tested independently from the UI. |
|
||||
| **Preferences** | Event/channel matrix. Users choose which channels are triggered for each event type. In-app notifications are always enabled. |
|
||||
|
||||
**User-centric event types:**
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `document.processed` | A document uploaded by the user was successfully processed and uploaded to destinations |
|
||||
| `document.failed` | A document uploaded by the user failed during processing |
|
||||
|
||||
**Email target configuration fields:**
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `smtp_host` | SMTP server hostname |
|
||||
| `smtp_port` | SMTP port (default `587`) |
|
||||
| `smtp_username` | SMTP login username |
|
||||
| `smtp_password` | SMTP login password (stored in database, masked in UI) |
|
||||
| `smtp_use_tls` | Enable STARTTLS (`true`/`false`, default `true`) |
|
||||
| `sender_email` | From address (defaults to `smtp_username` if omitted) |
|
||||
| `recipient_email` | Destination address for this target |
|
||||
|
||||
**Webhook target configuration fields:**
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `url` | HTTP(S) URL to POST the notification payload to |
|
||||
| `secret` | Optional secret string sent as `X-DocuElevate-Secret` header |
|
||||
|
||||
**Webhook payload format:**
|
||||
```json
|
||||
{
|
||||
"event": "document.processed",
|
||||
"title": "Document processed: invoice.pdf",
|
||||
"message": "Your document 'invoice.pdf' has been successfully processed and uploaded."
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** There are no additional environment variables for the per-user notification system — all settings are stored in the database and managed through the user-facing `/notifications` dashboard.
|
||||
|
||||
### Webhooks
|
||||
|
||||
Webhooks notify external systems via HTTP POST when document events occur.
|
||||
|
||||
@@ -194,6 +194,19 @@
|
||||
<i class="fas fa-circle-question mr-1 text-gray-400" aria-hidden="true"></i>Help
|
||||
</a>
|
||||
|
||||
<!-- Bell notification icon -->
|
||||
<a href="/notifications"
|
||||
id="notificationBell"
|
||||
class="relative p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||
aria-label="Notifications"
|
||||
title="Notifications"
|
||||
{% if request and request.url.path == '/notifications' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-bell" aria-hidden="true"></i>
|
||||
<span id="notificationBadge"
|
||||
class="hidden absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold"
|
||||
aria-live="polite"></span>
|
||||
</a>
|
||||
|
||||
<!-- Dark mode toggle -->
|
||||
<button
|
||||
id="darkModeToggle"
|
||||
@@ -283,6 +296,11 @@
|
||||
{% if request and request.url.path == '/integrations' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-plug mr-2 text-gray-400" aria-hidden="true"></i>Integrations
|
||||
</a>
|
||||
<a href="/notifications"
|
||||
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
|
||||
{% if request and request.url.path == '/notifications' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-bell mr-2 text-gray-400" aria-hidden="true"></i>Notifications
|
||||
</a>
|
||||
|
||||
<!-- Admin section in mobile menu – shown only for admin users via JS -->
|
||||
<div id="mobileAdminSection" class="hidden">
|
||||
@@ -427,6 +445,30 @@
|
||||
<!-- Common JS (shared) -->
|
||||
<script src="/static/js/common.js"></script>
|
||||
|
||||
<!-- Notification badge updater -->
|
||||
<script>
|
||||
(function() {
|
||||
async function updateNotificationBadge() {
|
||||
try {
|
||||
const resp = await fetch('/api/user-notifications/inbox/unread-count');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const badge = document.getElementById('notificationBadge');
|
||||
if (!badge) return;
|
||||
if (data.count > 0) {
|
||||
badge.textContent = data.count > 99 ? '99+' : data.count;
|
||||
badge.classList.remove('hidden');
|
||||
badge.setAttribute('aria-label', data.count + ' unread notifications');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', updateNotificationBadge);
|
||||
setInterval(updateNotificationBadge, 60000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Let child pages define extra scripts if needed -->
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,979 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Notifications – DocuElevate{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
class="container mx-auto px-4 py-8"
|
||||
x-data="notificationsDashboard()"
|
||||
x-init="init()"
|
||||
>
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<i class="fas fa-bell text-blue-500" aria-hidden="true"></i>
|
||||
Notifications
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||
Manage your notification inbox, targets, and event preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab nav ────────────────────────────────────────────────────────── -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 mb-6" role="tablist" aria-label="Notification sections">
|
||||
<nav class="-mb-px flex space-x-6">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'inbox'"
|
||||
:tabindex="activeTab === 'inbox' ? 0 : -1"
|
||||
@click="activeTab = 'inbox'"
|
||||
:class="activeTab === 'inbox'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
|
||||
class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-controls="panel-inbox"
|
||||
id="tab-inbox"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-inbox mr-1" aria-hidden="true"></i>
|
||||
Inbox
|
||||
<template x-if="unreadCount > 0">
|
||||
<span
|
||||
class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700"
|
||||
aria-label="unread notifications"
|
||||
x-text="unreadCount > 99 ? '99+' : unreadCount"
|
||||
></span>
|
||||
</template>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'settings'"
|
||||
:tabindex="activeTab === 'settings' ? 0 : -1"
|
||||
@click="activeTab = 'settings'"
|
||||
:class="activeTab === 'settings'
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
|
||||
class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-controls="panel-settings"
|
||||
id="tab-settings"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-sliders-h mr-1" aria-hidden="true"></i>
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
INBOX PANEL
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="panel-inbox"
|
||||
aria-labelledby="tab-inbox"
|
||||
x-show="activeTab === 'inbox'"
|
||||
>
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="inboxFilter" class="text-sm font-medium text-gray-700 dark:text-gray-300">Show:</label>
|
||||
<select
|
||||
id="inboxFilter"
|
||||
x-model="inboxFilter"
|
||||
@change="loadInbox()"
|
||||
class="text-sm border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
aria-label="Filter notifications"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="unread">Unread only</option>
|
||||
<option value="read">Read only</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="markAllRead()"
|
||||
:disabled="unreadCount === 0"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
style="min-height:44px;"
|
||||
aria-label="Mark all notifications as read"
|
||||
>
|
||||
<i class="fas fa-check-double mr-1" aria-hidden="true"></i> Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template x-if="inboxLoading">
|
||||
<div class="text-center py-12 text-gray-400" role="status" aria-live="polite">
|
||||
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
|
||||
<p class="mt-2 text-sm">Loading notifications…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="!inboxLoading && filteredNotifications().length === 0">
|
||||
<div class="text-center py-12 text-gray-400" role="status">
|
||||
<i class="fas fa-bell-slash text-4xl mb-3" aria-hidden="true"></i>
|
||||
<p class="text-sm">No notifications found.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Notifications list -->
|
||||
<template x-if="!inboxLoading && filteredNotifications().length > 0">
|
||||
<ul class="space-y-2" role="list" aria-label="Notification items">
|
||||
<template x-for="notif in filteredNotifications()" :key="notif.id">
|
||||
<li
|
||||
class="flex items-start gap-3 p-4 rounded-lg border transition-colors"
|
||||
:class="notif.is_read
|
||||
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'"
|
||||
>
|
||||
<!-- Event icon -->
|
||||
<div
|
||||
class="flex-shrink-0 h-9 w-9 rounded-full flex items-center justify-center"
|
||||
:class="notif.event_type === 'document.failed'
|
||||
? 'bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400'
|
||||
: 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i :class="notif.event_type === 'document.failed' ? 'fas fa-times-circle' : 'fas fa-check-circle'"></i>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium text-gray-900 dark:text-white"
|
||||
:class="notif.is_read ? '' : 'font-semibold'"
|
||||
x-text="notif.title"
|
||||
></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5" x-text="notif.message"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1" x-text="formatDate(notif.created_at)"></p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<template x-if="notif.file_id">
|
||||
<a
|
||||
:href="`/files/${notif.file_id}`"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
:aria-label="`View file for: ${notif.title}`"
|
||||
>
|
||||
<i class="fas fa-external-link-alt" aria-hidden="true"></i>
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="!notif.is_read">
|
||||
<button
|
||||
type="button"
|
||||
@click="markRead(notif)"
|
||||
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
|
||||
:aria-label="`Mark as read: ${notif.title}`"
|
||||
style="min-height:44px;min-width:44px;"
|
||||
title="Mark as read"
|
||||
>
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
SETTINGS PANEL
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="panel-settings"
|
||||
aria-labelledby="tab-settings"
|
||||
x-show="activeTab === 'settings'"
|
||||
>
|
||||
|
||||
<!-- ── Notification Targets ──────────────────────────────────────────── -->
|
||||
<section aria-labelledby="targets-heading" class="mb-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="targets-heading" class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-satellite-dish mr-2 text-indigo-500" aria-hidden="true"></i>Notification Targets
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="openAddTarget()"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-indigo-600 hover:bg-indigo-700 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
style="min-height:44px;"
|
||||
aria-label="Add notification target"
|
||||
>
|
||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i> Add Target
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-if="targetsLoading">
|
||||
<div class="text-center py-8 text-gray-400" role="status" aria-live="polite">
|
||||
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||
<span class="sr-only">Loading targets…</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!targetsLoading && targets.length === 0">
|
||||
<div class="text-center py-8 text-gray-400 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<i class="fas fa-satellite-dish text-3xl mb-2" aria-hidden="true"></i>
|
||||
<p class="text-sm">No notification targets configured.</p>
|
||||
<p class="text-xs mt-1">Add an email or webhook target to receive notifications outside the app.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!targetsLoading && targets.length > 0">
|
||||
<div class="space-y-3" role="list" aria-label="Notification targets">
|
||||
<template x-for="target in targets" :key="target.id">
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
role="listitem"
|
||||
>
|
||||
<!-- Type icon -->
|
||||
<div
|
||||
class="flex-shrink-0 h-10 w-10 rounded-full flex items-center justify-center"
|
||||
:class="target.channel_type === 'email'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-400'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i :class="target.channel_type === 'email' ? 'fas fa-envelope' : 'fas fa-globe'"></i>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="target.name"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 capitalize" x-text="target.channel_type"></p>
|
||||
</div>
|
||||
|
||||
<!-- Active badge -->
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="target.is_active
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="target.is_active ? 'Active' : 'Inactive'"
|
||||
></span>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="testTarget(target)"
|
||||
class="p-2 text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded"
|
||||
:aria-label="`Test target: ${target.name}`"
|
||||
style="min-height:44px;min-width:44px;"
|
||||
title="Send test notification"
|
||||
>
|
||||
<i class="fas fa-paper-plane" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openEditTarget(target)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
:aria-label="`Edit target: ${target.name}`"
|
||||
style="min-height:44px;min-width:44px;"
|
||||
title="Edit target"
|
||||
>
|
||||
<i class="fas fa-pencil-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="deleteTarget(target)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-red-500 rounded"
|
||||
:aria-label="`Delete target: ${target.name}`"
|
||||
style="min-height:44px;min-width:44px;"
|
||||
title="Delete target"
|
||||
>
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── Event Preferences ─────────────────────────────────────────────── -->
|
||||
<section aria-labelledby="prefs-heading">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="prefs-heading" class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-sliders-h mr-2 text-green-500" aria-hidden="true"></i>Event Preferences
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="savePreferences()"
|
||||
:disabled="prefsSaving"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-green-600 hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
style="min-height:44px;"
|
||||
aria-label="Save notification preferences"
|
||||
>
|
||||
<template x-if="prefsSaving">
|
||||
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
|
||||
</template>
|
||||
<template x-if="!prefsSaving">
|
||||
<i class="fas fa-save mr-1" aria-hidden="true"></i>
|
||||
</template>
|
||||
Save Preferences
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700" aria-label="Notification event preferences">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Event
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
In-App
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Webhook
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="eventType in eventTypes" :key="eventType">
|
||||
<tr>
|
||||
<!-- Event label -->
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white" x-text="eventLabels[eventType] || eventType"></td>
|
||||
|
||||
<!-- In-App (always enabled, non-editable) -->
|
||||
<td class="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
disabled
|
||||
class="h-4 w-4 text-blue-600 rounded opacity-60 cursor-not-allowed"
|
||||
:aria-label="`In-app notification for ${eventLabels[eventType] || eventType} (always enabled)`"
|
||||
title="In-app notifications are always enabled"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Email channel -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<template x-for="target in emailTargets()" :key="target.id">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPrefEnabled(eventType, 'email', target.id)"
|
||||
@change="togglePref(eventType, 'email', target.id, $event.target.checked)"
|
||||
class="h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
:aria-label="`Email via ${target.name} for ${eventLabels[eventType] || eventType}`"
|
||||
/>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300" x-text="target.name"></span>
|
||||
</label>
|
||||
</template>
|
||||
<template x-if="emailTargets().length === 0">
|
||||
<span class="text-xs text-gray-400 italic">No email targets</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Webhook channel -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<template x-for="target in webhookTargets()" :key="target.id">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPrefEnabled(eventType, 'webhook', target.id)"
|
||||
@change="togglePref(eventType, 'webhook', target.id, $event.target.checked)"
|
||||
class="h-4 w-4 text-purple-600 rounded focus:ring-purple-500"
|
||||
:aria-label="`Webhook via ${target.name} for ${eventLabels[eventType] || eventType}`"
|
||||
/>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300" x-text="target.name"></span>
|
||||
</label>
|
||||
</template>
|
||||
<template x-if="webhookTargets().length === 0">
|
||||
<span class="text-xs text-gray-400 italic">No webhook targets</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
TARGET MODAL (Add / Edit)
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
<div
|
||||
x-show="targetModalOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="targetModalMode === 'edit' ? 'modal-edit-title' : 'modal-add-title'"
|
||||
@keydown.escape.window="targetModalOpen = false"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3
|
||||
:id="targetModalMode === 'edit' ? 'modal-edit-title' : 'modal-add-title'"
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white"
|
||||
x-text="targetModalMode === 'edit' ? 'Edit Notification Target' : 'Add Notification Target'"
|
||||
></h3>
|
||||
<button
|
||||
type="button"
|
||||
@click="targetModalOpen = false"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
aria-label="Close modal"
|
||||
style="min-height:44px;min-width:44px;"
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal body -->
|
||||
<form @submit.prevent="submitTargetForm()" class="px-6 py-5 space-y-4">
|
||||
<!-- Channel type (only for new targets) -->
|
||||
<template x-if="targetModalMode === 'add'">
|
||||
<div>
|
||||
<label for="modal-channel-type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Channel Type <span class="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="modal-channel-type"
|
||||
x-model="targetForm.channel_type"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
required
|
||||
aria-required="true"
|
||||
>
|
||||
<option value="email">Email (SMTP)</option>
|
||||
<option value="webhook">Webhook (HTTP POST)</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="modal-target-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name <span class="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="modal-target-name"
|
||||
x-model="targetForm.name"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="e.g. My Gmail, Production Webhook"
|
||||
required
|
||||
aria-required="true"
|
||||
maxlength="255"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email-specific fields -->
|
||||
<template x-if="targetForm.channel_type === 'email'">
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="modal-smtp-host" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Host</label>
|
||||
<input
|
||||
type="text"
|
||||
id="modal-smtp-host"
|
||||
x-model="targetForm.config.smtp_host"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-smtp-port" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Port</label>
|
||||
<input
|
||||
type="number"
|
||||
id="modal-smtp-port"
|
||||
x-model.number="targetForm.config.smtp_port"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="587"
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-smtp-username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="modal-smtp-username"
|
||||
x-model="targetForm.config.smtp_username"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="user@example.com"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-smtp-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="modal-smtp-password"
|
||||
x-model="targetForm.config.smtp_password"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Leave blank to keep existing"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-recipient-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recipient Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="modal-recipient-email"
|
||||
x-model="targetForm.config.recipient_email"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-sender-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Sender Email (optional)</label>
|
||||
<input
|
||||
type="email"
|
||||
id="modal-sender-email"
|
||||
x-model="targetForm.config.sender_email"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="noreply@docuelevate.local"
|
||||
/>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="targetForm.config.smtp_use_tls"
|
||||
class="h-4 w-4 text-indigo-600 rounded focus:ring-indigo-500"
|
||||
aria-label="Use STARTTLS"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Use STARTTLS</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Webhook-specific fields -->
|
||||
<template x-if="targetForm.channel_type === 'webhook'">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="modal-webhook-url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="modal-webhook-url"
|
||||
x-model="targetForm.config.url"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="https://hooks.example.com/..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="modal-webhook-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Secret (optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="modal-webhook-secret"
|
||||
x-model="targetForm.config.secret"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
placeholder="Sent as X-DocuElevate-Secret header"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Active toggle (edit mode) -->
|
||||
<template x-if="targetModalMode === 'edit'">
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="targetForm.is_active"
|
||||
class="h-4 w-4 text-indigo-600 rounded focus:ring-indigo-500"
|
||||
aria-label="Target is active"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<template x-if="targetFormError">
|
||||
<p class="text-sm text-red-600 dark:text-red-400" role="alert" x-text="targetFormError"></p>
|
||||
</template>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="targetModalOpen = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="targetFormSaving"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<template x-if="targetFormSaving">
|
||||
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
|
||||
</template>
|
||||
<span x-text="targetModalMode === 'edit' ? 'Save Changes' : 'Add Target'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Global toast ───────────────────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="toast.visible"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-lg shadow-lg px-4 py-3 text-white text-sm font-medium"
|
||||
:class="toast.type === 'error' ? 'bg-red-600' : 'bg-green-600'"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
x-text="toast.message"
|
||||
></div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function notificationsDashboard() {
|
||||
return {
|
||||
// Tab state
|
||||
activeTab: 'inbox',
|
||||
|
||||
// Inbox
|
||||
notifications: [],
|
||||
inboxLoading: false,
|
||||
inboxFilter: 'all',
|
||||
unreadCount: 0,
|
||||
|
||||
// Targets
|
||||
targets: [],
|
||||
targetsLoading: false,
|
||||
|
||||
// Preferences
|
||||
eventTypes: [],
|
||||
eventLabels: {},
|
||||
prefs: {}, // event_type -> channel_type -> {is_enabled, target_id}
|
||||
prefsSaving: false,
|
||||
|
||||
// Target modal
|
||||
targetModalOpen: false,
|
||||
targetModalMode: 'add',
|
||||
editingTargetId: null,
|
||||
targetForm: {
|
||||
channel_type: 'email',
|
||||
name: '',
|
||||
is_active: true,
|
||||
config: { smtp_use_tls: true, smtp_port: 587 },
|
||||
},
|
||||
targetFormSaving: false,
|
||||
targetFormError: '',
|
||||
|
||||
// Toast
|
||||
toast: { visible: false, message: '', type: 'success' },
|
||||
|
||||
async init() {
|
||||
await Promise.all([this.loadInbox(), this.loadTargets(), this.loadPreferences()]);
|
||||
},
|
||||
|
||||
// ── Inbox ────────────────────────────────────────────────────────────
|
||||
|
||||
async loadInbox() {
|
||||
this.inboxLoading = true;
|
||||
try {
|
||||
const resp = await fetch('/api/user-notifications/inbox?limit=100');
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
this.notifications = await resp.json();
|
||||
this.unreadCount = this.notifications.filter(n => !n.is_read).length;
|
||||
} catch (e) {
|
||||
this.showToast('Failed to load notifications', 'error');
|
||||
} finally {
|
||||
this.inboxLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
filteredNotifications() {
|
||||
if (this.inboxFilter === 'unread') return this.notifications.filter(n => !n.is_read);
|
||||
if (this.inboxFilter === 'read') return this.notifications.filter(n => n.is_read);
|
||||
return this.notifications;
|
||||
},
|
||||
|
||||
async markRead(notif) {
|
||||
try {
|
||||
const resp = await fetch(`/api/user-notifications/inbox/${notif.id}/read`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error('Failed');
|
||||
notif.is_read = true;
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
this.updateBadge();
|
||||
} catch (e) {
|
||||
this.showToast('Failed to mark as read', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
const resp = await fetch('/api/user-notifications/inbox/read-all', { method: 'POST' });
|
||||
if (!resp.ok) throw new Error('Failed');
|
||||
this.notifications.forEach(n => { n.is_read = true; });
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.showToast('All notifications marked as read');
|
||||
} catch (e) {
|
||||
this.showToast('Failed to mark all as read', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
updateBadge() {
|
||||
const badge = document.getElementById('notificationBadge');
|
||||
if (!badge) return;
|
||||
if (this.unreadCount > 0) {
|
||||
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
|
||||
badge.classList.remove('hidden');
|
||||
badge.setAttribute('aria-label', this.unreadCount + ' unread notifications');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
},
|
||||
|
||||
// ── Targets ──────────────────────────────────────────────────────────
|
||||
|
||||
async loadTargets() {
|
||||
this.targetsLoading = true;
|
||||
try {
|
||||
const resp = await fetch('/api/user-notifications/targets');
|
||||
if (!resp.ok) throw new Error('Failed to load targets');
|
||||
this.targets = await resp.json();
|
||||
} catch (e) {
|
||||
this.showToast('Failed to load notification targets', 'error');
|
||||
} finally {
|
||||
this.targetsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
emailTargets() {
|
||||
return this.targets.filter(t => t.channel_type === 'email' && t.is_active);
|
||||
},
|
||||
|
||||
webhookTargets() {
|
||||
return this.targets.filter(t => t.channel_type === 'webhook' && t.is_active);
|
||||
},
|
||||
|
||||
openAddTarget() {
|
||||
this.targetModalMode = 'add';
|
||||
this.editingTargetId = null;
|
||||
this.targetForm = {
|
||||
channel_type: 'email',
|
||||
name: '',
|
||||
is_active: true,
|
||||
config: { smtp_use_tls: true, smtp_port: 587 },
|
||||
};
|
||||
this.targetFormError = '';
|
||||
this.targetModalOpen = true;
|
||||
},
|
||||
|
||||
openEditTarget(target) {
|
||||
this.targetModalMode = 'edit';
|
||||
this.editingTargetId = target.id;
|
||||
this.targetForm = {
|
||||
channel_type: target.channel_type,
|
||||
name: target.name,
|
||||
is_active: target.is_active,
|
||||
config: Object.assign({}, target.config),
|
||||
};
|
||||
this.targetFormError = '';
|
||||
this.targetModalOpen = true;
|
||||
},
|
||||
|
||||
async submitTargetForm() {
|
||||
this.targetFormSaving = true;
|
||||
this.targetFormError = '';
|
||||
try {
|
||||
let url, method, body;
|
||||
if (this.targetModalMode === 'add') {
|
||||
url = '/api/user-notifications/targets';
|
||||
method = 'POST';
|
||||
body = {
|
||||
channel_type: this.targetForm.channel_type,
|
||||
name: this.targetForm.name,
|
||||
is_active: this.targetForm.is_active,
|
||||
config: this.targetForm.config,
|
||||
};
|
||||
} else {
|
||||
url = `/api/user-notifications/targets/${this.editingTargetId}`;
|
||||
method = 'PUT';
|
||||
body = {
|
||||
name: this.targetForm.name,
|
||||
is_active: this.targetForm.is_active,
|
||||
config: this.targetForm.config,
|
||||
};
|
||||
}
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to save target');
|
||||
}
|
||||
await this.loadTargets();
|
||||
this.targetModalOpen = false;
|
||||
this.showToast(this.targetModalMode === 'edit' ? 'Target updated' : 'Target created');
|
||||
} catch (e) {
|
||||
this.targetFormError = e.message;
|
||||
} finally {
|
||||
this.targetFormSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTarget(target) {
|
||||
if (!confirm(`Delete notification target "${target.name}"? Associated preferences will also be removed.`)) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/user-notifications/targets/${target.id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Failed to delete');
|
||||
await this.loadTargets();
|
||||
await this.loadPreferences();
|
||||
this.showToast('Target deleted');
|
||||
} catch (e) {
|
||||
this.showToast('Failed to delete target', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async testTarget(target) {
|
||||
try {
|
||||
const resp = await fetch(`/api/user-notifications/targets/${target.id}/test`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Test failed');
|
||||
}
|
||||
this.showToast('Test notification sent!');
|
||||
} catch (e) {
|
||||
this.showToast(`Test failed: ${e.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Preferences ──────────────────────────────────────────────────────
|
||||
|
||||
async loadPreferences() {
|
||||
try {
|
||||
const resp = await fetch('/api/user-notifications/preferences');
|
||||
if (!resp.ok) throw new Error('Failed to load preferences');
|
||||
const data = await resp.json();
|
||||
this.eventTypes = data.event_types || [];
|
||||
this.eventLabels = data.event_labels || {};
|
||||
this.prefs = data.preferences || {};
|
||||
// Build flat list for isPrefEnabled lookups
|
||||
this._prefsList = [];
|
||||
for (const [et, channelMap] of Object.entries(this.prefs)) {
|
||||
for (const [ct, item] of Object.entries(channelMap)) {
|
||||
this._prefsList.push({
|
||||
event_type: et,
|
||||
channel_type: ct,
|
||||
target_id: item.target_id,
|
||||
is_enabled: item.is_enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.showToast('Failed to load preferences', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
isPrefEnabled(eventType, channelType, targetId) {
|
||||
const entry = this._prefKey(eventType, channelType, targetId);
|
||||
return entry ? entry.is_enabled : false;
|
||||
},
|
||||
|
||||
_prefKey(eventType, channelType, targetId) {
|
||||
if (!this._prefsList) return null;
|
||||
return this._prefsList.find(
|
||||
p => p.event_type === eventType && p.channel_type === channelType && p.target_id === targetId
|
||||
) || null;
|
||||
},
|
||||
|
||||
// Store pending pref changes as a dict: `${eventType}|${channelType}|${targetId}` -> is_enabled
|
||||
_pendingPrefs: {},
|
||||
_prefsList: [],
|
||||
|
||||
togglePref(eventType, channelType, targetId, enabled) {
|
||||
const k = `${eventType}|${channelType}|${targetId}`;
|
||||
this._pendingPrefs[k] = { event_type: eventType, channel_type: channelType, target_id: targetId, is_enabled: enabled };
|
||||
// Update in _prefsList for immediate UI feedback
|
||||
const idx = this._prefsList.findIndex(
|
||||
p => p.event_type === eventType && p.channel_type === channelType && p.target_id === targetId
|
||||
);
|
||||
if (idx >= 0) {
|
||||
this._prefsList[idx].is_enabled = enabled;
|
||||
} else {
|
||||
this._prefsList.push({ event_type: eventType, channel_type: channelType, target_id: targetId, is_enabled: enabled });
|
||||
}
|
||||
},
|
||||
|
||||
async savePreferences() {
|
||||
this.prefsSaving = true;
|
||||
try {
|
||||
// Build flat list from all pending changes
|
||||
const prefsList = Object.values(this._pendingPrefs).map(entry => ({
|
||||
event_type: entry.event_type,
|
||||
channel_type: entry.channel_type,
|
||||
target_id: entry.target_id,
|
||||
is_enabled: entry.is_enabled,
|
||||
}));
|
||||
const resp = await fetch('/api/user-notifications/preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preferences: prefsList }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to save');
|
||||
}
|
||||
this._pendingPrefs = {};
|
||||
await this.loadPreferences();
|
||||
this.showToast('Preferences saved');
|
||||
} catch (e) {
|
||||
this.showToast(`Failed to save preferences: ${e.message}`, 'error');
|
||||
} finally {
|
||||
this.prefsSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────
|
||||
|
||||
showToast(message, type = 'success') {
|
||||
this.toast = { visible: true, message, type };
|
||||
setTimeout(() => { this.toast.visible = false; }, 3500);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Add user notification tables (targets, preferences, in-app inbox)
|
||||
|
||||
Revision ID: 025_add_user_notifications
|
||||
Revises: 024_add_api_tokens
|
||||
Create Date: 2026-03-09
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "025_add_user_notifications"
|
||||
down_revision: Union[str, None] = "024_add_api_tokens"
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create user_notification_targets, user_notification_preferences, and in_app_notifications tables."""
|
||||
op.create_table(
|
||||
"user_notification_targets",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("owner_id", sa.String(), nullable=False),
|
||||
sa.Column("channel_type", sa.String(20), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("config", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_user_notification_targets_id", "user_notification_targets", ["id"])
|
||||
op.create_index("ix_user_notification_targets_owner_id", "user_notification_targets", ["owner_id"])
|
||||
|
||||
op.create_table(
|
||||
"user_notification_preferences",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("owner_id", sa.String(), nullable=False),
|
||||
sa.Column("event_type", sa.String(50), nullable=False),
|
||||
sa.Column("channel_type", sa.String(20), nullable=False),
|
||||
sa.Column("target_id", sa.Integer(), nullable=True),
|
||||
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("owner_id", "event_type", "channel_type", "target_id"),
|
||||
)
|
||||
op.create_index("ix_user_notification_preferences_id", "user_notification_preferences", ["id"])
|
||||
op.create_index("ix_user_notification_preferences_owner_id", "user_notification_preferences", ["owner_id"])
|
||||
|
||||
op.create_table(
|
||||
"in_app_notifications",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("owner_id", sa.String(), nullable=False),
|
||||
sa.Column("event_type", sa.String(50), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("is_read", sa.Boolean(), nullable=False, server_default="0"),
|
||||
sa.Column("file_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_in_app_notifications_id", "in_app_notifications", ["id"])
|
||||
op.create_index("ix_in_app_notifications_owner_id", "in_app_notifications", ["owner_id"])
|
||||
op.create_index("ix_in_app_notifications_is_read", "in_app_notifications", ["is_read"])
|
||||
op.create_index("ix_in_app_notifications_created_at", "in_app_notifications", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop user notification tables."""
|
||||
op.drop_index("ix_in_app_notifications_created_at", "in_app_notifications")
|
||||
op.drop_index("ix_in_app_notifications_is_read", "in_app_notifications")
|
||||
op.drop_index("ix_in_app_notifications_owner_id", "in_app_notifications")
|
||||
op.drop_index("ix_in_app_notifications_id", "in_app_notifications")
|
||||
op.drop_table("in_app_notifications")
|
||||
|
||||
op.drop_index("ix_user_notification_preferences_owner_id", "user_notification_preferences")
|
||||
op.drop_index("ix_user_notification_preferences_id", "user_notification_preferences")
|
||||
op.drop_table("user_notification_preferences")
|
||||
|
||||
op.drop_index("ix_user_notification_targets_owner_id", "user_notification_targets")
|
||||
op.drop_index("ix_user_notification_targets_id", "user_notification_targets")
|
||||
op.drop_table("user_notification_targets")
|
||||
@@ -0,0 +1,832 @@
|
||||
"""Tests for the per-user notification system (app/api/notifications.py)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OWNER = "notifuser@example.com"
|
||||
_OTHER_OWNER = "other@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def notif_engine():
|
||||
"""In-memory SQLite engine for notification tests."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def notif_session(notif_engine):
|
||||
"""DB session scoped to one test."""
|
||||
Session = sessionmaker(bind=notif_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def _make_client(notif_engine, owner_id: str = _OWNER) -> TestClient:
|
||||
"""Return a TestClient with *owner_id* injected as the authenticated user."""
|
||||
from app.api.notifications import _get_owner_id
|
||||
from app.main import app
|
||||
|
||||
Session = sessionmaker(bind=notif_engine)
|
||||
|
||||
def _override_get_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _override_owner():
|
||||
return owner_id
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
app.dependency_overrides[_get_owner_id] = _override_owner
|
||||
|
||||
return TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _cleanup(app):
|
||||
"""Remove dependency overrides after test."""
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Auth / 401 guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthGuard:
|
||||
"""Verify that unauthenticated requests are rejected."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_inbox_requires_auth(self):
|
||||
"""GET /api/user-notifications/inbox should return 401 when not authenticated."""
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
|
||||
resp = client.get("/api/user-notifications/inbox")
|
||||
assert resp.status_code == 401
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unread_count_requires_auth(self):
|
||||
"""GET /api/user-notifications/inbox/unread-count should return 401 when not authenticated."""
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
|
||||
resp = client.get("/api/user-notifications/inbox/unread-count")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Inbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInbox:
|
||||
"""Tests for the in-app notification inbox."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unread_count_empty(self, notif_engine):
|
||||
"""Unread count should be 0 when no notifications exist."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.get("/api/user-notifications/inbox/unread-count")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"count": 0}
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_inbox_empty(self, notif_engine):
|
||||
"""Listing inbox when empty should return an empty list."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.get("/api/user-notifications/inbox")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_inbox_shows_notifications(self, notif_engine, notif_session):
|
||||
"""Inbox should return notifications for the authenticated user."""
|
||||
from app.main import app
|
||||
|
||||
notif_session.add(
|
||||
InAppNotification(
|
||||
owner_id=_OWNER,
|
||||
event_type="document.processed",
|
||||
title="Test",
|
||||
message="Done",
|
||||
)
|
||||
)
|
||||
notif_session.commit()
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.get("/api/user-notifications/inbox")
|
||||
assert resp.status_code == 200
|
||||
items = resp.json()
|
||||
assert len(items) == 1
|
||||
assert items[0]["title"] == "Test"
|
||||
assert items[0]["is_read"] is False
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_inbox_isolation(self, notif_engine, notif_session):
|
||||
"""Users should only see their own notifications."""
|
||||
from app.main import app
|
||||
|
||||
notif_session.add(
|
||||
InAppNotification(
|
||||
owner_id=_OTHER_OWNER,
|
||||
event_type="document.processed",
|
||||
title="Other user notif",
|
||||
message="Not yours",
|
||||
)
|
||||
)
|
||||
notif_session.commit()
|
||||
|
||||
client = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
resp = client.get("/api/user-notifications/inbox")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unread_count_reflects_notifications(self, notif_engine, notif_session):
|
||||
"""Unread count should reflect actual unread notifications."""
|
||||
from app.main import app
|
||||
|
||||
for i in range(3):
|
||||
notif_session.add(
|
||||
InAppNotification(
|
||||
owner_id=_OWNER,
|
||||
event_type="document.processed",
|
||||
title=f"Notif {i}",
|
||||
message="",
|
||||
is_read=False,
|
||||
)
|
||||
)
|
||||
notif_session.commit()
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.get("/api/user-notifications/inbox/unread-count")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["count"] == 3
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mark_read(self, notif_engine, notif_session):
|
||||
"""Marking a notification as read should update is_read."""
|
||||
from app.main import app
|
||||
|
||||
notif = InAppNotification(
|
||||
owner_id=_OWNER,
|
||||
event_type="document.processed",
|
||||
title="Unread",
|
||||
message="",
|
||||
)
|
||||
notif_session.add(notif)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(notif)
|
||||
notif_id = notif.id
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.post(f"/api/user-notifications/inbox/{notif_id}/read")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify in DB
|
||||
notif_session.refresh(notif)
|
||||
assert notif.is_read is True
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mark_read_wrong_user(self, notif_engine, notif_session):
|
||||
"""Marking another user's notification should return 404."""
|
||||
from app.main import app
|
||||
|
||||
notif = InAppNotification(
|
||||
owner_id=_OTHER_OWNER,
|
||||
event_type="document.processed",
|
||||
title="Other",
|
||||
message="",
|
||||
)
|
||||
notif_session.add(notif)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(notif)
|
||||
notif_id = notif.id
|
||||
|
||||
client = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
resp = client.post(f"/api/user-notifications/inbox/{notif_id}/read")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mark_all_read(self, notif_engine, notif_session):
|
||||
"""Mark all read should set all user's notifications to read."""
|
||||
from app.main import app
|
||||
|
||||
for i in range(4):
|
||||
notif_session.add(
|
||||
InAppNotification(
|
||||
owner_id=_OWNER,
|
||||
event_type="document.processed",
|
||||
title=f"N{i}",
|
||||
message="",
|
||||
is_read=False,
|
||||
)
|
||||
)
|
||||
notif_session.commit()
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.post("/api/user-notifications/inbox/read-all")
|
||||
assert resp.status_code == 200
|
||||
|
||||
count_resp = client.get("/api/user-notifications/inbox/unread-count")
|
||||
assert count_resp.json()["count"] == 0
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Notification Targets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTargets:
|
||||
"""Tests for notification target CRUD."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_targets_empty(self, notif_engine):
|
||||
"""Listing targets when none exist should return empty list."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.get("/api/user-notifications/targets")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_email_target(self, notif_engine):
|
||||
"""Creating an email target should persist and mask the password in response."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/user-notifications/targets",
|
||||
json={
|
||||
"channel_type": "email",
|
||||
"name": "My Gmail",
|
||||
"config": {
|
||||
"smtp_host": "smtp.gmail.com",
|
||||
"smtp_port": 587,
|
||||
"smtp_username": "me@gmail.com",
|
||||
"smtp_password": "s3cr3t",
|
||||
"recipient_email": "me@gmail.com",
|
||||
"smtp_use_tls": True,
|
||||
},
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["channel_type"] == "email"
|
||||
assert data["name"] == "My Gmail"
|
||||
assert data["is_active"] is True
|
||||
# Password must be masked
|
||||
assert data["config"]["smtp_password"] == "****"
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_webhook_target(self, notif_engine):
|
||||
"""Creating a webhook target should persist correctly."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/user-notifications/targets",
|
||||
json={
|
||||
"channel_type": "webhook",
|
||||
"name": "Slack Webhook",
|
||||
"config": {"url": "https://hooks.slack.com/abc", "secret": ""},
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["channel_type"] == "webhook"
|
||||
assert data["name"] == "Slack Webhook"
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_target_invalid_channel_type(self, notif_engine):
|
||||
"""Creating a target with an invalid channel_type should return 422."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/user-notifications/targets",
|
||||
json={"channel_type": "sms", "name": "Bad", "config": {}},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_targets_returns_created(self, notif_engine):
|
||||
"""Listing targets should include newly created ones."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
client.post(
|
||||
"/api/user-notifications/targets",
|
||||
json={"channel_type": "webhook", "name": "W1", "config": {"url": "https://example.com"}},
|
||||
)
|
||||
client.post(
|
||||
"/api/user-notifications/targets",
|
||||
json={"channel_type": "email", "name": "E1", "config": {"smtp_host": "smtp.example.com"}},
|
||||
)
|
||||
resp = client.get("/api/user-notifications/targets")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_targets_isolation(self, notif_engine):
|
||||
"""Users should only see their own targets."""
|
||||
from app.main import app
|
||||
|
||||
client_a = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
client_a.post(
|
||||
"/api/user-notifications/targets",
|
||||
json={"channel_type": "webhook", "name": "Owner A target", "config": {"url": "https://a.example.com"}},
|
||||
)
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
client_b = _make_client(notif_engine, _OTHER_OWNER)
|
||||
try:
|
||||
resp = client_b.get("/api/user-notifications/targets")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_target(self, notif_engine):
|
||||
"""Updating a target should change its name and active status."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
create_resp = client.post(
|
||||
"/api/user-notifications/targets",
|
||||
json={"channel_type": "webhook", "name": "Old Name", "config": {"url": "https://x.com"}},
|
||||
)
|
||||
target_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.put(
|
||||
f"/api/user-notifications/targets/{target_id}",
|
||||
json={"name": "New Name", "is_active": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "New Name"
|
||||
assert data["is_active"] is False
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_target_wrong_user(self, notif_engine, notif_session):
|
||||
"""Updating another user's target should return 404."""
|
||||
from app.main import app
|
||||
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OTHER_OWNER,
|
||||
channel_type="webhook",
|
||||
name="Other target",
|
||||
config=json.dumps({"url": "https://other.com"}),
|
||||
)
|
||||
notif_session.add(target)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(target)
|
||||
|
||||
client = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
resp = client.put(
|
||||
f"/api/user-notifications/targets/{target.id}",
|
||||
json={"name": "Hacked"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_delete_target(self, notif_engine):
|
||||
"""Deleting a target should remove it from the list."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
create_resp = client.post(
|
||||
"/api/user-notifications/targets",
|
||||
json={"channel_type": "webhook", "name": "To Delete", "config": {"url": "https://x.com"}},
|
||||
)
|
||||
target_id = create_resp.json()["id"]
|
||||
|
||||
del_resp = client.delete(f"/api/user-notifications/targets/{target_id}")
|
||||
assert del_resp.status_code == 200
|
||||
|
||||
list_resp = client.get("/api/user-notifications/targets")
|
||||
assert list_resp.json() == []
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_delete_target_wrong_user(self, notif_engine, notif_session):
|
||||
"""Deleting another user's target should return 404."""
|
||||
from app.main import app
|
||||
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OTHER_OWNER,
|
||||
channel_type="webhook",
|
||||
name="Not yours",
|
||||
config=json.dumps({"url": "https://other.com"}),
|
||||
)
|
||||
notif_session.add(target)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(target)
|
||||
|
||||
client = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
resp = client.delete(f"/api/user-notifications/targets/{target.id}")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_delete_target_also_removes_preferences(self, notif_engine, notif_session):
|
||||
"""Deleting a target should also remove associated preferences."""
|
||||
from app.main import app
|
||||
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type="webhook",
|
||||
name="With prefs",
|
||||
config=json.dumps({"url": "https://x.com"}),
|
||||
)
|
||||
notif_session.add(target)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(target)
|
||||
|
||||
pref = UserNotificationPreference(
|
||||
owner_id=_OWNER,
|
||||
event_type="document.processed",
|
||||
channel_type="webhook",
|
||||
target_id=target.id,
|
||||
is_enabled=True,
|
||||
)
|
||||
notif_session.add(pref)
|
||||
notif_session.commit()
|
||||
|
||||
client = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
resp = client.delete(f"/api/user-notifications/targets/{target.id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
remaining = (
|
||||
notif_session.query(UserNotificationPreference)
|
||||
.filter(UserNotificationPreference.owner_id == _OWNER)
|
||||
.all()
|
||||
)
|
||||
assert remaining == []
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPreferences:
|
||||
"""Tests for notification preferences CRUD."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_preferences_empty(self, notif_engine):
|
||||
"""Getting preferences returns event_types and event_labels even with no prefs set."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(notif_engine)
|
||||
try:
|
||||
resp = client.get("/api/user-notifications/preferences")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "event_types" in data
|
||||
assert "event_labels" in data
|
||||
assert "preferences" in data
|
||||
assert "document.processed" in data["event_types"]
|
||||
assert "document.failed" in data["event_types"]
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_preferences(self, notif_engine, notif_session):
|
||||
"""Updating preferences should persist the changes."""
|
||||
from app.main import app
|
||||
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type="webhook",
|
||||
name="My Webhook",
|
||||
config=json.dumps({"url": "https://x.com"}),
|
||||
)
|
||||
notif_session.add(target)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(target)
|
||||
|
||||
client = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
resp = client.put(
|
||||
"/api/user-notifications/preferences",
|
||||
json={
|
||||
"preferences": [
|
||||
{
|
||||
"event_type": "document.processed",
|
||||
"channel_type": "webhook",
|
||||
"is_enabled": True,
|
||||
"target_id": target.id,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify stored
|
||||
pref = (
|
||||
notif_session.query(UserNotificationPreference)
|
||||
.filter(
|
||||
UserNotificationPreference.owner_id == _OWNER,
|
||||
UserNotificationPreference.event_type == "document.processed",
|
||||
UserNotificationPreference.channel_type == "webhook",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert pref is not None
|
||||
assert pref.is_enabled is True
|
||||
assert pref.target_id == target.id
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_preferences_upsert(self, notif_engine, notif_session):
|
||||
"""Updating preferences twice should upsert (not duplicate)."""
|
||||
from app.main import app
|
||||
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type="webhook",
|
||||
name="W",
|
||||
config=json.dumps({"url": "https://x.com"}),
|
||||
)
|
||||
notif_session.add(target)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(target)
|
||||
|
||||
client = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
pref_item = {
|
||||
"event_type": "document.processed",
|
||||
"channel_type": "webhook",
|
||||
"is_enabled": True,
|
||||
"target_id": target.id,
|
||||
}
|
||||
client.put("/api/user-notifications/preferences", json={"preferences": [pref_item]})
|
||||
# Disable it
|
||||
pref_item["is_enabled"] = False
|
||||
resp = client.put("/api/user-notifications/preferences", json={"preferences": [pref_item]})
|
||||
assert resp.status_code == 200
|
||||
|
||||
prefs = (
|
||||
notif_session.query(UserNotificationPreference)
|
||||
.filter(UserNotificationPreference.owner_id == _OWNER)
|
||||
.all()
|
||||
)
|
||||
assert len(prefs) == 1
|
||||
assert prefs[0].is_enabled is False
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_preferences_rejects_foreign_target(self, notif_engine, notif_session):
|
||||
"""Preferences referencing another user's target_id should be rejected."""
|
||||
from app.main import app
|
||||
|
||||
other_target = UserNotificationTarget(
|
||||
owner_id=_OTHER_OWNER,
|
||||
channel_type="webhook",
|
||||
name="Other webhook",
|
||||
config=json.dumps({"url": "https://other.com"}),
|
||||
)
|
||||
notif_session.add(other_target)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(other_target)
|
||||
|
||||
client = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
resp = client.put(
|
||||
"/api/user-notifications/preferences",
|
||||
json={
|
||||
"preferences": [
|
||||
{
|
||||
"event_type": "document.processed",
|
||||
"channel_type": "webhook",
|
||||
"is_enabled": True,
|
||||
"target_id": other_target.id,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_preferences_reflects_saved(self, notif_engine, notif_session):
|
||||
"""GET preferences should reflect previously saved preferences."""
|
||||
from app.main import app
|
||||
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type="email",
|
||||
name="Email target",
|
||||
config=json.dumps({"smtp_host": "smtp.example.com", "recipient_email": "me@example.com"}),
|
||||
)
|
||||
notif_session.add(target)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(target)
|
||||
|
||||
notif_session.add(
|
||||
UserNotificationPreference(
|
||||
owner_id=_OWNER,
|
||||
event_type="document.failed",
|
||||
channel_type="email",
|
||||
target_id=target.id,
|
||||
is_enabled=True,
|
||||
)
|
||||
)
|
||||
notif_session.commit()
|
||||
|
||||
client = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
resp = client.get("/api/user-notifications/preferences")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "document.failed" in data["preferences"]
|
||||
assert "email" in data["preferences"]["document.failed"]
|
||||
assert data["preferences"]["document.failed"]["email"]["is_enabled"] is True
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – user_notification service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUserNotificationService:
|
||||
"""Unit tests for the user notification dispatch service."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_in_app_notification(self, notif_engine, notif_session):
|
||||
"""create_in_app_notification should persist a record."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.utils.user_notification import create_in_app_notification
|
||||
|
||||
Session = sessionmaker(bind=notif_engine)
|
||||
|
||||
with patch("app.utils.user_notification.SessionLocal", Session):
|
||||
result = create_in_app_notification(
|
||||
owner_id=_OWNER,
|
||||
event_type="document.processed",
|
||||
title="Test",
|
||||
message="Done",
|
||||
file_id=42,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.owner_id == _OWNER
|
||||
assert result.title == "Test"
|
||||
assert result.file_id == 42
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_notify_user_document_processed(self, notif_engine):
|
||||
"""notify_user_document_processed should create an in-app notification."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.utils.user_notification import notify_user_document_processed
|
||||
|
||||
Session = sessionmaker(bind=notif_engine)
|
||||
|
||||
with patch("app.utils.user_notification.SessionLocal", Session):
|
||||
notify_user_document_processed(owner_id=_OWNER, filename="test.pdf", file_id=1)
|
||||
|
||||
s = Session()
|
||||
notifs = s.query(InAppNotification).filter(InAppNotification.owner_id == _OWNER).all()
|
||||
s.close()
|
||||
assert len(notifs) == 1
|
||||
assert "test.pdf" in notifs[0].title
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_notify_user_document_failed(self, notif_engine):
|
||||
"""notify_user_document_failed should create an in-app notification."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.utils.user_notification import notify_user_document_failed
|
||||
|
||||
Session = sessionmaker(bind=notif_engine)
|
||||
|
||||
with patch("app.utils.user_notification.SessionLocal", Session):
|
||||
notify_user_document_failed(owner_id=_OWNER, filename="doc.pdf", error="OCR timeout")
|
||||
|
||||
s = Session()
|
||||
notifs = s.query(InAppNotification).filter(InAppNotification.owner_id == _OWNER).all()
|
||||
s.close()
|
||||
assert len(notifs) == 1
|
||||
assert notifs[0].event_type == "document.failed"
|
||||
assert "OCR timeout" in notifs[0].message
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_send_webhook_notification_missing_url(self):
|
||||
"""_send_webhook_notification should return False when url is missing."""
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
|
||||
result = _send_webhook_notification({}, "document.processed", "Title", "Body")
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_send_email_notification_missing_host(self):
|
||||
"""_send_email_notification should return False when smtp_host is missing."""
|
||||
from app.utils.user_notification import _send_email_notification
|
||||
|
||||
result = _send_email_notification({"recipient_email": "me@example.com"}, "Title", "Body")
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_send_email_notification_missing_recipient(self):
|
||||
"""_send_email_notification should return False when recipient_email is missing."""
|
||||
from app.utils.user_notification import _send_email_notification
|
||||
|
||||
result = _send_email_notification({"smtp_host": "smtp.example.com"}, "Title", "Body")
|
||||
assert result is False
|
||||
Reference in New Issue
Block a user