From a0304c6d78baeaff9b413c870999d8ca6bc92f6b Mon Sep 17 00:00:00 2001 From: ValueOn AG
Hallo {data.targetUsername},
+Sie wurden eingeladen, dem Mandanten {mandateName} beizutreten.
+Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:
+ +
+ Oder kopieren Sie diesen Link in Ihren Browser:
+ {inviteUrl}
+
+ Diese Einladung ist {data.expiresInHours} Stunden gültig. +
++ Diese E-Mail wurde automatisch von PowerOn gesendet. +
+ + + """ + + emailConnector.send( + recipient=data.email, + subject=emailSubject, + message=emailBody + ) + emailSent = True + logger.info(f"Invitation email sent to {data.email} for user {data.targetUsername}") + except Exception as emailError: + logger.warning(f"Failed to send invitation email to {data.email}: {emailError}") + # Don't fail the invitation creation if email fails + + # Update the invitation record with emailSent status + if emailSent: + rootInterface.db.recordModify( + Invitation, + createdRecord.get("id"), + {"emailSent": True} + ) + createdRecord["emailSent"] = True + + # If the target user already exists, create an in-app notification + try: + existingUser = rootInterface.getUserByUsername(data.targetUsername) + if existingUser: + from modules.routes.routeNotifications import createInvitationNotification + from modules.datamodels.datamodelUam import Mandate + + # Get mandate name for notification + mandateRecords = rootInterface.db.getRecordset( + Mandate, + recordFilter={"id": str(context.mandateId)} + ) + mandateName = mandateRecords[0].get("mandateLabel", "PowerOn") if mandateRecords else "PowerOn" + inviterName = context.user.fullName or context.user.username + + createInvitationNotification( + userId=str(existingUser.id), + invitationId=str(createdRecord.get("id")), + mandateName=mandateName, + inviterName=inviterName + ) + logger.info(f"Created notification for existing user {data.targetUsername}") + except Exception as notifError: + logger.warning(f"Failed to create notification for user {data.targetUsername}: {notifError}") + # Don't fail the invitation if notification fails + logger.info( - f"User {context.user.id} created invitation for mandate {context.mandateId}, " - f"expires in {data.expiresInHours}h" + f"User {context.user.id} created invitation for user {data.targetUsername} " + f"to mandate {context.mandateId}, expires in {data.expiresInHours}h" ) return InvitationResponse( @@ -190,6 +291,7 @@ async def create_invitation( mandateId=str(createdRecord.get("mandateId")), featureInstanceId=createdRecord.get("featureInstanceId"), roleIds=createdRecord.get("roleIds", []), + targetUsername=createdRecord.get("targetUsername"), email=createdRecord.get("email"), createdBy=str(createdRecord.get("createdBy")), createdAt=createdRecord.get("createdAt"), @@ -199,7 +301,8 @@ async def create_invitation( revokedAt=createdRecord.get("revokedAt"), maxUses=createdRecord.get("maxUses", 1), currentUses=createdRecord.get("currentUses", 0), - inviteUrl=inviteUrl + inviteUrl=inviteUrl, + emailSent=emailSent ) except HTTPException: @@ -441,12 +544,38 @@ async def validate_invitation( roleIds=[] ) + # Get additional info for display + mandateId = invitation.get("mandateId") + mandateName = None + roleLabels = [] + targetUsername = invitation.get("targetUsername") + + # Get mandate name + from modules.datamodels.datamodelUam import Mandate + mandateRecords = rootInterface.db.getRecordset( + Mandate, + recordFilter={"id": mandateId} + ) + if mandateRecords: + mandateName = mandateRecords[0].get("name") + + # Get role names + roleIds = invitation.get("roleIds", []) + from modules.datamodels.datamodelRbac import Role + for roleId in roleIds: + roleRecords = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roleRecords: + roleLabels.append(roleRecords[0].get("roleLabel", roleId)) + return InvitationValidation( valid=True, reason=None, - mandateId=invitation.get("mandateId"), + mandateId=mandateId, + mandateName=mandateName, featureInstanceId=invitation.get("featureInstanceId"), - roleIds=invitation.get("roleIds", []) + roleIds=roleIds, + roleLabels=roleLabels, + targetUsername=targetUsername ) except Exception as e: @@ -513,6 +642,17 @@ async def accept_invitation( detail="Invitation has reached maximum uses" ) + # Validate username matches - the invitation is bound to a specific user + targetUsername = invitation.get("targetUsername") + if targetUsername and currentUser.username != targetUsername: + logger.warning( + f"User {currentUser.username} tried to accept invitation meant for {targetUsername}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Diese Einladung ist für Benutzer '{targetUsername}' bestimmt" + ) + mandateId = invitation.get("mandateId") roleIds = invitation.get("roleIds", []) featureInstanceId = invitation.get("featureInstanceId") diff --git a/modules/routes/routeNotifications.py b/modules/routes/routeNotifications.py new file mode 100644 index 00000000..2016a745 --- /dev/null +++ b/modules/routes/routeNotifications.py @@ -0,0 +1,575 @@ +# 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.interfaces.interfaceDbApp import getRootInterface +from modules.shared.timeUtils import getUtcTimestamp + +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 createInvitationNotification( + userId: str, + invitationId: str, + mandateName: str, + inviterName: str +) -> UserNotification: + """ + Create a notification for a pending invitation. + Called when an invitation is created for an existing user. + """ + return _createNotification( + userId=userId, + notificationType=NotificationType.INVITATION, + title="Neue Einladung", + message=f"{inviterName} hat Sie zu '{mandateName}' eingeladen.", + 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") +async 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)} + if status: + recordFilter["status"] = status + if type: + recordFilter["type"] = type + + # Get notifications + notifications = rootInterface.db.getRecordset( + model_class=UserNotification, + recordFilter=recordFilter + ) + + # Sort by creation date (newest first) and limit + notifications = sorted(notifications, key=lambda x: x.get("createdAt", 0), reverse=True) + if limit: + notifications = notifications[:limit] + + return 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") +async 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() + + notifications = rootInterface.db.getRecordset( + model_class=UserNotification, + recordFilter={ + "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") +async def markAsRead( + request: Request, + notificationId: str, + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Mark a notification as read. + """ + try: + rootInterface = getRootInterface() + + # Get the notification + notifications = rootInterface.db.getRecordset( + model_class=UserNotification, + recordFilter={"id": notificationId} + ) + + if not notifications: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found" + ) + + notification = notifications[0] + + # Verify ownership + if notification.get("userId") != currentUser.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="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") +async 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 + notifications = rootInterface.db.getRecordset( + model_class=UserNotification, + recordFilter={ + "userId": currentUser.id, + "status": NotificationStatus.UNREAD.value + } + ) + + currentTime = getUtcTimestamp() + updatedCount = 0 + + for notification in notifications: + rootInterface.db.recordModify( + model_class=UserNotification, + recordId=notification.get("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") +async 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 + notifications = rootInterface.db.getRecordset( + model_class=UserNotification, + recordFilter={"id": notificationId} + ) + + if not notifications: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found" + ) + + notification = notifications[0] + + # Verify ownership + if notification.get("userId") != currentUser.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to access this notification" + ) + + # Check if already actioned + if notification.get("status") == NotificationStatus.ACTIONED.value: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Notification has already been actioned" + ) + + # Validate action exists + actions = notification.get("actions", []) + validActionIds = [a.get("actionId") if isinstance(a, dict) else a.actionId for a in (actions or [])] + + 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.get("type") == NotificationType.INVITATION.value: + actionResult = await _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)}" + ) + + +async 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.get("referenceId") + if not invitationId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No invitation reference found" + ) + + # Get the invitation + invitations = rootInterface.db.getRecordset( + model_class=Invitation, + recordFilter={"id": invitationId} + ) + + if not invitations: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invitation not found" + ) + + invitation = invitations[0] + + # Verify username matches + if invitation.get("targetUsername") != currentUser.username: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This invitation is for a different user" + ) + + # Check if invitation is still valid + currentTime = getUtcTimestamp() + if invitation.get("expiresAt", 0) < currentTime: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has expired" + ) + + if invitation.get("revokedAt"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has been revoked" + ) + + if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation has reached maximum uses" + ) + + if actionId == "accept": + # Accept the invitation - assign roles and mandate access + mandateId = invitation.get("mandateId") + roleIds = invitation.get("roleIds", []) + + # Get mandate name for result message + mandates = rootInterface.db.getRecordset( + model_class=Mandate, + recordFilter={"id": mandateId} + ) + mandateName = mandates[0].get("mandateLabel", mandateId) if mandates else mandateId + + # Check if user already has this mandate + existingMemberships = rootInterface.db.getRecordset( + model_class=UserMandate, + recordFilter={ + "userId": currentUser.id, + "mandateId": mandateId + } + ) + + if existingMemberships: + # Update existing membership with new roles + existingMembership = existingMemberships[0] + existingRoles = existingMembership.get("roleIds", []) + mergedRoles = list(set(existingRoles + roleIds)) + + rootInterface.db.recordModify( + model_class=UserMandate, + recordId=existingMembership.get("id"), + record={"roleIds": mergedRoles} + ) + logger.info(f"Updated UserMandate for user {currentUser.id} in mandate {mandateId}") + else: + # Create new user-mandate relationship + userMandate = UserMandate( + userId=currentUser.id, + mandateId=mandateId, + roleIds=roleIds + ) + rootInterface.db.recordCreate( + model_class=UserMandate, + record=userMandate.model_dump() + ) + logger.info(f"Created UserMandate for user {currentUser.id} in mandate {mandateId}") + + # Mark invitation as used + rootInterface.db.recordModify( + model_class=Invitation, + recordId=invitationId, + record={ + "usedBy": currentUser.id, + "usedAt": currentTime, + "currentUses": invitation.get("currentUses", 0) + 1 + } + ) + + logger.info(f"User {currentUser.id} accepted invitation {invitationId} for mandate {mandateId}") + return f"Einladung angenommen. Sie haben jetzt Zugang zu '{mandateName}'." + + 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") +async def deleteNotification( + request: Request, + notificationId: str, + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Delete/dismiss a notification. + """ + try: + rootInterface = getRootInterface() + + # Get the notification + notifications = rootInterface.db.getRecordset( + model_class=UserNotification, + recordFilter={"id": notificationId} + ) + + if not notifications: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found" + ) + + notification = notifications[0] + + # Verify ownership + if notification.get("userId") != currentUser.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="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)}" + ) diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 8ab211cf..8f11a9af 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -20,6 +20,7 @@ from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate from modules.datamodels.datamodelSecurity import Token from modules.shared.configuration import APP_CONFIG +from modules.shared.timeUtils import getUtcTimestamp # Configure logger logger = logging.getLogger(__name__) @@ -322,6 +323,52 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.""" logger.error(f"Error sending registration email: {str(emailErr)}") # Don't fail registration if email fails - user can request reset later + # Check for pending invitations and create notifications + try: + from modules.datamodels.datamodelInvitation import Invitation + from modules.routes.routeNotifications import createInvitationNotification + from modules.datamodels.datamodelUam import Mandate + + currentTime = getUtcTimestamp() + pendingInvitations = appInterface.db.getRecordset( + model_class=Invitation, + recordFilter={"targetUsername": userData.username} + ) + + for invitation in pendingInvitations: + # Skip expired, revoked, or fully used invitations + if invitation.get("expiresAt", 0) < currentTime: + continue + if invitation.get("revokedAt"): + continue + if invitation.get("currentUses", 0) >= invitation.get("maxUses", 1): + continue + + # Get mandate name for notification + mandateId = invitation.get("mandateId") + mandateRecords = appInterface.db.getRecordset( + Mandate, + recordFilter={"id": mandateId} + ) + mandateName = mandateRecords[0].get("mandateLabel", "PowerOn") if mandateRecords else "PowerOn" + + # Get inviter name + inviterId = invitation.get("createdBy") + inviter = appInterface.getUserById(inviterId) if inviterId else None + inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn" + + createInvitationNotification( + userId=str(user.id), + invitationId=str(invitation.get("id")), + mandateName=mandateName, + inviterName=inviterName + ) + logger.info(f"Created notification for new user {userData.username} for invitation {invitation.get('id')}") + + except Exception as notifErr: + logger.warning(f"Failed to create notifications for pending invitations: {notifErr}") + # Don't fail registration if notification creation fails + return { "message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts." } diff --git a/modules/system/registry.py b/modules/system/registry.py index 5431b706..8477b045 100644 --- a/modules/system/registry.py +++ b/modules/system/registry.py @@ -111,7 +111,7 @@ def loadFeatureMainModules() -> Dict[str, Any]: def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]: """ Register all features' RBAC objects in the catalog. - Also registers system-level RBAC objects. + Also registers system-level RBAC objects and feature definitions. """ results = {} @@ -132,6 +132,20 @@ def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]: mainModules = loadFeatureMainModules() for featureName, module in mainModules.items(): + # Register feature definition in catalog (for /api/features/ endpoint) + if hasattr(module, "getFeatureDefinition"): + try: + featureDef = module.getFeatureDefinition() + catalogService.registerFeatureDefinition( + featureCode=featureDef.get("code", featureName), + label=featureDef.get("label", {"en": featureName, "de": featureName}), + icon=featureDef.get("icon", "mdi-puzzle") + ) + logger.info(f"Registered feature definition: {featureDef.get('code', featureName)}") + except Exception as e: + logger.error(f"Error registering feature definition for {featureName}: {e}") + + # Register RBAC objects (UI, RESOURCE, DATA) if hasattr(module, "registerFeature"): try: success = module.registerFeature(catalogService)