Merge branch 'main' into copilot/add-document-sharing-feature

This commit is contained in:
Christian Krakau-Louis
2026-03-08 23:12:27 +01:00
committed by GitHub
31 changed files with 5345 additions and 28 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
@@ -78,3 +79,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"}
+42 -1
View File
@@ -51,7 +51,48 @@ PIPELINE_STEP_TYPES: dict[str, dict[str, Any]] = {
"type": "boolean",
"default": False,
"description": "Always use cloud OCR even if the PDF already has embedded text.",
}
},
"ocr_language": {
"type": "select",
"default": "auto",
"description": (
"Language(s) used for OCR text extraction. Applies to Tesseract and EasyOCR "
"providers; Azure and Mistral perform auto-detection by default. "
"Use Tesseract codes such as 'eng', 'deu', or 'eng+deu' for multi-language "
"documents. 'auto' falls back to the global system setting."
),
"options": [
{"value": "auto", "label": "Auto (use system default)"},
{"value": "ara", "label": "Arabic"},
{"value": "chi_sim", "label": "Chinese (Simplified)"},
{"value": "chi_tra", "label": "Chinese (Traditional)"},
{"value": "ces", "label": "Czech"},
{"value": "dan", "label": "Danish"},
{"value": "nld", "label": "Dutch"},
{"value": "eng", "label": "English"},
{"value": "fin", "label": "Finnish"},
{"value": "fra", "label": "French"},
{"value": "deu", "label": "German"},
{"value": "ell", "label": "Greek"},
{"value": "heb", "label": "Hebrew"},
{"value": "hin", "label": "Hindi"},
{"value": "hun", "label": "Hungarian"},
{"value": "ita", "label": "Italian"},
{"value": "jpn", "label": "Japanese"},
{"value": "kor", "label": "Korean"},
{"value": "nor", "label": "Norwegian"},
{"value": "pol", "label": "Polish"},
{"value": "por", "label": "Portuguese"},
{"value": "ron", "label": "Romanian"},
{"value": "rus", "label": "Russian"},
{"value": "spa", "label": "Spanish"},
{"value": "swe", "label": "Swedish"},
{"value": "tha", "label": "Thai"},
{"value": "tur", "label": "Turkish"},
{"value": "ukr", "label": "Ukrainian"},
{"value": "vie", "label": "Vietnamese"},
],
},
},
},
"extract_metadata": {