25 KiB
Messaging Service Konzept
Übersicht
Das Messaging-System ermöglicht es, Nachrichten über verschiedene Kanäle (E-Mail, SMS, WhatsApp, Teams Chat, etc.) an registrierte Benutzer zu senden, basierend auf Subscriptions. Es ist mandantenbasiert und unterstützt mehrere Kanäle pro Subscription.
Architektur-Überlegungen
Kernkonzept
Das System besteht aus zwei Hauptkomponenten:
- Subscription-Management: Users können sich für Subscriptions registrieren und ihre bevorzugten Kanäle wählen
- Subscription-Funktionen: Jede Subscription hat eine eigene Funktion (
subSubscriptionXxxx.py), die komplett flexibel ist und die Nachrichten vorbereitet
Setup-Architektur
Wichtig: Die Datenbank mit den Subscriptions ist die stabile Basis-Referenz und die Grundlage des Systems.
Es gibt zwei Seiten, die an die Datenbank andocken:
-
User-Seite: Users können sich für Subscriptions registrieren (subscribe)
- Dies ist unabhängig davon, ob bereits eine Subscription-Funktion existiert
- Users können sich bereits subscriben, bevor eine Funktion implementiert ist
-
Funktions-Seite: Subscription-Funktionen können später hinzugefügt werden
- Eine Subscription-Funktion ist optional und kann nachträglich implementiert werden
- Wenn eine Subscription-Funktion fehlt, wird beim Trigger ein Fehler geloggt, aber das System bleibt stabil
- Die Datenbank-Struktur ist unabhängig von der Existenz der Funktionen
Workflow:
- Admin erstellt Subscription in der Datenbank (z.B. "SystemErrors")
- Users können sich sofort für diese Subscription registrieren
- Später kann die Subscription-Funktion (
subSubscriptionSystemErrors.py) hinzugefügt werden - Erst dann können Trigger die Subscription ausführen
Trigger-Mechanismus
Subscriptions können über verschiedene Trigger ausgeführt werden:
- Trigger-Route-Endpunkt: API-Endpunkt, der eine Subscription triggert
- Workflow-Action: Automatischer Workflow, der eine Subscription als Event auslöst
- Scheduled Job: Zeitgesteuerte Ausführung
- Event-basiert: System-Events (z.B. Audit-Log-Events)
Wichtig: Eine Subscription kann über alle Trigger-Typen ausgeführt werden. Es gibt keine Einschränkung pro Subscription.
Subscription-Funktionen
Jede Subscription hat eine eigene Funktion im Format subSubscriptionXxxx.py (z.B. subSubscriptionSystemErrors.py, subSubscriptionAuditLogin.py).
Naming-Regel: Die subscriptionId muss nur Buchstaben und Unterstriche (_) enthalten. Sie wird direkt als Dateiname verwendet:
subscriptionId: "SystemErrors" → Datei:subSubscriptionSystemErrors.pysubscriptionId: "audit_login" → Datei:subSubscriptionAuditLogin.py
Validierung: Bei der Erstellung einer Subscription wird geprüft, dass subscriptionId nur Buchstaben und _ enthält.
Diese Funktionen:
- Erhalten Event-Parameter vom Trigger (als Pydantic Model)
- Erhalten bereits die Registrierungen (werden vor dem Funktionsaufruf geholt)
- Bereiten die Nachrichten vor (können pro Kanal unterschiedlich sein)
- Rufen
sendMessagefür jeden Channel auf - Haben vollständige Flexibilität bei der Nachrichtenerstellung
Beispiel-Struktur:
# modules/services/serviceMessaging/subscriptions/subSubscriptionSystemErrors.py
from typing import List
from modules.datamodels.datamodelMessaging import (
MessagingEventParameters,
MessagingSubscriptionExecutionResult,
MessagingSubscriptionRegistration,
MessagingChannel
)
def execute(
eventParameters: MessagingEventParameters,
registrations: List[MessagingSubscriptionRegistration],
messagingService
) -> MessagingSubscriptionExecutionResult:
"""
Subscription-Funktion für System-Errors.
Erhält eventParameters vom Trigger und registrations bereits geholt.
"""
# Gruppiere nach Channel
emailRegistrations = [r for r in registrations if r.channel == MessagingChannel.EMAIL]
smsRegistrations = [r for r in registrations if r.channel == MessagingChannel.SMS]
# Bereite Nachrichten vor (können pro Channel unterschiedlich sein)
emailSubject = "System Error Report"
errors = eventParameters.triggerData.get('errors', [])
emailMessage = f"System errors detected: {errors}"
smsMessage = f"System Error: {len(errors)} errors detected"
messagesSent = 0
# Versende über sendMessage
for reg in emailRegistrations:
sendResult = messagingService.sendMessage(
subject=emailSubject,
message=emailMessage,
registration=reg
)
if sendResult.success:
messagesSent += 1
for reg in smsRegistrations:
sendResult = messagingService.sendMessage(
subject="", # SMS hat kein Subject
message=smsMessage,
registration=reg
)
if sendResult.success:
messagesSent += 1
return MessagingSubscriptionExecutionResult(
success=True,
messagesSent=messagesSent
)
Datenmodell
1. MessagingChannel (Enum)
class MessagingChannel(str, Enum):
EMAIL = "email"
SMS = "sms"
WHATSAPP = "whatsapp"
TEAMS_CHAT = "teams_chat"
# Weitere Kanäle können hier hinzugefügt werden
2. MessagingSubscription
id: UUIDsubscriptionId: String (eindeutiger Identifier, z.B. "SystemErrors", "audit_login")- Validierung: Nur Buchstaben und
_erlaubt
- Validierung: Nur Buchstaben und
subscriptionLabel: String (Anzeigename)mandateId: String (Mandanten-ID - wird automatisch vom Interface gesetzt)description: Optional[String]isSystemSubscription: Boolean (nur Admin kann System-Subscriptions erstellen)enabled: Boolean- System-Felder:
creationDate,lastModified,createdBy,modifiedBy
3. MessagingSubscriptionRegistration
id: UUIDsubscriptionId: String (Referenz zur Subscription)userId: String (Referenz zum User)channel: MessagingChannelchannelConfig: String (z.B. E-Mail-Adresse, Telefonnummer, Teams User ID)enabled: Boolean (User kann sich temporär deaktivieren)- System-Felder:
creationDate,lastModified
4. MessagingDelivery
id: UUIDsubscriptionId: String (Referenz zur Subscription)userId: String (Referenz zum User)channel: MessagingChannelstatus: Enum (PENDING, SENT, FAILED)errorMessage: Optional[String]sentAt: Optional[Float] (Timestamp wenn gesendet)- System-Felder:
creationDate
5. MessagingEventParameters (Pydantic Model)
triggerData: dict - Event-Daten vom Trigger als Dictionary/JSON
6. MessagingSendResult (Pydantic Model)
success: BooleandeliveryId: Optional[String] (ID des MessagingDelivery Records)errorMessage: Optional[String]
7. MessagingSubscriptionExecutionResult (Pydantic Model)
success: BooleanmessagesSent: IntegererrorMessage: Optional[String]extra="allow"für zusätzliche Felder
RBAC-Berechtigungsmodell
Access Rules für MessagingSubscription
- Context:
DATA - Item:
MessagingSubscription - Permissions:
- Admin: Alle CRUD-Operationen auf alle Subscriptions
- Mandate-Admin: CRUD auf Subscriptions des eigenen Mandanten (außer System-Subscriptions)
- User: Read auf Subscriptions des eigenen Mandanten, Create/Update/Delete nur auf eigene Registrierungen
Access Rules für MessagingSubscriptionRegistration
- Context:
DATA - Item:
MessagingSubscriptionRegistration - Permissions:
- User: CRUD auf eigene Registrierungen
- Admin/Mandate-Admin: Read auf alle Registrierungen des Mandanten
Access Rules für MessagingDelivery
- Context:
DATA - Item:
MessagingDelivery - Permissions:
- Admin/Mandate-Admin: Read auf alle Deliveries
- User: Read auf eigene Deliveries
Service-Architektur
serviceMessaging/mainServiceMessaging.py
class MessagingService:
def __init__(self, services):
self.services = services
self._messagingInterface = None # interfaceMessaging
# Core Messaging
def sendMessage(
self,
subject: str,
message: str,
registration: MessagingSubscriptionRegistration
) -> MessagingSendResult:
"""
Sendet eine Nachricht über einen Channel an einen User.
Erstellt MessagingDelivery Record.
Args:
subject: Subject der Nachricht (für E-Mail, leer für SMS)
message: Nachrichtentext
registration: MessagingSubscriptionRegistration mit Channel-Info und userId
Returns:
MessagingSendResult mit Status und Delivery-ID
"""
# Erstelle Delivery Record
delivery = MessagingDelivery(
subscriptionId=registration.subscriptionId,
userId=registration.userId,
channel=registration.channel,
status=DeliveryStatus.PENDING
)
# Speichere Delivery Record
deliveryRecord = self.services.interfaceDbComponent.createDelivery(delivery)
try:
# Versende über interfaceMessaging
success = self._getMessagingInterface().send(
channel=registration.channel,
recipient=registration.channelConfig,
subject=subject,
message=message
)
if success:
# Update Delivery Record
self.services.interfaceDbComponent.updateDelivery(
deliveryRecord["id"],
{
"status": DeliveryStatus.SENT,
"sentAt": getUtcTimestamp()
}
)
return MessagingSendResult(
success=True,
deliveryId=deliveryRecord["id"]
)
else:
# Update Delivery Record mit Fehler
self.services.interfaceDbComponent.updateDelivery(
deliveryRecord["id"],
{
"status": DeliveryStatus.FAILED,
"errorMessage": "Failed to send message"
}
)
return MessagingSendResult(
success=False,
deliveryId=deliveryRecord["id"],
errorMessage="Failed to send message"
)
except Exception as e:
logger.error(f"Error sending message: {str(e)}")
# Update Delivery Record mit Fehler
self.services.interfaceDbComponent.updateDelivery(
deliveryRecord["id"],
{
"status": DeliveryStatus.FAILED,
"errorMessage": str(e)
}
)
return MessagingSendResult(
success=False,
deliveryId=deliveryRecord["id"],
errorMessage=str(e)
)
def executeSubscription(
self,
subscriptionId: str,
eventParameters: MessagingEventParameters
) -> MessagingSubscriptionExecutionResult:
"""
Führt eine Subscription-Funktion aus.
Args:
subscriptionId: ID der Subscription
eventParameters: Parameter vom Trigger (als Pydantic Model)
Returns:
MessagingSubscriptionExecutionResult
Raises:
ValueError: Wenn Subscription nicht existiert oder nicht enabled ist
FileNotFoundError: Wenn Subscription-Funktion nicht gefunden wird
"""
# Prüfe ob Subscription existiert und enabled ist
subscription = self.services.interfaceDbComponent.getSubscription(subscriptionId)
if not subscription:
raise ValueError(f"Subscription {subscriptionId} not found")
if not subscription.enabled:
logger.warning(f"Subscription {subscriptionId} is disabled, skipping execution")
return MessagingSubscriptionExecutionResult(
success=False,
messagesSent=0,
errorMessage="Subscription is disabled"
)
# Hole alle aktiven Registrierungen für diese Subscription
registrations = self._getSubscribers(subscriptionId)
if not registrations:
logger.info(f"No active registrations for subscription {subscriptionId}")
return MessagingSubscriptionExecutionResult(
success=True,
messagesSent=0
)
# Lade Subscription-Funktion dynamisch
subscriptionFunction = self._loadSubscriptionFunction(subscriptionId)
if not subscriptionFunction:
errorMsg = f"Subscription function not found for {subscriptionId}"
logger.error(errorMsg)
raise FileNotFoundError(errorMsg)
# Führe Funktion aus mit Registrierungen
try:
return subscriptionFunction.execute(eventParameters, registrations, self)
except Exception as e:
logger.error(f"Error executing subscription {subscriptionId}: {str(e)}", exc_info=True)
return MessagingSubscriptionExecutionResult(
success=False,
messagesSent=0,
errorMessage=str(e)
)
# Helper Methods
def _getSubscribers(
self,
subscriptionId: str,
channel: Optional[MessagingChannel] = None
) -> List[MessagingSubscriptionRegistration]:
"""Holt alle aktiven Subscriber einer Subscription"""
return self.services.interfaceDbComponent.getAllRegistrations(
subscriptionId=subscriptionId,
filters={"enabled": True} if not channel else {"enabled": True, "channel": channel.value}
)
def _loadSubscriptionFunction(self, subscriptionId: str) -> Optional[Callable]:
"""
Lädt die Subscription-Funktion dynamisch.
Returns:
Callable mit execute-Methode oder None wenn nicht gefunden
Note:
subscriptionId wird direkt als Dateiname verwendet (z.B. "SystemErrors" -> subSubscriptionSystemErrors.py)
"""
# Format: subSubscription{subscriptionId}.py
functionName = f"subSubscription{subscriptionId}"
moduleName = f"modules.services.serviceMessaging.subscriptions.{functionName}"
try:
# Dynamisches Import
import importlib
subscriptionModule = importlib.import_module(moduleName)
return subscriptionModule
except ImportError:
# Funktion existiert noch nicht - das ist OK
logger.debug(f"Subscription function {moduleName} not found (this is OK if not yet implemented)")
return None
def _getMessagingInterface(self):
"""Holt das Messaging-Interface (interfaceMessaging)"""
if not self._messagingInterface:
from modules.interfaces.interfaceMessaging import getInterface
self._messagingInterface = getInterface()
return self._messagingInterface
Connector-Architektur
Überblick
Für jeden Channel gibt es einen separaten Connector in modules/connectors/:
connectorMessagingEmail.py- Azure Communication ServicesconnectorMessagingSms.py- TwilioconnectorMessagingWhatsapp.py- WhatsApp API (zukünftig)connectorMessagingTeams.py- Microsoft Teams API (zukünftig)
Interface: interfaceMessaging.py
Das Interface modules/interfaces/interfaceMessaging.py stellt eine einheitliche Schnittstelle bereit, die alle Connectors nach dem gleichen Schema verwendet:
class MessagingInterface:
def send(
self,
channel: MessagingChannel,
recipient: str,
subject: str,
message: str
) -> bool:
"""
Sendet eine Nachricht über den angegebenen Channel.
Args:
channel: MessagingChannel Enum
recipient: Empfänger-Adresse (E-Mail, Telefonnummer, etc.)
subject: Betreff (für E-Mail, leer für SMS)
message: Nachrichtentext
Returns:
bool: True wenn erfolgreich, False bei Fehler
"""
# Wähle Connector basierend auf Channel
if channel == MessagingChannel.EMAIL:
connector = ConnectorMessagingEmail()
elif channel == MessagingChannel.SMS:
connector = ConnectorMessagingSms()
elif channel == MessagingChannel.WHATSAPP:
connector = ConnectorMessagingWhatsapp()
elif channel == MessagingChannel.TEAMS_CHAT:
connector = ConnectorMessagingTeams()
else:
logger.error(f"Unknown channel: {channel}")
return False
# Rufe Connector mit einheitlichem Schema auf
return connector.send(recipient=recipient, subject=subject, message=message)
Connector-Struktur
Jeder Connector implementiert die gleiche Schnittstelle:
# modules/connectors/connectorMessagingEmail.py
class ConnectorMessagingEmail:
def __init__(self):
# Initialisiere Azure Communication Services Client
pass
def send(self, recipient: str, subject: str, message: str) -> bool:
"""
Sendet E-Mail über Azure Communication Services.
Args:
recipient: E-Mail-Adresse
subject: Betreff
message: Nachrichtentext (kann HTML enthalten)
Returns:
bool: True wenn erfolgreich
"""
# Implementierung hier
pass
# modules/connectors/connectorMessagingSms.py
class ConnectorMessagingSms:
def __init__(self):
# Initialisiere Twilio Client
pass
def send(self, recipient: str, subject: str, message: str) -> bool:
"""
Sendet SMS über Twilio.
Args:
recipient: Telefonnummer (mit Ländercode)
subject: Wird ignoriert (SMS hat kein Subject)
message: Nachrichtentext
Returns:
bool: True wenn erfolgreich
"""
# Implementierung hier
pass
Vorteile:
- Einheitliches Schema für alle Channels
- Einfache Erweiterung um neue Channels
- Klare Trennung zwischen Service-Logik und Channel-Implementierung
- Connectors können unabhängig getestet werden
Interface-Methoden (interfaceDbComponentObjects.py)
# Subscription Management
def getAllSubscriptions(self, pagination: Optional[PaginationParams] = None) -> Union[List[MessagingSubscription], PaginatedResult]
def getSubscription(self, subscriptionId: str) -> Optional[MessagingSubscription]
def getSubscriptionById(self, id: str) -> Optional[MessagingSubscription] # By UUID
def createSubscription(self, subscriptionData: Dict[str, Any]) -> Dict[str, Any]
def updateSubscription(self, subscriptionId: str, updateData: Dict[str, Any]) -> Dict[str, Any]
def deleteSubscription(self, subscriptionId: str) -> bool
# Registration Management
def getAllRegistrations(self, subscriptionId: Optional[str] = None, userId: Optional[str] = None,
pagination: Optional[PaginationParams] = None) -> Union[List[MessagingSubscriptionRegistration], PaginatedResult]
def getRegistration(self, registrationId: str) -> Optional[MessagingSubscriptionRegistration]
def createRegistration(self, registrationData: Dict[str, Any]) -> Dict[str, Any]
def updateRegistration(self, registrationId: str, updateData: Dict[str, Any]) -> Dict[str, Any]
def deleteRegistration(self, registrationId: str) -> bool
def subscribeUser(self, subscriptionId: str, userId: str, channel: MessagingChannel, channelConfig: str) -> Dict[str, Any]
def unsubscribeUser(self, subscriptionId: str, userId: str, channel: MessagingChannel) -> bool
# Delivery Management
def createDelivery(self, delivery: MessagingDelivery) -> Dict[str, Any]
def updateDelivery(self, deliveryId: str, updateData: Dict[str, Any]) -> Dict[str, Any]
def getDeliveries(self, subscriptionId: Optional[str] = None, userId: Optional[str] = None,
pagination: Optional[PaginationParams] = None) -> Union[List[MessagingDelivery], PaginatedResult]
def getDelivery(self, deliveryId: str) -> Optional[MessagingDelivery]
Route-Struktur (routeMessaging.py)
Rate Limits
- Subscription Endpoints: 60 requests/minute pro Session
- Registration Endpoints: 60 requests/minute pro Session
- Trigger Endpoints: 60 requests/minute pro
subscriptionId
Subscription Endpoints
GET /api/messaging/subscriptions- Liste aller SubscriptionsPOST /api/messaging/subscriptions- Neue Subscription erstellenGET /api/messaging/subscriptions/{subscriptionId}- Subscription abrufenPUT /api/messaging/subscriptions/{subscriptionId}- Subscription aktualisierenDELETE /api/messaging/subscriptions/{subscriptionId}- Subscription löschen
Registration Endpoints
GET /api/messaging/subscriptions/{subscriptionId}/registrations- Registrierungen einer SubscriptionPOST /api/messaging/subscriptions/{subscriptionId}/subscribe- User zu Subscription hinzufügenDELETE /api/messaging/subscriptions/{subscriptionId}/unsubscribe- User von Subscription entfernenGET /api/messaging/registrations- Eigene Registrierungen des UsersPUT /api/messaging/registrations/{registrationId}- Registrierung aktualisieren (z.B. enabled/disabled)DELETE /api/messaging/registrations/{registrationId}- Registrierung löschen
Trigger Endpoints
POST /api/messaging/trigger/{subscriptionId}- Trigger-Endpunkt für externe Systeme/Workflows- Body:
{"eventParameters": {...}} - Führt
executeSubscriptionaus - Rate Limit: 60 requests/minute pro
subscriptionId
- Body:
Delivery Endpoints
GET /api/messaging/deliveries- Delivery-HistorieGET /api/messaging/deliveries/{deliveryId}- Delivery abrufen
Use Cases
1. Trigger-Route-Endpunkt
@router.post("/api/messaging/trigger/{subscriptionId}")
@limiter.limit("60/minute", key_func=lambda: f"{request.path_params['subscriptionId']}")
async def trigger_subscription(
request: Request,
subscriptionId: str,
eventParameters: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser)
):
"""Trigger-Endpunkt für externe Systeme"""
# RBAC-Check: Nur Admin/Mandate-Admin kann triggern
messagingService = request.app.state.services.messaging
# Konvertiere Dict zu Pydantic Model
eventParams = MessagingEventParameters(triggerData=eventParameters)
executionResult = messagingService.executeSubscription(subscriptionId, eventParams)
return executionResult
2. Workflow-Action
Workflow kann messaging.executeSubscription Action aufrufen mit:
subscriptionId: StringeventParameters: Dict (wird zu MessagingEventParameters konvertiert)
3. Scheduled Job (System Errors)
def _sendSystemErrorsJob(self):
"""Tägliches Mail an Admin mit Log-Errors"""
# Sammle Errors aus Log
errors = self._collectLogErrors()
if errors:
messagingService = self.services.messaging
eventParams = MessagingEventParameters(triggerData={"errors": errors, "timestamp": getUtcTimestamp()})
messagingService.executeSubscription(
subscriptionId="SystemErrors",
eventParameters=eventParams
)
4. Audit Log Events
# In audit_logger.py
def logAuditEvent(eventType: str, userId: str, details: Dict):
# ... existing audit logging ...
# Trigger messaging if subscription exists
if eventType == "login":
messagingService = getMessagingService(getAdminUser())
eventParams = MessagingEventParameters(
triggerData={
"eventType": eventType,
"userId": userId,
"details": details,
"timestamp": getUtcTimestamp()
}
)
messagingService.executeSubscription(
subscriptionId="audit_login",
eventParameters=eventParams
)
Error Handling
Fehler werden wie in anderen Modulen behandelt:
- Normale Logger-Ausgabe mit
logger.error(),logger.warning(),logger.info() - Exceptions werden geloggt mit
exc_info=Truefür Stack-Traces - Keine speziellen Error-Handler, Standard-Python-Exception-Handling
Konfiguration
Environment Variables
# Email (Azure Communication Services)
MESSAGING_ACS_CONNECTION_STRING=...
MESSAGING_ACS_SENDER_EMAIL=...
# SMS (Twilio)
MESSAGING_TWILIO_ACCOUNT_SID=...
MESSAGING_TWILIO_AUTH_TOKEN=...
MESSAGING_TWILIO_FROM_NUMBER=...
# WhatsApp (zukünftig)
MESSAGING_WHATSAPP_API_KEY=...
# Teams Chat (zukünftig)
MESSAGING_TEAMS_APP_ID=...
MESSAGING_TEAMS_APP_SECRET=...
Implementierungsreihenfolge
- Datenmodelle (
datamodelMessaging.py) ✅ - Connectors (
connectorMessagingEmail.py,connectorMessagingSms.py) - Interface (
interfaceMessaging.py) - Interface-Methoden (
interfaceDbComponentObjects.py) - Service-Implementierung (
serviceMessaging/mainServiceMessaging.py) - Routes (
routeMessaging.py) - Integration (Service in
__init__.pyregistrieren, Routes registrieren) - Subscription-Funktionen (
serviceMessaging/subscriptions/)- Können nachträglich hinzugefügt werden
- System funktioniert auch ohne Funktionen (Users können sich subscriben)
- Tests (Unit-Tests für Service, Integration-Tests für Routes)
Wichtig: Die Datenbank-Struktur ist die Basis. Subscription-Funktionen sind optional und können später hinzugefügt werden.