Date: Mon, 30 Mar 2026 23:03:36 +0200
Subject: [PATCH 15/33] fixed onboarding flow
---
modules/datamodels/datamodelSubscription.py | 1 +
modules/features/automation/mainAutomation.py | 2 +-
.../features/automation2/mainAutomation2.py | 19 +-
modules/features/commcoach/mainCommcoach.py | 34 +-
.../commcoach/tests/test_mainCommcoach.py | 2 +-
.../neutralization/mainNeutralization.py | 37 +-
modules/features/realEstate/mainRealEstate.py | 53 ++-
.../teamsbot/interfaceFeatureTeamsbot.py | 15 -
modules/features/teamsbot/mainTeamsbot.py | 22 +-
modules/features/trustee/mainTrustee.py | 49 +-
modules/interfaces/interfaceDbApp.py | 63 ++-
modules/interfaces/interfaceDbBilling.py | 11 -
modules/interfaces/interfaceDbManagement.py | 23 +-
modules/routes/routeDataUsers.py | 39 +-
modules/routes/routeInvitations.py | 51 +--
modules/routes/routeSecurityLocal.py | 424 ++++++++++++------
modules/routes/routeStore.py | 14 +-
.../services/serviceChat/mainServiceChat.py | 12 +-
.../mainServiceGeneration.py | 2 +-
19 files changed, 550 insertions(+), 323 deletions(-)
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index c5547c0a..8fcf10f2 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -31,6 +31,7 @@ OPERATIVE_STATUSES = {SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.TRIA
ALLOWED_TRANSITIONS = {
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.ACTIVE),
+ (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.TRIALING),
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.SCHEDULED),
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.EXPIRED),
(SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE),
diff --git a/modules/features/automation/mainAutomation.py b/modules/features/automation/mainAutomation.py
index 4bb30f7f..d56804fd 100644
--- a/modules/features/automation/mainAutomation.py
+++ b/modules/features/automation/mainAutomation.py
@@ -227,7 +227,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
- "autoCreateInstance": True, # Automatically create instance in root mandate during bootstrap
+ "autoCreateInstance": False,
}
diff --git a/modules/features/automation2/mainAutomation2.py b/modules/features/automation2/mainAutomation2.py
index 08038e68..c0bee3fe 100644
--- a/modules/features/automation2/mainAutomation2.py
+++ b/modules/features/automation2/mainAutomation2.py
@@ -60,12 +60,25 @@ RESOURCE_OBJECTS = [
]
TEMPLATE_ROLES = [
+ {
+ "roleLabel": "automation2-viewer",
+ "description": {
+ "en": "Automation2 Viewer - View workflows (read-only)",
+ "de": "Automation2 Betrachter - Workflows ansehen (nur lesen)",
+ "fr": "Visualiseur Automation2 - Consulter les workflows (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.automation2.workflows", "view": True},
+ {"context": "UI", "item": "ui.feature.automation2.workflows-tasks", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
{
"roleLabel": "automation2-user",
"description": {
"en": "Automation2 User - Use automation2 flow builder",
"de": "Automation2 Benutzer - Flow-Builder nutzen",
- "fr": "Utilisateur Automation2 - Utiliser le flow builder"
+ "fr": "Utilisateur Automation2 - Utiliser le flow builder",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.automation2.editor", "view": True},
@@ -75,7 +88,7 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.automation2.node-types", "view": True},
{"context": "RESOURCE", "item": "resource.feature.automation2.execute", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
- ]
+ ],
},
{
"roleLabel": "automation2-admin",
@@ -188,7 +201,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
- "autoCreateInstance": True,
+ "autoCreateInstance": False,
}
diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py
index e8abcee8..9d949e13 100644
--- a/modules/features/commcoach/mainCommcoach.py
+++ b/modules/features/commcoach/mainCommcoach.py
@@ -109,12 +109,27 @@ RESOURCE_OBJECTS = [
]
TEMPLATE_ROLES = [
+ {
+ "roleLabel": "commcoach-viewer",
+ "description": {
+ "en": "Communication Coach Viewer - View coaching data (read-only)",
+ "de": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
+ "fr": "Visualiseur Coach Communication - Consulter les donnees coaching (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
+ {"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
+ {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
+ {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
{
"roleLabel": "commcoach-user",
"description": {
"en": "Communication Coach User - Can manage own coaching contexts and sessions",
"de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
- "fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions"
+ "fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
@@ -132,7 +147,20 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True},
- ]
+ ],
+ },
+ {
+ "roleLabel": "commcoach-admin",
+ "description": {
+ "en": "Communication Coach Admin - All UI and API actions; data scoped to own records",
+ "de": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze",
+ "fr": "Administrateur Coach Communication - Toute l'UI et les API; donnees propres",
+ },
+ "accessRules": [
+ {"context": "UI", "item": None, "view": True},
+ {"context": "RESOURCE", "item": None, "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
+ ],
},
]
@@ -142,7 +170,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
- "autoCreateInstance": True,
+ "autoCreateInstance": False,
}
diff --git a/modules/features/commcoach/tests/test_mainCommcoach.py b/modules/features/commcoach/tests/test_mainCommcoach.py
index 85d85cf6..6be563b6 100644
--- a/modules/features/commcoach/tests/test_mainCommcoach.py
+++ b/modules/features/commcoach/tests/test_mainCommcoach.py
@@ -31,7 +31,7 @@ class TestFeatureDefinition:
assert defn["code"] == "commcoach"
assert "label" in defn
assert "icon" in defn
- assert defn["autoCreateInstance"] is True
+ assert defn["autoCreateInstance"] is False
class TestRbacObjects:
diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py
index d32b441f..bfe97a13 100644
--- a/modules/features/neutralization/mainNeutralization.py
+++ b/modules/features/neutralization/mainNeutralization.py
@@ -45,34 +45,55 @@ RESOURCE_OBJECTS = [
# Template roles for this feature
TEMPLATE_ROLES = [
+ {
+ "roleLabel": "neutralization-viewer",
+ "description": {
+ "en": "Neutralization Viewer - View neutralization data (read-only)",
+ "de": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)",
+ "fr": "Visualiseur neutralisation - Consulter les données de neutralisation (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
+ {
+ "roleLabel": "neutralization-user",
+ "description": {
+ "en": "Neutralization User - Use neutralization tools and manage own data",
+ "de": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten",
+ "fr": "Utilisateur neutralisation - Utiliser les outils et gérer ses propres données",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
+ {"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
+ ],
+ },
{
"roleLabel": "neutralization-admin",
"description": {
"en": "Neutralization Administrator - Full access to neutralization settings and data",
"de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
- "fr": "Administrateur neutralisation - Accès complet aux paramètres et données"
+ "fr": "Administrateur neutralisation - Accès complet aux paramètres et données",
},
"accessRules": [
- # Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True},
- # Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
- ]
+ ],
},
{
"roleLabel": "neutralization-analyst",
"description": {
"en": "Neutralization Analyst - Analyze and process neutralization data",
"de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
- "fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation"
+ "fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation",
},
"accessRules": [
- # UI access to specific views - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
- # Group-level DATA access (read-only for sensitive config)
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"},
- ]
+ ],
},
]
diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py
index 2ae2378b..dfe310d5 100644
--- a/modules/features/realEstate/mainRealEstate.py
+++ b/modules/features/realEstate/mainRealEstate.py
@@ -39,52 +39,57 @@ RESOURCE_OBJECTS = [
# Template roles for this feature with AccessRules
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [
+ {
+ "roleLabel": "realestate-viewer",
+ "description": {
+ "en": "Real Estate Viewer - View property information (read-only)",
+ "de": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)",
+ "fr": "Visualiseur immobilier - Consulter les informations immobilières (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
+ {
+ "roleLabel": "realestate-user",
+ "description": {
+ "en": "Real Estate User - Create and manage own property records",
+ "de": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten",
+ "fr": "Utilisateur immobilier - Créer et gérer ses propres données immobilières",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
+ {"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
+ ],
+ },
{
"roleLabel": "realestate-admin",
"description": {
"en": "Real Estate Administrator - Full access to all property data and settings",
"de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
- "fr": "Administrateur immobilier - Accès complet aux données et paramètres"
+ "fr": "Administrateur immobilier - Accès complet aux données et paramètres",
},
"accessRules": [
- # Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True},
- # Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
- # Admin resources
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True},
- ]
+ ],
},
{
"roleLabel": "realestate-manager",
"description": {
"en": "Real Estate Manager - Manage properties and tenants",
"de": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
- "fr": "Gestionnaire immobilier - Gérer les propriétés et locataires"
+ "fr": "Gestionnaire immobilier - Gérer les propriétés et locataires",
},
"accessRules": [
- # UI access to map view
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
- # Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
- # Resource: create projects
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
- ]
- },
- {
- "roleLabel": "realestate-viewer",
- "description": {
- "en": "Real Estate Viewer - View property information",
- "de": "Immobilien-Betrachter - Immobilien-Informationen einsehen",
- "fr": "Visualiseur immobilier - Consulter les informations immobilières"
- },
- "accessRules": [
- # UI access to map view (read-only)
- {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
- # Read-only DATA access (my records)
- {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
- ]
+ ],
},
]
diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py
index 9be96393..4d6519d8 100644
--- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py
+++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py
@@ -10,7 +10,6 @@ from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector
-from modules.shared.timeUtils import getIsoTimestamp
from modules.shared.configuration import APP_CONFIG
from .datamodelTeamsbot import (
@@ -104,13 +103,10 @@ class TeamsbotObjects:
def createSession(self, sessionData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new session."""
- sessionData["creationDate"] = getIsoTimestamp()
- sessionData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotSession, sessionData)
def updateSession(self, sessionId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update session fields."""
- updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotSession, sessionId, updates)
def deleteSession(self, sessionId: str) -> bool:
@@ -149,7 +145,6 @@ class TeamsbotObjects:
def createTranscript(self, transcriptData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new transcript segment."""
- transcriptData["creationDate"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotTranscript, transcriptData)
def updateTranscript(self, transcriptId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@@ -180,7 +175,6 @@ class TeamsbotObjects:
def createBotResponse(self, responseData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new bot response record."""
- responseData["creationDate"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotBotResponse, responseData)
def _deleteResponsesBySession(self, sessionId: str) -> int:
@@ -216,13 +210,10 @@ class TeamsbotObjects:
def createSystemBot(self, botData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new system bot account."""
- botData["creationDate"] = getIsoTimestamp()
- botData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotSystemBot, botData)
def updateSystemBot(self, botId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update a system bot account."""
- updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotSystemBot, botId, updates)
def deleteSystemBot(self, botId: str) -> bool:
@@ -243,13 +234,10 @@ class TeamsbotObjects:
def createUserSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
"""Create user settings."""
- settingsData["creationDate"] = getIsoTimestamp()
- settingsData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotUserSettings, settingsData)
def updateUserSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update user settings."""
- updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotUserSettings, settingsId, updates)
def deleteUserSettings(self, settingsId: str) -> bool:
@@ -270,13 +258,10 @@ class TeamsbotObjects:
def createUserAccount(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create saved MS credentials."""
- data["creationDate"] = getIsoTimestamp()
- data["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotUserAccount, data)
def updateUserAccount(self, accountId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update saved MS credentials."""
- updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotUserAccount, accountId, updates)
def deleteUserAccount(self, accountId: str) -> bool:
diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py
index 97cc107e..afdce822 100644
--- a/modules/features/teamsbot/mainTeamsbot.py
+++ b/modules/features/teamsbot/mainTeamsbot.py
@@ -103,25 +103,35 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True},
]
},
+ {
+ "roleLabel": "teamsbot-viewer",
+ "description": {
+ "en": "Teams Bot Viewer - View sessions and transcripts (read-only)",
+ "de": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
+ "fr": "Visualiseur Teams Bot - Consulter les sessions et transcriptions (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
+ {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
{
"roleLabel": "teamsbot-user",
"description": {
"en": "Teams Bot User - Can start/stop sessions and view transcripts",
"de": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
- "fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions"
+ "fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions",
},
"accessRules": [
- # UI access to dashboard and sessions (not settings)
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
- # Own records only
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
- # Start and stop sessions
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
- ]
+ ],
},
]
@@ -132,7 +142,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON,
- "autoCreateInstance": True,
+ "autoCreateInstance": False,
}
diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py
index 606da308..45824b1b 100644
--- a/modules/features/trustee/mainTrustee.py
+++ b/modules/features/trustee/mainTrustee.py
@@ -170,60 +170,81 @@ RESOURCE_OBJECTS = [
# Note: UI item=None means ALL views, specific items restrict to named views
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [
+ {
+ "roleLabel": "trustee-viewer",
+ "description": {
+ "en": "Trustee Viewer - View trustee data (read-only)",
+ "de": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)",
+ "fr": "Visualiseur fiduciaire - Consulter les données fiduciaires (lecture seule)",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
+ ],
+ },
+ {
+ "roleLabel": "trustee-user",
+ "description": {
+ "en": "Trustee User - Create and manage own trustee records",
+ "de": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten",
+ "fr": "Utilisateur fiduciaire - Créer et gérer ses propres données fiduciaires",
+ },
+ "accessRules": [
+ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
+ {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
+ ],
+ },
{
"roleLabel": "trustee-admin",
"description": {
"en": "Trustee Administrator - Full access to all trustee data and settings",
"de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
- "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires"
+ "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires",
},
"accessRules": [
- # Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True},
- # Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
- # Admin resource: manage instance roles
{"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True},
- ]
+ ],
},
{
"roleLabel": "trustee-accountant",
"description": {
"en": "Trustee Accountant - Manage accounting and financial data",
"de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
- "fr": "Comptable fiduciaire - Gérer les données comptables et financières"
+ "fr": "Comptable fiduciaire - Gérer les données comptables et financières",
},
"accessRules": [
- # UI access to main views (not admin views, not expense-import) - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
- # Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
- # Accounting sync permission
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True},
- ]
+ ],
},
{
"roleLabel": "trustee-client",
"description": {
"en": "Trustee Client - View own accounting data and documents",
"de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
- "fr": "Client fiduciaire - Consulter ses propres données comptables et documents"
+ "fr": "Client fiduciaire - Consulter ses propres données comptables et documents",
},
"accessRules": [
- # UI access to main views + expense-import - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
{"context": "UI", "item": "ui.feature.trustee.scan-upload", "view": True},
- # Own records only (MY level)
{"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
- ]
+ ],
},
]
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index ffde890f..d980eb56 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1446,7 +1446,7 @@ class AppObjects:
if not adminRoleId:
raise ValueError(f"No admin role found for mandate {mandateId} — cannot assign user without role")
- self.createUserMandate(userId, mandateId, roleIds=[adminRoleId])
+ self.createUserMandate(userId, mandateId, roleIds=[adminRoleId], skipCapacityCheck=True)
subscription = MandateSubscription(
mandateId=mandateId,
@@ -1454,8 +1454,10 @@ class AppObjects:
status=SubscriptionStatusEnum.PENDING,
)
if plan.trialDays:
- pass # trialEndsAt set on ACTIVE transition
- self.db.recordCreate(MandateSubscription, subscription.model_dump())
+ pass # trialEndsAt set on ACTIVE/TRIALING transition
+ from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+ subInterface = _getSubRoot()
+ subInterface.createSubscription(subscription)
featureInterface = getFeatureInterface(self.db)
mainModules = loadFeatureMainModules()
@@ -1513,50 +1515,58 @@ class AppObjects:
"""
Activate PENDING subscriptions for all mandates where this user is a member.
Called on login — trial period begins NOW, not at registration.
+ Uses the subscription interface (poweron_billing) for all subscription operations.
Returns number of activated subscriptions.
"""
from modules.datamodels.datamodelSubscription import (
- MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS,
+ SubscriptionStatusEnum, BUILTIN_PLANS,
)
+ from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
from datetime import datetime, timezone, timedelta
activated = 0
+ subInterface = _getSubRoot()
+
userMandates = self.db.getRecordset(
UserMandate, recordFilter={"userId": userId, "enabled": True}
)
for um in userMandates:
mandateId = um.get("mandateId")
- subs = self.db.getRecordset(
- MandateSubscription,
- recordFilter={"mandateId": mandateId, "status": SubscriptionStatusEnum.PENDING.value}
- )
- for sub in subs:
+ allSubs = subInterface.listForMandate(mandateId)
+ pendingSubs = [s for s in allSubs if s.get("status") == SubscriptionStatusEnum.PENDING.value]
+
+ for sub in pendingSubs:
subId = sub.get("id")
planKey = sub.get("planKey")
plan = BUILTIN_PLANS.get(planKey)
now = datetime.now(timezone.utc)
- updateData = {
- "status": SubscriptionStatusEnum.TRIALING.value if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE.value,
+ targetStatus = SubscriptionStatusEnum.TRIALING if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE
+ additionalData = {
"currentPeriodStart": now.isoformat(),
}
if plan and plan.trialDays:
trialEnd = now + timedelta(days=plan.trialDays)
- updateData["trialEndsAt"] = trialEnd.isoformat()
- updateData["currentPeriodEnd"] = trialEnd.isoformat()
+ additionalData["trialEndsAt"] = trialEnd.isoformat()
+ additionalData["currentPeriodEnd"] = trialEnd.isoformat()
elif plan and plan.billingPeriod:
from modules.datamodels.datamodelSubscription import BillingPeriodEnum
if plan.billingPeriod == BillingPeriodEnum.MONTHLY:
- updateData["currentPeriodEnd"] = (now + timedelta(days=30)).isoformat()
+ additionalData["currentPeriodEnd"] = (now + timedelta(days=30)).isoformat()
elif plan.billingPeriod == BillingPeriodEnum.YEARLY:
- updateData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat()
+ additionalData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat()
try:
- self.db.recordModify(MandateSubscription, subId, updateData)
+ subInterface.transitionStatus(
+ subId,
+ expectedFromStatus=SubscriptionStatusEnum.PENDING,
+ toStatus=targetStatus,
+ additionalData=additionalData,
+ )
activated += 1
- logger.info(f"Activated subscription {subId} (plan={planKey}) for mandate {mandateId}: {updateData.get('status')}")
+ logger.info(f"Activated subscription {subId} (plan={planKey}) for mandate {mandateId}: {targetStatus.value}")
except Exception as e:
logger.error(f"Failed to activate subscription {subId}: {e}")
@@ -1848,7 +1858,7 @@ class AppObjects:
logger.error(f"Error getting UserMandates: {e}")
return []
- def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
+ def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None, *, skipCapacityCheck: bool = False) -> UserMandate:
"""
Create a UserMandate record (add user to mandate).
Also creates a billing audit account for the user if billing is configured.
@@ -1859,6 +1869,8 @@ class AppObjects:
userId: User ID
mandateId: Mandate ID
roleIds: List of role IDs to assign (at least one required)
+ skipCapacityCheck: If True, skip subscription capacity check (used during initial provisioning
+ when the subscription hasn't been created yet)
Returns:
Created UserMandate object
@@ -1871,7 +1883,8 @@ class AppObjects:
if existing:
raise ValueError(f"User {userId} is already member of mandate {mandateId}")
- self._checkSubscriptionCapacity(mandateId, "users", delta=1)
+ if not skipCapacityCheck:
+ self._checkSubscriptionCapacity(mandateId, "users", delta=1)
userMandate = UserMandate(
userId=userId,
@@ -2551,6 +2564,18 @@ class AppObjects:
logger.error(f"Error getting invitations for target username {targetUsername}: {e}")
return []
+ def getInvitationsByEmail(self, email: str) -> List[Invitation]:
+ """Get all invitations for a target email address (email-only invitations)."""
+ try:
+ records = self.db.getRecordset(Invitation, recordFilter={"email": email})
+ result = []
+ for record in records:
+ result.append(Invitation(**dict(record)))
+ return result
+ except Exception as e:
+ logger.error(f"Error getting invitations for email {email}: {e}")
+ return []
+
# ============================================
# Additional Helper Methods
# ============================================
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index bb2dc5c9..948f8918 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -586,17 +586,6 @@ class BillingObjects:
# Create transaction record (always on transaction.accountId for audit)
transactionDict = transaction.model_dump(exclude_none=True)
- ts = getUtcTimestamp()
- uid = str(self.userId) if self.userId else None
- if transactionDict.get("sysCreatedAt") is None:
- transactionDict["sysCreatedAt"] = ts
- if transactionDict.get("sysModifiedAt") is None:
- transactionDict["sysModifiedAt"] = ts
- if uid:
- if transactionDict.get("sysCreatedBy") is None:
- transactionDict["sysCreatedBy"] = uid
- if transactionDict.get("sysModifiedBy") is None:
- transactionDict["sysModifiedBy"] = uid
created = self.db.recordCreate(BillingTransaction, transactionDict)
# Update balance on the target account
diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py
index 0a16b734..2df85164 100644
--- a/modules/interfaces/interfaceDbManagement.py
+++ b/modules/interfaces/interfaceDbManagement.py
@@ -823,7 +823,7 @@ class ComponentObjects:
mimeType=file["mimeType"],
fileHash=file["fileHash"],
fileSize=file["fileSize"],
- creationDate=file["creationDate"]
+ sysCreatedAt=file.get("sysCreatedAt") or file.get("creationDate"),
)
def getMimeType(self, fileName: str) -> str:
@@ -928,9 +928,11 @@ class ComponentObjects:
fileItems = []
for file in files:
try:
- creationDate = file.get("creationDate")
- if creationDate is None or not isinstance(creationDate, (int, float)) or creationDate <= 0:
- file["creationDate"] = getUtcTimestamp()
+ sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate")
+ if sysCreatedAt is None or not isinstance(sysCreatedAt, (int, float)) or sysCreatedAt <= 0:
+ file["sysCreatedAt"] = getUtcTimestamp()
+ else:
+ file["sysCreatedAt"] = sysCreatedAt
fileName = file.get("fileName")
if not fileName or fileName == "None":
@@ -977,20 +979,19 @@ class ComponentObjects:
file = filteredFiles[0]
try:
- # Get creation date from record or use current time
- creationDate = file.get("creationDate")
- if not creationDate:
- creationDate = getUtcTimestamp()
-
+ sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate")
+ if not sysCreatedAt:
+ sysCreatedAt = getUtcTimestamp()
+
return FileItem(
id=file.get("id"),
mandateId=file.get("mandateId"),
+ featureInstanceId=file.get("featureInstanceId", ""),
fileName=file.get("fileName"),
mimeType=file.get("mimeType"),
- workflowId=file.get("workflowId"),
fileHash=file.get("fileHash"),
fileSize=file.get("fileSize"),
- creationDate=creationDate
+ sysCreatedAt=sysCreatedAt,
)
except Exception as e:
logger.error(f"Error converting file record: {str(e)}")
diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py
index a1da658b..7cce66ca 100644
--- a/modules/routes/routeDataUsers.py
+++ b/modules/routes/routeDataUsers.py
@@ -920,30 +920,29 @@ def send_password_link(
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
try:
- from modules.serviceHub import Services
- services = Services(targetUser)
-
+ from modules.routes.routeSecurityLocal import _buildAuthEmailHtml, _sendAuthEmail
+
emailSubject = "PowerOn - Passwort setzen"
- emailBody = f"""
-Hallo {targetUser.fullName or targetUser.username},
+ emailHtml = _buildAuthEmailHtml(
+ greeting=f"Hallo {targetUser.fullName or targetUser.username}",
+ bodyLines=[
+ "Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert.",
+ "",
+ f"Ihr Benutzername: {targetUser.username}",
+ "",
+ "Klicken Sie auf die Schaltfläche, um Ihr Passwort zu setzen:",
+ ],
+ buttonText="Passwort setzen",
+ buttonUrl=magicLink,
+ footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator.",
+ )
-Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert.
-
-Ihr Benutzername: {targetUser.username}
-
-Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
-{magicLink}
-
-Dieser Link ist {expiryHours} Stunden gültig.
-
-Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator.
-"""
-
- emailSent = services.messaging.sendEmailDirect(
+ emailSent = _sendAuthEmail(
recipient=targetUser.email,
subject=emailSubject,
- message=emailBody,
- userId=str(targetUser.id)
+ message="",
+ userId=str(targetUser.id),
+ htmlOverride=emailHtml,
)
if not emailSent:
diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py
index cb913137..ccefcc87 100644
--- a/modules/routes/routeInvitations.py
+++ b/modules/routes/routeInvitations.py
@@ -292,37 +292,24 @@ def create_invitation(
emailConnector = ConnectorMessagingEmail()
if instance_label:
emailSubject = f"Einladung zur Feature-Instanz {instance_label}"
- invite_text = f"der Feature-Instanz {instance_label} (Mandant: {mandateName}) beizutreten"
+ invite_desc = f"der Feature-Instanz «{instance_label}» (Mandant: {mandateName}) beizutreten"
else:
emailSubject = f"Einladung zu {mandateName}"
- invite_text = f"dem Mandanten {mandateName} beizutreten"
- emailBody = f"""
-
-
- Sie wurden eingeladen!
- Hallo {display_name},
- Sie wurden eingeladen, {invite_text}.
- Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:
-
-
- Einladung annehmen
-
-
-
- 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.
-
-
-
- """
-
+ invite_desc = f"dem Mandanten «{mandateName}» beizutreten"
+
+ from modules.routes.routeSecurityLocal import _buildAuthEmailHtml
+ emailBody = _buildAuthEmailHtml(
+ greeting=f"Hallo {display_name}",
+ bodyLines=[
+ f"Sie wurden eingeladen, {invite_desc}.",
+ "",
+ "Klicken Sie auf die Schaltfläche, um die Einladung anzunehmen:",
+ ],
+ buttonText="Einladung annehmen",
+ buttonUrl=inviteUrl,
+ footerText=f"Diese Einladung ist {data.expiresInHours} Stunden gültig.",
+ )
+
emailConnector.send(
recipient=email_val,
subject=emailSubject,
@@ -376,6 +363,8 @@ def create_invitation(
f"to {target_desc}, expires in {data.expiresInHours}h"
)
+ # Invitation extends PowerOnModel: recordCreate/_saveRecord set sysCreatedAt and sysCreatedBy automatically.
+ # API response uses createdAt/createdBy; map from the system fields (no separate createdAt column on model).
return InvitationResponse(
id=str(createdRecord.get("id")),
token=str(createdRecord.get("token")),
@@ -384,8 +373,8 @@ def create_invitation(
roleIds=createdRecord.get("roleIds", []),
targetUsername=createdRecord.get("targetUsername"),
email=createdRecord.get("email"),
- createdBy=str(createdRecord.get("createdBy")),
- createdAt=createdRecord.get("createdAt"),
+ createdBy=str(createdRecord["sysCreatedBy"]),
+ createdAt=float(createdRecord["sysCreatedAt"]),
expiresAt=createdRecord.get("expiresAt"),
usedBy=createdRecord.get("usedBy"),
usedAt=createdRecord.get("usedAt"),
diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py
index 11b6cb0f..9ec4fc38 100644
--- a/modules/routes/routeSecurityLocal.py
+++ b/modules/routes/routeSecurityLocal.py
@@ -26,36 +26,122 @@ from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__)
-def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None) -> bool:
+def _buildAuthEmailHtml(
+ greeting: str,
+ bodyLines: list,
+ buttonText: str = None,
+ buttonUrl: str = None,
+ footerText: str = None,
+) -> str:
+ """Build a branded HTML email for authentication flows.
+
+ Uses the same visual design as notifyMandateAdmins._renderHtmlEmail
+ (dark header, clean body, operator footer).
+ """
+ import html as _html
+
+ paragraphsHtml = ""
+ for line in bodyLines:
+ if line == "":
+ paragraphsHtml += '
\n'
+ else:
+ escaped = _html.escape(str(line))
+ paragraphsHtml += f'{escaped}
\n'
+
+ buttonBlock = ""
+ if buttonText and buttonUrl:
+ buttonBlock = f'''
+
+ {_html.escape(buttonUrl)}
+
'''
+
+ footerNote = ""
+ if footerText:
+ footerNote = f'{_html.escape(footerText)}
\n'
+
+ operatorLine = ""
+ try:
+ from modules.shared.configuration import APP_CONFIG
+ parts = [p for p in [
+ APP_CONFIG.get("Operator_CompanyName", ""),
+ APP_CONFIG.get("Operator_Address", ""),
+ APP_CONFIG.get("Operator_VatNumber", ""),
+ ] if p]
+ if parts:
+ operatorLine = (
+ f''
+ f'{_html.escape(" | ".join(parts))}
\n'
+ )
+ except Exception:
+ pass
+
+ return f'''
+
+
+
+
+
+
+
+
+ PowerOn
+ |
+
+
+ {_html.escape(greeting)}
+
+ {paragraphsHtml}
+ {buttonBlock}
+
+ {footerNote}
+ |
+
+ |
+
+ Diese E-Mail wurde automatisch von PowerOn versendet.
+
+ {operatorLine}
+ |
+
+ |
+
+
+'''
+
+
+def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None, htmlOverride: str = None) -> bool:
"""
Send authentication-related email directly without requiring full Services initialization.
Used for registration, password reset, and other auth flows.
-
+
Args:
recipient: Email address
subject: Email subject
- message: Plain text message (will be converted to HTML)
+ message: Plain text fallback (ignored when htmlOverride is given)
userId: Optional user ID for logging
-
+ htmlOverride: Pre-built branded HTML (from _buildAuthEmailHtml)
+
Returns:
bool: True if email was sent successfully
"""
try:
- import html
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.datamodels.datamodelMessaging import MessagingChannel
-
- # Convert plain text to simple HTML
- escaped = html.escape(message)
- escaped = escaped.replace('\n', '
\n')
- htmlMessage = f"""
-
-
-
-{escaped}
-
-"""
-
+
+ htmlMessage = htmlOverride
+ if not htmlMessage:
+ import html
+ escaped = html.escape(message)
+ escaped = escaped.replace('\n', '
\n')
+ htmlMessage = f'{escaped}'
+
messagingInterface = getMessagingInterface()
success = messagingInterface.send(
channel=MessagingChannel.EMAIL,
@@ -63,12 +149,12 @@ def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = Non
subject=subject,
message=htmlMessage
)
-
+
if success:
logger.info(f"Auth email sent successfully to {recipient} (userId: {userId})")
else:
logger.warning(f"Failed to send auth email to {recipient} (userId: {userId})")
-
+
return success
except Exception as e:
logger.error(f"Error sending auth email to {recipient}: {str(e)}", exc_info=True)
@@ -88,15 +174,43 @@ router = APIRouter(
)
def _ensureHomeMandate(rootInterface, user) -> None:
- """Ensure user has a Home mandate. Creates 'Home {username}' if none exists."""
- userMandates = rootInterface.getUserMandates(str(user.id))
+ """Ensure user has a Home mandate, but only if they have no mandate memberships
+ AND no pending invitations.
+
+ Invited users should NOT get a Home mandate — they join existing mandates via
+ invitation acceptance and can create their own later via onboarding.
+ """
+ userId = str(user.id)
+ userMandates = rootInterface.getUserMandates(userId)
+
+ if userMandates:
+ for um in userMandates:
+ mandate = rootInterface.getMandate(um.mandateId)
+ if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem:
+ return
+ logger.debug(f"User {user.username} has {len(userMandates)} mandate(s) but no Home — skipping auto-creation")
+ return
+
+ try:
+ from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf
+ appIf = _getRootIf()
+ normalizedEmail = (user.email or "").strip().lower() if user.email else None
+ pendingByUsername = appIf.getInvitationsByTargetUsername(user.username)
+ pendingByEmail = appIf.getInvitationsByEmail(normalizedEmail) if normalizedEmail else []
+ seenIds = set()
+ for inv in pendingByUsername + pendingByEmail:
+ if inv.id in seenIds:
+ continue
+ seenIds.add(inv.id)
+ if not inv.revokedAt and (inv.currentUses or 0) < (inv.maxUses or 1):
+ logger.info(f"User {user.username} has pending invitation(s) — skipping Home mandate creation")
+ return
+ except Exception as e:
+ logger.warning(f"Could not check pending invitations for {user.username}: {e}")
+
homeMandateName = f"Home {user.username}"
- for um in userMandates:
- mandate = rootInterface.getMandate(um.mandateId)
- if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem:
- return
rootInterface._provisionMandateForUser(
- userId=str(user.id),
+ userId=userId,
mandateName=homeMandateName,
planKey="TRIAL_7D",
)
@@ -191,7 +305,14 @@ def login(
# Save access token
userInterface.saveAccessToken(token)
- # Activate PENDING subscriptions on first login
+ # Ensure user has a Home mandate (created on first login if missing)
+ try:
+ _ensureHomeMandate(rootInterface, user)
+ except Exception as homeErr:
+ logger.error(f"Error ensuring Home mandate for user {user.username}: {homeErr}")
+
+ # Activate PENDING subscriptions on first login (runs AFTER _ensureHomeMandate
+ # so that a freshly provisioned Home mandate subscription is also activated)
try:
activatedCount = rootInterface._activatePendingSubscriptions(str(user.id))
if activatedCount > 0:
@@ -199,12 +320,6 @@ def login(
except Exception as subErr:
logger.error(f"Error activating subscriptions on login: {subErr}")
- # Ensure user has a Home mandate (created on first login if missing)
- try:
- _ensureHomeMandate(rootInterface, user)
- except Exception as homeErr:
- logger.error(f"Error ensuring Home mandate for user {user.username}: {homeErr}")
-
# Log successful login (app log file + audit DB for traceability)
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
try:
@@ -282,35 +397,28 @@ def register_user(
) -> Dict[str, Any]:
"""Register a new local user (magic link based - no password required).
+ Unified registration path: invited users skip Home mandate provisioning
+ (they join the inviting mandate instead). Non-invited users get a Home
+ mandate with TRIAL_7D. Company mandate creation is deferred to onboarding.
+
Args:
userData: User data (username, email, fullName, language)
frontendUrl: The frontend URL to use in magic link (REQUIRED - provided by frontend)
+ registrationType: Kept for backward compat but ignored (company mandates via onboarding)
+ companyName: Kept for backward compat but ignored
"""
try:
- # Get gateway interface with root privileges since this is a public endpoint
appInterface = getRootInterface()
-
- # Note: User registration does NOT require mandateId context
- # Users are mandate-independent (Multi-Tenant Design)
- # Mandate assignment happens via createUserMandate() after registration
-
- # Frontend URL is required - no fallback
baseUrl = frontendUrl.rstrip("/")
-
- # Normalize email
normalizedEmail = userData.email.lower().strip() if userData.email else None
- # Note: Email can be shared across multiple users (different mandates)
- # Username uniqueness is enforced in createUser() - that's the primary constraint
-
- # Create user with local authentication (no password - magic link based)
user = appInterface.createUser(
username=userData.username,
- password=None, # No password - will be set via magic link
+ password=None,
email=normalizedEmail,
fullName=userData.fullName,
language=userData.language,
- enabled=True, # Users are enabled by default (can login after setting password)
+ enabled=True,
authenticationAuthority=AuthAuthority.LOCAL
)
@@ -320,35 +428,51 @@ def register_user(
detail="Failed to register user"
)
- # Provision Home mandate for every new user ("Home {username}")
- provisionResult = None
+ # Check for pending invitations BEFORE provisioning.
+ # Search by both username AND email (email-only invitations have targetUsername=None).
+ hasPendingInvitations = False
+ validInvitations = []
try:
- homeMandateName = f"Home {user.username}"
- provisionResult = appInterface._provisionMandateForUser(
- userId=str(user.id),
- mandateName=homeMandateName,
- planKey="TRIAL_7D",
- )
- logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
- except Exception as provErr:
- logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}")
+ from modules.datamodels.datamodelInvitation import Invitation
- # If company registration, also create a company mandate with the paid plan
- if registrationType == "company":
- if not companyName:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="companyName is required for company registration"
- )
+ currentTime = getUtcTimestamp()
+ pendingByUsername = appInterface.getInvitationsByTargetUsername(userData.username)
+ pendingByEmail = appInterface.getInvitationsByEmail(normalizedEmail) if normalizedEmail else []
+
+ seenIds = set()
+ allPending = pendingByUsername + pendingByEmail
+ for invitation in allPending:
+ if invitation.id in seenIds:
+ continue
+ seenIds.add(invitation.id)
+ if (invitation.expiresAt or 0) < currentTime:
+ continue
+ if invitation.revokedAt:
+ continue
+ if (invitation.currentUses or 0) >= (invitation.maxUses or 1):
+ continue
+ validInvitations.append(invitation)
+
+ hasPendingInvitations = len(validInvitations) > 0
+ except Exception as invErr:
+ logger.warning(f"Failed to check pending invitations: {invErr}")
+
+ # Only provision Home mandate if user has NO pending invitations.
+ # Invited users join existing mandates; they can create their own later via onboarding.
+ provisionResult = None
+ if not hasPendingInvitations:
try:
- companyResult = appInterface._provisionMandateForUser(
+ homeMandateName = f"Home {user.username}"
+ provisionResult = appInterface._provisionMandateForUser(
userId=str(user.id),
- mandateName=companyName,
- planKey="STANDARD_MONTHLY",
+ mandateName=homeMandateName,
+ planKey="TRIAL_7D",
)
- logger.info(f"Provisioned company mandate for user {user.id}: {companyResult}")
- except Exception as compErr:
- logger.error(f"Error provisioning company mandate for user {user.id}: {compErr}")
+ logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
+ except Exception as provErr:
+ logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}")
+ else:
+ logger.info(f"Skipping Home mandate for user {user.id} — has {len(validInvitations)} pending invitation(s)")
# Generate reset token for password setup
token, expires = appInterface.generateResetTokenAndExpiry()
@@ -360,57 +484,43 @@ def register_user(
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
emailSubject = "PowerOn Registrierung - Passwort setzen"
- emailBody = f"""Hallo {user.fullName or user.username},
+ emailHtml = _buildAuthEmailHtml(
+ greeting=f"Hallo {user.fullName or user.username}",
+ bodyLines=[
+ "Vielen Dank für Ihre Registrierung bei PowerOn.",
+ "",
+ f"Ihr Benutzername: {user.username}",
+ "",
+ "Klicken Sie auf die Schaltfläche, um Ihr Passwort zu setzen:",
+ ],
+ buttonText="Passwort setzen",
+ buttonUrl=magicLink,
+ footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.",
+ )
-Vielen Dank für Ihre Registrierung bei PowerOn.
-
-Ihr Benutzername: {user.username}
-
-Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
-{magicLink}
-
-Dieser Link ist {expiryHours} Stunden gültig.
-
-Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
-
emailSent = _sendAuthEmail(
recipient=user.email,
subject=emailSubject,
- message=emailBody,
- userId=str(user.id)
+ message="",
+ userId=str(user.id),
+ htmlOverride=emailHtml,
)
if not emailSent:
logger.warning(f"Failed to send registration email to {user.email}")
except Exception as emailErr:
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.getInvitationsByTargetUsername(userData.username)
-
- for invitation in pendingInvitations:
- # Skip expired, revoked, or fully used invitations
- if (invitation.expiresAt or 0) < currentTime:
- continue
- if invitation.revokedAt:
- continue
- if (invitation.currentUses or 0) >= (invitation.maxUses or 1):
- continue
+ # Create notifications for pending invitations
+ for invitation in validInvitations:
+ try:
+ from modules.routes.routeNotifications import createInvitationNotification
- # Get mandate name for notification using interface method
mandateId = invitation.mandateId
mandate = appInterface.getMandate(mandateId)
mandateName = (mandate.label or mandate.name) if mandate else "PowerOn"
- # Get inviter name
- inviterId = invitation.createdBy
+ inviterId = invitation.sysCreatedBy
inviter = appInterface.getUser(inviterId) if inviterId else None
inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn"
@@ -421,16 +531,15 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
inviterName=inviterName
)
logger.info(f"Created notification for new user {userData.username} for invitation {invitation.id}")
-
- except Exception as notifErr:
- logger.warning(f"Failed to create notifications for pending invitations: {notifErr}")
- # Don't fail registration if notification creation fails
+ except Exception as notifErr:
+ logger.warning(f"Failed to create notification for invitation {invitation.id}: {notifErr}")
responseData = {
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
}
if provisionResult:
responseData["mandateId"] = provisionResult.get("mandateId")
+ responseData["hasInvitations"] = hasPendingInvitations
return responseData
except ValueError as e:
@@ -676,24 +785,26 @@ def password_reset_request(
# Send email using dedicated auth email function
emailSubject = "PowerOn - Passwort zurücksetzen"
- emailBody = f"""Hallo {user.fullName or user.username},
+ emailHtml = _buildAuthEmailHtml(
+ greeting=f"Hallo {user.fullName or user.username}",
+ bodyLines=[
+ "Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.",
+ "",
+ f"Benutzername: {user.username}",
+ "",
+ "Klicken Sie auf die Schaltfläche, um Ihr Passwort zurückzusetzen:",
+ ],
+ buttonText="Passwort zurücksetzen",
+ buttonUrl=magicLink,
+ footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.",
+ )
-Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.
-
-Benutzername: {user.username}
-
-Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:
-{magicLink}
-
-Dieser Link ist {expiryHours} Stunden gültig.
-
-Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren."""
-
emailSent = _sendAuthEmail(
recipient=user.email,
subject=emailSubject,
- message=emailBody,
- userId=str(user.id)
+ message="",
+ userId=str(user.id),
+ htmlOverride=emailHtml,
)
if emailSent:
@@ -725,24 +836,63 @@ def onboarding_provision(
companyName: str = Body(None, embed=True),
planKey: str = Body("TRIAL_7D", embed=True),
) -> Dict[str, Any]:
- """Post-login onboarding: ensure Home mandate exists and optionally create a company mandate."""
+ """Post-login onboarding: create a mandate for the user.
+
+ Guard: user can only create a mandate if they are NOT already admin in any
+ non-system mandate. This prevents duplicate provisioning.
+ """
try:
+ from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
+ from modules.datamodels.datamodelRbac import Role
+
appInterface = getRootInterface()
+ db = appInterface.db
+ userId = str(currentUser.id)
- _ensureHomeMandate(appInterface, currentUser)
+ # Check if user already has admin role in a non-system mandate
+ userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
+ hasAdminMandate = False
+ for um in userMandates:
+ mandateId = um.get("mandateId")
+ mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
+ if mandate and mandate[0].get("isSystem"):
+ continue
+ umId = um.get("id")
+ umRoles = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId})
+ for umRole in umRoles:
+ roleId = umRole.get("roleId")
+ roles = db.getRecordset(Role, recordFilter={"id": roleId})
+ for role in roles:
+ if "admin" in (role.get("roleLabel") or "").lower():
+ hasAdminMandate = True
+ break
+ if hasAdminMandate:
+ break
+ if hasAdminMandate:
+ break
- result = None
- if companyName and companyName.strip():
- if planKey not in ("STANDARD_MONTHLY", "STANDARD_YEARLY"):
- planKey = "STANDARD_MONTHLY"
- result = appInterface._provisionMandateForUser(
- userId=str(currentUser.id),
- mandateName=companyName.strip(),
- planKey=planKey,
- )
+ if hasAdminMandate:
+ logger.info(f"Onboarding: user {currentUser.username} already has admin mandate — skipping provisioning")
+ return {
+ "message": "User already has an admin mandate",
+ "mandateId": None,
+ "alreadyProvisioned": True,
+ }
+
+ mandateName = (companyName.strip() if companyName and companyName.strip()
+ else f"Home {currentUser.username}")
+
+ if planKey not in ("TRIAL_7D", "STANDARD_MONTHLY", "STANDARD_YEARLY"):
+ planKey = "TRIAL_7D"
+
+ result = appInterface._provisionMandateForUser(
+ userId=userId,
+ mandateName=mandateName,
+ planKey=planKey,
+ )
try:
- activatedCount = appInterface._activatePendingSubscriptions(str(currentUser.id))
+ activatedCount = appInterface._activatePendingSubscriptions(userId)
if activatedCount > 0:
logger.info(f"Activated {activatedCount} pending subscription(s) for user {currentUser.username} during onboarding")
except Exception as subErr:
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index 5c6f782a..0d6e68ec 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -136,8 +136,8 @@ def listUserMandates(
) -> List[Dict[str, Any]]:
"""
List mandates where the user can activate features (admin mandates).
- If user has 0 admin mandates, auto-provisions a personal mandate so the
- Store always has a clear mandate context.
+ Returns empty list if user has no admin mandates — the frontend handles
+ this via OnboardingAssistant/OnboardingWizard to create a mandate.
"""
try:
rootInterface = getRootInterface()
@@ -145,16 +145,6 @@ def listUserMandates(
userId = str(context.user.id)
adminMandateIds = _getUserAdminMandateIds(db, userId)
- if not adminMandateIds:
- homeMandateName = f"Home {context.user.username}"
- provisionResult = rootInterface._provisionMandateForUser(
- userId=userId,
- mandateName=homeMandateName,
- planKey="TRIAL_7D",
- )
- adminMandateIds = [provisionResult["mandateId"]]
- logger.info(f"Auto-provisioned personal mandate {adminMandateIds[0]} for user {userId} on Store access")
-
result = []
for mid in adminMandateIds:
records = db.getRecordset(Mandate, recordFilter={"id": mid})
diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
index 40769fae..f3a74b1e 100644
--- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py
+++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
@@ -422,7 +422,7 @@ class ChatService:
"size": fileItem.fileSize,
"mimeType": fileItem.mimeType,
"fileHash": fileItem.fileHash,
- "creationDate": fileItem.creationDate,
+ "creationDate": fileItem.sysCreatedAt,
"tags": getattr(fileItem, "tags", None),
"folderId": getattr(fileItem, "folderId", None),
"description": getattr(fileItem, "description", None),
@@ -482,7 +482,7 @@ class ChatService:
"fileName": fileItem.fileName,
"mimeType": fileItem.mimeType,
"fileSize": fileItem.fileSize,
- "creationDate": fileItem.creationDate,
+ "creationDate": fileItem.sysCreatedAt,
"tags": getattr(fileItem, "tags", None),
"folderId": getattr(fileItem, "folderId", None),
"description": getattr(fileItem, "description", None),
@@ -524,7 +524,7 @@ class ChatService:
mandateId=self._context.mandate_id or "",
userId=self.user.id if self.user else "",
)
- return self.interfaceDbComponent.db.recordCreate(DataSource, ds)
+ return self.interfaceDbApp.db.recordCreate(DataSource, ds)
def listDataSources(self, featureInstanceId: str = None) -> List[Dict[str, Any]]:
"""List data sources, optionally filtered by feature instance."""
@@ -532,19 +532,19 @@ class ChatService:
recordFilter = {}
if featureInstanceId:
recordFilter["featureInstanceId"] = featureInstanceId
- return self.interfaceDbComponent.db.getRecordset(DataSource, recordFilter=recordFilter)
+ return self.interfaceDbApp.db.getRecordset(DataSource, recordFilter=recordFilter)
def getDataSource(self, dataSourceId: str) -> Optional[Dict[str, Any]]:
"""Get a single data source by ID."""
from modules.datamodels.datamodelDataSource import DataSource
- results = self.interfaceDbComponent.db.getRecordset(DataSource, recordFilter={"id": dataSourceId})
+ results = self.interfaceDbApp.db.getRecordset(DataSource, recordFilter={"id": dataSourceId})
return results[0] if results else None
def deleteDataSource(self, dataSourceId: str) -> bool:
"""Delete a data source."""
from modules.datamodels.datamodelDataSource import DataSource
try:
- self.interfaceDbComponent.db.recordDelete(DataSource, dataSourceId)
+ self.interfaceDbApp.db.recordDelete(DataSource, dataSourceId)
return True
except Exception as e:
logger.error(f"Failed to delete DataSource {dataSourceId}: {e}")
diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
index 8fccd4e4..99da173e 100644
--- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
+++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py
@@ -346,7 +346,7 @@ class GenerationService:
"size": file_item.fileSize,
"mimeType": file_item.mimeType,
"fileHash": getattr(file_item, 'fileHash', None),
- "creationDate": getattr(file_item, 'creationDate', None)
+ "creationDate": getattr(file_item, 'sysCreatedAt', None)
}
return None
except Exception as e:
From 4cf24884cba5867b7fa29ea2bfa8ef17bb0d30ea Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 00:06:01 +0200
Subject: [PATCH 16/33] fixed keys and sandbox for int
---
env_dev.env | 8 ++++----
env_int.env | 8 ++++----
env_prod.env | 6 +++---
3 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/env_dev.env b/env_dev.env
index 2a6d715c..30ffd079 100644
--- a/env_dev.env
+++ b/env_dev.env
@@ -48,18 +48,18 @@ Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
-Service_CLICKUP_CLIENT_SECRET = CZECD706WLSX6UV13YI4ACNW50ADZHHXDAJALHE0YE030QFSI6Y9HP4Y61JT7CF0
+Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
-STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09
-STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGcHNWTWpBWkFHRExtdU01N3RyZzNsMjhUS3NiVTNCZmMwN2NEcFZ6UkQ1a2I0aUkyNU4wR2dUdHJXYmtkaEFRUnFpcThObHBEQmJkdEFnT1FXeUxOTlU3UDFNRzl6LWdpRFpYdExvY3FTTG9MTkswdEhrVkNKQVFucnBjSnhLNm4=
+STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
+STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
STRIPE_API_VERSION = 2026-01-28.clover
# AI configuration
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09
-Connector_AiPerplexity_API_SECRET = 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
+Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5ZmdDZ3hrSElrMnQzNFAtel9wX191VjVzN2g1LWZoa0V1YklubEdmMEJDdEZiR1RWeVZrM3V3enBHX3p6WUtTS0kwYkFyVEF0Nm8zX05CelVQcFJUc0lwVW5iNFczc1p1WWJ2WFBmd0lpLUxxWndEeUh0b2hGUHVpN19vb19nMTBnV1A1VmNpWERVX05lQ29VS20wTjZ3PT0=
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFREd0pyQXRKb1F5eGtHSnkyOGZiUnlBOFc0b3Vzcndrc3ViRm1nMDJIOEZKYWxqdWNkZGh5N0Z4R0JlQmxXSG5pVnJUR2VYckZhMWNMZ1FNeXJ3enJLVlpiblhOZTNleUg3ZzZyUzRZanFSeDlVMkI=
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
diff --git a/env_int.env b/env_int.env
index 5f331e5c..61981de1 100644
--- a/env_int.env
+++ b/env_int.env
@@ -48,18 +48,18 @@ Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/go
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
-Service_CLICKUP_CLIENT_SECRET = CZECD706WLSX6UV13YI4ACNW50ADZHHXDAJALHE0YE030QFSI6Y9HP4Y61JT7CF0
+Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
-STRIPE_SECRET_KEY_SECRET = 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
-STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnBudkpGamJBNW91VUdEaThWRTFiTWpyb3NqSDJJcGtjNkhUVVZqVElxUWExY05KcllSYVk1SkRuS1NjYWpZUk1uU29nb2pzdXUxRzBsOEgyRWtmUEw3dUF4ejFIXzNwTVZRM1R1bVVhTUs4ZHJMT0V4Xy1pcHVfWlBaQV9wVXo5MGlQYXA=
+STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
+STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5VUszOWllM1E1YXlsdldIdENlUTd4bWhycVNBZVZzSWxlMjd4NEJwRnFVbnRaNTlGOWUyLVdxRUxySEtGRDdfbEVHM1dFTU93SHZtY1RKZkh0NG92M2cwYTQxQjQ0SFhqNXZnd21jbE52WW0wZC1oMlY3OXFFSV9sd2M1TC10N0hZa2Zha3FzX1FhcE14alo2TGFHX3QybHFxOTlQWWFZR3pabkRtOEp1Zm1zOFlrbDF0MFNkUjUyVFI5NUNZaU5TRXF4X29tcEQ2RUR1MTlXcUoxbTl0dz09
STRIPE_API_VERSION = 2026-01-28.clover
# AI configuration
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09
-Connector_AiPerplexity_API_SECRET = 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
+Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnB5dkd6UkhtU3lhYmZMSlo0bklQZ2s3UTFBSkprZTNwWkg5Q2lVa0wtenhxWXpva21xVDVMRjdKSmhpTmxWS05IUTRoRHdCbktSRVVjcVFnY1RfV0N2S2dyV0dTMlhxQlRFVm41RkFTWVQzQThuVkZwdlNuVC05QlVRVXB6Qjk3akNpYmY1MFR6R1ByMzlIMllRZlRRYVVRN2ZBPT0=
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1cDZoOWEzSktneHZEV2JndTNmWlNSMV9KbFNIZmQzeVlrNE5qUEIwcUlBSGM1a0hOZ3J6djIyOVhnZzI3M1dIUkdicl9FVXF3RGktMmlEYmhnaHJfWTdGUkktSXVUSGdQMC1vSEV6VE8zR2F1SVk=
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
diff --git a/env_prod.env b/env_prod.env
index a4bdea05..093b6509 100644
--- a/env_prod.env
+++ b/env_prod.env
@@ -48,18 +48,18 @@ Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/g
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
-Service_CLICKUP_CLIENT_SECRET = CZECD706WLSX6UV13YI4ACNW50ADZHHXDAJALHE0YE030QFSI6Y9HP4Y61JT7CF0
+Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
-STRIPE_SECRET_KEY_SECRET = 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
+STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
STRIPE_API_VERSION = 2026-01-28.clover
# AI configuration
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
-Connector_AiPerplexity_API_SECRET = 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
+Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
From 3be09679369a16453fb8d5024ce8e27bf42c92d2 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 00:15:47 +0200
Subject: [PATCH 17/33] fixed changed customer in stripe
---
.../mainServiceSubscription.py | 62 ++++++++++++++++---
1 file changed, 54 insertions(+), 8 deletions(-)
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index 944da4f7..55bff123 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -283,14 +283,34 @@ class SubscriptionService:
subscriptionData["trial_end"] = trialEndTs
self._interface.updateFields(subRecord["id"], {"effectiveFrom": periodEnd.isoformat()})
- session = stripe.checkout.Session.create(
- mode="subscription",
- customer=stripeCustomerId,
- line_items=lineItems,
- success_url=successUrl,
- cancel_url=cancelUrl,
- subscription_data=subscriptionData,
- )
+ session = None
+ for attempt in range(2):
+ try:
+ session = stripe.checkout.Session.create(
+ mode="subscription",
+ customer=stripeCustomerId,
+ line_items=lineItems,
+ success_url=successUrl,
+ cancel_url=cancelUrl,
+ subscription_data=subscriptionData,
+ )
+ break
+ except Exception as e:
+ if attempt == 0 and self._isStripeMissingCustomerError(e):
+ logger.warning(
+ "Stripe reports missing customer %s for mandate %s — "
+ "clearing stored stripeCustomerId (wrong account, deleted customer, or copied DB).",
+ stripeCustomerId,
+ mandateId,
+ )
+ self._clearStoredStripeCustomerId(mandateId)
+ stripeCustomerId = self._resolveStripeCustomer(mandateId)
+ if not stripeCustomerId:
+ raise ValueError(
+ f"Could not recreate Stripe customer for mandate {mandateId}"
+ ) from e
+ continue
+ raise
if not session or not session.url:
raise ValueError("Stripe Checkout Session creation failed")
@@ -298,6 +318,32 @@ class SubscriptionService:
logger.info("Checkout session %s created for mandate %s, plan %s", session.id, mandateId, plan.planKey)
return session.url
+ @staticmethod
+ def _isStripeMissingCustomerError(exc: BaseException) -> bool:
+ code = getattr(exc, "code", None)
+ param = getattr(exc, "param", None)
+ if code == "resource_missing" and param == "customer":
+ return True
+ body = getattr(exc, "json_body", None)
+ if isinstance(body, dict):
+ err = body.get("error")
+ if isinstance(err, dict):
+ return err.get("code") == "resource_missing" and err.get("param") == "customer"
+ return False
+
+ def _clearStoredStripeCustomerId(self, mandateId: str) -> None:
+ try:
+ from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
+
+ billingIf = getBillingInterface(self.currentUser, mandateId)
+ settings = billingIf.getSettings(mandateId)
+ if not settings or not settings.get("stripeCustomerId"):
+ return
+ billingIf.updateSettings(settings["id"], {"stripeCustomerId": None})
+ logger.info("Cleared stripeCustomerId on billing settings for mandate %s", mandateId)
+ except Exception as e:
+ logger.error("Failed to clear stripeCustomerId for mandate %s: %s", mandateId, e)
+
def _resolveStripeCustomer(self, mandateId: str) -> Optional[str]:
try:
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
From 7cbcaacda1c8991984fb5ddf89bebd5ded395e08 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 00:24:56 +0200
Subject: [PATCH 18/33] managed stripe change in env to trigger db refresh
---
.../serviceSubscription/stripeBootstrap.py | 66 +++++++++++++------
1 file changed, 45 insertions(+), 21 deletions(-)
diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
index 38ac29e1..edb2df1f 100644
--- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
+++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
@@ -154,6 +154,23 @@ def _createStripePrice(stripe, productId: str, unitAmountCHF: float, interval: s
return price.id
+def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:
+ """Quick check whether at least the stored product IDs still exist in Stripe.
+ Returns False when running against a different Stripe account or after DB copy."""
+ try:
+ if mapping.stripeProductIdUsers:
+ stripe.Product.retrieve(mapping.stripeProductIdUsers)
+ if mapping.stripeProductIdInstances:
+ stripe.Product.retrieve(mapping.stripeProductIdInstances)
+ return True
+ except Exception as e:
+ code = getattr(e, "code", None)
+ if code == "resource_missing":
+ return False
+ logger.debug("Stripe validation check failed (non-critical): %s", e)
+ return False
+
+
def bootstrapStripePrices() -> None:
"""Ensure all paid plans have separate Stripe Products for users and instances."""
try:
@@ -183,30 +200,37 @@ def bootstrapStripePrices() -> None:
hasAllPrices = mapping.stripePriceIdUsers and mapping.stripePriceIdInstances
hasAllProducts = mapping.stripeProductIdUsers and mapping.stripeProductIdInstances
if hasAllPrices and hasAllProducts:
- changed = False
- reconciledUsers = _reconcilePrice(
- stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers,
- plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
- )
- if reconciledUsers != mapping.stripePriceIdUsers:
- changed = True
+ if _validateStripeIdsExist(stripe, mapping):
+ changed = False
+ reconciledUsers = _reconcilePrice(
+ stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers,
+ plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
+ )
+ if reconciledUsers != mapping.stripePriceIdUsers:
+ changed = True
- reconciledInstances = _reconcilePrice(
- stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
- plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz",
- )
- if reconciledInstances != mapping.stripePriceIdInstances:
- changed = True
+ reconciledInstances = _reconcilePrice(
+ stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
+ plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz",
+ )
+ if reconciledInstances != mapping.stripePriceIdInstances:
+ changed = True
- if changed:
- db.recordModify(StripePlanPrice, mapping.id, {
- "stripePriceIdUsers": reconciledUsers,
- "stripePriceIdInstances": reconciledInstances,
- })
- logger.info("Reconciled Stripe prices for plan %s: users=%s, instances=%s", planKey, reconciledUsers, reconciledInstances)
+ if changed:
+ db.recordModify(StripePlanPrice, mapping.id, {
+ "stripePriceIdUsers": reconciledUsers,
+ "stripePriceIdInstances": reconciledInstances,
+ })
+ logger.info("Reconciled Stripe prices for plan %s: users=%s, instances=%s", planKey, reconciledUsers, reconciledInstances)
+ else:
+ logger.debug("Stripe prices up-to-date for plan %s", planKey)
+ continue
else:
- logger.debug("Stripe prices up-to-date for plan %s", planKey)
- continue
+ logger.warning(
+ "Stored Stripe IDs for plan %s reference unknown objects "
+ "(likely wrong Stripe account or copied DB) — re-provisioning.",
+ planKey,
+ )
productIdUsers = None
productIdInstances = None
From b53a7f363def5d36cc975561ac129daed555743a Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 00:47:28 +0200
Subject: [PATCH 19/33] fixes stripe
---
app.py | 25 +++++++++++++------------
env_int.env | 2 +-
modules/routes/routeSubscription.py | 21 +++++++++++++++++++--
3 files changed, 33 insertions(+), 15 deletions(-)
diff --git a/app.py b/app.py
index 63e5652a..f29436cc 100644
--- a/app.py
+++ b/app.py
@@ -489,18 +489,6 @@ def getAllowedOrigins():
CORS_ORIGIN_REGEX = r"https://.*\.(poweron\.swiss|poweron-center\.net)"
-# CORS configuration using environment variables
-app.add_middleware(
- CORSMiddleware,
- allow_origins=getAllowedOrigins(),
- allow_origin_regex=CORS_ORIGIN_REGEX,
- allow_credentials=True,
- allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
- allow_headers=["*"],
- expose_headers=["*"],
- max_age=86400, # Increased caching for preflight requests
-)
-
# SlowAPI rate limiter initialization
from modules.auth import limiter
from slowapi.errors import RateLimitExceeded
@@ -538,6 +526,19 @@ app.add_middleware(
ProactiveTokenRefreshMiddleware, enabled=True, check_interval_minutes=5
)
+# CORS must be registered LAST so it wraps the whole stack: every response (errors, CSRF 403,
+# rate limits) still gets Access-Control-Allow-Origin for browser cross-origin calls.
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=getAllowedOrigins(),
+ allow_origin_regex=CORS_ORIGIN_REGEX,
+ allow_credentials=True,
+ allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
+ allow_headers=["*"],
+ expose_headers=["*"],
+ max_age=86400,
+)
+
# Include all routers
from modules.routes.routeAdmin import router as generalRouter
diff --git a/env_int.env b/env_int.env
index 61981de1..fc9c0efd 100644
--- a/env_int.env
+++ b/env_int.env
@@ -53,7 +53,7 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/conn
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
-STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5VUszOWllM1E1YXlsdldIdENlUTd4bWhycVNBZVZzSWxlMjd4NEJwRnFVbnRaNTlGOWUyLVdxRUxySEtGRDdfbEVHM1dFTU93SHZtY1RKZkh0NG92M2cwYTQxQjQ0SFhqNXZnd21jbE52WW0wZC1oMlY3OXFFSV9sd2M1TC10N0hZa2Zha3FzX1FhcE14alo2TGFHX3QybHFxOTlQWWFZR3pabkRtOEp1Zm1zOFlrbDF0MFNkUjUyVFI5NUNZaU5TRXF4X29tcEQ2RUR1MTlXcUoxbTl0dz09
+STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
STRIPE_API_VERSION = 2026-01-28.clover
# AI configuration
diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py
index 88d0b21c..3193292c 100644
--- a/modules/routes/routeSubscription.py
+++ b/modules/routes/routeSubscription.py
@@ -291,14 +291,31 @@ def verifyCheckout(
logger.error("Failed to retrieve checkout session %s: %s", data.sessionId, e)
raise HTTPException(status_code=400, detail="Invalid session ID")
- if session.get("status") != "complete" or session.get("payment_status") != "paid":
+ payStatus = session.get("payment_status")
+ if session.get("status") != "complete":
+ return {"status": "pending", "message": "Checkout not yet completed"}
+ # Subscription checkouts with trial / $0 first period use no_payment_required, not paid.
+ if payStatus not in ("paid", "no_payment_required"):
return {"status": "pending", "message": "Checkout not yet completed"}
if session.get("mode") != "subscription":
raise HTTPException(status_code=400, detail="Not a subscription checkout session")
from modules.routes.routeBilling import _handleSubscriptionCheckoutCompleted
- _handleSubscriptionCheckoutCompleted(session, f"verify-{data.sessionId}")
+
+ try:
+ _handleSubscriptionCheckoutCompleted(session, f"verify-{data.sessionId}")
+ except Exception as e:
+ logger.exception(
+ "verifyCheckout: handler failed for session %s mandate %s: %s",
+ data.sessionId,
+ mandateId,
+ e,
+ )
+ raise HTTPException(
+ status_code=500,
+ detail="Subscription-Aktivierung nach Checkout fehlgeschlagen. Bitte erneut versuchen oder Support informieren.",
+ ) from e
return {"status": "activated", "message": "Subscription activated"}
From 350c6994738480665eac0e3d6bfb510a059148f4 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 01:12:25 +0200
Subject: [PATCH 20/33] fexed stripe webhook
---
modules/interfaces/interfaceDbBilling.py | 19 +++++++++++++
modules/routes/routeSubscription.py | 34 +++++++++++++++---------
2 files changed, 41 insertions(+), 12 deletions(-)
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index 948f8918..d8c052c9 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -994,6 +994,25 @@ class BillingObjects:
)
return created
+ def ensureActivationBudget(self, mandateId: str, planKey: str) -> Optional[Dict[str, Any]]:
+ """Idempotent: credit the activation budget only if no SUBSCRIPTION credit exists yet."""
+ poolAccount = self.getMandateAccount(mandateId)
+ if not poolAccount:
+ return self.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
+
+ existing = self.db.getRecordset(
+ BillingTransaction,
+ recordFilter={
+ "accountId": poolAccount["id"],
+ "transactionType": TransactionTypeEnum.CREDIT.value,
+ "referenceType": ReferenceTypeEnum.SUBSCRIPTION.value,
+ },
+ )
+ if existing:
+ return None
+
+ return self.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
+
# =========================================================================
# Workflow Cost Query
# =========================================================================
diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py
index 3193292c..9f1f0bf8 100644
--- a/modules/routes/routeSubscription.py
+++ b/modules/routes/routeSubscription.py
@@ -273,10 +273,8 @@ def verifyCheckout(
):
"""Verify a Stripe Checkout Session and activate the subscription if paid.
- This is the synchronous counterpart to the checkout.session.completed webhook.
- It's called by the frontend immediately after returning from Stripe to handle
- environments where webhooks may be delayed or unavailable (e.g. localhost dev).
- The logic is idempotent — if the webhook already processed the session, this is a no-op.
+ Idempotent: if the webhook already processed the session, returns success.
+ Called by the frontend immediately after returning from Stripe.
"""
mandateId = _resolveMandateId(context)
if not mandateId:
@@ -294,7 +292,6 @@ def verifyCheckout(
payStatus = session.get("payment_status")
if session.get("status") != "complete":
return {"status": "pending", "message": "Checkout not yet completed"}
- # Subscription checkouts with trial / $0 first period use no_payment_required, not paid.
if payStatus not in ("paid", "no_payment_required"):
return {"status": "pending", "message": "Checkout not yet completed"}
@@ -306,18 +303,31 @@ def verifyCheckout(
try:
_handleSubscriptionCheckoutCompleted(session, f"verify-{data.sessionId}")
except Exception as e:
- logger.exception(
- "verifyCheckout: handler failed for session %s mandate %s: %s",
+ logger.warning(
+ "verifyCheckout: handler raised for session %s mandate %s: %s",
data.sessionId,
mandateId,
e,
)
- raise HTTPException(
- status_code=500,
- detail="Subscription-Aktivierung nach Checkout fehlgeschlagen. Bitte erneut versuchen oder Support informieren.",
- ) from e
- return {"status": "activated", "message": "Subscription activated"}
+ from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
+ getService as getSubscriptionService,
+ )
+ from modules.datamodels.datamodelSubscription import OPERATIVE_STATUSES
+
+ subService = getSubscriptionService(context.user, mandateId)
+ operative = subService.getOperativeSubscription(mandateId)
+ if operative and operative.get("status") in [s.value for s in OPERATIVE_STATUSES]:
+ planKey = operative.get("planKey", "")
+ if planKey:
+ try:
+ from modules.interfaces.interfaceDbBilling import _getRootInterface as _getBillingRoot
+ _getBillingRoot().ensureActivationBudget(mandateId, planKey)
+ except Exception as ex:
+ logger.warning("verifyCheckout: ensureActivationBudget failed: %s", ex)
+ return {"status": "activated", "message": "Subscription activated"}
+
+ return {"status": "pending", "message": "Subscription activation pending — webhook may still be processing."}
# =============================================================================
From ef39d01e1679d4378ac461cf87d5807df4bf240b Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 01:51:08 +0200
Subject: [PATCH 21/33] fixed database issue subscriptions
---
modules/auth/csrf.py | 15 ++++++++------
modules/interfaces/interfaceDbApp.py | 25 ++++++++++++----------
modules/routes/routeDataMandates.py | 31 +++++++++++++++++++++++++++-
modules/routes/routeStore.py | 11 ++++++----
4 files changed, 60 insertions(+), 22 deletions(-)
diff --git a/modules/auth/csrf.py b/modules/auth/csrf.py
index 7cc0c07c..bac4b0c3 100644
--- a/modules/auth/csrf.py
+++ b/modules/auth/csrf.py
@@ -88,12 +88,15 @@ class CSRFMiddleware(BaseHTTPMiddleware):
content={"detail": "Invalid CSRF token format"}
)
- # Additional CSRF validation could be added here:
- # - Check token against session
- # - Validate token expiration
- # - Verify token origin
-
- return await call_next(request)
+ try:
+ return await call_next(request)
+ except Exception as exc:
+ logger.error("Unhandled exception in %s %s: %s", request.method, request.url.path, exc)
+ from fastapi.responses import JSONResponse
+ return JSONResponse(
+ status_code=500,
+ content={"detail": "Internal server error"},
+ )
def _is_valid_csrf_token(self, token: str) -> bool:
"""
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index d980eb56..2ac768fd 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1728,8 +1728,10 @@ class AppObjects:
self.db.recordDelete(UserMandate, um.get("id"))
logger.info(f"Cascade: deleted {len(memberships)} UserMandates for mandate {mandateId}")
- # 3. Cancel Stripe subscriptions + delete MandateSubscription records
- subs = self.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
+ # 3. Cancel Stripe subscriptions + delete MandateSubscription records (poweron_billing)
+ from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+ subInterface = _getSubRoot()
+ subs = subInterface.listForMandate(mandateId)
for sub in subs:
subId = sub.get("id")
stripeSubId = sub.get("stripeSubscriptionId")
@@ -1741,20 +1743,21 @@ class AppObjects:
logger.info(f"Cancelled Stripe subscription {stripeSubId} for mandate {mandateId}")
except Exception as e:
logger.warning(f"Failed to cancel Stripe sub {stripeSubId}: {e}")
- self.db.recordDelete(MandateSubscription, subId)
+ subInterface.db.recordDelete(MandateSubscription, subId)
logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}")
- # 3b. Delete Billing data
- billingTxs = self.db.getRecordset(BillingTransaction, recordFilter={"mandateId": mandateId}) if hasattr(BillingTransaction, '__table_name__') else []
- billingAccounts = self.db.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId})
+ # 3b. Delete Billing data (poweron_billing)
+ from modules.interfaces.interfaceDbBilling import _getRootInterface as _getBillingRoot
+ billingDb = _getBillingRoot().db
+ billingAccounts = billingDb.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId})
for acc in billingAccounts:
- accTxs = self.db.getRecordset(BillingTransaction, recordFilter={"accountId": acc.get("id")})
+ accTxs = billingDb.getRecordset(BillingTransaction, recordFilter={"accountId": acc.get("id")})
for tx in accTxs:
- self.db.recordDelete(BillingTransaction, tx.get("id"))
- self.db.recordDelete(BillingAccount, acc.get("id"))
- billingSettings = self.db.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId})
+ billingDb.recordDelete(BillingTransaction, tx.get("id"))
+ billingDb.recordDelete(BillingAccount, acc.get("id"))
+ billingSettings = billingDb.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId})
for bs in billingSettings:
- self.db.recordDelete(BillingSettings, bs.get("id"))
+ billingDb.recordDelete(BillingSettings, bs.get("id"))
if billingAccounts or billingSettings:
logger.info(f"Cascade: deleted billing data for mandate {mandateId}")
diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py
index 2c2bd31c..e98fd1cc 100644
--- a/modules/routes/routeDataMandates.py
+++ b/modules/routes/routeDataMandates.py
@@ -318,7 +318,36 @@ def create_mandate(
logger.warning(
f"Could not create default billing settings for mandate {newMandate.id}: {billingErr}"
)
-
+
+ try:
+ from modules.datamodels.datamodelSubscription import (
+ MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS,
+ )
+ from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+ from datetime import datetime, timezone, timedelta
+
+ planKey = mandateData.get("planKey", "TRIAL_7D")
+ plan = BUILTIN_PLANS.get(planKey)
+ if plan:
+ now = datetime.now(timezone.utc)
+ targetStatus = SubscriptionStatusEnum.TRIALING if plan.trialDays else SubscriptionStatusEnum.ACTIVE
+ sub = MandateSubscription(
+ mandateId=str(newMandate.id),
+ planKey=planKey,
+ status=targetStatus,
+ recurring=plan.autoRenew and not plan.trialDays,
+ startedAt=now,
+ currentPeriodStart=now,
+ )
+ if plan.trialDays:
+ sub.trialEndsAt = now + timedelta(days=plan.trialDays)
+ sub.currentPeriodEnd = now + timedelta(days=plan.trialDays)
+ subInterface = _getSubRoot()
+ subInterface.createSubscription(sub)
+ logger.info(f"Created {targetStatus.value} subscription ({planKey}) for mandate {newMandate.id}")
+ except Exception as subErr:
+ logger.error(f"Failed to create subscription for mandate {newMandate.id}: {subErr}")
+
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
return newMandate
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index 0d6e68ec..4af0f6b7 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -187,9 +187,12 @@ def getSubscriptionInfo(
"budgetAiCHF": None,
}
- from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS
- subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
- if not subs:
+ from modules.datamodels.datamodelSubscription import BUILTIN_PLANS
+ from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+
+ subInterface = _getSubRoot()
+ allSubs = subInterface.listForMandate(mandateId)
+ if not allSubs:
return {
"plan": None,
"maxDataVolumeMB": None,
@@ -197,7 +200,7 @@ def getSubscriptionInfo(
"budgetAiCHF": None,
}
- sub = subs[0]
+ sub = allSubs[0]
plan = BUILTIN_PLANS.get(sub.get("planKey"))
currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
From b142c0fa6cf5027039b487f4e40c515cbcc4f905 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 02:05:16 +0200
Subject: [PATCH 22/33] NT-Problem: Unhandled exception: get
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Root Cause: Die Stripe-Python-Bibliothek auf INT hat keine .get() Methode auf Stripe-Objekten. Wenn session.get("payment_status") aufgerufen wird, sucht Python via __getattr__ nach einem Feld namens "get" → AttributeError("get").
Bestätigung: In routeBilling.py gab es bereits einen hasattr(session, "get")-Check (Zeile 998) — jemand kannte das Problem.
Fix: Alle Stripe-Objekte werden sofort nach dem API-Call in dict() konvertiert
---
modules/routes/routeBilling.py | 18 +++++++++++-------
modules/routes/routeSubscription.py | 3 ++-
.../mainServiceSubscription.py | 10 ++++++----
.../serviceSubscription/stripeBootstrap.py | 2 +-
4 files changed, 20 insertions(+), 13 deletions(-)
diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py
index 0f612d45..d899ad2a 100644
--- a/modules/routes/routeBilling.py
+++ b/modules/routes/routeBilling.py
@@ -994,9 +994,10 @@ def _handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
from modules.security.rootAccess import getRootUser
from datetime import datetime, timezone
- metadata = {}
- if hasattr(session, "get"):
- metadata = session.get("metadata") or {}
+ if not isinstance(session, dict):
+ session = dict(session)
+
+ metadata = session.get("metadata") or {}
subscriptionRecordId = metadata.get("subscriptionRecordId")
mandateId = metadata.get("mandateId")
planKey = metadata.get("planKey", "")
@@ -1009,7 +1010,7 @@ def _handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
- subObj = stripe.Subscription.retrieve(stripeSub)
+ subObj = dict(stripe.Subscription.retrieve(stripeSub))
metadata = subObj.get("metadata") or {}
subscriptionRecordId = metadata.get("subscriptionRecordId")
mandateId = metadata.get("mandateId")
@@ -1041,7 +1042,7 @@ def _handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
- stripeSub = stripe.Subscription.retrieve(stripeSubId, expand=["items"])
+ stripeSub = dict(stripe.Subscription.retrieve(stripeSubId, expand=["items"]))
if stripeSub.get("current_period_start"):
stripeData["currentPeriodStart"] = datetime.fromtimestamp(
@@ -1054,8 +1055,11 @@ def _handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import getStripePricesForPlan
priceMapping = getStripePricesForPlan(planKey)
- for item in stripeSub.get("items", {}).get("data", []):
- priceId = item.get("price", {}).get("id", "")
+ items = stripeSub.get("items") or {}
+ if not isinstance(items, dict):
+ items = dict(items)
+ for item in items.get("data", []):
+ priceId = (item.get("price") or {}).get("id", "")
if priceMapping and priceId == priceMapping.stripePriceIdUsers:
stripeData["stripeItemIdUsers"] = item["id"]
elif priceMapping and priceId == priceMapping.stripePriceIdInstances:
diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py
index 9f1f0bf8..99bdb4e6 100644
--- a/modules/routes/routeSubscription.py
+++ b/modules/routes/routeSubscription.py
@@ -284,7 +284,8 @@ def verifyCheckout(
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
- session = stripe.checkout.Session.retrieve(data.sessionId)
+ rawSession = stripe.checkout.Session.retrieve(data.sessionId)
+ session = dict(rawSession)
except Exception as e:
logger.error("Failed to retrieve checkout session %s: %s", data.sessionId, e)
raise HTTPException(status_code=400, detail="Invalid session ID")
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index 55bff123..5d2249a0 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -425,7 +425,7 @@ class SubscriptionService:
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
- stripeSub = stripe.Subscription.modify(stripeSubId, cancel_at_period_end=True)
+ stripeSub = dict(stripe.Subscription.modify(stripeSubId, cancel_at_period_end=True))
pUrl = (stripeSub.get("metadata") or {}).get("platformUrl", "")
except Exception as e:
logger.error("Failed to set cancel_at_period_end for %s: %s", stripeSubId, e)
@@ -488,7 +488,7 @@ class SubscriptionService:
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
- stripeSub = stripe.Subscription.retrieve(stripeSubId)
+ stripeSub = dict(stripe.Subscription.retrieve(stripeSubId))
pUrl = (stripeSub.get("metadata") or {}).get("platformUrl", "")
stripe.Subscription.cancel(stripeSubId)
except Exception as e:
@@ -673,7 +673,8 @@ def _buildInvoiceSummaryHtml(
stripe = getStripeClient()
invoices = stripe.Invoice.list(subscription=stripeSubId, limit=1)
if invoices.data:
- hostedUrl = invoices.data[0].get("hosted_invoice_url", "")
+ inv = dict(invoices.data[0]) if not isinstance(invoices.data[0], dict) else invoices.data[0]
+ hostedUrl = inv.get("hosted_invoice_url", "")
if hostedUrl:
invoiceLink = (
f''
@@ -714,7 +715,8 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
stripe = getStripeClient()
invoices = stripe.Invoice.list(subscription=stripeSubId, limit=1)
if invoices.data:
- hostedUrl = invoices.data[0].get("hosted_invoice_url", "")
+ inv = dict(invoices.data[0]) if not isinstance(invoices.data[0], dict) else invoices.data[0]
+ hostedUrl = inv.get("hosted_invoice_url", "")
if hostedUrl:
parts.append(
f'
'
diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
index edb2df1f..1e44217b 100644
--- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
+++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
@@ -108,7 +108,7 @@ def _findExistingStripePrice(stripe, productId: str, unitAmount: int, interval:
def _getStripePriceAmount(stripe, priceId: str) -> Optional[int]:
"""Retrieve the unit_amount (in Rappen) of an existing Stripe Price."""
try:
- price = stripe.Price.retrieve(priceId)
+ price = dict(stripe.Price.retrieve(priceId))
return price.get("unit_amount") if price else None
except Exception:
return None
From bc370ef4754fe2f3cec471875d613c3e640f58b1 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 02:14:33 +0200
Subject: [PATCH 23/33] =?UTF-8?q?Alle=207=20Stellen=20im=20Code,=20die=20S?=
=?UTF-8?q?tripe-Objekte=20in=20Dicts=20konvertieren,=20nutzen=20jetzt=20s?=
=?UTF-8?q?tripeToDict().=20Das=20funktioniert=20unabh=C3=A4ngig=20von=20d?=
=?UTF-8?q?er=20Stripe-Bibliotheksversion=20auf=20DEV=20und=20INT.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
modules/routes/routeBilling.py | 12 ++++++++----
modules/routes/routeSubscription.py | 4 ++--
.../mainServiceSubscription.py | 12 ++++++++----
.../serviceSubscription/stripeBootstrap.py | 3 ++-
modules/shared/stripeClient.py | 17 ++++++++++++++++-
5 files changed, 36 insertions(+), 12 deletions(-)
diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py
index d899ad2a..5029e485 100644
--- a/modules/routes/routeBilling.py
+++ b/modules/routes/routeBilling.py
@@ -875,7 +875,8 @@ def confirmCheckoutSession(
if not session:
raise HTTPException(status_code=404, detail="Stripe Checkout Session not found")
- session_dict = session.to_dict_recursive() if hasattr(session, "to_dict_recursive") else dict(session)
+ from modules.shared.stripeClient import stripeToDict
+ session_dict = stripeToDict(session)
metadata = session_dict.get("metadata") or {}
mandate_id = metadata.get("mandateId")
user_id = metadata.get("userId") or None
@@ -995,7 +996,8 @@ def _handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
from datetime import datetime, timezone
if not isinstance(session, dict):
- session = dict(session)
+ from modules.shared.stripeClient import stripeToDict
+ session = stripeToDict(session)
metadata = session.get("metadata") or {}
subscriptionRecordId = metadata.get("subscriptionRecordId")
@@ -1010,7 +1012,8 @@ def _handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
- subObj = dict(stripe.Subscription.retrieve(stripeSub))
+ from modules.shared.stripeClient import stripeToDict
+ subObj = stripeToDict(stripe.Subscription.retrieve(stripeSub))
metadata = subObj.get("metadata") or {}
subscriptionRecordId = metadata.get("subscriptionRecordId")
mandateId = metadata.get("mandateId")
@@ -1042,7 +1045,8 @@ def _handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
- stripeSub = dict(stripe.Subscription.retrieve(stripeSubId, expand=["items"]))
+ from modules.shared.stripeClient import stripeToDict
+ stripeSub = stripeToDict(stripe.Subscription.retrieve(stripeSubId, expand=["items"]))
if stripeSub.get("current_period_start"):
stripeData["currentPeriodStart"] = datetime.fromtimestamp(
diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py
index 99bdb4e6..97a7f23b 100644
--- a/modules/routes/routeSubscription.py
+++ b/modules/routes/routeSubscription.py
@@ -282,10 +282,10 @@ def verifyCheckout(
_assertMandateAdmin(context, mandateId)
try:
- from modules.shared.stripeClient import getStripeClient
+ from modules.shared.stripeClient import getStripeClient, stripeToDict
stripe = getStripeClient()
rawSession = stripe.checkout.Session.retrieve(data.sessionId)
- session = dict(rawSession)
+ session = stripeToDict(rawSession)
except Exception as e:
logger.error("Failed to retrieve checkout session %s: %s", data.sessionId, e)
raise HTTPException(status_code=400, detail="Invalid session ID")
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index 5d2249a0..9535a2da 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -425,7 +425,8 @@ class SubscriptionService:
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
- stripeSub = dict(stripe.Subscription.modify(stripeSubId, cancel_at_period_end=True))
+ from modules.shared.stripeClient import stripeToDict
+ stripeSub = stripeToDict(stripe.Subscription.modify(stripeSubId, cancel_at_period_end=True))
pUrl = (stripeSub.get("metadata") or {}).get("platformUrl", "")
except Exception as e:
logger.error("Failed to set cancel_at_period_end for %s: %s", stripeSubId, e)
@@ -488,7 +489,8 @@ class SubscriptionService:
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
- stripeSub = dict(stripe.Subscription.retrieve(stripeSubId))
+ from modules.shared.stripeClient import stripeToDict
+ stripeSub = stripeToDict(stripe.Subscription.retrieve(stripeSubId))
pUrl = (stripeSub.get("metadata") or {}).get("platformUrl", "")
stripe.Subscription.cancel(stripeSubId)
except Exception as e:
@@ -673,7 +675,8 @@ def _buildInvoiceSummaryHtml(
stripe = getStripeClient()
invoices = stripe.Invoice.list(subscription=stripeSubId, limit=1)
if invoices.data:
- inv = dict(invoices.data[0]) if not isinstance(invoices.data[0], dict) else invoices.data[0]
+ from modules.shared.stripeClient import stripeToDict
+ inv = stripeToDict(invoices.data[0])
hostedUrl = inv.get("hosted_invoice_url", "")
if hostedUrl:
invoiceLink = (
@@ -715,7 +718,8 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
stripe = getStripeClient()
invoices = stripe.Invoice.list(subscription=stripeSubId, limit=1)
if invoices.data:
- inv = dict(invoices.data[0]) if not isinstance(invoices.data[0], dict) else invoices.data[0]
+ from modules.shared.stripeClient import stripeToDict
+ inv = stripeToDict(invoices.data[0])
hostedUrl = inv.get("hosted_invoice_url", "")
if hostedUrl:
parts.append(
diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
index 1e44217b..14e9424a 100644
--- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
+++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
@@ -108,7 +108,8 @@ def _findExistingStripePrice(stripe, productId: str, unitAmount: int, interval:
def _getStripePriceAmount(stripe, priceId: str) -> Optional[int]:
"""Retrieve the unit_amount (in Rappen) of an existing Stripe Price."""
try:
- price = dict(stripe.Price.retrieve(priceId))
+ from modules.shared.stripeClient import stripeToDict
+ price = stripeToDict(stripe.Price.retrieve(priceId))
return price.get("unit_amount") if price else None
except Exception:
return None
diff --git a/modules/shared/stripeClient.py b/modules/shared/stripeClient.py
index 9c7b4c67..3f7dd3a7 100644
--- a/modules/shared/stripeClient.py
+++ b/modules/shared/stripeClient.py
@@ -8,13 +8,28 @@ API key, API version, and fallback handling across billing and subscription flow
"""
import logging
-from typing import Optional
+import json
+from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
_stripeInitialized = False
+def stripeToDict(obj) -> Dict[str, Any]:
+ """Convert a Stripe object to a plain dict, compatible with all stripe-python versions."""
+ if isinstance(obj, dict):
+ return obj
+ if hasattr(obj, "to_dict_recursive"):
+ return obj.to_dict_recursive()
+ if hasattr(obj, "to_dict"):
+ return obj.to_dict()
+ try:
+ return json.loads(str(obj))
+ except (json.JSONDecodeError, TypeError):
+ return dict(obj)
+
+
def getStripeClient():
"""
Initialize and return the configured Stripe SDK module.
From 695c652a56b683bf7f04c385a2e937d273ec7f25 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 13:31:25 +0200
Subject: [PATCH 24/33] mandate admin fixes
---
modules/interfaces/interfaceDbApp.py | 83 +++++++++++++++++++++---
modules/interfaces/interfaceDbBilling.py | 2 +-
modules/routes/routeAdminFeatures.py | 2 +
modules/routes/routeDataMandates.py | 40 +++++++-----
modules/routes/routeInvitations.py | 9 ++-
modules/routes/routeStore.py | 62 +++++++++++++++---
modules/routes/routeSystem.py | 2 +
7 files changed, 162 insertions(+), 38 deletions(-)
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 2ac768fd..27ec5fcf 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1074,19 +1074,66 @@ class AppObjects:
return False
def _deleteUserReferencedData(self, userId: str) -> None:
- """Deletes all data associated with a user."""
+ """Deletes all data associated with a user (full cascade)."""
try:
- # Delete user auth events
+ from modules.datamodels.datamodelRbac import FeatureAccessRole, UserMandateRole
+ from modules.datamodels.datamodelNotification import UserNotification
+ from modules.datamodels.datamodelInvitation import Invitation
+
+ # 1. FeatureAccess + FeatureAccessRole
+ accesses = self.db.getRecordset(FeatureAccess, recordFilter={"userId": userId})
+ for acc in accesses:
+ accId = acc.get("id")
+ if not accId:
+ continue
+ roles = self.db.getRecordset(FeatureAccessRole, recordFilter={"featureAccessId": accId})
+ for role in roles:
+ self.db.recordDelete(FeatureAccessRole, role.get("id"))
+ self.db.recordDelete(FeatureAccess, accId)
+ if accesses:
+ logger.info(f"User cascade: deleted {len(accesses)} FeatureAccess records for user {userId}")
+
+ # 2. UserMandate + UserMandateRole
+ memberships = self.db.getRecordset(UserMandate, recordFilter={"userId": userId})
+ for um in memberships:
+ umId = um.get("id")
+ if not umId:
+ continue
+ umRoles = self.db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId})
+ for umr in umRoles:
+ self.db.recordDelete(UserMandateRole, umr.get("id"))
+ self.db.recordDelete(UserMandate, umId)
+ if memberships:
+ logger.info(f"User cascade: deleted {len(memberships)} UserMandate records for user {userId}")
+
+ # 3. UserNotifications
+ notifications = self.db.getRecordset(UserNotification, recordFilter={"userId": userId})
+ for notif in notifications:
+ self.db.recordDelete(UserNotification, notif.get("id"))
+ if notifications:
+ logger.info(f"User cascade: deleted {len(notifications)} notifications for user {userId}")
+
+ # 4. Invitations (by email)
+ user = self.getUser(userId)
+ userEmail = getattr(user, "email", None) if user else None
+ if userEmail:
+ invitations = self.db.getRecordset(Invitation, recordFilter={"email": userEmail})
+ for inv in invitations:
+ self.db.recordDelete(Invitation, inv.get("id"))
+ if invitations:
+ logger.info(f"User cascade: deleted {len(invitations)} invitations for {userEmail}")
+
+ # 5. AuthEvents
events = self.db.getRecordset(AuthEvent, recordFilter={"userId": userId})
for event in events:
self.db.recordDelete(AuthEvent, event["id"])
- # Delete user tokens
+ # 6. Tokens
tokens = self.db.getRecordset(Token, recordFilter={"userId": userId})
for token in tokens:
self.db.recordDelete(Token, token["id"])
- # Delete user connections
+ # 7. UserConnections
connections = self.db.getRecordset(
UserConnection, recordFilter={"userId": userId}
)
@@ -1448,14 +1495,23 @@ class AppObjects:
self.createUserMandate(userId, mandateId, roleIds=[adminRoleId], skipCapacityCheck=True)
+ from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+ from datetime import datetime, timezone, timedelta
+
+ now = datetime.now(timezone.utc)
+ targetStatus = SubscriptionStatusEnum.TRIALING if plan.trialDays else SubscriptionStatusEnum.ACTIVE
subscription = MandateSubscription(
mandateId=mandateId,
planKey=planKey,
- status=SubscriptionStatusEnum.PENDING,
+ status=targetStatus,
+ startedAt=now.isoformat(),
+ currentPeriodStart=now.isoformat(),
)
if plan.trialDays:
- pass # trialEndsAt set on ACTIVE/TRIALING transition
- from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+ trialEnd = now + timedelta(days=plan.trialDays)
+ subscription.trialEndsAt = trialEnd.isoformat()
+ subscription.currentPeriodEnd = trialEnd.isoformat()
+
subInterface = _getSubRoot()
subInterface.createSubscription(subscription)
@@ -1584,8 +1640,9 @@ class AppObjects:
if not mandate:
raise ValueError(f"Mandate {mandateId} not found")
- # Strip immutable/protected fields from update data
- _protectedFields = {"id", "isSystem"}
+ _protectedFields = {"id"}
+ if not getattr(self.currentUser, "isSysAdmin", False):
+ _protectedFields.add("isSystem")
_sanitizedData = {k: v for k, v in updateData.items() if k not in _protectedFields}
# Update mandate data using model
@@ -1761,6 +1818,14 @@ class AppObjects:
if billingAccounts or billingSettings:
logger.info(f"Cascade: deleted billing data for mandate {mandateId}")
+ # 3c. Delete Invitations for this mandate
+ from modules.datamodels.datamodelInvitation import Invitation
+ invitations = self.db.getRecordset(Invitation, recordFilter={"mandateId": mandateId})
+ for inv in invitations:
+ self.db.recordDelete(Invitation, inv.get("id"))
+ if invitations:
+ logger.info(f"Cascade: deleted {len(invitations)} Invitations for mandate {mandateId}")
+
# 4. Delete mandate-level Roles
from modules.datamodels.datamodelRbac import Role, AccessRule
roles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId})
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index d8c052c9..1ea1786a 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -1145,7 +1145,7 @@ class BillingObjects:
continue
mandate = rootInterface.getMandate(mandateId)
- if not mandate:
+ if not mandate or not getattr(mandate, "enabled", True):
continue
mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index e69df7b9..9d05daf6 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -159,6 +159,8 @@ def get_my_feature_instances(
mandateId = str(instance.mandateId)
if mandateId not in mandatesMap:
mandate = rootInterface.getMandate(mandateId)
+ if mandate and not getattr(mandate, "enabled", True):
+ continue
if mandate:
mandatesMap[mandateId] = {
"id": mandateId,
diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py
index e98fd1cc..1615a03a 100644
--- a/modules/routes/routeDataMandates.py
+++ b/modules/routes/routeDataMandates.py
@@ -125,11 +125,11 @@ def get_mandates(
# SysAdmin: all mandates
result = appInterface.getAllMandates(pagination=paginationParams)
else:
- # MandateAdmin: only their mandates
+ # MandateAdmin: only their enabled mandates
allMandates = []
for mandateId in adminMandateIds:
mandate = appInterface.getMandate(mandateId)
- if mandate:
+ if mandate and getattr(mandate, "enabled", True):
mandateDict = mandate if isinstance(mandate, dict) else mandate.model_dump() if hasattr(mandate, 'model_dump') else vars(mandate)
allMandates.append(mandateDict)
result = allMandates
@@ -411,41 +411,47 @@ def update_mandate(
def delete_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to delete"),
+ force: bool = Query(False, description="Hard-delete with full cascade (irreversible)"),
currentUser: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]:
"""
Delete a mandate.
+ Default: soft-delete (sets enabled=False, 30-day retention).
+ With ?force=true: hard-delete with full cascade (irreversible).
+ Requires X-Confirm-Name header matching the mandate name for hard-delete.
MULTI-TENANT: SysAdmin-only.
"""
try:
appInterface = interfaceDbApp.getRootInterface()
-
- # Check if mandate exists
+
existingMandate = appInterface.getMandate(mandateId)
if not existingMandate:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Mandate {mandateId} not found"
)
-
- # MULTI-TENANT: Delete all UserMandate entries for this mandate first
- userMandates = appInterface.getUserMandatesByMandate(mandateId)
- for um in userMandates:
- appInterface.deleteUserMandate(str(um.userId), mandateId)
- logger.info(f"Deleted {len(userMandates)} UserMandate entries for mandate {mandateId}")
-
- # Delete mandate
+
+ if force:
+ confirmName = request.headers.get("X-Confirm-Name", "")
+ mandateName = getattr(existingMandate, "name", "") or ""
+ if confirmName != mandateName:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Hard-delete requires X-Confirm-Name header matching the mandate name"
+ )
+
try:
- appInterface.deleteMandate(mandateId)
+ appInterface.deleteMandate(mandateId, force=force)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
-
- logger.info(f"Mandate {mandateId} deleted by SysAdmin {currentUser.id}")
-
- return {"message": f"Mandate {mandateId} deleted successfully"}
+
+ mode = "hard-deleted" if force else "soft-deleted"
+ logger.info(f"Mandate {mandateId} {mode} by SysAdmin {currentUser.id}")
+
+ return {"message": f"Mandate {mandateId} {mode} successfully"}
except HTTPException:
raise
except Exception as e:
diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py
index ccefcc87..8e3be0ba 100644
--- a/modules/routes/routeInvitations.py
+++ b/modules/routes/routeInvitations.py
@@ -678,8 +678,15 @@ def validate_invitation(
roleLabels = []
targetUsername = invitation.targetUsername
- # Get mandate name
mandate = rootInterface.getMandate(str(mandateId)) if mandateId else None
+ if mandate and not getattr(mandate, "enabled", True):
+ return InvitationValidation(
+ valid=False,
+ reason="Mandate is disabled",
+ mandateId=None,
+ featureInstanceId=None,
+ roleIds=[]
+ )
if mandate:
mandateName = mandate.label or mandate.name
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index 4af0f6b7..ab50087c 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -87,6 +87,35 @@ def _isUserAdminInMandate(db, userId: str, mandateId: str) -> bool:
return False
+def _autoActivatePending(subInterface, pendingSub: Dict[str, Any]) -> None:
+ """Auto-activate a PENDING subscription to its target operative status."""
+ from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS
+ from datetime import datetime, timezone, timedelta
+
+ subId = pendingSub.get("id")
+ planKey = pendingSub.get("planKey", "")
+ plan = BUILTIN_PLANS.get(planKey)
+ now = datetime.now(timezone.utc)
+ targetStatus = SubscriptionStatusEnum.TRIALING if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE
+
+ additionalData = {"currentPeriodStart": now.isoformat()}
+ if plan and plan.trialDays:
+ trialEnd = now + timedelta(days=plan.trialDays)
+ additionalData["trialEndsAt"] = trialEnd.isoformat()
+ additionalData["currentPeriodEnd"] = trialEnd.isoformat()
+
+ try:
+ subInterface.transitionStatus(
+ subId,
+ expectedFromStatus=SubscriptionStatusEnum.PENDING,
+ toStatus=targetStatus,
+ additionalData=additionalData,
+ )
+ logger.info("Auto-activated PENDING subscription %s -> %s for mandate", subId, targetStatus.value)
+ except Exception as e:
+ logger.warning("Failed to auto-activate PENDING subscription %s: %s", subId, e)
+
+
def _getUserAdminMandateIds(db, userId: str) -> List[str]:
"""Get all mandate IDs where user is admin."""
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
@@ -150,6 +179,8 @@ def listUserMandates(
records = db.getRecordset(Mandate, recordFilter={"id": mid})
if records:
m = records[0]
+ if not m.get("enabled", True):
+ continue
result.append({
"id": mid,
"name": m.get("name", ""),
@@ -200,13 +231,15 @@ def getSubscriptionInfo(
"budgetAiCHF": None,
}
- sub = allSubs[0]
+ operative = subInterface.getOperativeForMandate(mandateId)
+ sub = operative or allSubs[0]
plan = BUILTIN_PLANS.get(sub.get("planKey"))
currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
return {
"plan": sub.get("planKey"),
"status": sub.get("status"),
+ "operative": operative is not None,
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
"maxFeatureInstances": plan.maxFeatureInstances if plan else None,
"budgetAiCHF": plan.budgetAiCHF if plan else None,
@@ -241,7 +274,7 @@ def listStoreFeatures(
for um in userMandates:
mid = um.get("mandateId")
mRecord = db.getRecordset(Mandate, recordFilter={"id": mid})
- if mRecord and not mRecord[0].get("isSystem"):
+ if mRecord and not mRecord[0].get("isSystem") and mRecord[0].get("enabled", True):
userMandateIds.append(mid)
storeFeatures = _getStoreFeatures(catalogService)
@@ -302,7 +335,22 @@ def activateStoreFeature(
subInterface = _getSubRoot()
operative = subInterface.getOperativeForMandate(mandateId)
+
if not operative:
+ allSubs = subInterface.listForMandate(mandateId)
+ pendingSubs = [s for s in allSubs if s.get("status") == SubscriptionStatusEnum.PENDING.value]
+ if pendingSubs:
+ _autoActivatePending(subInterface, pendingSubs[0])
+ operative = subInterface.getOperativeForMandate(mandateId)
+
+ if not operative:
+ allSubs = subInterface.listForMandate(mandateId)
+ statuses = [s.get("status") for s in allSubs] if allSubs else []
+ logger.warning(
+ "Store activate 402: no operative subscription for mandate %s. "
+ "Found %d subscription(s) with statuses: %s",
+ mandateId, len(allSubs), statuses,
+ )
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="Kein aktives Abonnement. Bitte zuerst ein Abo abschliessen.",
@@ -310,14 +358,8 @@ def activateStoreFeature(
planKey = operative.get("planKey", "")
plan = BUILTIN_PLANS.get(planKey)
- isBillable = plan is not None and (plan.pricePerFeatureInstanceCHF or 0) > 0
-
- if isBillable:
- if not operative.get("stripeSubscriptionId") or not operative.get("stripeItemIdInstances"):
- raise HTTPException(
- status_code=status.HTTP_402_PAYMENT_REQUIRED,
- detail="Stripe-Abonnement ist nicht vollständig eingerichtet — Aktivierung nicht möglich.",
- )
+ hasStripeIds = bool(operative.get("stripeSubscriptionId") and operative.get("stripeItemIdInstances"))
+ isBillable = hasStripeIds and plan is not None and (plan.pricePerFeatureInstanceCHF or 0) > 0
# ── 2. Capacity check ───────────────────────────────────────────
if plan and plan.maxFeatureInstances is not None:
diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py
index 5a08202c..f287d908 100644
--- a/modules/routes/routeSystem.py
+++ b/modules/routes/routeSystem.py
@@ -168,6 +168,8 @@ def _buildDynamicBlock(
mandateId = str(instance.mandateId)
if mandateId not in mandatesMap:
mandate = rootInterface.getMandate(mandateId)
+ if not mandate or not getattr(mandate, "enabled", True):
+ continue
mandateName = (mandate.label or mandate.name) if mandate else mandateId
mandatesMap[mandateId] = {
"id": mandateId,
From c6e7438dfa5758cda193b56973f35e33ee1c6992 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 20:56:35 +0200
Subject: [PATCH 25/33] teams bot adapt vars
---
modules/features/teamsbot/service.py | 45 ++++++++++------------------
1 file changed, 16 insertions(+), 29 deletions(-)
diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py
index 773cc1c9..9e59f653 100644
--- a/modules/features/teamsbot/service.py
+++ b/modules/features/teamsbot/service.py
@@ -17,6 +17,8 @@ from fastapi import WebSocket
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
from modules.shared.timeUtils import getUtcTimestamp, getIsoTimestamp
+from modules.serviceCenter import getService as _getServiceCenterService
+from modules.serviceCenter.context import ServiceCenterContext
from .datamodelTeamsbot import (
TeamsbotSessionStatus,
@@ -35,18 +37,18 @@ logger = logging.getLogger(__name__)
# =========================================================================
-# Minimal Service Context (for AI billing in bridge callbacks)
+# AI Service Factory (for billing-aware AI calls)
# =========================================================================
-class _ServiceContext:
- """Minimal context providing user/mandate info for AiService billing.
- Used by bridge callbacks where a full Services instance is not available."""
-
- def __init__(self, user, mandateId, featureInstanceId=None):
- self.user = user
- self.mandateId = mandateId
- self.featureInstanceId = featureInstanceId
- self.featureCode = "teamsbot"
+def _createAiService(user, mandateId, featureInstanceId=None):
+ """Create a properly wired AiService via the service center."""
+ ctx = ServiceCenterContext(
+ user=user,
+ mandate_id=mandateId,
+ feature_instance_id=featureInstanceId,
+ feature_code="teamsbot",
+ )
+ return _getServiceCenterService("ai", ctx)
# =========================================================================
@@ -1062,11 +1064,7 @@ class TeamsbotService:
# Call SPEECH_TEAMS
try:
- from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService
-
- # Create minimal service context for AI billing
- serviceContext = _ServiceContext(self.currentUser, self.mandateId, self.instanceId)
- aiService = AiService(serviceCenter=serviceContext)
+ aiService = _createAiService(self.currentUser, self.mandateId, self.instanceId)
await aiService.ensureAiObjectsInitialized()
request = AiCallRequest(
@@ -1684,11 +1682,7 @@ class TeamsbotService:
"""Summarize a long user-provided session context to its essential points.
This reduces token usage in every subsequent AI call."""
try:
- from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService
- from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
-
- serviceContext = _ServiceContext(self.currentUser, self.mandateId, self.instanceId)
- aiService = AiService(serviceCenter=serviceContext)
+ aiService = _createAiService(self.currentUser, self.mandateId, self.instanceId)
await aiService.ensureAiObjectsInitialized()
request = AiCallRequest(
@@ -1738,11 +1732,7 @@ class TeamsbotService:
lines.append(f"[{speaker}]: {text}")
textToSummarize = "\n".join(lines)
- from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService
- from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
-
- serviceContext = _ServiceContext(self.currentUser, self.mandateId, self.instanceId)
- aiService = AiService(serviceCenter=serviceContext)
+ aiService = _createAiService(self.currentUser, self.mandateId, self.instanceId)
await aiService.ensureAiObjectsInitialized()
request = AiCallRequest(
@@ -1783,10 +1773,7 @@ class TeamsbotService:
for t in transcripts
)
- from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService
-
- serviceContext = _ServiceContext(self.currentUser, self.mandateId, self.instanceId)
- aiService = AiService(serviceCenter=serviceContext)
+ aiService = _createAiService(self.currentUser, self.mandateId, self.instanceId)
await aiService.ensureAiObjectsInitialized()
request = AiCallRequest(
From 413dcd9b6c28967f06e9d4ed4356a96af0b52fd2 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 21:59:26 +0200
Subject: [PATCH 26/33] fix hard delete cascade: wrong import for
FeatureAccessRole
Made-with: Cursor
---
modules/interfaces/interfaceDbApp.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 27ec5fcf..01863b41 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1076,7 +1076,6 @@ class AppObjects:
def _deleteUserReferencedData(self, userId: str) -> None:
"""Deletes all data associated with a user (full cascade)."""
try:
- from modules.datamodels.datamodelRbac import FeatureAccessRole, UserMandateRole
from modules.datamodels.datamodelNotification import UserNotification
from modules.datamodels.datamodelInvitation import Invitation
@@ -1699,7 +1698,6 @@ class AppObjects:
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.datamodels.datamodelBilling import BillingSettings, BillingAccount, BillingTransaction
- from modules.datamodels.datamodelRbac import FeatureAccessRole, UserMandateRole
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
From 5a40b54524c3ea4d1b9725a16ea8165037b8ce75 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 23:40:59 +0200
Subject: [PATCH 27/33] fixed data source
---
.../datamodels/datamodelFeatureDataSource.py | 6 +-
modules/features/commcoach/mainCommcoach.py | 21 ++-
modules/features/teamsbot/mainTeamsbot.py | 21 ++-
modules/features/trustee/mainTrustee.py | 24 ++-
.../workspace/routeFeatureWorkspace.py | 149 +++++++++++++++++-
.../services/serviceAgent/featureDataAgent.py | 15 +-
.../serviceAgent/featureDataProvider.py | 70 ++++++--
.../services/serviceAgent/mainServiceAgent.py | 6 +
8 files changed, 282 insertions(+), 30 deletions(-)
diff --git a/modules/datamodels/datamodelFeatureDataSource.py b/modules/datamodels/datamodelFeatureDataSource.py
index 80ceb03c..02de0a67 100644
--- a/modules/datamodels/datamodelFeatureDataSource.py
+++ b/modules/datamodels/datamodelFeatureDataSource.py
@@ -6,7 +6,7 @@ A FeatureDataSource links a FeatureInstance table (DATA_OBJECT) to a workspace
so the agent can query structured feature data (e.g. TrusteePosition rows).
"""
-from typing import Optional
+from typing import Dict, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
@@ -39,6 +39,10 @@ class FeatureDataSource(PowerOnModel):
description="Whether this data source should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
+ recordFilter: Optional[Dict[str, str]] = Field(
+ default=None,
+ description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
+ )
registerModelLabels(
diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py
index 9d949e13..d21da056 100644
--- a/modules/features/commcoach/mainCommcoach.py
+++ b/modules/features/commcoach/mainCommcoach.py
@@ -36,12 +36,22 @@ DATA_OBJECTS = [
{
"objectKey": "data.feature.commcoach.CoachingContext",
"label": {"en": "Coaching Context", "de": "Coaching-Kontext", "fr": "Contexte coaching"},
- "meta": {"table": "CoachingContext", "fields": ["id", "title", "category", "status"]}
+ "meta": {
+ "table": "CoachingContext",
+ "fields": ["id", "title", "category", "status"],
+ "isParent": True,
+ "displayFields": ["title", "category", "status"],
+ }
},
{
"objectKey": "data.feature.commcoach.CoachingSession",
"label": {"en": "Coaching Session", "de": "Coaching-Session", "fr": "Session coaching"},
- "meta": {"table": "CoachingSession", "fields": ["id", "contextId", "status", "summary"]}
+ "meta": {
+ "table": "CoachingSession",
+ "fields": ["id", "contextId", "status", "summary"],
+ "parentTable": "CoachingContext",
+ "parentKey": "contextId",
+ }
},
{
"objectKey": "data.feature.commcoach.CoachingMessage",
@@ -51,7 +61,12 @@ DATA_OBJECTS = [
{
"objectKey": "data.feature.commcoach.CoachingTask",
"label": {"en": "Coaching Task", "de": "Coaching-Aufgabe", "fr": "Tache coaching"},
- "meta": {"table": "CoachingTask", "fields": ["id", "contextId", "title", "status"]}
+ "meta": {
+ "table": "CoachingTask",
+ "fields": ["id", "contextId", "title", "status"],
+ "parentTable": "CoachingContext",
+ "parentKey": "contextId",
+ }
},
{
"objectKey": "data.feature.commcoach.CoachingScore",
diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py
index afdce822..ea6d3b01 100644
--- a/modules/features/teamsbot/mainTeamsbot.py
+++ b/modules/features/teamsbot/mainTeamsbot.py
@@ -39,17 +39,32 @@ DATA_OBJECTS = [
{
"objectKey": "data.feature.teamsbot.TeamsbotSession",
"label": {"en": "Session", "de": "Sitzung", "fr": "Session"},
- "meta": {"table": "TeamsbotSession", "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"]}
+ "meta": {
+ "table": "TeamsbotSession",
+ "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
+ "isParent": True,
+ "displayFields": ["botName", "status", "startedAt"],
+ }
},
{
"objectKey": "data.feature.teamsbot.TeamsbotTranscript",
"label": {"en": "Transcript", "de": "Transkript", "fr": "Transcription"},
- "meta": {"table": "TeamsbotTranscript", "fields": ["id", "sessionId", "speaker", "text", "timestamp"]}
+ "meta": {
+ "table": "TeamsbotTranscript",
+ "fields": ["id", "sessionId", "speaker", "text", "timestamp"],
+ "parentTable": "TeamsbotSession",
+ "parentKey": "sessionId",
+ }
},
{
"objectKey": "data.feature.teamsbot.TeamsbotBotResponse",
"label": {"en": "Bot Response", "de": "Bot-Antwort", "fr": "Réponse du bot"},
- "meta": {"table": "TeamsbotBotResponse", "fields": ["id", "sessionId", "responseText", "detectedIntent"]}
+ "meta": {
+ "table": "TeamsbotBotResponse",
+ "fields": ["id", "sessionId", "responseText", "detectedIntent"],
+ "parentTable": "TeamsbotSession",
+ "parentKey": "sessionId",
+ }
},
{
"objectKey": "data.feature.teamsbot.*",
diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py
index 45824b1b..2fd82bc5 100644
--- a/modules/features/trustee/mainTrustee.py
+++ b/modules/features/trustee/mainTrustee.py
@@ -58,10 +58,25 @@ UI_OBJECTS = [
# DATA Objects for RBAC catalog (tables/entities)
# Used for AccessRules on data-level permissions
DATA_OBJECTS = [
+ {
+ "objectKey": "data.feature.trustee.TrusteeOrganisation",
+ "label": {"en": "Organisation", "de": "Organisation", "fr": "Organisation"},
+ "meta": {
+ "table": "TrusteeOrganisation",
+ "fields": ["id", "label", "enabled"],
+ "isParent": True,
+ "displayFields": ["label"],
+ }
+ },
{
"objectKey": "data.feature.trustee.TrusteePosition",
"label": {"en": "Position", "de": "Position", "fr": "Position"},
- "meta": {"table": "TrusteePosition", "fields": ["id", "label", "description", "organisationId"]}
+ "meta": {
+ "table": "TrusteePosition",
+ "fields": ["id", "label", "description", "organisationId"],
+ "parentTable": "TrusteeOrganisation",
+ "parentKey": "organisationId",
+ }
},
{
"objectKey": "data.feature.trustee.TrusteeDocument",
@@ -71,7 +86,12 @@ DATA_OBJECTS = [
{
"objectKey": "data.feature.trustee.TrusteeAccountingConfig",
"label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"},
- "meta": {"table": "TrusteeAccountingConfig", "fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"]}
+ "meta": {
+ "table": "TrusteeAccountingConfig",
+ "fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"],
+ "parentTable": "TrusteeOrganisation",
+ "parentKey": "organisationId",
+ }
},
{
"objectKey": "data.feature.trustee.TrusteeAccountingSync",
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index 7feef4db..ae0154dc 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -270,12 +270,19 @@ def _buildFeatureDataSourceContext(featureDataSourceIds: List[str]) -> str:
tableFields = obj.get("meta", {}).get("fields", [])
break
+ recordFilter = fds.get("recordFilter")
+ filterLine = ""
+ if recordFilter and isinstance(recordFilter, dict):
+ filterParts = [f"{k} = {v}" for k, v in recordFilter.items()]
+ filterLine = f"\n recordFilter: {', '.join(filterParts)} (data is scoped to this record)"
+
parts.append(
f"- featureInstanceId: {fiId}\n"
f" feature: {featureCode}\n"
f" instance: \"{instanceLabel}\"\n"
f" table: {tableName} ({label})\n"
f" fields: {', '.join(tableFields) if tableFields else 'all'}"
+ f"{filterLine}"
)
except Exception as e:
logger.warning(f"Error loading FeatureDataSource {fdsId}: {e}")
@@ -1336,8 +1343,8 @@ async def listFeatureConnections(
instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
- """List feature instances the user has access to across ALL mandates."""
- _validateInstanceAccess(instanceId, context)
+ """List feature instances the user has access to, scoped to the workspace mandate."""
+ wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.security.rbacCatalog import getCatalogService
from modules.datamodels.datamodelUam import Mandate
@@ -1352,8 +1359,14 @@ async def listFeatureConnections(
if not userMandates:
return JSONResponse({"featureConnectionsByMandate": []})
+ allowedMandateIds = {um.mandateId for um in userMandates}
+ if wsMandateId and wsMandateId in allowedMandateIds:
+ allowedMandateIds = {wsMandateId}
+
mandateLabels: dict = {}
for um in userMandates:
+ if um.mandateId not in allowedMandateIds:
+ continue
try:
rows = rootIf.db.getRecordset(Mandate, recordFilter={"id": um.mandateId})
if rows:
@@ -1365,6 +1378,8 @@ async def listFeatureConnections(
byMandate: dict = {}
seenIds: set = set()
for um in userMandates:
+ if um.mandateId not in allowedMandateIds:
+ continue
allInstances = rootIf.getFeatureInstancesByMandate(um.mandateId)
for inst in allInstances:
if inst.id in seenIds:
@@ -1418,7 +1433,7 @@ async def listFeatureConnectionTables(
context: RequestContext = Depends(getRequestContext),
):
"""List data tables (DATA_OBJECTS) for a feature instance, filtered by RBAC."""
- _validateInstanceAccess(instanceId, context)
+ wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.security.rbacCatalog import getCatalogService
@@ -1428,6 +1443,8 @@ async def listFeatureConnectionTables(
raise HTTPException(status_code=404, detail="Feature instance not found")
mandateId = str(inst.mandateId) if inst.mandateId else None
+ if wsMandateId and mandateId and mandateId != wsMandateId:
+ raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate")
catalog = getCatalogService()
try:
@@ -1448,16 +1465,132 @@ async def listFeatureConnectionTables(
tables = []
for obj in accessible:
meta = obj.get("meta", {})
- tables.append({
+ node = {
"objectKey": obj.get("objectKey", ""),
"tableName": meta.get("table", ""),
"label": obj.get("label", {}),
"fields": meta.get("fields", []),
- })
+ }
+ if meta.get("isParent"):
+ node["isParent"] = True
+ node["displayFields"] = meta.get("displayFields", [])
+ if meta.get("parentTable"):
+ node["parentTable"] = meta["parentTable"]
+ node["parentKey"] = meta.get("parentKey", "")
+ tables.append(node)
return JSONResponse({"tables": tables})
+@router.get("/{instanceId}/feature-connections/{fiId}/parent-objects/{tableName}")
+@limiter.limit("120/minute")
+async def listParentObjects(
+ request: Request,
+ instanceId: str = Path(...),
+ fiId: str = Path(..., description="Feature instance ID"),
+ tableName: str = Path(..., description="Parent table name from DATA_OBJECTS"),
+ context: RequestContext = Depends(getRequestContext),
+):
+ """List records from a parent table so the user can pick a specific record to scope data."""
+ wsMandateId, _ = _validateInstanceAccess(instanceId, context)
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ from modules.security.rbacCatalog import getCatalogService
+
+ rootIf = getRootInterface()
+ inst = rootIf.getFeatureInstance(fiId)
+ if not inst:
+ raise HTTPException(status_code=404, detail="Feature instance not found")
+
+ featureCode = inst.featureCode
+ mandateId = str(inst.mandateId) if inst.mandateId else ""
+ if wsMandateId and mandateId and mandateId != wsMandateId:
+ raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate")
+ catalog = getCatalogService()
+
+ parentObj = None
+ for obj in catalog.getDataObjects(featureCode):
+ meta = obj.get("meta", {})
+ if meta.get("table") == tableName and meta.get("isParent"):
+ parentObj = obj
+ break
+ if not parentObj:
+ raise HTTPException(status_code=400, detail=f"Table '{tableName}' is not a registered parent table")
+
+ displayFields = parentObj["meta"].get("displayFields", [])
+ selectCols = ', '.join(f'"{f}"' for f in (["id"] + displayFields)) if displayFields else "*"
+
+ from modules.connectors.connectorDbPostgre import DatabaseConnector
+ from modules.shared.configuration import APP_CONFIG
+ featureDbName = f"poweron_{featureCode.lower()}"
+ featureDbConn = None
+ try:
+ featureDbConn = DatabaseConnector(
+ dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
+ dbDatabase=featureDbName,
+ dbUser=APP_CONFIG.get("DB_USER"),
+ dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
+ dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
+ userId=str(context.user.id),
+ )
+ conn = featureDbConn.connection
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND LOWER(table_name) = LOWER(%s) "
+ "AND column_name IN ('featureInstanceId', 'instanceId')",
+ [tableName],
+ )
+ instanceCols = [row["column_name"] for row in cur.fetchall()]
+ instanceCol = "featureInstanceId" if "featureInstanceId" in instanceCols else "instanceId"
+
+ cur.execute(
+ "SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND LOWER(table_name) = LOWER(%s) "
+ "AND column_name = 'userId'",
+ [tableName],
+ )
+ hasUserId = cur.rowcount > 0
+
+ sql = (
+ f'SELECT {selectCols} FROM "{tableName}" '
+ f'WHERE "{instanceCol}" = %s'
+ )
+ params = [fiId]
+ if mandateId:
+ sql += ' AND "mandateId" = %s'
+ params.append(mandateId)
+ if hasUserId:
+ sql += ' AND "userId" = %s'
+ params.append(str(context.user.id))
+ sql += ' ORDER BY "id" DESC LIMIT 100'
+ cur.execute(sql, params)
+ rows = []
+ for row in cur.fetchall():
+ r = dict(row)
+ for k, v in r.items():
+ if hasattr(v, "isoformat"):
+ r[k] = v.isoformat()
+ elif isinstance(v, (bytes, bytearray)):
+ r[k] = f""
+ displayParts = [str(r.get(f, "")) for f in displayFields if r.get(f) is not None]
+ rows.append({
+ "id": r.get("id", ""),
+ "displayLabel": " | ".join(displayParts) if displayParts else r.get("id", ""),
+ "fields": {f: r.get(f) for f in displayFields},
+ })
+ except Exception as e:
+ logger.error(f"listParentObjects({tableName}) failed: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Failed to list parent objects: {e}")
+ finally:
+ if featureDbConn:
+ try:
+ featureDbConn.close()
+ except Exception:
+ pass
+
+ return JSONResponse({"parentObjects": rows})
+
+
class CreateFeatureDataSourceRequest(BaseModel):
"""Request body for adding a feature table as data source."""
featureInstanceId: str = Field(description="Feature instance ID")
@@ -1465,6 +1598,7 @@ class CreateFeatureDataSourceRequest(BaseModel):
tableName: str = Field(description="Table name from DATA_OBJECTS")
objectKey: str = Field(description="RBAC object key")
label: str = Field(description="User-visible label")
+ recordFilter: Optional[dict] = Field(default=None, description="Record-level filter for scoping")
@router.post("/{instanceId}/feature-datasources")
@@ -1476,13 +1610,15 @@ async def createFeatureDataSource(
context: RequestContext = Depends(getRequestContext),
):
"""Create a FeatureDataSource for this workspace instance."""
- _validateInstanceAccess(instanceId, context)
+ wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
rootIf = getRootInterface()
inst = rootIf.getFeatureInstance(body.featureInstanceId)
mandateId = str(inst.mandateId) if inst else (str(context.mandateId) if context.mandateId else "")
+ if wsMandateId and mandateId and mandateId != wsMandateId:
+ raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate")
fds = FeatureDataSource(
featureInstanceId=body.featureInstanceId,
@@ -1493,6 +1629,7 @@ async def createFeatureDataSource(
mandateId=mandateId,
userId=str(context.user.id),
workspaceInstanceId=instanceId,
+ recordFilter=body.recordFilter,
)
created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump())
return JSONResponse(created if isinstance(created, dict) else fds.model_dump())
diff --git a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
index e36745df..8ef0bfcc 100644
--- a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py
@@ -38,6 +38,7 @@ async def runFeatureDataAgent(
aiCallFn: Callable[[AiCallRequest], Awaitable[AiCallResponse]],
dbConnector,
instanceLabel: str = "",
+ tableFilters: Optional[Dict[str, Dict[str, str]]] = None,
) -> str:
"""Run the feature data sub-agent and return the textual result.
@@ -51,13 +52,14 @@ async def runFeatureDataAgent(
aiCallFn: AI call function (with billing).
dbConnector: DatabaseConnector for queries.
instanceLabel: Human-readable instance name for context.
+ tableFilters: Per-table record filters from FeatureDataSource.recordFilter.
Returns:
Plain-text answer produced by the sub-agent.
"""
provider = FeatureDataProvider(dbConnector)
- registry = _buildSubAgentTools(provider, featureInstanceId, mandateId)
+ registry = _buildSubAgentTools(provider, featureInstanceId, mandateId, tableFilters or {})
for tbl in selectedTables:
meta = tbl.get("meta", {})
@@ -103,9 +105,18 @@ def _buildSubAgentTools(
provider: FeatureDataProvider,
featureInstanceId: str,
mandateId: str,
+ tableFilters: Dict[str, Dict[str, str]] = None,
) -> ToolRegistry:
"""Register browseTable and queryTable as sub-agent tools."""
registry = ToolRegistry()
+ _tableFilters = tableFilters or {}
+
+ def _recordFilterToList(tableName: str) -> Optional[List[Dict[str, Any]]]:
+ """Convert a recordFilter dict to a list of {field, op, value} filter dicts."""
+ rf = _tableFilters.get(tableName)
+ if not rf:
+ return None
+ return [{"field": k, "op": "=", "value": v} for k, v in rf.items()]
async def _browseTable(args: Dict[str, Any], context: Dict[str, Any]):
tableName = args.get("tableName", "")
@@ -121,6 +132,7 @@ def _buildSubAgentTools(
fields=fields,
limit=min(limit, 200),
offset=offset,
+ extraFilters=_recordFilterToList(tableName),
)
return ToolResult(
toolCallId="", toolName="browseTable",
@@ -147,6 +159,7 @@ def _buildSubAgentTools(
orderBy=orderBy,
limit=min(limit, 200),
offset=offset,
+ extraFilters=_recordFilterToList(tableName),
)
return ToolResult(
toolCallId="", toolName="queryTable",
diff --git a/modules/serviceCenter/services/serviceAgent/featureDataProvider.py b/modules/serviceCenter/services/serviceAgent/featureDataProvider.py
index 40bf0c6b..25a0ff95 100644
--- a/modules/serviceCenter/services/serviceAgent/featureDataProvider.py
+++ b/modules/serviceCenter/services/serviceAgent/featureDataProvider.py
@@ -69,28 +69,36 @@ class FeatureDataProvider:
fields: List[str] = None,
limit: int = 50,
offset: int = 0,
+ extraFilters: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""List rows from a feature table with pagination.
Returns ``{"rows": [...], "total": N, "limit": L, "offset": O}``.
"""
_validateTableName(tableName)
- scopeFilter = _buildScopeFilter(tableName, featureInstanceId, mandateId)
+ conn = self._db.connection
+ scopeFilter = _buildScopeFilter(tableName, featureInstanceId, mandateId, dbConnection=conn)
+ extraWhere, extraParams = _buildFilterClauses(extraFilters)
+
+ fullWhere = scopeFilter["where"]
+ allParams = list(scopeFilter["params"])
+ if extraWhere:
+ fullWhere += " AND " + extraWhere
+ allParams.extend(extraParams)
try:
- conn = self._db.connection
with conn.cursor() as cur:
- countSql = f'SELECT COUNT(*) FROM "{tableName}" WHERE {scopeFilter["where"]}'
- cur.execute(countSql, scopeFilter["params"])
+ countSql = f'SELECT COUNT(*) FROM "{tableName}" WHERE {fullWhere}'
+ cur.execute(countSql, allParams)
total = cur.fetchone()["count"] if cur.rowcount else 0
selectCols = ", ".join(f'"{f}"' for f in fields) if fields else "*"
dataSql = (
f'SELECT {selectCols} FROM "{tableName}" '
- f'WHERE {scopeFilter["where"]} '
+ f'WHERE {fullWhere} '
f'ORDER BY "id" LIMIT %s OFFSET %s'
)
- cur.execute(dataSql, scopeFilter["params"] + [limit, offset])
+ cur.execute(dataSql, allParams + [limit, offset])
rows = [_serializeRow(dict(r)) for r in cur.fetchall()]
return {"rows": rows, "total": total, "limit": limit, "offset": offset}
@@ -108,14 +116,19 @@ class FeatureDataProvider:
orderBy: str = None,
limit: int = 50,
offset: int = 0,
+ extraFilters: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""Query a feature table with optional filters.
``filters`` is a list of ``{"field": "x", "op": "=", "value": "y"}``.
+ ``extraFilters`` are mandatory record-level scoping filters injected by the pipeline.
"""
_validateTableName(tableName)
- scopeFilter = _buildScopeFilter(tableName, featureInstanceId, mandateId)
- extraWhere, extraParams = _buildFilterClauses(filters)
+ conn = self._db.connection
+ scopeFilter = _buildScopeFilter(tableName, featureInstanceId, mandateId, dbConnection=conn)
+
+ combinedFilters = list(filters or []) + list(extraFilters or [])
+ extraWhere, extraParams = _buildFilterClauses(combinedFilters if combinedFilters else None)
fullWhere = scopeFilter["where"]
allParams = list(scopeFilter["params"])
@@ -124,7 +137,6 @@ class FeatureDataProvider:
allParams.extend(extraParams)
try:
- conn = self._db.connection
with conn.cursor() as cur:
countSql = f'SELECT COUNT(*) FROM "{tableName}" WHERE {fullWhere}'
cur.execute(countSql, allParams)
@@ -149,6 +161,34 @@ class FeatureDataProvider:
# helpers
# ------------------------------------------------------------------
+_instanceColCache: Dict[str, str] = {}
+
+
+def _resolveInstanceColumn(tableName: str, dbConnection=None) -> str:
+ """Detect whether the table uses ``instanceId`` or ``featureInstanceId``."""
+ if tableName in _instanceColCache:
+ return _instanceColCache[tableName]
+ if dbConnection:
+ try:
+ with dbConnection.cursor() as cur:
+ cur.execute(
+ "SELECT column_name FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND LOWER(table_name) = LOWER(%s) "
+ "AND column_name IN ('featureInstanceId', 'instanceId')",
+ [tableName],
+ )
+ cols = [row["column_name"] for row in cur.fetchall()]
+ if "featureInstanceId" in cols:
+ _instanceColCache[tableName] = "featureInstanceId"
+ return "featureInstanceId"
+ if "instanceId" in cols:
+ _instanceColCache[tableName] = "instanceId"
+ return "instanceId"
+ except Exception:
+ pass
+ return "instanceId"
+
+
def _validateTableName(tableName: str):
if not tableName or not _isValidIdentifier(tableName):
raise ValueError(f"Invalid table name: {tableName}")
@@ -159,17 +199,19 @@ def _isValidIdentifier(name: str) -> bool:
return name.isidentifier()
-def _buildScopeFilter(tableName: str, featureInstanceId: str, mandateId: str) -> Dict[str, Any]:
+def _buildScopeFilter(tableName: str, featureInstanceId: str, mandateId: str, dbConnection=None) -> Dict[str, Any]:
"""Build the mandatory WHERE clause that scopes rows to the feature instance.
- Feature tables usually have either ``featureInstanceId`` or a combination
- of ``mandateId`` + an org/context FK. We try ``featureInstanceId`` first,
- then fall back to ``mandateId``.
+ Feature tables use either ``instanceId`` (commcoach, teamsbot) or
+ ``featureInstanceId`` (trustee) as the FK. We detect the actual column
+ from ``information_schema`` when a DB connection is provided.
"""
+ instanceCol = _resolveInstanceColumn(tableName, dbConnection)
+
conditions = []
params = []
- conditions.append('"featureInstanceId" = %s')
+ conditions.append(f'"{instanceCol}" = %s')
params.append(featureInstanceId)
if mandateId:
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index 9a702aa0..08950ea3 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -3192,11 +3192,16 @@ def _registerCoreTools(registry: ToolRegistry, services):
from modules.security.rbacCatalog import getCatalogService
catalog = getCatalogService()
+ tableFilters = {}
if not featureDataSources:
selectedTables = catalog.getDataObjects(featureCode)
else:
allObjs = {o["meta"]["table"]: o for o in catalog.getDataObjects(featureCode) if "meta" in o and "table" in o.get("meta", {})}
selectedTables = [allObjs[ds["tableName"]] for ds in featureDataSources if ds.get("tableName") in allObjs]
+ for ds in featureDataSources:
+ rf = ds.get("recordFilter")
+ if rf and isinstance(rf, dict) and ds.get("tableName"):
+ tableFilters[ds["tableName"]] = rf
if not selectedTables:
return ToolResult(
@@ -3239,6 +3244,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
aiCallFn=_subAgentAiCall,
dbConnector=featureDbConn,
instanceLabel=instanceLabel,
+ tableFilters=tableFilters,
)
finally:
try:
From 0a5fa20cb8fdc8b318cbe073d4aac97a23e73970 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Wed, 1 Apr 2026 21:59:28 +0200
Subject: [PATCH 28/33] fixed voice feat commcoach
---
modules/datamodels/datamodelAi.py | 1 +
.../features/commcoach/datamodelCommcoach.py | 4 +
.../commcoach/routeFeatureCommcoach.py | 15 +-
.../features/commcoach/serviceCommcoach.py | 615 +++++++++++++++---
.../features/commcoach/serviceCommcoachAi.py | 63 +-
.../commcoach/serviceCommcoachIndexer.py | 223 +++++++
.../commcoach/serviceCommcoachScheduler.py | 56 +-
modules/interfaces/interfaceAiObjects.py | 18 +-
modules/routes/routeVoiceGoogle.py | 4 +-
.../services/serviceAgent/agentLoop.py | 15 +-
.../services/serviceAgent/mainServiceAgent.py | 104 +--
.../services/serviceAgent/toolRegistry.py | 14 +-
12 files changed, 932 insertions(+), 200 deletions(-)
create mode 100644 modules/features/commcoach/serviceCommcoachIndexer.py
diff --git a/modules/datamodels/datamodelAi.py b/modules/datamodels/datamodelAi.py
index 96e05185..662eded2 100644
--- a/modules/datamodels/datamodelAi.py
+++ b/modules/datamodels/datamodelAi.py
@@ -172,6 +172,7 @@ class AiCallRequest(BaseModel):
contentParts: Optional[List['ContentPart']] = None # Content parts for model-aware chunking
messages: Optional[List[Dict[str, Any]]] = Field(default=None, description="OpenAI-style messages for multi-turn agent conversations")
tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="Tool definitions for native function calling")
+ toolChoice: Optional[Any] = Field(default=None, description="Tool choice: 'auto', 'none', or specific tool (passed through to model call)")
requireNeutralization: Optional[bool] = Field(default=None, description="Per-request neutralization override: True=force, False=skip, None=use config")
diff --git a/modules/features/commcoach/datamodelCommcoach.py b/modules/features/commcoach/datamodelCommcoach.py
index 635ba19a..82be6044 100644
--- a/modules/features/commcoach/datamodelCommcoach.py
+++ b/modules/features/commcoach/datamodelCommcoach.py
@@ -228,6 +228,10 @@ class UpdateContextRequest(BaseModel):
class SendMessageRequest(BaseModel):
content: str = Field(description="User message text")
contentType: Optional[CoachingMessageContentType] = CoachingMessageContentType.TEXT
+ fileIds: Optional[List[str]] = Field(default=None, description="Attached file IDs for agent context")
+ dataSourceIds: Optional[List[str]] = Field(default=None, description="Personal data source IDs")
+ featureDataSourceIds: Optional[List[str]] = Field(default=None, description="Feature data source IDs")
+ allowedProviders: Optional[List[str]] = Field(default=None, description="Allowed AI providers")
class CreateTaskRequest(BaseModel):
diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py
index ccb4d342..8ffd3eca 100644
--- a/modules/features/commcoach/routeFeatureCommcoach.py
+++ b/modules/features/commcoach/routeFeatureCommcoach.py
@@ -334,9 +334,8 @@ async def startSession(
try:
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
- from .serviceCommcoach import _getUserVoicePrefs
+ from .serviceCommcoach import _getUserVoicePrefs, _stripMarkdownForTts, _buildTtsConfigErrorMessage
language, voiceName = _getUserVoicePrefs(userId, mandateId)
- from .serviceCommcoach import _stripMarkdownForTts
ttsResult = await voiceInterface.textToSpeech(
text=_stripMarkdownForTts(greetingText),
languageCode=language,
@@ -349,8 +348,12 @@ async def startSession(
audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode()
).decode()
yield f"data: {json.dumps({'type': 'ttsAudio', 'data': {'audio': audioB64, 'format': 'mp3'}})}\n\n"
+ else:
+ errorDetail = ttsResult.get("error", "Text-to-Speech failed")
+ yield f"data: {json.dumps({'type': 'error', 'data': {'message': _buildTtsConfigErrorMessage(language, voiceName, errorDetail), 'detail': errorDetail, 'ttsLanguage': language, 'ttsVoice': voiceName}})}\n\n"
except Exception as e:
logger.warning(f"TTS failed for resumed session: {e}")
+ yield f"data: {json.dumps({'type': 'error', 'data': {'message': 'Die konfigurierte Stimme für diese Sprache ist ungültig oder nicht verfügbar. Bitte passe sie unter Einstellungen > Stimme & Sprache an.', 'detail': str(e)}})}\n\n"
yield f"data: {json.dumps({'type': 'complete', 'data': {}, 'timestamp': getIsoTimestamp()})}\n\n"
return StreamingResponse(
@@ -511,7 +514,13 @@ async def sendMessageStream(
_activeProcessTasks.pop(sessionId, None)
task = asyncio.create_task(
- service.processMessage(sessionId, contextId, body.content, interface)
+ service.processMessage(
+ sessionId, contextId, body.content, interface,
+ fileIds=body.fileIds,
+ dataSourceIds=body.dataSourceIds,
+ featureDataSourceIds=body.featureDataSourceIds,
+ allowedProviders=body.allowedProviders,
+ )
)
task.add_done_callback(_onTaskDone)
_activeProcessTasks[sessionId] = task
diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py
index 5e5aa810..332a4a01 100644
--- a/modules/features/commcoach/serviceCommcoach.py
+++ b/modules/features/commcoach/serviceCommcoach.py
@@ -6,6 +6,7 @@ Manages the coaching pipeline: message processing, AI calls, scoring, task extra
"""
import re
+import html
import logging
import json
import asyncio
@@ -43,25 +44,117 @@ from .serviceCommcoachContextRetrieval import (
logger = logging.getLogger(__name__)
+def _selectConfiguredVoice(
+ language: str,
+ voiceMap: Any,
+ legacyVoice: Optional[str] = None,
+ legacyLanguage: Optional[str] = None,
+) -> Optional[str]:
+ """Resolve the configured TTS voice for a language from ttsVoiceMap, then legacy ttsVoice."""
+ normalizedLanguage = str(language or "").strip()
+ normalizedLower = normalizedLanguage.lower()
+ baseLanguage = normalizedLower.split("-", 1)[0] if normalizedLower else ""
+
+ if isinstance(voiceMap, dict) and voiceMap:
+ direct = voiceMap.get(normalizedLanguage)
+ if isinstance(direct, str) and direct.strip():
+ return direct.strip()
+
+ directBase = voiceMap.get(baseLanguage)
+ if isinstance(directBase, str) and directBase.strip():
+ return directBase.strip()
+
+ for mapKey, mapValue in voiceMap.items():
+ if not isinstance(mapValue, str) or not mapValue.strip():
+ continue
+ keyNorm = str(mapKey or "").strip().lower()
+ if keyNorm == normalizedLower or keyNorm == baseLanguage or (baseLanguage and keyNorm.startswith(baseLanguage + "-")):
+ return mapValue.strip()
+
+ if legacyVoice and str(legacyVoice).strip():
+ legacyLangNorm = str(legacyLanguage or "").strip().lower()
+ if not legacyLangNorm or legacyLangNorm == normalizedLower:
+ return str(legacyVoice).strip()
+
+ return None
+
+
+def _buildTtsConfigErrorMessage(language: str, voiceName: Optional[str], rawError: str = "") -> str:
+ if voiceName:
+ return (
+ f'Die konfigurierte Stimme "{voiceName}" für {language} ist ungültig oder nicht verfügbar. '
+ 'Bitte passe sie unter Einstellungen > Stimme & Sprache an.'
+ )
+ return (
+ f'Für die Sprache {language} ist keine gültige TTS-Stimme konfiguriert. '
+ 'Bitte prüfe die Einstellungen unter Stimme & Sprache.'
+ )
+
+
def _getUserVoicePrefs(userId: str, mandateId: Optional[str] = None) -> tuple:
"""Load voice language and voiceName from central UserVoicePreferences.
Returns (language, voiceName) tuple."""
try:
from modules.datamodels.datamodelUam import UserVoicePreferences
- from modules.security.rootAccess import getRootInterface
+ from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()
prefs = rootIf.db.getRecordset(
UserVoicePreferences,
- recordFilter={"userId": userId, "mandateId": mandateId}
+ recordFilter={"userId": userId}
)
- if not prefs and mandateId:
- prefs = rootIf.db.getRecordset(
- UserVoicePreferences,
- recordFilter={"userId": userId}
- )
if prefs:
- p = prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
- return (p.get("ttsLanguage") or p.get("sttLanguage") or "de-DE", p.get("ttsVoice"))
+ allPrefs = [
+ pref if isinstance(pref, dict) else pref.model_dump()
+ for pref in prefs
+ ]
+ scopedPref = next(
+ (
+ pref for pref in allPrefs
+ if str(pref.get("mandateId") or "").strip() == str(mandateId or "").strip()
+ ),
+ None,
+ )
+ globalPref = next(
+ (
+ pref for pref in allPrefs
+ if not str(pref.get("mandateId") or "").strip()
+ ),
+ None,
+ )
+
+ language = (
+ (globalPref or {}).get("ttsLanguage")
+ or (globalPref or {}).get("sttLanguage")
+ or (scopedPref or {}).get("ttsLanguage")
+ or (scopedPref or {}).get("sttLanguage")
+ or "de-DE"
+ )
+
+ scopedVoiceFromMap = _selectConfiguredVoice(
+ language=language,
+ voiceMap=(scopedPref or {}).get("ttsVoiceMap"),
+ )
+ globalVoice = _selectConfiguredVoice(
+ language=language,
+ voiceMap=(globalPref or {}).get("ttsVoiceMap"),
+ legacyVoice=(globalPref or {}).get("ttsVoice"),
+ legacyLanguage=(globalPref or {}).get("ttsLanguage"),
+ )
+ scopedLegacyVoice = _selectConfiguredVoice(
+ language=language,
+ voiceMap=None,
+ legacyVoice=(scopedPref or {}).get("ttsVoice"),
+ legacyLanguage=(scopedPref or {}).get("ttsLanguage"),
+ )
+ anyPref = allPrefs[0]
+ fallbackVoice = _selectConfiguredVoice(
+ language=language,
+ voiceMap=(anyPref or {}).get("ttsVoiceMap"),
+ legacyVoice=(anyPref or {}).get("ttsVoice"),
+ legacyLanguage=(anyPref or {}).get("ttsLanguage"),
+ )
+ voiceName = scopedVoiceFromMap or globalVoice or scopedLegacyVoice or fallbackVoice
+ return (language, voiceName)
except Exception as e:
logger.warning(f"Failed to load UserVoicePreferences for user={userId}: {e}")
return ("de-DE", None)
@@ -111,26 +204,91 @@ def cleanupSessionEvents(sessionId: str):
CHUNK_WORD_SIZE = 4
CHUNK_DELAY_SECONDS = 0.05
-def _wrapEmailHtml(contentHtml: str) -> str:
- """Wrap AI-generated HTML content in a styled email shell."""
- return f"""
-
-
-
-
-
-
-
Coaching-Session Zusammenfassung
-
PowerOn CommCoach
-
-
{contentHtml}
-
-
Diese Zusammenfassung wurde automatisch erstellt.
-
-
-
-
-"""
+
+def _normalizeEmailBulletList(values: Any, maxItems: int = 4) -> List[str]:
+ items: List[str] = []
+ if not isinstance(values, list):
+ return items
+ for value in values:
+ text = str(value or "").strip()
+ if text:
+ items.append(text)
+ if len(items) >= maxItems:
+ break
+ return items
+
+
+def _buildSummaryEmailBlock(
+ emailData: Optional[Dict[str, Any]],
+ summary: str,
+ contextTitle: str,
+) -> str:
+ """Render a stable, mail-client-friendly CommCoach summary block."""
+ payload = emailData or {}
+ headline = str(payload.get("headline") or contextTitle or "Coaching-Session").strip()
+ intro = str(payload.get("intro") or "").strip()
+ coreTopic = str(payload.get("coreTopic") or "").strip()
+ insights = _normalizeEmailBulletList(payload.get("insights"))
+ nextSteps = _normalizeEmailBulletList(payload.get("nextSteps"))
+ progress = _normalizeEmailBulletList(payload.get("progress"))
+
+ if not (intro or coreTopic or insights or nextSteps or progress):
+ escapedSummary = html.escape(summary or "").replace("\n", "
")
+ return (
+ ''
+ f'
{html.escape(headline)}
'
+ f'
{escapedSummary}
'
+ '
'
+ )
+
+ def _renderSection(title: str, bodyHtml: str) -> str:
+ if not bodyHtml:
+ return ""
+ return (
+ '| '
+ f' {html.escape(title)} '
+ f'{bodyHtml} '
+ ' |
'
+ )
+
+ def _renderList(values: List[str]) -> str:
+ if not values:
+ return ""
+ rows = "".join(
+ ''
+ '| • | '
+ f'{html.escape(item)} | '
+ '
'
+ for item in values
+ )
+ return f''
+
+ introHtml = f'{html.escape(intro)}
' if intro else ""
+ coreTopicHtml = f'{html.escape(coreTopic)}
' if coreTopic else ""
+
+ sectionsHtml = "".join([
+ _renderSection("Kernbotschaft", introHtml),
+ _renderSection("Kernthema", coreTopicHtml),
+ _renderSection("Erkenntnisse", _renderList(insights)),
+ _renderSection("Nächste Schritte", _renderList(nextSteps)),
+ _renderSection("Fortschritt", _renderList(progress)),
+ ])
+
+ return (
+ ''
+ ''
+ f'{html.escape(headline)}'
+ f'Thema: {html.escape(contextTitle)} '
+ ''
+ f'{sectionsHtml}'
+ ' '
+ ' |
'
+ '
'
+ )
DOC_INTENT_MAX_DOCS = 3
DOC_CONTENT_MAX_CHARS = 3000
@@ -160,7 +318,7 @@ def _stripPendingUserMessages(messages: List[Dict[str, Any]]) -> List[Dict[str,
def _parseAiJsonResponse(rawText: str) -> Dict[str, Any]:
- """Parse the structured JSON response from AI. Strips optional markdown code fences."""
+ """Parse optional structured AI output; otherwise treat free text as normal response."""
text = rawText.strip()
if text.startswith("```"):
lines = text.split("\n")
@@ -169,10 +327,14 @@ def _parseAiJsonResponse(rawText: str) -> Dict[str, Any]:
lines = lines[:-1]
text = "\n".join(lines)
try:
- return json.loads(text)
+ parsed = json.loads(text)
+ if isinstance(parsed, dict):
+ if parsed.get("text") and not parsed.get("speech"):
+ parsed["speech"] = parsed.get("text")
+ return parsed
+ return {"text": rawText.strip(), "speech": rawText.strip(), "documents": []}
except json.JSONDecodeError:
- logger.warning(f"AI JSON parse failed, using raw text: {text[:200]}")
- return {"text": rawText.strip(), "speech": "", "documents": []}
+ return {"text": rawText.strip(), "speech": rawText.strip(), "documents": []}
async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mandateId: str,
@@ -197,8 +359,20 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand
audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode()
).decode()
await emitSessionEvent(sessionId, "ttsAudio", {"audio": audioB64, "format": "mp3"})
+ return
+ errorDetail = ttsResult.get("error", "Text-to-Speech failed")
+ await emitSessionEvent(sessionId, "error", {
+ "message": _buildTtsConfigErrorMessage(language, voiceName, errorDetail),
+ "detail": errorDetail,
+ "ttsLanguage": language,
+ "ttsVoice": voiceName,
+ })
except Exception as e:
logger.warning(f"TTS failed for session {sessionId}: {e}")
+ await emitSessionEvent(sessionId, "error", {
+ "message": _buildTtsConfigErrorMessage("de-DE", None, str(e)),
+ "detail": str(e),
+ })
def _resolveFileNameAndMime(title: str) -> tuple:
@@ -400,6 +574,151 @@ def _getDocumentSummaries(contextId: str, userId: str, interface,
return None
+def _createCommcoachRagFn(
+ userId: str,
+ featureInstanceId: str,
+ mandateId: str,
+ context: Dict[str, Any],
+ tasks: List[Dict[str, Any]],
+ currentUser=None,
+):
+ """Create a CommCoach-specific RAG function combining KnowledgeService RAG with live coaching DB context."""
+
+ async def _buildRagContext(
+ currentPrompt: str, workflowId: str, userId: str,
+ featureInstanceId: str, mandateId: str, **kwargs
+ ) -> str:
+ parts = []
+
+ # 1. Standard KnowledgeService RAG (finds indexed session chunks + files)
+ try:
+ from modules.serviceCenter import getService
+ from modules.serviceCenter.context import ServiceCenterContext
+ serviceContext = ServiceCenterContext(
+ user=currentUser,
+ mandate_id=mandateId,
+ feature_instance_id=featureInstanceId,
+ )
+ knowledgeService = getService("knowledge", serviceContext)
+ ragContext = await knowledgeService.buildAgentContext(
+ currentPrompt=currentPrompt,
+ workflowId=workflowId,
+ userId=userId,
+ featureInstanceId=featureInstanceId,
+ mandateId=mandateId,
+ )
+ if ragContext:
+ parts.append(ragContext)
+ except Exception as e:
+ logger.debug(f"CommCoach RAG knowledge context failed: {e}")
+
+ # 2. Live coaching DB context (current goals, tasks, rolling overview)
+ liveContext = []
+ goals = _parseJsonField(context.get("goals")) if context else None
+ if goals:
+ goalTexts = [g.get("text", g) if isinstance(g, dict) else str(g) for g in goals if g]
+ if goalTexts:
+ liveContext.append("Aktuelle Ziele:\n" + "\n".join(f"- {g}" for g in goalTexts))
+
+ openTasks = [t for t in (tasks or []) if t.get("status") in ("open", "inProgress")]
+ if openTasks:
+ taskLines = [f"- {t.get('title', '')}" for t in openTasks[:5]]
+ liveContext.append("Offene Aufgaben:\n" + "\n".join(taskLines))
+
+ rollingOverview = context.get("rollingOverview") if context else None
+ if rollingOverview:
+ liveContext.append(f"Gesamtüberblick bisheriger Sessions:\n{rollingOverview[:500]}")
+
+ insights = _parseJsonField(context.get("insights")) if context else None
+ if insights:
+ insightTexts = [i.get("text", i) if isinstance(i, dict) else str(i) for i in insights[-5:] if i]
+ if insightTexts:
+ liveContext.append("Bisherige Erkenntnisse:\n" + "\n".join(f"- {t}" for t in insightTexts))
+
+ if liveContext:
+ parts.append("--- Coaching-Kontext (Live) ---\n" + "\n\n".join(liveContext))
+
+ return "\n\n".join(parts) if parts else ""
+
+ return _buildRagContext
+
+
+def _parseJsonField(value, fallback=None):
+ if not value:
+ return fallback
+ if isinstance(value, (list, dict)):
+ return value
+ try:
+ return json.loads(value)
+ except (json.JSONDecodeError, TypeError):
+ return fallback
+
+
+_RESEARCH_KEYWORDS = re.compile(
+ r"\b(such|recherchier|schau nach|im web|finde heraus|google|online|nachschlagen|"
+ r"search|look up|find out|browse)\b",
+ re.IGNORECASE,
+)
+
+
+def _shouldActivateTools(
+ fileIds: Optional[List[str]],
+ dataSourceIds: Optional[List[str]],
+ featureDataSourceIds: Optional[List[str]],
+ userMessage: str,
+) -> bool:
+ """Decide whether the agent should have tools activated for this turn."""
+ if fileIds:
+ return True
+ if dataSourceIds:
+ return True
+ if featureDataSourceIds:
+ return True
+ if _RESEARCH_KEYWORDS.search(userMessage or ""):
+ return True
+ return False
+
+
+def _buildConversationHistory(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """Convert coaching messages to OpenAI-style conversation history for the agent."""
+ history = []
+ for msg in messages:
+ role = msg.get("role", "user")
+ content = msg.get("content", "")
+ if role in ("user", "assistant") and content:
+ history.append({"role": role, "content": content})
+ return history
+
+
+_TTS_WORD_LIMIT = 200
+
+
+async def _prepareSpeechText(fullText: str, callAiFn) -> str:
+ """Prepare text for TTS. Short responses used directly; long ones get summarized."""
+ cleaned = _stripMarkdownForTts(fullText)
+ wordCount = len(cleaned.split())
+ if wordCount <= _TTS_WORD_LIMIT:
+ return cleaned
+ try:
+ prompt = f"""Fasse den folgenden Text in 3-4 natürlichen, gesprochenen Sätzen zusammen.
+Der Text soll vorgelesen werden – schreibe daher natürlich und flüssig, keine Aufzählungen.
+Behalte die wichtigsten Punkte und den Ton bei.
+
+Text:
+{cleaned[:3000]}
+
+Antworte NUR mit der gekürzten Sprachversion."""
+ response = await callAiFn(
+ "Du kürzt Texte für Sprachausgabe. Antworte kurz und natürlich.",
+ prompt,
+ )
+ if response and response.errorCount == 0 and response.content:
+ return response.content.strip()
+ except Exception as e:
+ logger.warning(f"Speech summary generation failed: {e}")
+ return cleaned[:1500]
+
+
class CommcoachService:
"""Coaching orchestrator: processes messages, calls AI, extracts tasks and scores."""
@@ -409,14 +728,20 @@ class CommcoachService:
self.instanceId = instanceId
self.userId = str(currentUser.id)
- async def processMessage(self, sessionId: str, contextId: str, userContent: str, interface) -> Dict[str, Any]:
+ async def processMessage(
+ self, sessionId: str, contextId: str, userContent: str, interface,
+ fileIds: Optional[List[str]] = None,
+ dataSourceIds: Optional[List[str]] = None,
+ featureDataSourceIds: Optional[List[str]] = None,
+ allowedProviders: Optional[List[str]] = None,
+ ) -> Dict[str, Any]:
"""
- Process a user message through the coaching pipeline:
+ Process a user message through the agent-based coaching pipeline:
1. Store user message
- 2. Build context with history
- 3. Call AI for coaching response
- 4. Store assistant message
- 5. Emit SSE events
+ 2. Build coaching system prompt + session history
+ 3. Run AgentService with CommCoach RAG and optional tools
+ 4. Map agent events to CommCoach SSE events
+ 5. Post-processing: store message, TTS, tasks, scores
"""
from . import interfaceFeatureCommcoach as interfaceDb
@@ -474,88 +799,62 @@ class CommcoachService:
logger.warning(f"History compression failed for session {sessionId}: {e}")
previousMessages = messages[-20:]
- # Combine all pending user messages (after last assistant message) as the user prompt
combinedUserPrompt = _buildCombinedUserPrompt(previousMessages)
if not combinedUserPrompt:
combinedUserPrompt = userContent
- # Strip pending user messages from previousMessages to avoid redundancy in system prompt
contextMessages = _stripPendingUserMessages(previousMessages)
-
tasks = interface.getTasks(contextId, self.userId)
await emitSessionEvent(sessionId, "status", {"label": "Kontext wird geladen..."})
- retrievalResult = await self._buildRetrievalContext(
- contextId, sessionId, combinedUserPrompt, context, interface
- )
-
persona = _resolvePersona(session, interface)
- documentSummaries = _getDocumentSummaries(
- contextId, self.userId, interface, mandateId=self.mandateId, instanceId=self.instanceId
- )
-
- # Document intent detection (pre-AI-call)
- referencedDocumentContents = None
- allDocs = _getPlatformFileList(self.mandateId, self.instanceId) if documentSummaries else []
- if allDocs:
- await emitSessionEvent(sessionId, "status", {"label": "Dokumente werden geprueft..."})
- docIntent = await _resolveDocumentIntent(combinedUserPrompt, allDocs, self._callAi)
- if not docIntent.get("noDocumentAction"):
- docIdsToLoad = list(set((docIntent.get("read") or []) + (docIntent.get("update") or [])))
- if docIdsToLoad:
- referencedDocumentContents = _loadDocumentContents(
- docIdsToLoad, interface, mandateId=self.mandateId, instanceId=self.instanceId
- )
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
context,
contextMessages,
tasks,
- previousSessionSummaries=retrievalResult.get("previousSessionSummaries"),
earlierSummary=earlierSummary,
- rollingOverview=retrievalResult.get("rollingOverview"),
- retrievedSession=retrievalResult.get("retrievedSession"),
- retrievedByTopic=retrievalResult.get("retrievedByTopic"),
persona=persona,
- documentSummaries=documentSummaries,
- referencedDocumentContents=referencedDocumentContents,
)
- if retrievalResult.get("intent") == RetrievalIntent.SUMMARIZE_ALL:
- systemPrompt += "\n\nWICHTIG: Der Benutzer möchte eine Gesamtzusammenfassung. Erstelle eine umfassende Zusammenfassung aller genannten Sessions und der aktuellen Session."
+ # Build conversation history for the agent
+ conversationHistory = _buildConversationHistory(contextMessages)
+
+ # Dynamic tool activation
+ useTools = _shouldActivateTools(fileIds, dataSourceIds, featureDataSourceIds, combinedUserPrompt)
- # Call AI
await emitSessionEvent(sessionId, "status", {"label": "Coach formuliert Antwort..."})
try:
- aiResponse = await self._callAi(systemPrompt, combinedUserPrompt)
+ agentResponse = await self._runAgent(
+ sessionId=sessionId,
+ prompt=combinedUserPrompt,
+ systemPrompt=systemPrompt,
+ conversationHistory=conversationHistory,
+ context=context,
+ tasks=tasks,
+ fileIds=fileIds,
+ useTools=useTools,
+ allowedProviders=allowedProviders,
+ )
except asyncio.CancelledError:
logger.info(f"processMessage cancelled for session {sessionId} (new message arrived)")
return createdUserMsg
except Exception as e:
- logger.error(f"AI call failed for session {sessionId}: {e}")
+ logger.error(f"Agent call failed for session {sessionId}: {e}")
await emitSessionEvent(sessionId, "error", {"message": f"AI error: {str(e)}"})
return createdUserMsg
- responseRaw = aiResponse.content.strip() if aiResponse and aiResponse.errorCount == 0 else ""
+ textContent = agentResponse or ""
- if not responseRaw:
- parsed = {"text": "Entschuldigung, ich konnte gerade nicht antworten. Bitte versuche es erneut.", "speech": "", "documents": []}
- else:
- parsed = _parseAiJsonResponse(responseRaw)
-
- textContent = parsed.get("text", "")
- speechContent = parsed.get("speech", "")
- documents = parsed.get("documents", [])
+ if not textContent:
+ textContent = "Entschuldigung, ich konnte gerade nicht antworten. Bitte versuche es erneut."
if asyncio.current_task() and asyncio.current_task().cancelled():
logger.info(f"processMessage cancelled before storing response for session {sessionId}")
return createdUserMsg
- for doc in documents:
- await _saveOrUpdateDocument(doc, contextId, self.userId, self.mandateId, self.instanceId, interface, sessionId, user=self.currentUser)
-
assistantMsg = CoachingMessage(
sessionId=sessionId,
contextId=contextId,
@@ -571,8 +870,11 @@ class CommcoachService:
await emitSessionEvent(sessionId, "status", {"label": "Antwort wird verarbeitet..."})
+ # TTS: use free-text directly; for long responses, generate speech summary
+ speechText = await _prepareSpeechText(textContent, self._callAi)
+
ttsTask = asyncio.create_task(
- _generateAndEmitTts(sessionId, speechContent, self.currentUser, self.mandateId, self.instanceId, interface)
+ _generateAndEmitTts(sessionId, speechText, self.currentUser, self.mandateId, self.instanceId, interface)
)
await _emitChunkedResponse(sessionId, createdAssistantMsg, textContent)
await ttsTask
@@ -580,6 +882,75 @@ class CommcoachService:
await emitSessionEvent(sessionId, "complete", {})
return createdAssistantMsg
+ async def _runAgent(
+ self,
+ sessionId: str,
+ prompt: str,
+ systemPrompt: str,
+ conversationHistory: List[Dict[str, Any]],
+ context: Dict[str, Any],
+ tasks: List[Dict[str, Any]],
+ fileIds: Optional[List[str]] = None,
+ useTools: bool = False,
+ allowedProviders: Optional[List[str]] = None,
+ ) -> str:
+ """Run the AgentService for a coaching message. Returns the final text response."""
+ from modules.serviceCenter import getService
+ from modules.serviceCenter.context import ServiceCenterContext
+ from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentConfig, AgentEventTypeEnum
+
+ serviceContext = ServiceCenterContext(
+ user=self.currentUser,
+ mandate_id=self.mandateId,
+ feature_instance_id=self.instanceId,
+ )
+ agentService = getService("agent", serviceContext)
+
+ config = AgentConfig(
+ toolSet="commcoach" if useTools else "none",
+ maxRounds=3 if useTools else 1,
+ temperature=0.4,
+ )
+
+ buildRagContextFn = _createCommcoachRagFn(
+ userId=self.userId,
+ featureInstanceId=self.instanceId,
+ mandateId=self.mandateId,
+ context=context,
+ tasks=tasks,
+ currentUser=self.currentUser,
+ )
+
+ finalText = ""
+ async for event in agentService.runAgent(
+ prompt=prompt,
+ fileIds=fileIds,
+ config=config,
+ toolSet=config.toolSet,
+ workflowId=f"commcoach:{sessionId}",
+ conversationHistory=conversationHistory,
+ buildRagContextFn=buildRagContextFn,
+ systemPromptOverride=systemPrompt,
+ ):
+ if event.type == AgentEventTypeEnum.CHUNK:
+ chunk = event.content or ""
+ finalText += chunk
+ elif event.type == AgentEventTypeEnum.MESSAGE:
+ finalText += event.content or ""
+ elif event.type == AgentEventTypeEnum.FINAL:
+ if not finalText:
+ finalText = event.content or ""
+ elif event.type == AgentEventTypeEnum.TOOL_CALL:
+ await emitSessionEvent(sessionId, "toolCall", event.data or {})
+ elif event.type == AgentEventTypeEnum.TOOL_RESULT:
+ await emitSessionEvent(sessionId, "toolResult", event.data or {})
+ elif event.type == AgentEventTypeEnum.AGENT_PROGRESS:
+ await emitSessionEvent(sessionId, "agentProgress", event.data or {})
+ elif event.type == AgentEventTypeEnum.ERROR:
+ await emitSessionEvent(sessionId, "error", {"message": event.content or "Agent error"})
+
+ return finalText.strip()
+
async def processSessionOpening(self, sessionId: str, contextId: str, interface) -> Dict[str, Any]:
"""
Generate and stream the opening greeting for a new session.
@@ -742,9 +1113,9 @@ class CommcoachService:
})
return session
- # Generate summary (AI returns JSON with summary + emailHtml)
+ # Generate summary (AI returns JSON with summary + structured email payload)
summary = None
- emailHtml = None
+ emailData = None
try:
summaryPrompt = aiPrompts.buildSummaryPrompt(messages, context.get("title", "Coaching"))
summaryResponse = await self._callAi("Du bist ein präziser Zusammenfasser. Antworte NUR als JSON.", summaryPrompt)
@@ -752,7 +1123,10 @@ class CommcoachService:
parsed = aiPrompts.parseJsonResponse(summaryResponse.content.strip(), None)
if isinstance(parsed, dict):
summary = parsed.get("summary") or parsed.get("text")
- emailHtml = parsed.get("emailHtml")
+ if isinstance(parsed.get("email"), dict):
+ emailData = parsed.get("email")
+ elif isinstance(parsed.get("emailData"), dict):
+ emailData = parsed.get("emailData")
else:
summary = summaryResponse.content.strip()
except Exception as e:
@@ -843,6 +1217,40 @@ class CommcoachService:
except Exception as e:
logger.warning(f"Insight generation failed: {e}")
+ # Index session data for RAG-based long-term memory
+ try:
+ from .serviceCommcoachIndexer import indexSessionData
+ from modules.serviceCenter import getService
+ from modules.serviceCenter.context import ServiceCenterContext
+
+ serviceContext = ServiceCenterContext(
+ user=self.currentUser,
+ mandate_id=self.mandateId,
+ feature_instance_id=self.instanceId,
+ )
+ knowledgeService = getService("knowledge", serviceContext)
+ parsedGoals = aiPrompts._parseJsonField(context.get("goals") if context else None, [])
+ parsedInsights = aiPrompts._parseJsonField(context.get("insights") if context else None, [])
+ allTasks = interface.getTasks(contextId, self.userId)
+
+ await indexSessionData(
+ sessionId=sessionId,
+ contextId=contextId,
+ userId=self.userId,
+ featureInstanceId=self.instanceId,
+ mandateId=self.mandateId,
+ messages=messages,
+ summary=summary,
+ keyTopics=keyTopics,
+ goals=parsedGoals,
+ insights=parsedInsights,
+ tasks=allTasks,
+ contextTitle=context.get("title", "Coaching") if context else "Coaching",
+ knowledgeService=knowledgeService,
+ )
+ except Exception as e:
+ logger.warning(f"Coaching session indexing failed (non-blocking): {e}")
+
# Calculate duration
startedAt = session.get("startedAt", "")
durationSeconds = 0
@@ -898,7 +1306,7 @@ class CommcoachService:
# Send email summary
if summary:
contextTitle = context.get("title", "Coaching") if context else "Coaching"
- await self._sendSessionEmail(session, summary, emailHtml, contextTitle, interface)
+ await self._sendSessionEmail(session, summary, emailData, contextTitle, interface)
await emitSessionEvent(sessionId, "sessionState", {
"status": "completed",
@@ -949,8 +1357,15 @@ class CommcoachService:
except Exception as e:
logger.warning(f"Failed to update streak: {e}")
- async def _sendSessionEmail(self, session: Dict[str, Any], summary: str, emailHtml: str, contextTitle: str, interface):
- """Send session summary via email if enabled. Uses AI-generated HTML directly."""
+ async def _sendSessionEmail(
+ self,
+ session: Dict[str, Any],
+ summary: str,
+ emailData: Optional[Dict[str, Any]],
+ contextTitle: str,
+ interface,
+ ):
+ """Send session summary via email with the standard PowerOn layout."""
try:
profile = interface.getProfile(self.userId, self.instanceId)
if profile and not profile.get("emailSummaryEnabled", True):
@@ -958,6 +1373,7 @@ class CommcoachService:
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.interfaces.interfaceDbApp import getRootInterface
+ from modules.shared.notifyMandateAdmins import _renderHtmlEmail, _resolveMandateName
rootInterface = getRootInterface()
user = rootInterface.getUser(self.userId)
@@ -966,9 +1382,18 @@ class CommcoachService:
messaging = getMessagingInterface()
subject = f"Coaching-Session Zusammenfassung: {contextTitle}"
-
- contentHtml = emailHtml if emailHtml else f"{summary}
"
- htmlMessage = _wrapEmailHtml(contentHtml)
+ mandateName = _resolveMandateName(self.mandateId)
+ contentHtml = _buildSummaryEmailBlock(emailData, summary, contextTitle)
+ htmlMessage = _renderHtmlEmail(
+ "Coaching-Session Zusammenfassung",
+ [
+ f'Thema: {contextTitle}',
+ "Hier ist die kompakte Zusammenfassung deiner abgeschlossenen Session.",
+ ],
+ mandateName,
+ footerNote="Diese Zusammenfassung wurde automatisch aus deiner Coaching-Session erstellt.",
+ rawHtmlBlock=contentHtml,
+ )
messaging.send("email", user.email, subject, htmlMessage)
interface.updateSession(session.get("id"), {"emailSent": True})
diff --git a/modules/features/commcoach/serviceCommcoachAi.py b/modules/features/commcoach/serviceCommcoachAi.py
index 97deb373..8b916005 100644
--- a/modules/features/commcoach/serviceCommcoachAi.py
+++ b/modules/features/commcoach/serviceCommcoachAi.py
@@ -168,29 +168,18 @@ Handlungsprinzip:
- Wenn der Benutzer dich bittet, etwas zu erstellen (Dokument, Präsentation, Checkliste, Plan), dann TU ES SOFORT. Frage NICHT nochmals nach Bestätigung.
- Verwende alle verfügbaren Informationen aus dem Chat-Verlauf, den Dokumenten und dem Kontext.
- Wenn der Benutzer sagt "erstelle", "mach", "schreib", dann liefere das fertige Ergebnis — keine Aufzählung von Punkten, die du "gleich umsetzen wirst".
+- Dir wird automatisch relevanter Kontext aus früheren Sessions bereitgestellt (Relevant Knowledge). Nutze diesen für Kontinuität und Bezugnahme auf frühere Gespräche.
Antwortformat:
-Du antwortest IMMER als reines JSON-Objekt mit exakt diesen Feldern:
-{"text": "...", "speech": "...", "documents": []}
+- Antworte direkt als Freitext (KEIN JSON). Markdown-Formatierung ist erlaubt.
+- Halte Antworten gesprächig und kurz (2-6 Sätze im Normalfall), wie in einem echten Coaching-Gespräch.
+- Bei komplexen Themen oder wenn der Benutzer Details anfragt, darf die Antwort ausführlicher sein.
+- Dein Text wird sowohl angezeigt als auch vorgelesen – schreibe daher natürlich und gut sprechbar.
-"text": Dein schriftlicher Chat-Text. Details, Struktur, Übungen, Beispiele. Markdown-Formatierung erlaubt.
-"speech": Dein gesprochener Kommentar. Natürlich, wie ein Gespräch. Fasse zusammen, kommentiere, motiviere, stelle Fragen. Lies NICHT den Text vor, ergänze ihn mündlich. 2-4 Sätze, reiner Redetext ohne Formatierung.
-"documents": Dokumente die der Benutzer aufbewahren kann. Erstelle ein Dokument wenn: der Benutzer explizit darum bittet, du strukturierte Inhalte lieferst, oder Material zum Aufbewahren sinnvoll ist. Wenn keine: leeres Array [].
-
-Dokument-Format:
-{"title": "Dateiname_mit_Extension.html", "content": "...vollstaendiger Inhalt..."}
-- Der Title IST der Dateiname inkl. Extension (.html, .md, .txt etc.)
-- Fuer HTML-Dokumente: Erstelle VOLLSTAENDIGES, professionell gestyltes HTML mit inline CSS. Kein Markdown, sondern fertiges HTML mit Farben, Layout, Typografie.
-- Fuer andere Dokumente: Verwende Markdown.
-- WICHTIG: Der Content muss VOLLSTAENDIG und AUSFUEHRLICH sein. Keine Platzhalter, keine "hier kommt..."-Abschnitte. Schreibe echte, detaillierte Inhalte basierend auf allen verfuegbaren Informationen aus dem Chat und den Dokumenten.
-- Laengenbeschraenkung fuer Dokumente: KEINE. Schreibe so viel wie noetig fuer ein vollstaendiges Ergebnis.
-
-Kanalverteilung:
-- Fakten, Listen, Übungen -> text
-- Empathie, Einordnung, Nachfragen -> speech
-- Erstellte Dateien, Materialien zum Aufbewahren -> documents
-
-WICHTIG: Antworte NUR mit dem JSON-Objekt. Kein Text vor oder nach dem JSON."""
+Tool-Nutzung:
+- Du hast Zugriff auf Tools (Dateien lesen, Web-Suche, Datenquellen abfragen) wenn der Benutzer Dateien/Quellen angehängt hat oder Recherche benötigt.
+- Nutze Tools NUR wenn nötig. Für normales Coaching-Gespräch: antworte direkt ohne Tools.
+- Wenn du ein Tool nutzt, erkläre kurz was du tust."""
if contextDescription:
prompt += f"\n\nKontext-Beschreibung: {contextDescription}"
@@ -279,7 +268,7 @@ Fuer ein NEUES Dokument: {"title": "...", "content": "...Inhalt..."}"""
def buildSummaryPrompt(messages: List[Dict[str, Any]], contextTitle: str) -> str:
- """Build a prompt to generate a session summary as JSON with plain text and styled HTML email."""
+ """Build a prompt to generate a session summary plus structured email content."""
conversation = ""
for msg in messages:
role = "Benutzer" if msg.get("role") == "user" else "Coach"
@@ -287,27 +276,33 @@ def buildSummaryPrompt(messages: List[Dict[str, Any]], contextTitle: str) -> str
return f"""Erstelle eine Zusammenfassung dieser Coaching-Session zum Thema "{contextTitle}".
-Antworte AUSSCHLIESSLICH als JSON mit zwei Feldern:
+Antworte AUSSCHLIESSLICH als JSON im folgenden Format:
{{
- "summary": "Kompakte Zusammenfassung als Plaintext (fuer Anzeige in der App). Struktur: 1. Kernthema, 2. Erkenntnisse, 3. Naechste Schritte, 4. Fortschritt.",
- "emailHtml": "...
"
+ "summary": "Kompakte Plaintext-Zusammenfassung fuer die App. Struktur: Kernthema, Erkenntnisse, Naechste Schritte, Fortschritt.",
+ "email": {{
+ "headline": "Kurze, professionelle Titelzeile fuer die E-Mail",
+ "intro": "1-2 Saetze, die den Kern der Session auf den Punkt bringen",
+ "coreTopic": "Das zentrale Thema in einem praezisen Satz",
+ "insights": ["Erkenntnis 1", "Erkenntnis 2"],
+ "nextSteps": ["Naechster Schritt 1", "Naechster Schritt 2"],
+ "progress": ["Fortschritt 1", "Fortschritt 2"]
+ }}
}}
-Fuer "emailHtml": Erstelle ein professionell formatiertes HTML-Fragment (KEIN vollstaendiges HTML-Dokument, nur der Inhalt-Block).
-Verwende inline CSS fuer schoene Darstellung in E-Mail-Clients:
-- Verwende fuer Abschnitte (color: #1e40af; margin: 20px 0 8px; font-size: 16px)
-- Verwende /- fuer Stichpunkte (margin: 4px 0; line-height: 1.6)
-- Verwende fuer Hervorhebungen
-- Verwende
fuer Fliesstext (color: #374151; line-height: 1.65; font-size: 15px)
-- Verwende
als Trenner
-
-Fuer "summary": Kompakter Plaintext ohne HTML/Markdown. Abschnitte mit Zeilenumbruechen trennen.
+Regeln:
+- KEIN HTML erzeugen.
+- "summary" ist reiner Plaintext ohne Markdown.
+- "headline" kurz und professionell.
+- "intro" in natuerlichem Business-Deutsch.
+- "insights", "nextSteps" und "progress" jeweils als kurze Stichpunkte.
+- Maximal 4 Eintraege pro Liste.
+- Wenn eine Liste leer ist, gib [] zurueck.
Gespräch:
{conversation}
-Antworte auf Deutsch, sachlich und kompakt. NUR JSON, keine Erklaerungen."""
+Antworte auf Deutsch, sachlich, klar und kompakt. NUR JSON, keine Erklaerungen."""
def buildScoringPrompt(messages: List[Dict[str, Any]], contextCategory: str) -> str:
diff --git a/modules/features/commcoach/serviceCommcoachIndexer.py b/modules/features/commcoach/serviceCommcoachIndexer.py
new file mode 100644
index 00000000..b43764a1
--- /dev/null
+++ b/modules/features/commcoach/serviceCommcoachIndexer.py
@@ -0,0 +1,223 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""
+CommCoach Session Indexer.
+Indexes coaching session data into the knowledge store (pgvector) for RAG-based long-term memory.
+Called after session completion to ensure semantic searchability across 20+ sessions.
+"""
+
+import logging
+import uuid
+import json
+from typing import List, Dict, Any, Optional
+
+logger = logging.getLogger(__name__)
+
+_COACHING_FILE_PREFIX = "coaching-session:"
+
+
+async def indexSessionData(
+ sessionId: str,
+ contextId: str,
+ userId: str,
+ featureInstanceId: str,
+ mandateId: str,
+ messages: List[Dict[str, Any]],
+ summary: Optional[str],
+ keyTopics: Optional[str],
+ goals: Optional[List[Any]],
+ insights: Optional[List[Any]],
+ tasks: Optional[List[Dict[str, Any]]],
+ contextTitle: str = "",
+ knowledgeService=None,
+):
+ """Index a completed coaching session into the knowledge store.
+
+ Creates ContentChunks with embeddings for:
+ - Each User+Assistant message pair (maximum detail depth)
+ - Session summary
+ - Key topics (individually, for precise retrieval)
+ - Current goals
+ - New insights
+ - Tasks (open + done)
+ """
+ if not knowledgeService:
+ logger.warning("No knowledge service available for coaching indexer")
+ return
+
+ syntheticFileId = f"{_COACHING_FILE_PREFIX}{sessionId}"
+
+ chunks = []
+
+ # 1. Message pairs (User + Assistant) as individual chunks
+ messagePairs = _extractMessagePairs(messages)
+ for idx, pair in enumerate(messagePairs):
+ chunks.append({
+ "contentObjectId": f"{sessionId}:msg-pair:{idx}",
+ "data": pair["text"],
+ "contextRef": {
+ "containerPath": f"session:{sessionId}",
+ "location": f"message-pair-{idx}",
+ "type": "coaching-message-pair",
+ "contextId": contextId,
+ "sessionId": sessionId,
+ "contextTitle": contextTitle,
+ },
+ })
+
+ # 2. Session summary
+ if summary:
+ chunks.append({
+ "contentObjectId": f"{sessionId}:summary",
+ "data": f"Session-Zusammenfassung ({contextTitle}): {summary}",
+ "contextRef": {
+ "containerPath": f"session:{sessionId}",
+ "location": "summary",
+ "type": "coaching-session-summary",
+ "contextId": contextId,
+ "sessionId": sessionId,
+ "contextTitle": contextTitle,
+ },
+ })
+
+ # 3. Key topics (each as separate chunk for precise retrieval)
+ parsedTopics = _parseJsonSafe(keyTopics, [])
+ for tidx, topic in enumerate(parsedTopics):
+ topicStr = str(topic).strip()
+ if topicStr:
+ chunks.append({
+ "contentObjectId": f"{sessionId}:topic:{tidx}",
+ "data": f"Coaching-Thema ({contextTitle}): {topicStr}",
+ "contextRef": {
+ "containerPath": f"session:{sessionId}",
+ "location": f"topic-{tidx}",
+ "type": "coaching-key-topic",
+ "contextId": contextId,
+ "sessionId": sessionId,
+ "contextTitle": contextTitle,
+ },
+ })
+
+ # 4. Goals
+ if goals:
+ goalTexts = [g.get("text", g) if isinstance(g, dict) else str(g) for g in goals if g]
+ if goalTexts:
+ goalsStr = "\n".join(f"- {g}" for g in goalTexts)
+ chunks.append({
+ "contentObjectId": f"{sessionId}:goals",
+ "data": f"Coaching-Ziele ({contextTitle}):\n{goalsStr}",
+ "contextRef": {
+ "containerPath": f"session:{sessionId}",
+ "location": "goals",
+ "type": "coaching-goals",
+ "contextId": contextId,
+ "sessionId": sessionId,
+ "contextTitle": contextTitle,
+ },
+ })
+
+ # 5. Insights
+ if insights:
+ insightTexts = [i.get("text", i) if isinstance(i, dict) else str(i) for i in insights if i]
+ if insightTexts:
+ insightsStr = "\n".join(f"- {t}" for t in insightTexts)
+ chunks.append({
+ "contentObjectId": f"{sessionId}:insights",
+ "data": f"Coaching-Erkenntnisse ({contextTitle}):\n{insightsStr}",
+ "contextRef": {
+ "containerPath": f"session:{sessionId}",
+ "location": "insights",
+ "type": "coaching-insights",
+ "contextId": contextId,
+ "sessionId": sessionId,
+ "contextTitle": contextTitle,
+ },
+ })
+
+ # 6. Tasks
+ if tasks:
+ taskLines = []
+ for t in tasks:
+ status = t.get("status", "open")
+ title = t.get("title", "")
+ if title:
+ taskLines.append(f"- [{status}] {title}")
+ if taskLines:
+ tasksStr = "\n".join(taskLines)
+ chunks.append({
+ "contentObjectId": f"{sessionId}:tasks",
+ "data": f"Coaching-Aufgaben ({contextTitle}):\n{tasksStr}",
+ "contextRef": {
+ "containerPath": f"session:{sessionId}",
+ "location": "tasks",
+ "type": "coaching-tasks",
+ "contextId": contextId,
+ "sessionId": sessionId,
+ "contextTitle": contextTitle,
+ },
+ })
+
+ if not chunks:
+ logger.info(f"No chunks to index for session {sessionId}")
+ return
+
+ logger.info(f"Indexing {len(chunks)} chunks for coaching session {sessionId}")
+
+ try:
+ contentObjects = [
+ {
+ "contentObjectId": c["contentObjectId"],
+ "contentType": "text",
+ "data": c["data"],
+ "contextRef": c["contextRef"],
+ }
+ for c in chunks
+ ]
+
+ await knowledgeService.indexFile(
+ fileId=syntheticFileId,
+ fileName=f"coaching-session-{sessionId[:8]}",
+ mimeType="application/x-coaching-session",
+ userId=userId,
+ featureInstanceId=featureInstanceId,
+ mandateId=mandateId,
+ contentObjects=contentObjects,
+ )
+ logger.info(f"Successfully indexed coaching session {sessionId} ({len(chunks)} chunks)")
+ except Exception as e:
+ logger.error(f"Failed to index coaching session {sessionId}: {e}", exc_info=True)
+
+
+def _extractMessagePairs(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]:
+ """Extract User+Assistant pairs from message list."""
+ pairs = []
+ i = 0
+ while i < len(messages):
+ msg = messages[i]
+ if msg.get("role") == "user":
+ userText = (msg.get("content") or "").strip()
+ assistantText = ""
+ if i + 1 < len(messages) and messages[i + 1].get("role") == "assistant":
+ assistantText = (messages[i + 1].get("content") or "").strip()
+ i += 2
+ else:
+ i += 1
+ if userText:
+ text = f"Benutzer: {userText}"
+ if assistantText:
+ text += f"\nCoach: {assistantText}"
+ pairs.append({"text": text})
+ else:
+ i += 1
+ return pairs
+
+
+def _parseJsonSafe(value, fallback):
+ if not value:
+ return fallback
+ if isinstance(value, (list, dict)):
+ return value
+ try:
+ return json.loads(value)
+ except (json.JSONDecodeError, TypeError):
+ return fallback
diff --git a/modules/features/commcoach/serviceCommcoachScheduler.py b/modules/features/commcoach/serviceCommcoachScheduler.py
index 3db548cf..dcbc1e86 100644
--- a/modules/features/commcoach/serviceCommcoachScheduler.py
+++ b/modules/features/commcoach/serviceCommcoachScheduler.py
@@ -6,11 +6,44 @@ Handles daily reminders and scheduled email summaries.
"""
import logging
+import html
from typing import Dict, Any, List
logger = logging.getLogger(__name__)
+def _buildReminderHtmlBlock(contextTitles: List[str], streakDays: int) -> str:
+ rows = "".join(
+ ''
+ '| • | '
+ f'{html.escape(title)} | '
+ '
'
+ for title in contextTitles[:3]
+ )
+ topicsBlock = (
+ ''
+ '| '
+ ' Aktive Coaching-Themen '
+ f''
+ ' |
'
+ )
+ streakBlock = (
+ ''
+ '| '
+ ' Dein Rhythmus '
+ f'Aktueller Streak: '
+ f'{int(streakDays or 0)} Tage '
+ ' |
'
+ )
+ return topicsBlock + streakBlock
+
+
def registerScheduledJobs(eventManagement):
"""Register CommCoach scheduled jobs with the event management system."""
try:
@@ -31,6 +64,7 @@ async def _runDailyReminders():
from modules.connectors.connectorDbPostgre import DatabaseConnector
from .datamodelCommcoach import CoachingUserProfile, CoachingContextStatus
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
+ from modules.shared.notifyMandateAdmins import _renderHtmlEmail, _resolveMandateName
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
db = DatabaseConnector(
@@ -71,15 +105,21 @@ async def _runDailyReminders():
contextTitles = [c.get("title", "Unbenannt") for c in contexts[:3]]
contextList = ", ".join(contextTitles)
- subject = "Dein taegliches Coaching wartet"
- message = f"""
- Zeit fuer dein Coaching
- Du hast aktive Coaching-Themen: {contextList}
- Nimm dir 10 Minuten fuer eine kurze Session. Konsistenz ist der Schluessel zu Fortschritt.
- Dein aktueller Streak: {profile.get('streakDays', 0)} Tage
- """
+ subject = "Dein tägliches Coaching wartet"
+ mandateName = _resolveMandateName(profile.get("mandateId"))
+ htmlMessage = _renderHtmlEmail(
+ "Zeit für dein tägliches Coaching",
+ [
+ f"Du hast aktuell {len(contexts)} aktive Coaching-Themen.",
+ "Schon 10 Minuten reichen oft, um einen Gedanken zu klären, eine nächste Aktion festzulegen oder ein Gespräch vorzubereiten.",
+ f"Im Fokus: {contextList}",
+ ],
+ mandateName,
+ footerNote="Diese Erinnerung wurde automatisch auf Basis deiner CommCoach-Einstellungen versendet.",
+ rawHtmlBlock=_buildReminderHtmlBlock(contextTitles, int(profile.get("streakDays", 0) or 0)),
+ )
- messaging.send("email", user.email, subject, message)
+ messaging.send("email", user.email, subject, htmlMessage)
sentCount += 1
except Exception as e:
logger.warning(f"Failed to send reminder to user {profile.get('userId')}: {e}")
diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py
index f0aedc87..a859ffa7 100644
--- a/modules/interfaces/interfaceAiObjects.py
+++ b/modules/interfaces/interfaceAiObjects.py
@@ -134,7 +134,7 @@ class AiObjects:
logger.info(f"Attempting AI call with model: {model.name} (attempt {attempt + 1}/{len(failoverModelList)})")
if request.messages:
- response = await self._callWithMessages(model, request.messages, options, request.tools)
+ response = await self._callWithMessages(model, request.messages, options, request.tools, toolChoice=request.toolChoice)
else:
response = await self._callWithModel(model, prompt, context, options)
@@ -149,7 +149,7 @@ class AiObjects:
await asyncio.sleep(retryAfter + 0.5)
try:
if request.messages:
- response = await self._callWithMessages(model, request.messages, options, request.tools)
+ response = await self._callWithMessages(model, request.messages, options, request.tools, toolChoice=request.toolChoice)
else:
response = await self._callWithModel(model, prompt, context, options)
logger.info(f"AI call successful with {model.name} after rate-limit retry")
@@ -288,7 +288,8 @@ class AiObjects:
async def _callWithMessages(self, model: AiModel, messages: List[Dict[str, Any]],
options: AiCallOptions = None,
- tools: List[Dict[str, Any]] = None) -> AiCallResponse:
+ tools: List[Dict[str, Any]] = None,
+ toolChoice: Any = None) -> AiCallResponse:
"""Call a model with pre-built messages (agent mode). Supports tools for native function calling."""
import json as _json
@@ -302,7 +303,8 @@ class AiObjects:
messages=messages,
model=model,
options=options or {},
- tools=tools
+ tools=tools,
+ toolChoice=toolChoice,
)
modelResponse = await model.functionCall(modelCall)
@@ -379,7 +381,7 @@ class AiObjects:
for attempt, model in enumerate(failoverModelList):
try:
logger.info(f"Streaming AI call with model: {model.name} (attempt {attempt + 1})")
- async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools):
+ async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools, toolChoice=request.toolChoice):
yield chunk
return
@@ -390,7 +392,7 @@ class AiObjects:
logger.info(f"Rate limit on {model.name}, waiting {retryAfter:.1f}s before retry")
await asyncio.sleep(retryAfter + 0.5)
try:
- async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools):
+ async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools, toolChoice=request.toolChoice):
yield chunk
return
except Exception as retryErr:
@@ -421,6 +423,7 @@ class AiObjects:
async def _callWithMessagesStream(
self, model: AiModel, messages: List[Dict[str, Any]],
options: AiCallOptions = None, tools: List[Dict[str, Any]] = None,
+ toolChoice: Any = None,
) -> AsyncGenerator[Union[str, AiCallResponse], None]:
"""Stream a model call. Yields str deltas, then final AiCallResponse with billing."""
from modules.datamodels.datamodelAi import AiModelCall, AiModelResponse
@@ -429,7 +432,7 @@ class AiObjects:
startTime = time.time()
if not model.functionCallStream:
- response = await self._callWithMessages(model, messages, options, tools)
+ response = await self._callWithMessages(model, messages, options, tools, toolChoice=toolChoice)
if response.content:
yield response.content
yield response
@@ -438,6 +441,7 @@ class AiObjects:
modelCall = AiModelCall(
messages=messages, model=model,
options=options or {}, tools=tools,
+ toolChoice=toolChoice,
)
finalModelResponse = None
diff --git a/modules/routes/routeVoiceGoogle.py b/modules/routes/routeVoiceGoogle.py
index 1c796361..309e59bb 100644
--- a/modules/routes/routeVoiceGoogle.py
+++ b/modules/routes/routeVoiceGoogle.py
@@ -444,7 +444,7 @@ async def health_check(currentUser: User = Depends(getCurrentUser)):
async def get_voice_settings(currentUser: User = Depends(getCurrentUser)):
"""Get voice settings for the current user (reads from UserVoicePreferences)."""
from modules.datamodels.datamodelUam import UserVoicePreferences
- from modules.security.rootAccess import getRootInterface
+ from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
userId = str(currentUser.id)
@@ -464,7 +464,7 @@ async def save_voice_settings(
):
"""Save voice settings for the current user (writes to UserVoicePreferences)."""
from modules.datamodels.datamodelUam import UserVoicePreferences, _normalizeTtsVoiceMap
- from modules.security.rootAccess import getRootInterface
+ from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
userId = str(currentUser.id)
diff --git a/modules/serviceCenter/services/serviceAgent/agentLoop.py b/modules/serviceCenter/services/serviceAgent/agentLoop.py
index c196d237..fa76141d 100644
--- a/modules/serviceCenter/services/serviceAgent/agentLoop.py
+++ b/modules/serviceCenter/services/serviceAgent/agentLoop.py
@@ -48,6 +48,7 @@ async def runAgentLoop(
conversationHistory: List[Dict[str, Any]] = None,
persistRoundMemoryFn: Callable[..., Awaitable[None]] = None,
getExternalMemoryKeysFn: Callable[[], List[str]] = None,
+ systemPromptOverride: str = None,
) -> AsyncGenerator[AgentEvent, None]:
"""Run the agent loop. Yields AgentEvent for each step (SSE-ready).
@@ -74,16 +75,20 @@ async def runAgentLoop(
featureInstanceId=featureInstanceId
)
- tools = toolRegistry.getTools()
- toolDefinitions = toolRegistry.formatToolsForFunctionCalling()
+ activeToolSet = config.toolSet if config else None
+ tools = toolRegistry.getTools(toolSet=activeToolSet)
+ toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet)
# Text-based tool descriptions are ONLY used as fallback when native function
# calling is unavailable. Including both creates conflicting instructions
# (text ```tool_call format vs native tool_use blocks) and can cause the model
# to respond with plain text instead of actual tool calls.
- toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt()
+ toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet)
- systemPrompt = buildSystemPrompt(tools, toolsText, userLanguage=userLanguage)
+ if systemPromptOverride:
+ systemPrompt = systemPromptOverride
+ else:
+ systemPrompt = buildSystemPrompt(tools, toolsText, userLanguage=userLanguage)
conversation = ConversationManager(systemPrompt)
if conversationHistory:
conversation.loadHistory(conversationHistory)
@@ -168,7 +173,7 @@ async def runAgentLoop(
temperature=config.temperature
),
messages=conversation.messages,
- tools=toolDefinitions
+ tools=toolDefinitions if toolDefinitions else None,
)
try:
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index 08950ea3..b370b827 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -132,6 +132,8 @@ class AgentService:
additionalTools: List[Dict[str, Any]] = None,
userLanguage: str = "",
conversationHistory: List[Dict[str, Any]] = None,
+ buildRagContextFn: Callable = None,
+ systemPromptOverride: str = None,
) -> AsyncGenerator[AgentEvent, None]:
"""Run an agent with the given prompt and tools.
@@ -144,6 +146,8 @@ class AgentService:
additionalTools: Extra tool definitions to register dynamically
userLanguage: ISO 639-1 language code; falls back to user.language from profile
conversationHistory: Prior messages for follow-up context
+ buildRagContextFn: Optional custom RAG context builder (overrides default)
+ systemPromptOverride: Optional system prompt override (replaces generated prompt)
Yields:
AgentEvent for each step (SSE-ready)
@@ -163,7 +167,8 @@ class AgentService:
aiCallFn = self._createAiCallFn()
aiCallStreamFn = self._createAiCallStreamFn()
getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId)
- buildRagContextFn = self._createBuildRagContextFn()
+ if buildRagContextFn is None:
+ buildRagContextFn = self._createBuildRagContextFn()
persistRoundMemoryFn = self._createPersistRoundMemoryFn(workflowId)
getExternalMemoryKeysFn = self._createGetExternalMemoryKeysFn(workflowId)
@@ -183,6 +188,7 @@ class AgentService:
conversationHistory=conversationHistory,
persistRoundMemoryFn=persistRoundMemoryFn,
getExternalMemoryKeysFn=getExternalMemoryKeysFn,
+ systemPromptOverride=systemPromptOverride,
):
if event.type == AgentEventTypeEnum.AGENT_SUMMARY:
await self._persistTrace(workflowId, event.data or {})
@@ -2610,54 +2616,54 @@ def _registerCoreTools(registry: ToolRegistry, services):
if not voiceName:
try:
from modules.datamodels.datamodelUam import UserVoicePreferences
- from modules.security.rootAccess import getRootInterface
+ from modules.interfaces.interfaceDbApp import getRootInterface
userId = context.get("userId", "")
if userId:
rootIf = getRootInterface()
prefRecords = rootIf.db.getRecordset(
UserVoicePreferences,
- recordFilter={"userId": userId, "mandateId": mandateId}
+ recordFilter={"userId": userId}
)
- if not prefRecords and mandateId:
- prefRecords = rootIf.db.getRecordset(
- UserVoicePreferences,
- recordFilter={"userId": userId}
- )
if prefRecords:
- vs = prefRecords[0] if isinstance(prefRecords[0], dict) else prefRecords[0].model_dump() if hasattr(prefRecords[0], "model_dump") else prefRecords[0]
- voiceMap = vs.get("ttsVoiceMap", {}) or {}
- if isinstance(voiceMap, dict) and voiceMap:
- selectedKey = None
- selectedVoiceEntry = None
- baseLanguage = language.split("-")[0].lower() if isinstance(language, str) and language else ""
+ allPrefs = [
+ r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else r
+ for r in prefRecords
+ ]
+ _mid = str(mandateId or "").strip()
+ scopedPref = next((p for p in allPrefs if str(p.get("mandateId") or "").strip() == _mid), None)
+ globalPref = next((p for p in allPrefs if not str(p.get("mandateId") or "").strip()), None)
- if isinstance(language, str) and language in voiceMap:
- selectedKey = language
- selectedVoiceEntry = voiceMap[language]
+ def _resolveVoiceFromMap(prefDict, lang):
+ vm = (prefDict or {}).get("ttsVoiceMap", {}) or {}
+ if not isinstance(vm, dict) or not vm:
+ return None
+ baseLang = lang.split("-")[0].lower() if isinstance(lang, str) and lang else ""
+ langNorm = str(lang or "").strip()
+ if langNorm in vm:
+ entry = vm[langNorm]
+ return entry.get("voiceName") if isinstance(entry, dict) else entry
+ if baseLang and baseLang in vm:
+ entry = vm[baseLang]
+ return entry.get("voiceName") if isinstance(entry, dict) else entry
+ if baseLang:
+ for mk, mv in vm.items():
+ mkn = str(mk).lower()
+ if mkn == baseLang or mkn.startswith(f"{baseLang}-"):
+ return mv.get("voiceName") if isinstance(mv, dict) else mv
+ return None
- if selectedVoiceEntry is None and baseLanguage and baseLanguage in voiceMap:
- selectedKey = baseLanguage
- selectedVoiceEntry = voiceMap[baseLanguage]
-
- if selectedVoiceEntry is None and baseLanguage:
- for mapKey, mapValue in voiceMap.items():
- mapKeyNorm = str(mapKey).lower()
- if mapKeyNorm == baseLanguage or mapKeyNorm.startswith(f"{baseLanguage}-"):
- selectedKey = str(mapKey)
- selectedVoiceEntry = mapValue
- break
-
- if selectedVoiceEntry is not None:
- voiceName = (
- selectedVoiceEntry.get("voiceName")
- if isinstance(selectedVoiceEntry, dict)
- else selectedVoiceEntry
- )
- logger.info(
- f"textToSpeech: using configured voice '{voiceName}' for requested language '{language}' (matched key '{selectedKey}')"
- )
- if not voiceName and vs.get("ttsVoice") and vs.get("ttsLanguage") == language:
- voiceName = vs["ttsVoice"]
+ voiceName = (
+ _resolveVoiceFromMap(scopedPref, language)
+ or _resolveVoiceFromMap(globalPref, language)
+ or _resolveVoiceFromMap(allPrefs[0], language)
+ )
+ if not voiceName:
+ for candidate in [globalPref, scopedPref, allPrefs[0]]:
+ if candidate and candidate.get("ttsVoice") and candidate.get("ttsLanguage") == language:
+ voiceName = candidate["ttsVoice"]
+ break
+ if voiceName:
+ logger.info(f"textToSpeech: using configured voice '{voiceName}' for language '{language}'")
except Exception as prefErr:
logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}")
@@ -3416,3 +3422,21 @@ def _registerCoreTools(registry: ToolRegistry, services):
},
readOnly=True,
)
+
+ # Tag core-only tools so restricted toolSets (e.g. "commcoach") exclude them.
+ # Tools NOT in this set remain toolSet=None → available to ALL sets.
+ _CORE_ONLY_TOOLS = {
+ "listFiles", "listFolders", "tagFile", "moveFile", "createFolder",
+ "writeFile", "deleteFile", "renameFile", "translateText",
+ "deleteFolder", "renameFolder", "moveFolder", "copyFile", "replaceInFile",
+ "listConnections", "uploadToExternal", "sendMail", "downloadFromDataSource",
+ "browseContainer", "readContentObjects", "extractContainerItem",
+ "summarizeContent", "describeImage", "renderDocument",
+ "textToSpeech", "generateImage", "createChart",
+ "speechToText", "detectLanguage", "neutralizeData", "executeCode",
+ "listWorkflowHistory", "readWorkflowMessages",
+ }
+ for _toolName in _CORE_ONLY_TOOLS:
+ _td = registry.getTool(_toolName)
+ if _td:
+ _td.toolSet = "core"
diff --git a/modules/serviceCenter/services/serviceAgent/toolRegistry.py b/modules/serviceCenter/services/serviceAgent/toolRegistry.py
index d241bb93..b4b5cd86 100644
--- a/modules/serviceCenter/services/serviceAgent/toolRegistry.py
+++ b/modules/serviceCenter/services/serviceAgent/toolRegistry.py
@@ -125,20 +125,22 @@ class ToolRegistry:
durationMs=durationMs
)
- def formatToolsForPrompt(self) -> str:
- """Format all tools as text for system prompt (text-based fallback)."""
+ def formatToolsForPrompt(self, toolSet: str = None) -> str:
+ """Format tools as text for system prompt (text-based fallback)."""
+ tools = self.getTools(toolSet=toolSet) if toolSet else list(self._tools.values())
parts = []
- for tool in self._tools.values():
+ for tool in tools:
paramStr = ", ".join(
f"{k}: {v}" for k, v in tool.parameters.items()
) if tool.parameters else "none"
parts.append(f"- **{tool.name}**: {tool.description}\n Parameters: {{{paramStr}}}")
return "\n".join(parts)
- def formatToolsForFunctionCalling(self) -> List[Dict[str, Any]]:
- """Format all tools as OpenAI-compatible function definitions for native function calling."""
+ def formatToolsForFunctionCalling(self, toolSet: str = None) -> List[Dict[str, Any]]:
+ """Format tools as OpenAI-compatible function definitions for native function calling."""
+ tools = self.getTools(toolSet=toolSet) if toolSet else list(self._tools.values())
functions = []
- for tool in self._tools.values():
+ for tool in tools:
functions.append({
"type": "function",
"function": {
From 563018b5e1ace8364600fe8f72fdb789c539a82b Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Wed, 1 Apr 2026 22:16:08 +0200
Subject: [PATCH 29/33] fixed tool execute subprocess
---
.../services/serviceAgent/sandboxExecutor.py | 14 ++++----------
1 file changed, 4 insertions(+), 10 deletions(-)
diff --git a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py
index 1882d7eb..15362e65 100644
--- a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py
+++ b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py
@@ -3,7 +3,6 @@
"""Sandboxed code execution for the AI agent executeCode tool."""
import logging
-import signal
import sys
import io
import traceback
@@ -72,15 +71,12 @@ async def executePython(code: str) -> Dict[str, Any]:
sys.stdout = capturedOutput
sys.stderr = capturedOutput
- if sys.platform != "win32":
- signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError("Execution timed out")))
- signal.alarm(_MAX_EXECUTION_TIME_S)
+ # Do not use signal.SIGALRM here: _run executes inside a thread-pool worker
+ # (asyncio.run_in_executor). signal.signal only works on the main thread.
+ # Wall-clock limit is enforced by asyncio.wait_for around run_in_executor.
exec(compile(code, "", "exec"), restrictedGlobals)
- if sys.platform != "win32":
- signal.alarm(0)
-
output = capturedOutput.getvalue()
if len(output) > _MAX_OUTPUT_CHARS:
output = output[:_MAX_OUTPUT_CHARS] + f"\n... (truncated at {_MAX_OUTPUT_CHARS} chars)"
@@ -94,14 +90,12 @@ async def executePython(code: str) -> Dict[str, Any]:
finally:
sys.stdout = oldStdout
sys.stderr = oldStderr
- if sys.platform != "win32":
- signal.alarm(0)
loop = asyncio.get_event_loop()
try:
result = await asyncio.wait_for(
loop.run_in_executor(None, _run),
- timeout=_MAX_EXECUTION_TIME_S + 5,
+ timeout=float(_MAX_EXECUTION_TIME_S) + 5.0,
)
return result
except asyncio.TimeoutError:
From 93f28f57df1910efe6bf92819995eed1c68b26a4 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 2 Apr 2026 11:58:34 +0200
Subject: [PATCH 30/33] fix sequenst subscription and mandate
---
modules/interfaces/interfaceDbApp.py | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 01863b41..5e346a86 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1492,9 +1492,8 @@ class AppObjects:
if not adminRoleId:
raise ValueError(f"No admin role found for mandate {mandateId} — cannot assign user without role")
- self.createUserMandate(userId, mandateId, roleIds=[adminRoleId], skipCapacityCheck=True)
-
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
+ from modules.interfaces.interfaceDbBilling import _getRootInterface as _getBillingRoot
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
@@ -1514,6 +1513,20 @@ class AppObjects:
subInterface = _getSubRoot()
subInterface.createSubscription(subscription)
+ try:
+ billingRoot = _getBillingRoot()
+ billingRoot.getOrCreateSettings(mandateId)
+ billingRoot.ensureActivationBudget(mandateId, planKey)
+ except Exception as billingEx:
+ logger.error(
+ "Initial billing setup failed for mandate %s (plan=%s): %s",
+ mandateId,
+ planKey,
+ billingEx,
+ )
+
+ self.createUserMandate(userId, mandateId, roleIds=[adminRoleId], skipCapacityCheck=True)
+
featureInterface = getFeatureInterface(self.db)
mainModules = loadFeatureMainModules()
createdInstances = []
@@ -1552,6 +1565,8 @@ class AppObjects:
except Exception as e:
logger.error(f"Error auto-creating instance for '{featureName}': {e}")
+ self._syncSubscriptionQuantity(mandateId)
+
logger.info(f"Provisioned mandate {mandateId} (plan={planKey}) for user {userId}, instances={createdInstances}")
return {
"mandateId": mandateId,
From 268c4b8e1e3112e032d0635bc71f1235b1f29d09 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 2 Apr 2026 13:09:04 +0200
Subject: [PATCH 31/33] prices
---
modules/datamodels/datamodelSubscription.py | 8 +-
.../serviceSubscription/stripeBootstrap.py | 118 +++++++++++++++---
2 files changed, 107 insertions(+), 19 deletions(-)
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index 8fcf10f2..227ba5eb 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -217,8 +217,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
},
billingPeriod=BillingPeriodEnum.MONTHLY,
- pricePerUserCHF=90.0,
- pricePerFeatureInstanceCHF=150.0,
+ pricePerUserCHF=19.0,
+ pricePerFeatureInstanceCHF=29.0,
maxDataVolumeMB=1024,
budgetAiCHF=10.0,
),
@@ -231,8 +231,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.",
},
billingPeriod=BillingPeriodEnum.YEARLY,
- pricePerUserCHF=1080.0,
- pricePerFeatureInstanceCHF=1800.0,
+ pricePerUserCHF=228.0,
+ pricePerFeatureInstanceCHF=348.0,
maxDataVolumeMB=1024,
budgetAiCHF=120.0,
),
diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
index 14e9424a..869ab52f 100644
--- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
+++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py
@@ -9,6 +9,12 @@ so that invoice line items show clear, descriptive names:
- "Feature-Instanzen"
Idempotent — safe to call on every startup.
+
+Source of truth for unit amounts is BUILTIN_PLANS (CHF). On each run, persisted
+Stripe Price IDs are reconciled: if Stripe's unit_amount differs from the
+catalog, a new Price is created, the old one is archived, and poweron_billing
+StripePlanPrice is updated. Other stale active Prices on the same Product
+(same recurring interval) are archived so only the catalog-matching Price stays active.
"""
import logging
@@ -93,12 +99,25 @@ def _createStripeProduct(stripe, name: str, description: str, planKey: str, line
return product.id
-def _findExistingStripePrice(stripe, productId: str, unitAmount: int, interval: str) -> Optional[str]:
+def _recurringMatches(recurring: Dict, interval: str, intervalCount: int) -> bool:
+ if not recurring:
+ return False
+ if recurring.get("interval") != interval:
+ return False
+ ic = recurring.get("interval_count")
+ if ic is None:
+ ic = 1
+ return int(ic) == int(intervalCount)
+
+
+def _findExistingStripePrice(
+ stripe, productId: str, unitAmount: int, interval: str, intervalCount: int = 1,
+) -> Optional[str]:
try:
prices = stripe.Price.list(product=productId, active=True, limit=50)
for p in prices.data:
recurring = p.get("recurring") or {}
- if p.get("unit_amount") == unitAmount and recurring.get("interval") == interval:
+ if p.get("unit_amount") == unitAmount and _recurringMatches(recurring, interval, intervalCount):
return p.id
except Exception:
pass
@@ -115,24 +134,43 @@ def _getStripePriceAmount(stripe, priceId: str) -> Optional[int]:
return None
-def _reconcilePrice(stripe, productId: str, oldPriceId: str, expectedCHF: float, interval: str, nickname: str) -> str:
+def _reconcilePrice(
+ stripe,
+ productId: str,
+ oldPriceId: str,
+ expectedCHF: float,
+ interval: str,
+ nickname: str,
+ intervalCount: int = 1,
+) -> str:
"""If the stored Stripe Price has a different amount, create a new one and deactivate the old."""
- expectedCents = int(expectedCHF * 100)
- actualCents = _getStripePriceAmount(stripe, oldPriceId)
+ from modules.shared.stripeClient import stripeToDict
- if actualCents == expectedCents:
+ expectedCents = int(round(expectedCHF * 100))
+ actualCents = _getStripePriceAmount(stripe, oldPriceId)
+ matchesRecurring = False
+ try:
+ raw = stripe.Price.retrieve(oldPriceId)
+ pd = stripeToDict(raw)
+ matchesRecurring = _recurringMatches(pd.get("recurring") or {}, interval, intervalCount)
+ except Exception:
+ pass
+
+ if actualCents == expectedCents and matchesRecurring:
return oldPriceId
logger.warning(
- "Price drift detected for %s: Stripe has %s Rappen, catalog expects %s Rappen. Rotating price.",
+ "Price drift or recurring mismatch for %s: Stripe amount=%s Rappen (expected %s). Rotating price.",
oldPriceId, actualCents, expectedCents,
)
- existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval)
+ existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval, intervalCount)
if existingMatch:
newPriceId = existingMatch
else:
- newPriceId = _createStripePrice(stripe, productId, expectedCHF, interval, nickname)
+ newPriceId = _createStripePrice(
+ stripe, productId, expectedCHF, interval, nickname, intervalCount,
+ )
try:
stripe.Price.modify(oldPriceId, active=False)
@@ -143,18 +181,45 @@ def _reconcilePrice(stripe, productId: str, oldPriceId: str, expectedCHF: float,
return newPriceId
-def _createStripePrice(stripe, productId: str, unitAmountCHF: float, interval: str, nickname: str) -> str:
+def _createStripePrice(
+ stripe, productId: str, unitAmountCHF: float, interval: str, nickname: str, intervalCount: int = 1,
+) -> str:
price = stripe.Price.create(
product=productId,
- unit_amount=int(unitAmountCHF * 100),
+ unit_amount=int(round(unitAmountCHF * 100)),
currency="chf",
- recurring={"interval": interval},
+ recurring={"interval": interval, "interval_count": intervalCount},
nickname=nickname,
)
logger.info("Created Stripe Price %s (%s, %s CHF/%s)", price.id, nickname, unitAmountCHF, interval)
return price.id
+def _archiveOtherRecurringPrices(
+ stripe, productId: Optional[str], keepPriceId: Optional[str], interval: str, intervalCount: int = 1,
+) -> None:
+ """Archive every other active recurring price on the product (same interval pattern)."""
+ if not productId or not keepPriceId:
+ return
+ try:
+ prices = stripe.Price.list(product=productId, active=True, limit=100)
+ for p in prices.data:
+ if p.id == keepPriceId:
+ continue
+ recurring = p.get("recurring") or {}
+ if not recurring:
+ continue
+ if not _recurringMatches(recurring, interval, intervalCount):
+ continue
+ try:
+ stripe.Price.modify(p.id, active=False)
+ logger.info("Archived stale Stripe Price %s on product %s", p.id, productId)
+ except Exception as ex:
+ logger.warning("Could not archive price %s: %s", p.id, ex)
+ except Exception as e:
+ logger.warning("Stale price archive pass failed for product %s: %s", productId, e)
+
+
def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:
"""Quick check whether at least the stored product IDs still exist in Stripe.
Returns False when running against a different Stripe account or after DB copy."""
@@ -195,6 +260,7 @@ def bootstrapStripePrices() -> None:
continue
interval = stripePeriod["interval"]
+ intervalCount = int(stripePeriod.get("interval_count") or 1)
if planKey in existing:
mapping = existing[planKey]
@@ -206,6 +272,7 @@ def bootstrapStripePrices() -> None:
reconciledUsers = _reconcilePrice(
stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers,
plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
+ intervalCount,
)
if reconciledUsers != mapping.stripePriceIdUsers:
changed = True
@@ -213,16 +280,27 @@ def bootstrapStripePrices() -> None:
reconciledInstances = _reconcilePrice(
stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances,
plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz",
+ intervalCount,
)
if reconciledInstances != mapping.stripePriceIdInstances:
changed = True
+ _archiveOtherRecurringPrices(
+ stripe, mapping.stripeProductIdUsers, reconciledUsers, interval, intervalCount,
+ )
+ _archiveOtherRecurringPrices(
+ stripe, mapping.stripeProductIdInstances, reconciledInstances, interval, intervalCount,
+ )
+
if changed:
db.recordModify(StripePlanPrice, mapping.id, {
"stripePriceIdUsers": reconciledUsers,
"stripePriceIdInstances": reconciledInstances,
})
- logger.info("Reconciled Stripe prices for plan %s: users=%s, instances=%s", planKey, reconciledUsers, reconciledInstances)
+ logger.info(
+ "Reconciled Stripe prices for plan %s to catalog (CHF): users=%s, instances=%s",
+ planKey, reconciledUsers, reconciledInstances,
+ )
else:
logger.debug("Stripe prices up-to-date for plan %s", planKey)
continue
@@ -245,11 +323,16 @@ def bootstrapStripePrices() -> None:
stripe, "Benutzer-Lizenzen", f"Benutzer-Lizenzen für {plan.title.get('de', planKey)}",
planKey, "users",
)
- priceIdUsers = _findExistingStripePrice(stripe, productIdUsers, int(plan.pricePerUserCHF * 100), interval)
+ userCents = int(round(plan.pricePerUserCHF * 100))
+ priceIdUsers = _findExistingStripePrice(
+ stripe, productIdUsers, userCents, interval, intervalCount,
+ )
if not priceIdUsers:
priceIdUsers = _createStripePrice(
stripe, productIdUsers, plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz",
+ intervalCount,
)
+ _archiveOtherRecurringPrices(stripe, productIdUsers, priceIdUsers, interval, intervalCount)
if plan.pricePerFeatureInstanceCHF > 0:
productIdInstances = _findStripeProduct(stripe, planKey, "instances")
@@ -258,14 +341,19 @@ def bootstrapStripePrices() -> None:
stripe, "Feature-Instanzen", f"Feature-Instanzen für {plan.title.get('de', planKey)}",
planKey, "instances",
)
+ instCents = int(round(plan.pricePerFeatureInstanceCHF * 100))
priceIdInstances = _findExistingStripePrice(
- stripe, productIdInstances, int(plan.pricePerFeatureInstanceCHF * 100), interval,
+ stripe, productIdInstances, instCents, interval, intervalCount,
)
if not priceIdInstances:
priceIdInstances = _createStripePrice(
stripe, productIdInstances, plan.pricePerFeatureInstanceCHF, interval,
f"{planKey} — Feature-Instanz",
+ intervalCount,
)
+ _archiveOtherRecurringPrices(
+ stripe, productIdInstances, priceIdInstances, interval, intervalCount,
+ )
persistData = {
"stripeProductId": "",
From ecbdd1ea74c9ef944f3b3b9edebb750217f36185 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 2 Apr 2026 14:12:49 +0200
Subject: [PATCH 32/33] pricing
---
modules/datamodels/datamodelSubscription.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index 227ba5eb..1791e7a9 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -217,8 +217,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
},
billingPeriod=BillingPeriodEnum.MONTHLY,
- pricePerUserCHF=19.0,
- pricePerFeatureInstanceCHF=29.0,
+ pricePerUserCHF=79.0,
+ pricePerFeatureInstanceCHF=119.0,
maxDataVolumeMB=1024,
budgetAiCHF=10.0,
),
@@ -231,8 +231,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.",
},
billingPeriod=BillingPeriodEnum.YEARLY,
- pricePerUserCHF=228.0,
- pricePerFeatureInstanceCHF=348.0,
+ pricePerUserCHF=948.0,
+ pricePerFeatureInstanceCHF=1428.0,
maxDataVolumeMB=1024,
budgetAiCHF=120.0,
),
From 50bf59879fded9d7ee7b7bbad6e75f63e7892802 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 2 Apr 2026 23:53:36 +0200
Subject: [PATCH 33/33] fix: mandate subscription provisioning, capacity
errors, invitations API
Made-with: Cursor
---
modules/interfaces/interfaceDbApp.py | 4 ++-
modules/routes/routeDataMandates.py | 6 ++++
modules/routes/routeInvitations.py | 3 +-
.../mainServiceSubscription.py | 33 ++++++++++++++++---
4 files changed, 39 insertions(+), 7 deletions(-)
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 5e346a86..d52c23d6 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -1990,8 +1990,10 @@ class AppObjects:
cleanedRecord = dict(createdRecord)
return UserMandate(**cleanedRecord)
except Exception as e:
+ if e.__class__.__name__ == "SubscriptionCapacityException":
+ raise
logger.error(f"Error creating UserMandate: {e}")
- raise ValueError(f"Failed to create UserMandate: {e}")
+ raise ValueError(f"Failed to create UserMandate: {e}") from e
def _ensureUserBillingAccount(self, userId: str, mandateId: str) -> None:
"""
diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py
index 1615a03a..cb6a3efc 100644
--- a/modules/routes/routeDataMandates.py
+++ b/modules/routes/routeDataMandates.py
@@ -31,6 +31,7 @@ from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeNotifications import create_access_change_notification
+from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
# =============================================================================
@@ -795,6 +796,11 @@ def add_user_to_mandate(
except HTTPException:
raise
+ except SubscriptionCapacityException as cap:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=cap.message,
+ )
except Exception as e:
logger.error(f"Error adding user to mandate: {e}")
raise HTTPException(
diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py
index 8e3be0ba..6e34eb88 100644
--- a/modules/routes/routeInvitations.py
+++ b/modules/routes/routeInvitations.py
@@ -41,11 +41,10 @@ class InvitationCreate(BaseModel):
- Mandate-level: featureInstanceId omitted, roleIds are mandate-level roles (user, viewer, admin)
- Feature-instance-level: featureInstanceId required, roleIds are instance-level roles
- Email is required for new users; targetUsername is optional.
At least one of email or targetUsername must be provided.
"""
targetUsername: Optional[str] = Field(None, description="Username of the user to invite (must match on acceptance)")
- email: Optional[str] = Field(None, description="Email address to send invitation link (required for new users)")
+ email: Optional[str] = Field(None, description="Email address to send invitation link (optional if targetUsername is set)")
featureInstanceId: Optional[str] = Field(None, description="Feature instance to grant access to (optional for mandate-level invitations)")
roleIds: List[str] = Field(..., description="Role IDs: mandate-level (user, viewer, admin) or instance-level")
frontendUrl: str = Field(..., description="Frontend URL for building the invite link (provided by frontend)")
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index 9535a2da..89e20112 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -786,15 +786,40 @@ class SubscriptionInactiveException(Exception):
return out
+_SUBSCRIPTION_LIMITS_UI_HINT_DE = (
+ " Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
+ "Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
+)
+
+
class SubscriptionCapacityException(Exception):
def __init__(self, resourceType: str, currentCount: int, maxAllowed: int, message: Optional[str] = None):
self.resourceType = resourceType
self.currentCount = currentCount
self.maxAllowed = maxAllowed
- self.message = message or (
- f"Ihr Plan erlaubt maximal {maxAllowed} {'Benutzer' if resourceType == 'users' else 'Feature-Instanzen'} "
- f"(aktuell {currentCount}). Bitte wechseln Sie zu einem grösseren Plan."
- )
+ if message is not None:
+ self.message = message
+ elif resourceType == "users":
+ self.message = (
+ f"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
+ f"Benutzer zulässig (derzeit {currentCount}). "
+ f"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
+ ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
+ elif resourceType == "featureInstances":
+ self.message = (
+ f"Es sind höchstens {maxAllowed} aktive Feature-Instanzen erlaubt (derzeit {currentCount}). "
+ f"Bitte Abonnement erweitern oder eine Instanz entfernen."
+ ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
+ elif resourceType == "dataVolumeMB":
+ self.message = (
+ f"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
+ f"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
+ ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
+ else:
+ self.message = (
+ f"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
+ f"aktuell {currentCount}, erlaubt {maxAllowed})."
+ ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
super().__init__(self.message)
def toClientDict(self) -> Dict[str, Any]: