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:
Christian Krakau-Louis
2026-03-08 23:10:27 +01:00
committed by GitHub
11 changed files with 2773 additions and 0 deletions
+2
View File
@@ -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)
+484
View File
@@ -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"}
+47
View File
@@ -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)
+233
View File
@@ -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,
)
+2
View File
@@ -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
+20
View File
@@ -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"},
)
+49
View File
@@ -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.
+42
View File
@@ -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")
+832
View File
@@ -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