From af44af98b894eadd8e3513ce8d25214066f837ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:17:39 +0000 Subject: [PATCH 01/16] Initial plan From b03ea416386fac4d550d320df62ba4a38d33d0dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:30:08 +0000 Subject: [PATCH 02/16] Initial plan From 5a77e36ac688444f2dae939a4d0df1cca14f6204 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:34:13 +0000 Subject: [PATCH 03/16] Initial plan From fcefd0978f36fc66ec2a34c9ab26021e38b1fcfb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:34:25 +0000 Subject: [PATCH 04/16] feat(notifications): add per-user notification system with inbox, email, and webhook targets - Add UserNotificationTarget, UserNotificationPreference, InAppNotification models - Add migration 025_add_user_notifications (tables + indexes) - Add app/utils/user_notification.py dispatch service - Add app/api/notifications.py REST endpoints (inbox, targets, preferences) - Add app/views/notifications.py view route - Add frontend/templates/notifications_dashboard.html Alpine.js dashboard - Add bell icon with unread badge in base.html nav (desktop + mobile) - Register routers in app/api/__init__.py and app/views/__init__.py - Add 32 unit tests in tests/test_notifications_api.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/__init__.py | 2 + app/api/notifications.py | 484 +++++++++ app/models.py | 47 + app/utils/user_notification.py | 227 ++++ app/views/__init__.py | 2 + app/views/notifications.py | 20 + frontend/templates/base.html | 43 + .../templates/notifications_dashboard.html | 979 ++++++++++++++++++ .../versions/025_add_user_notifications.py | 83 ++ tests/test_notifications_api.py | 832 +++++++++++++++ 10 files changed, 2719 insertions(+) create mode 100644 app/api/notifications.py create mode 100644 app/utils/user_notification.py create mode 100644 app/views/notifications.py create mode 100644 frontend/templates/notifications_dashboard.html create mode 100644 migrations/versions/025_add_user_notifications.py create mode 100644 tests/test_notifications_api.py diff --git a/app/api/__init__.py b/app/api/__init__.py index dcee1703..311c572e 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) diff --git a/app/api/notifications.py b/app/api/notifications.py new file mode 100644 index 00000000..7927b255 --- /dev/null +++ b/app/api/notifications.py @@ -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"} diff --git a/app/models.py b/app/models.py index 687bf3da..be05e2b5 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/utils/user_notification.py b/app/utils/user_notification.py new file mode 100644 index 00000000..b7b79fd1 --- /dev/null +++ b/app/utils/user_notification.py @@ -0,0 +1,227 @@ +"""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 + """ + 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 + """ + 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, + ) diff --git a/app/views/__init__.py b/app/views/__init__.py index b8fb9b0d..6e50a63e 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -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 diff --git a/app/views/notifications.py b/app/views/notifications.py new file mode 100644 index 00000000..7e4e26a3 --- /dev/null +++ b/app/views/notifications.py @@ -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"}, + ) diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 4ef16130..ba25d36c 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -194,6 +194,20 @@ Help + + + + + + Integrations + + Notifications + @@ -427,6 +446,30 @@ + + + {% block scripts %}{% endblock %}