gateway/modules/services/serviceMessaging/CONCEPT.md
2025-12-09 23:25:06 +01:00

674 lines
25 KiB
Markdown

# 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:
1. **Subscription-Management**: Users können sich für Subscriptions registrieren und ihre bevorzugten Kanäle wählen
2. **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:
1. **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
2. **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**:
1. Admin erstellt Subscription in der Datenbank (z.B. "SystemErrors")
2. Users können sich sofort für diese Subscription registrieren
3. Später kann die Subscription-Funktion (`subSubscriptionSystemErrors.py`) hinzugefügt werden
4. 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.py`
- `subscriptionId`: "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 `sendMessage` für jeden Channel auf
- Haben vollständige Flexibilität bei der Nachrichtenerstellung
**Beispiel-Struktur:**
```python
# 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)
```python
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`: UUID
- `subscriptionId`: String (eindeutiger Identifier, z.B. "SystemErrors", "audit_login")
- **Validierung**: Nur Buchstaben und `_` erlaubt
- `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`: UUID
- `subscriptionId`: String (Referenz zur Subscription)
- `userId`: String (Referenz zum User)
- `channel`: MessagingChannel
- `channelConfig`: 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`: UUID
- `subscriptionId`: String (Referenz zur Subscription)
- `userId`: String (Referenz zum User)
- `channel`: MessagingChannel
- `status`: 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`: Boolean
- `deliveryId`: Optional[String] (ID des MessagingDelivery Records)
- `errorMessage`: Optional[String]
### 7. MessagingSubscriptionExecutionResult (Pydantic Model)
- `success`: Boolean
- `messagesSent`: Integer
- `errorMessage`: 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
```python
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 Services
- `connectorMessagingSms.py` - Twilio
- `connectorMessagingWhatsapp.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:
```python
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:
```python
# 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
```
```python
# 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)
```python
# 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 Subscriptions
- `POST /api/messaging/subscriptions` - Neue Subscription erstellen
- `GET /api/messaging/subscriptions/{subscriptionId}` - Subscription abrufen
- `PUT /api/messaging/subscriptions/{subscriptionId}` - Subscription aktualisieren
- `DELETE /api/messaging/subscriptions/{subscriptionId}` - Subscription löschen
### Registration Endpoints
- `GET /api/messaging/subscriptions/{subscriptionId}/registrations` - Registrierungen einer Subscription
- `POST /api/messaging/subscriptions/{subscriptionId}/subscribe` - User zu Subscription hinzufügen
- `DELETE /api/messaging/subscriptions/{subscriptionId}/unsubscribe` - User von Subscription entfernen
- `GET /api/messaging/registrations` - Eigene Registrierungen des Users
- `PUT /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 `executeSubscription` aus
- Rate Limit: 60 requests/minute pro `subscriptionId`
### Delivery Endpoints
- `GET /api/messaging/deliveries` - Delivery-Historie
- `GET /api/messaging/deliveries/{deliveryId}` - Delivery abrufen
## Use Cases
### 1. Trigger-Route-Endpunkt
```python
@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`: String
- `eventParameters`: Dict (wird zu MessagingEventParameters konvertiert)
### 3. Scheduled Job (System Errors)
```python
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
```python
# 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=True` für Stack-Traces
- Keine speziellen Error-Handler, Standard-Python-Exception-Handling
## Konfiguration
### Environment Variables
```env
# 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
1. **Datenmodelle** (`datamodelMessaging.py`) ✅
2. **Connectors** (`connectorMessagingEmail.py`, `connectorMessagingSms.py`)
3. **Interface** (`interfaceMessaging.py`)
4. **Interface-Methoden** (`interfaceDbComponentObjects.py`)
5. **Service-Implementierung** (`serviceMessaging/mainServiceMessaging.py`)
6. **Routes** (`routeMessaging.py`)
7. **Integration** (Service in `__init__.py` registrieren, Routes registrieren)
8. **Subscription-Funktionen** (`serviceMessaging/subscriptions/`)
- Können nachträglich hinzugefügt werden
- System funktioniert auch ohne Funktionen (Users können sich subscriben)
9. **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.