598 lines
21 KiB
Python
598 lines
21 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Notification routes for in-app notifications.
|
|
Provides user-specific notification inbox with support for actionable notifications.
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
|
from typing import List, Dict, Any, Optional
|
|
from fastapi import status
|
|
import logging
|
|
from pydantic import BaseModel, Field
|
|
|
|
from modules.auth import limiter, getCurrentUser
|
|
from modules.datamodels.datamodelUam import User
|
|
from modules.datamodels.datamodelNotification import (
|
|
UserNotification,
|
|
NotificationType,
|
|
NotificationStatus,
|
|
NotificationAction
|
|
)
|
|
from modules.datamodels.datamodelRbac import Role
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.shared.timeUtils import getUtcTimestamp
|
|
from modules.shared.i18nRegistry import apiRouteContext
|
|
routeApiMsg = apiRouteContext("routeNotifications")
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/api/notifications",
|
|
tags=["Notifications"],
|
|
responses={404: {"description": "Not found"}}
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Request/Response Models
|
|
# =============================================================================
|
|
|
|
class NotificationActionRequest(BaseModel):
|
|
"""Request model for executing a notification action"""
|
|
actionId: str = Field(..., description="ID of the action to execute (e.g., 'accept', 'decline')")
|
|
|
|
|
|
class UnreadCountResponse(BaseModel):
|
|
"""Response model for unread count"""
|
|
count: int
|
|
|
|
|
|
# =============================================================================
|
|
# Helper Functions
|
|
# =============================================================================
|
|
|
|
def _createNotification(
|
|
userId: str,
|
|
notificationType: NotificationType,
|
|
title: str,
|
|
message: str,
|
|
referenceType: Optional[str] = None,
|
|
referenceId: Optional[str] = None,
|
|
actions: Optional[List[NotificationAction]] = None,
|
|
icon: Optional[str] = None,
|
|
expiresAt: Optional[float] = None
|
|
) -> UserNotification:
|
|
"""
|
|
Create a notification for a user.
|
|
This is a helper function that can be imported by other modules.
|
|
"""
|
|
rootInterface = getRootInterface()
|
|
|
|
notification = UserNotification(
|
|
userId=userId,
|
|
type=notificationType,
|
|
title=title,
|
|
message=message,
|
|
referenceType=referenceType,
|
|
referenceId=referenceId,
|
|
actions=actions,
|
|
icon=icon,
|
|
expiresAt=expiresAt
|
|
)
|
|
|
|
# Store in database
|
|
rootInterface.db.recordCreate(
|
|
model_class=UserNotification,
|
|
record=notification.model_dump()
|
|
)
|
|
|
|
logger.info(f"Created notification {notification.id} for user {userId}: {title}")
|
|
return notification
|
|
|
|
|
|
def create_access_change_notification(
|
|
userId: str,
|
|
title: str,
|
|
message: str,
|
|
reference_type: str,
|
|
reference_id: Optional[str] = None,
|
|
) -> None:
|
|
"""
|
|
In-app notification for mandate/feature access changes (triggers client nav refresh).
|
|
Failures are logged only so RBAC mutations still succeed.
|
|
"""
|
|
try:
|
|
_createNotification(
|
|
userId=userId,
|
|
notificationType=NotificationType.SYSTEM,
|
|
title=title,
|
|
message=message,
|
|
referenceType=reference_type,
|
|
referenceId=reference_id,
|
|
icon="shield",
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Could not create access-change notification for user {userId}: {e}")
|
|
|
|
|
|
def createInvitationNotification(
|
|
userId: str,
|
|
invitationId: str,
|
|
mandateName: str,
|
|
inviterName: str,
|
|
featureInstanceName: Optional[str] = None
|
|
) -> UserNotification:
|
|
"""
|
|
Create a notification for a pending invitation.
|
|
Called when an invitation is created for an existing user.
|
|
If featureInstanceName is set, the message refers to the feature instance; otherwise to the mandate.
|
|
"""
|
|
if featureInstanceName:
|
|
msg = f"{inviterName} hat Sie zur Feature-Instanz '{featureInstanceName}' eingeladen."
|
|
else:
|
|
msg = f"{inviterName} hat Sie zu '{mandateName}' eingeladen."
|
|
return _createNotification(
|
|
userId=userId,
|
|
notificationType=NotificationType.INVITATION,
|
|
title="Neue Einladung",
|
|
message=msg,
|
|
referenceType="Invitation",
|
|
referenceId=invitationId,
|
|
icon="mail",
|
|
actions=[
|
|
NotificationAction(actionId="accept", label="Annehmen", style="primary"),
|
|
NotificationAction(actionId="decline", label="Ablehnen", style="danger")
|
|
]
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# API Endpoints
|
|
# =============================================================================
|
|
|
|
@router.get("", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
def getNotifications(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
status: Optional[str] = None,
|
|
type: Optional[str] = None,
|
|
limit: int = 50
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get all notifications for the current user.
|
|
|
|
Optionally filter by status (unread, read, actioned, dismissed) or type.
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
# Build filter
|
|
recordFilter = {"userId": str(currentUser.id)}
|
|
# Get notifications (Pydantic models, sorted and limited)
|
|
notifications = rootInterface.getNotificationsByUser(
|
|
userId=str(currentUser.id),
|
|
status=status,
|
|
limit=limit
|
|
)
|
|
|
|
# Apply type filter if needed (not common, so filter post-fetch)
|
|
if type:
|
|
notifications = [n for n in notifications if n.type == type]
|
|
|
|
# Convert to dicts for response
|
|
return [n.model_dump() for n in notifications]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting notifications: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to get notifications: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/unread-count", response_model=UnreadCountResponse)
|
|
@limiter.limit("120/minute")
|
|
def getUnreadCount(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser)
|
|
) -> UnreadCountResponse:
|
|
"""
|
|
Get the count of unread notifications for the current user.
|
|
Used for the notification badge in the header.
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
# Get unread notifications (Pydantic models)
|
|
notifications = rootInterface.getNotificationsByUser(
|
|
userId=str(currentUser.id),
|
|
status=NotificationStatus.UNREAD.value
|
|
)
|
|
|
|
return UnreadCountResponse(count=len(notifications))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting unread count: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to get unread count: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.put("/{notificationId}/read", response_model=Dict[str, Any])
|
|
@limiter.limit("60/minute")
|
|
def markAsRead(
|
|
request: Request,
|
|
notificationId: str,
|
|
currentUser: User = Depends(getCurrentUser)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Mark a notification as read.
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
# Get the notification (Pydantic model)
|
|
notification = rootInterface.getNotification(notificationId)
|
|
|
|
if not notification:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=routeApiMsg("Notification not found")
|
|
)
|
|
|
|
# Verify ownership
|
|
if str(notification.userId) != str(currentUser.id):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=routeApiMsg("Not authorized to access this notification")
|
|
)
|
|
|
|
# Update status
|
|
rootInterface.db.recordModify(
|
|
model_class=UserNotification,
|
|
recordId=notificationId,
|
|
record={
|
|
"status": NotificationStatus.READ.value,
|
|
"readAt": getUtcTimestamp()
|
|
}
|
|
)
|
|
|
|
return {"message": "Notification marked as read", "id": notificationId}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error marking notification as read: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to mark notification as read: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.put("/mark-all-read", response_model=Dict[str, Any])
|
|
@limiter.limit("10/minute")
|
|
def markAllAsRead(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Mark all notifications as read for the current user.
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
# Get all unread notifications (Pydantic models)
|
|
notifications = rootInterface.getNotificationsByUser(
|
|
userId=str(currentUser.id),
|
|
status=NotificationStatus.UNREAD.value
|
|
)
|
|
|
|
currentTime = getUtcTimestamp()
|
|
updatedCount = 0
|
|
|
|
for notification in notifications:
|
|
rootInterface.db.recordModify(
|
|
model_class=UserNotification,
|
|
recordId=str(notification.id),
|
|
record={
|
|
"status": NotificationStatus.READ.value,
|
|
"readAt": currentTime
|
|
}
|
|
)
|
|
updatedCount += 1
|
|
|
|
return {"message": f"Marked {updatedCount} notifications as read", "count": updatedCount}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error marking all notifications as read: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to mark notifications as read: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.post("/{notificationId}/action", response_model=Dict[str, Any])
|
|
@limiter.limit("30/minute")
|
|
def executeAction(
|
|
request: Request,
|
|
notificationId: str,
|
|
actionRequest: NotificationActionRequest,
|
|
currentUser: User = Depends(getCurrentUser)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Execute an action on a notification (e.g., accept/decline invitation).
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
# Get the notification (Pydantic model)
|
|
notification = rootInterface.getNotification(notificationId)
|
|
|
|
if not notification:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=routeApiMsg("Notification not found")
|
|
)
|
|
|
|
# Verify ownership
|
|
if str(notification.userId) != str(currentUser.id):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=routeApiMsg("Not authorized to access this notification")
|
|
)
|
|
|
|
# Check if already actioned
|
|
if notification.status == NotificationStatus.ACTIONED.value:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("Notification has already been actioned")
|
|
)
|
|
|
|
# Validate action exists
|
|
actions = notification.actions or []
|
|
validActionIds = [a.get("actionId") if isinstance(a, dict) else a.actionId for a in actions]
|
|
|
|
if actionRequest.actionId not in validActionIds:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid action. Valid actions: {validActionIds}"
|
|
)
|
|
|
|
# Execute action based on notification type
|
|
actionResult = None
|
|
|
|
if notification.type == NotificationType.INVITATION.value:
|
|
actionResult = _handleInvitationAction(
|
|
notification=notification,
|
|
actionId=actionRequest.actionId,
|
|
currentUser=currentUser,
|
|
rootInterface=rootInterface
|
|
)
|
|
else:
|
|
# Generic action handling
|
|
actionResult = f"Action '{actionRequest.actionId}' executed"
|
|
|
|
# Update notification status
|
|
rootInterface.db.recordModify(
|
|
model_class=UserNotification,
|
|
recordId=notificationId,
|
|
record={
|
|
"status": NotificationStatus.ACTIONED.value,
|
|
"actionTaken": actionRequest.actionId,
|
|
"actionResult": actionResult,
|
|
"actionedAt": getUtcTimestamp()
|
|
}
|
|
)
|
|
|
|
return {
|
|
"message": actionResult,
|
|
"action": actionRequest.actionId,
|
|
"notificationId": notificationId
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error executing notification action: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to execute action: {str(e)}"
|
|
)
|
|
|
|
|
|
def _handleInvitationAction(
|
|
notification: Dict[str, Any],
|
|
actionId: str,
|
|
currentUser: User,
|
|
rootInterface
|
|
) -> str:
|
|
"""Handle accept/decline actions for invitation notifications."""
|
|
from modules.datamodels.datamodelInvitation import Invitation
|
|
from modules.datamodels.datamodelUam import Mandate
|
|
from modules.datamodels.datamodelMembership import UserMandate
|
|
|
|
invitationId = notification.referenceId
|
|
if not invitationId:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("No invitation reference found")
|
|
)
|
|
|
|
# Get the invitation (Pydantic model)
|
|
invitation = rootInterface.getInvitation(invitationId)
|
|
|
|
if not invitation:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=routeApiMsg("Invitation not found")
|
|
)
|
|
|
|
# Verify user matches (username or email)
|
|
targetUsername = (invitation.targetUsername or "").strip() or None
|
|
invitationEmail = (invitation.email or "").strip().lower() if invitation.email else None
|
|
currentUserEmail = (currentUser.email or "").strip().lower() if getattr(currentUser, "email", None) else None
|
|
if targetUsername:
|
|
if currentUser.username != targetUsername:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=routeApiMsg("This invitation is for a different user")
|
|
)
|
|
elif invitationEmail:
|
|
if not currentUserEmail or currentUserEmail != invitationEmail:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=routeApiMsg("This invitation is for a different user")
|
|
)
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("Invitation has no target user or email")
|
|
)
|
|
|
|
# Check if invitation is still valid
|
|
currentTime = getUtcTimestamp()
|
|
expiresAt = invitation.expiresAt or 0
|
|
if expiresAt < currentTime:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("Invitation has expired")
|
|
)
|
|
|
|
if invitation.revokedAt:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("Invitation has been revoked")
|
|
)
|
|
|
|
currentUses = invitation.currentUses or 0
|
|
maxUses = invitation.maxUses or 1
|
|
if currentUses >= maxUses:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("Invitation has reached maximum uses")
|
|
)
|
|
|
|
if actionId == "accept":
|
|
mandateId = str(invitation.mandateId) if invitation.mandateId else None
|
|
roleIds = list(invitation.roleIds or [])
|
|
featureInstanceId = str(invitation.featureInstanceId) if invitation.featureInstanceId else None
|
|
|
|
if featureInstanceId:
|
|
# Feature-instance invitation: create FeatureAccess (or add roles to existing)
|
|
# Do NOT add system "user" role - instance roles only; createFeatureAccess auto-assigns mandate user via Regel 4
|
|
existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId)
|
|
if existingAccess:
|
|
for roleId in roleIds:
|
|
try:
|
|
rootInterface.addRoleToFeatureAccess(str(existingAccess.id), roleId)
|
|
except Exception:
|
|
pass # Role might already be assigned
|
|
logger.info(f"User {currentUser.id} already had feature access, added roles to instance {featureInstanceId}")
|
|
else:
|
|
rootInterface.createFeatureAccess(
|
|
userId=str(currentUser.id),
|
|
featureInstanceId=featureInstanceId,
|
|
roleIds=roleIds
|
|
)
|
|
logger.info(f"User {currentUser.id} granted feature access to instance {featureInstanceId}")
|
|
instance = rootInterface.getFeatureInstance(featureInstanceId)
|
|
displayName = (instance.label or instance.featureCode) if instance else featureInstanceId
|
|
resultMessage = f"Einladung angenommen. Sie haben jetzt Zugang zur Feature-Instanz '{displayName}'."
|
|
else:
|
|
# Mandate-level invitation: assign roles and mandate access
|
|
userRole = rootInterface.getRoleByLabel("user")
|
|
if userRole:
|
|
userRoleId = str(userRole.id)
|
|
if userRoleId and userRoleId not in roleIds:
|
|
roleIds = roleIds + [userRoleId]
|
|
logger.debug(f"Added system 'user' role {userRoleId} to invitation roles")
|
|
mandate = rootInterface.getMandate(mandateId) if mandateId else None
|
|
mandateName = (mandate.label or mandate.name) if mandate else mandateId
|
|
existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId) if mandateId else None
|
|
if existingMembership:
|
|
logger.info(f"User {currentUser.id} already has membership in mandate {mandateId}, adding roles via UserMandateRole")
|
|
for roleId in roleIds:
|
|
rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId)
|
|
else:
|
|
rootInterface.createUserMandate(str(currentUser.id), mandateId, roleIds)
|
|
logger.info(f"Created UserMandate for user {currentUser.id} in mandate {mandateId}")
|
|
resultMessage = f"Einladung angenommen. Sie haben jetzt Zugang zu '{mandateName}'."
|
|
|
|
# Mark invitation as used
|
|
rootInterface.db.recordModify(
|
|
model_class=Invitation,
|
|
recordId=invitationId,
|
|
record={
|
|
"usedBy": str(currentUser.id),
|
|
"usedAt": currentTime,
|
|
"currentUses": currentUses + 1
|
|
}
|
|
)
|
|
target_desc = f"feature instance {featureInstanceId}" if featureInstanceId else f"mandate {mandateId}"
|
|
logger.info(f"User {currentUser.id} accepted invitation {invitationId} for {target_desc}")
|
|
return resultMessage
|
|
|
|
elif actionId == "decline":
|
|
# Decline the invitation
|
|
# We don't revoke it, just mark the notification as declined
|
|
logger.info(f"User {currentUser.id} declined invitation {invitationId}")
|
|
return "Einladung abgelehnt."
|
|
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unknown action: {actionId}"
|
|
)
|
|
|
|
|
|
@router.delete("/{notificationId}", response_model=Dict[str, Any])
|
|
@limiter.limit("30/minute")
|
|
def deleteNotification(
|
|
request: Request,
|
|
notificationId: str,
|
|
currentUser: User = Depends(getCurrentUser)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Delete/dismiss a notification.
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
# Get the notification (Pydantic model)
|
|
notification = rootInterface.getNotification(notificationId)
|
|
|
|
if not notification:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=routeApiMsg("Notification not found")
|
|
)
|
|
|
|
# Verify ownership
|
|
if str(notification.userId) != str(currentUser.id):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=routeApiMsg("Not authorized to delete this notification")
|
|
)
|
|
|
|
# Mark as dismissed (soft delete)
|
|
rootInterface.db.recordModify(
|
|
model_class=UserNotification,
|
|
recordId=notificationId,
|
|
record={
|
|
"status": NotificationStatus.DISMISSED.value
|
|
}
|
|
)
|
|
|
|
return {"message": "Notification dismissed", "id": notificationId}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error deleting notification: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to delete notification: {str(e)}"
|
|
)
|