From 8b161ed41066b2dc1738403cb6434c9ca08730c0 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 24 Mar 2026 14:16:46 +0100
Subject: [PATCH] unified data - step 1
---
modules/datamodels/datamodelDataSource.py | 17 +
modules/datamodels/datamodelFiles.py | 17 +
modules/datamodels/datamodelKnowledge.py | 10 +
modules/datamodels/datamodelSubscription.py | 6 +
modules/datamodels/datamodelUam.py | 60 ++-
modules/datamodels/datamodelVoice.py | 6 +-
.../datamodelFeatureNeutralizer.py | 17 +
.../interfaceFeatureNeutralizer.py | 15 +
.../neutralization/neutralizePlayground.py | 6 +
.../neutralization/routeFeatureNeutralizer.py | 60 +++
.../workspace/datamodelFeatureWorkspace.py | 39 +-
.../workspace/interfaceFeatureWorkspace.py | 118 +----
modules/interfaces/interfaceBootstrap.py | 24 +-
modules/interfaces/interfaceDbApp.py | 225 +++++++-
modules/interfaces/interfaceDbKnowledge.py | 97 +++-
modules/migration/__init__.py | 1 +
modules/migration/migrateRootUsers.py | 213 ++++++++
modules/routes/routeDataFiles.py | 71 +++
modules/routes/routeSecurityGoogle.py | 12 +-
modules/routes/routeSecurityLocal.py | 168 +++++-
modules/routes/routeStore.py | 479 ++++++++++--------
.../services/serviceAgent/mainServiceAgent.py | 1 +
.../services/serviceAi/mainServiceAi.py | 80 ++-
.../serviceKnowledge/mainServiceKnowledge.py | 55 +-
.../methodContext/actions/neutralizeData.py | 12 +-
modules/workflows/workflowManager.py | 48 +-
tests/test_phase123_basic.py | 323 ++++++++++++
27 files changed, 1764 insertions(+), 416 deletions(-)
create mode 100644 modules/migration/__init__.py
create mode 100644 modules/migration/migrateRootUsers.py
create mode 100644 tests/test_phase123_basic.py
diff --git a/modules/datamodels/datamodelDataSource.py b/modules/datamodels/datamodelDataSource.py
index f8238fab..47578b03 100644
--- a/modules/datamodels/datamodelDataSource.py
+++ b/modules/datamodels/datamodelDataSource.py
@@ -30,6 +30,21 @@ class DataSource(BaseModel):
autoSync: bool = Field(default=False, description="Automatically sync on schedule")
lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp")
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
+ scope: str = Field(
+ default="personal",
+ description="Data visibility scope: personal, featureInstance, mandate, global",
+ json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
+ {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
+ {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
+ {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
+ {"value": "global", "label": {"en": "Global", "de": "Global"}},
+ ]}
+ )
+ neutralize: bool = Field(
+ default=False,
+ description="Whether this data source should be neutralized before AI processing",
+ json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
+ )
registerModelLabels(
@@ -48,6 +63,8 @@ registerModelLabels(
"autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"},
"lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"},
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
+ "scope": {"en": "Scope", "de": "Sichtbarkeit"},
+ "neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
},
)
diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py
index afaad996..f95a0ef1 100644
--- a/modules/datamodels/datamodelFiles.py
+++ b/modules/datamodels/datamodelFiles.py
@@ -24,6 +24,21 @@ class FileItem(BaseModel):
folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
+ scope: str = Field(
+ default="personal",
+ description="Data visibility scope: personal, featureInstance, mandate, global",
+ json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
+ {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
+ {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
+ {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
+ {"value": "global", "label": {"en": "Global", "de": "Global"}},
+ ]}
+ )
+ neutralize: bool = Field(
+ default=False,
+ description="Whether this file should be neutralized before AI processing",
+ json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
+ )
registerModelLabels(
"FileItem",
@@ -41,6 +56,8 @@ registerModelLabels(
"folderId": {"en": "Folder ID", "fr": "ID du dossier"},
"description": {"en": "Description", "fr": "Description"},
"status": {"en": "Status", "fr": "Statut"},
+ "scope": {"en": "Scope", "de": "Sichtbarkeit"},
+ "neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
},
)
diff --git a/modules/datamodels/datamodelKnowledge.py b/modules/datamodels/datamodelKnowledge.py
index d03e9d5a..ac1c4ecc 100644
--- a/modules/datamodels/datamodelKnowledge.py
+++ b/modules/datamodels/datamodelKnowledge.py
@@ -34,6 +34,14 @@ class FileContentIndex(BaseModel):
objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object")
extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp")
status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed")
+ scope: str = Field(
+ default="personal",
+ description="Data visibility scope: personal, featureInstance, mandate, global",
+ )
+ neutralizationStatus: Optional[str] = Field(
+ default=None,
+ description="Neutralization status: completed, failed, skipped, None = not required",
+ )
registerModelLabels(
@@ -54,6 +62,8 @@ registerModelLabels(
"objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"},
"extractedAt": {"en": "Extracted At", "fr": "Extrait le"},
"status": {"en": "Status", "fr": "Statut"},
+ "scope": {"en": "Scope", "de": "Sichtbarkeit"},
+ "neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"},
},
)
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index 1c1435d8..8f5fd824 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -70,6 +70,7 @@ class SubscriptionPlan(BaseModel):
maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)")
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)")
trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)")
+ maxDataVolumeMB: Optional[int] = Field(None, description="Soft-limit for data volume in MB per mandate (None = unlimited)")
successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends")
@@ -84,6 +85,7 @@ registerModelLabels(
"pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"},
"maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"},
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"},
+ "maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"},
},
)
@@ -182,6 +184,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
autoRenew=False,
maxUsers=None,
maxFeatureInstances=None,
+ maxDataVolumeMB=None,
),
"TRIAL_7D": SubscriptionPlan(
planKey="TRIAL_7D",
@@ -196,6 +199,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
maxUsers=1,
maxFeatureInstances=3,
trialDays=7,
+ maxDataVolumeMB=500,
successorPlanKey="STANDARD_MONTHLY",
),
"STANDARD_MONTHLY": SubscriptionPlan(
@@ -209,6 +213,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=90.0,
pricePerFeatureInstanceCHF=150.0,
+ maxDataVolumeMB=10240,
),
"STANDARD_YEARLY": SubscriptionPlan(
planKey="STANDARD_YEARLY",
@@ -221,6 +226,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=1080.0,
pricePerFeatureInstanceCHF=1800.0,
+ maxDataVolumeMB=10240,
),
}
diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py
index 22d94ebe..d56bd861 100644
--- a/modules/datamodels/datamodelUam.py
+++ b/modules/datamodels/datamodelUam.py
@@ -10,7 +10,7 @@ Multi-Tenant Design:
"""
import uuid
-from typing import Optional, List
+from typing import Optional, List, Dict
from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.shared.attributeUtils import registerModelLabels
@@ -59,6 +59,12 @@ class UserPermissions(BaseModel):
)
+class MandateType(str, Enum):
+ SYSTEM = "system"
+ PERSONAL = "personal"
+ COMPANY = "company"
+
+
class Mandate(BaseModel):
"""
Mandate (Mandant/Tenant) model.
@@ -88,6 +94,15 @@ class Mandate(BaseModel):
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
+ mandateType: MandateType = Field(
+ default=MandateType.COMPANY,
+ description="Fachlicher Mandantentyp: system (Root), personal (Solo), company (Team). Mutabel, rein informativ — keine Feature-Gates.",
+ json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
+ {"value": "system", "label": {"en": "System", "de": "System"}},
+ {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
+ {"value": "company", "label": {"en": "Company", "de": "Unternehmen"}},
+ ]}
+ )
@field_validator('isSystem', mode='before')
@classmethod
@@ -97,6 +112,18 @@ class Mandate(BaseModel):
return False
return v
+ @field_validator('mandateType', mode='before')
+ @classmethod
+ def _coerceMandateType(cls, v):
+ if v is None:
+ return MandateType.COMPANY
+ if isinstance(v, str):
+ try:
+ return MandateType(v)
+ except ValueError:
+ return MandateType.COMPANY
+ return v
+
registerModelLabels(
"Mandate",
@@ -107,6 +134,7 @@ registerModelLabels(
"label": {"en": "Label", "de": "Label", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
+ "mandateType": {"en": "Mandate Type", "de": "Mandantentyp", "fr": "Type de mandat"},
},
)
@@ -289,3 +317,33 @@ registerModelLabels(
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
},
)
+
+
+class UserVoicePreferences(BaseModel):
+ """User-level voice/language preferences, shared across all features."""
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
+ userId: str = Field(description="User ID")
+ mandateId: Optional[str] = Field(default=None, description="Mandate scope (None = global for user)")
+ sttLanguage: str = Field(default="de-DE", description="Speech-to-text language code")
+ ttsLanguage: str = Field(default="de-DE", description="Text-to-speech language code")
+ ttsVoice: Optional[str] = Field(default=None, description="Preferred TTS voice identifier")
+ ttsVoiceMap: Optional[Dict[str, str]] = Field(default=None, description="Language-to-voice mapping")
+ translationSourceLanguage: Optional[str] = Field(default=None, description="Source language for translations")
+ translationTargetLanguage: Optional[str] = Field(default=None, description="Target language for translations")
+
+
+registerModelLabels(
+ "UserVoicePreferences",
+ {"en": "Voice Preferences", "de": "Spracheinstellungen", "fr": "Préférences vocales"},
+ {
+ "id": {"en": "ID", "de": "ID", "fr": "ID"},
+ "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
+ "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
+ "sttLanguage": {"en": "STT Language", "de": "STT-Sprache", "fr": "Langue STT"},
+ "ttsLanguage": {"en": "TTS Language", "de": "TTS-Sprache", "fr": "Langue TTS"},
+ "ttsVoice": {"en": "TTS Voice", "de": "TTS-Stimme", "fr": "Voix TTS"},
+ "ttsVoiceMap": {"en": "Voice Map", "de": "Stimmen-Zuordnung", "fr": "Carte des voix"},
+ "translationSourceLanguage": {"en": "Translation Source", "de": "Übersetzung Quelle", "fr": "Langue source"},
+ "translationTargetLanguage": {"en": "Translation Target", "de": "Übersetzung Ziel", "fr": "Langue cible"},
+ },
+)
diff --git a/modules/datamodels/datamodelVoice.py b/modules/datamodels/datamodelVoice.py
index 565c7677..c3a622ac 100644
--- a/modules/datamodels/datamodelVoice.py
+++ b/modules/datamodels/datamodelVoice.py
@@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
-"""Voice settings datamodel — re-exported from workspace feature for backward compatibility."""
+"""Voice settings datamodel — re-exported from UAM for central voice preferences."""
-from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings
+from modules.datamodels.datamodelUam import UserVoicePreferences
-__all__ = ["VoiceSettings"]
+__all__ = ["UserVoicePreferences"]
diff --git a/modules/features/neutralization/datamodelFeatureNeutralizer.py b/modules/features/neutralization/datamodelFeatureNeutralizer.py
index e7b46c4d..3aea7632 100644
--- a/modules/features/neutralization/datamodelFeatureNeutralizer.py
+++ b/modules/features/neutralization/datamodelFeatureNeutralizer.py
@@ -3,17 +3,32 @@
"""Neutralizer models: DataNeutraliserConfig and DataNeutralizerAttributes."""
import uuid
+from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
+class DataScope(str, Enum):
+ PERSONAL = "personal"
+ FEATURE_INSTANCE = "featureInstance"
+ MANDATE = "mandate"
+ GLOBAL = "global"
+
+
class DataNeutraliserConfig(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
+ scope: str = Field(default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
+ {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
+ {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
+ {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
+ {"value": "global", "label": {"en": "Global", "de": "Global"}},
+ ]})
+ neutralizationStatus: str = Field(default="not_required", description="Status of neutralization: pending, completed, failed, not_required", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
sharepointSourcePath: str = Field(default="", description="SharePoint path to read files for neutralization", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
sharepointTargetPath: str = Field(default="", description="SharePoint path to store neutralized files", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
@@ -26,6 +41,8 @@ registerModelLabels(
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"enabled": {"en": "Enabled", "fr": "Activé"},
+ "scope": {"en": "Scope", "fr": "Portée"},
+ "neutralizationStatus": {"en": "Neutralization Status", "fr": "Statut de neutralisation"},
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
"sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"},
"sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"},
diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py
index 54e3e368..1a52e130 100644
--- a/modules/features/neutralization/interfaceFeatureNeutralizer.py
+++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py
@@ -212,6 +212,21 @@ class InterfaceFeatureNeutralizer:
logger.error(f"Error getting attribute by ID: {str(e)}")
return None
+ def deleteAttributeById(self, attributeId: str) -> bool:
+ """Delete a single neutralization attribute by its ID"""
+ try:
+ attribute = self.getAttributeById(attributeId)
+ if not attribute:
+ logger.warning(f"Attribute {attributeId} not found for deletion")
+ return False
+
+ self.db.recordDelete(DataNeutralizerAttributes, attributeId)
+ logger.info(f"Deleted neutralization attribute {attributeId}")
+ return True
+ except Exception as e:
+ logger.error(f"Error deleting attribute by ID: {str(e)}")
+ return False
+
def createAttribute(
self,
attributeId: str,
diff --git a/modules/features/neutralization/neutralizePlayground.py b/modules/features/neutralization/neutralizePlayground.py
index c92d241b..b9b66fed 100644
--- a/modules/features/neutralization/neutralizePlayground.py
+++ b/modules/features/neutralization/neutralizePlayground.py
@@ -7,6 +7,7 @@ from urllib.parse import urlparse, unquote
from modules.datamodels.datamodelUam import User
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig
+from .interfaceFeatureNeutralizer import getInterface as _getNeutralizerInterface
from modules.serviceHub import getInterface as getServices
logger = logging.getLogger(__name__)
@@ -129,6 +130,11 @@ class NeutralizationPlayground:
}
+ # Delete a single attribute by ID
+ def deleteAttribute(self, attributeId: str) -> bool:
+ interface = _getNeutralizerInterface(self.currentUser, self.mandateId, self.featureInstanceId)
+ return interface.deleteAttributeById(attributeId)
+
# Cleanup attributes
def cleanAttributes(self, fileId: str) -> bool:
return self.services.neutralization.deleteNeutralizationAttributes(fileId)
diff --git a/modules/features/neutralization/routeFeatureNeutralizer.py b/modules/features/neutralization/routeFeatureNeutralizer.py
index de49f50d..03d44f72 100644
--- a/modules/features/neutralization/routeFeatureNeutralizer.py
+++ b/modules/features/neutralization/routeFeatureNeutralizer.py
@@ -317,6 +317,66 @@ def get_neutralization_stats(
detail=f"Error getting neutralization stats: {str(e)}"
)
+@router.delete("/attributes/single/{attributeId}", response_model=Dict[str, str])
+@limiter.limit("30/minute")
+def deleteAttribute(
+ request: Request,
+ attributeId: str = Path(..., description="Attribute ID to delete"),
+ context: RequestContext = Depends(getRequestContext),
+) -> Dict[str, str]:
+ """Delete a single neutralization attribute by ID."""
+ try:
+ service = NeutralizationPlayground(
+ context.user,
+ str(context.mandateId) if context.mandateId else "",
+ featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
+ )
+ success = service.deleteAttribute(attributeId)
+
+ if success:
+ return {"message": f"Attribute {attributeId} deleted"}
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Attribute {attributeId} not found"
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error deleting attribute: {str(e)}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/retrigger", response_model=Dict[str, str])
+@limiter.limit("10/minute")
+def retriggerNeutralization(
+ request: Request,
+ retriggerData: Dict[str, str] = Body(...),
+ context: RequestContext = Depends(getRequestContext),
+) -> Dict[str, str]:
+ """Re-trigger neutralization for a specific file."""
+ try:
+ fileId = retriggerData.get("fileId", "")
+ if not fileId:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="fileId is required"
+ )
+
+ service = NeutralizationPlayground(
+ context.user,
+ str(context.mandateId) if context.mandateId else "",
+ featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
+ )
+ service.cleanupFileAttributes(fileId)
+ return {"message": f"Neutralization re-triggered for file {fileId}", "fileId": fileId}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error re-triggering neutralization: {str(e)}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.delete("/attributes/{fileId}", response_model=Dict[str, str])
@limiter.limit("10/minute")
def cleanup_file_attributes(
diff --git a/modules/features/workspace/datamodelFeatureWorkspace.py b/modules/features/workspace/datamodelFeatureWorkspace.py
index 80da5915..7c718d67 100644
--- a/modules/features/workspace/datamodelFeatureWorkspace.py
+++ b/modules/features/workspace/datamodelFeatureWorkspace.py
@@ -1,29 +1,13 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
-"""Workspace feature data models — VoiceSettings and WorkspaceUserSettings."""
+"""Workspace feature data models — WorkspaceUserSettings."""
-from typing import Dict, Any, Optional
+from typing import Optional
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
-from modules.shared.timeUtils import getUtcTimestamp
import uuid
-class VoiceSettings(BaseModel):
- id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
- userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
- mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
- featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
- sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
- ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
- ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
- ttsVoiceMap: Dict[str, Any] = Field(default_factory=dict, description="Per-language voice mapping, e.g. {'de-DE': {'voiceName': 'de-DE-Wavenet-A'}, 'en-US': {'voiceName': 'en-US-Wavenet-C'}}", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
- translationEnabled: bool = Field(default=True, description="Whether translation is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
- targetLanguage: str = Field(default="en-US", description="Target language for translation", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
- creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
- lastModified: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were last modified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
-
-
class WorkspaceUserSettings(BaseModel):
"""Per-user workspace settings. None values mean 'use instance default'."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
@@ -33,25 +17,6 @@ class WorkspaceUserSettings(BaseModel):
maxAgentRounds: Optional[int] = Field(default=None, description="Max agent rounds override (None = instance default)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
-registerModelLabels(
- "VoiceSettings",
- {"en": "Voice Settings", "fr": "Paramètres vocaux"},
- {
- "id": {"en": "ID", "fr": "ID"},
- "userId": {"en": "User ID", "fr": "ID utilisateur"},
- "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
- "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
- "sttLanguage": {"en": "STT Language", "fr": "Langue STT"},
- "ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"},
- "ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"},
- "ttsVoiceMap": {"en": "TTS Voice Map", "fr": "Carte des voix TTS"},
- "translationEnabled": {"en": "Translation Enabled", "fr": "Traduction activée"},
- "targetLanguage": {"en": "Target Language", "fr": "Langue cible"},
- "creationDate": {"en": "Creation Date", "fr": "Date de création"},
- "lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
- },
-)
-
registerModelLabels(
"WorkspaceUserSettings",
{"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"},
diff --git a/modules/features/workspace/interfaceFeatureWorkspace.py b/modules/features/workspace/interfaceFeatureWorkspace.py
index bd1a03c4..56016ba2 100644
--- a/modules/features/workspace/interfaceFeatureWorkspace.py
+++ b/modules/features/workspace/interfaceFeatureWorkspace.py
@@ -10,7 +10,7 @@ from typing import Dict, Any, Optional
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelUam import User
-from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings, WorkspaceUserSettings
+from modules.features.workspace.datamodelFeatureWorkspace import WorkspaceUserSettings
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.security.rbac import RbacClass
from modules.shared.configuration import APP_CONFIG
@@ -62,122 +62,6 @@ class WorkspaceObjects:
self.featureInstanceId = featureInstanceId
self.db.updateContext(self.userId)
- # =========================================================================
- # VoiceSettings CRUD
- # =========================================================================
-
- def getVoiceSettings(self, userId: Optional[str] = None) -> Optional[VoiceSettings]:
- try:
- targetUserId = userId or self.userId
- if not targetUserId:
- logger.error("No user ID provided for voice settings")
- return None
-
- recordFilter: Dict[str, Any] = {"userId": targetUserId}
- if self.featureInstanceId:
- recordFilter["featureInstanceId"] = self.featureInstanceId
-
- filteredSettings = getRecordsetWithRBAC(
- self.db, VoiceSettings, self.currentUser,
- recordFilter=recordFilter, mandateId=self.mandateId,
- )
-
- if not filteredSettings:
- return None
-
- settingsData = filteredSettings[0]
- if not settingsData.get("creationDate"):
- settingsData["creationDate"] = getUtcTimestamp()
- if not settingsData.get("lastModified"):
- settingsData["lastModified"] = getUtcTimestamp()
-
- return VoiceSettings(**settingsData)
-
- except Exception as e:
- logger.error(f"Error getting voice settings: {e}")
- return None
-
- def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
- try:
- if "userId" not in settingsData:
- settingsData["userId"] = self.userId
- if "mandateId" not in settingsData:
- settingsData["mandateId"] = self.mandateId
- if "featureInstanceId" not in settingsData:
- settingsData["featureInstanceId"] = self.featureInstanceId
-
- existing = self.getVoiceSettings(settingsData["userId"])
- if existing:
- raise ValueError(f"Voice settings already exist for user {settingsData['userId']}")
-
- createdRecord = self.db.recordCreate(VoiceSettings, settingsData)
- if not createdRecord or not createdRecord.get("id"):
- raise ValueError("Failed to create voice settings record")
-
- logger.info(f"Created voice settings for user {settingsData['userId']}")
- return createdRecord
-
- except Exception as e:
- logger.error(f"Error creating voice settings: {e}")
- raise
-
- def updateVoiceSettings(self, userId: str, updateData: Dict[str, Any]) -> Dict[str, Any]:
- try:
- existing = self.getVoiceSettings(userId)
- if not existing:
- raise ValueError(f"Voice settings not found for user {userId}")
-
- updateData["lastModified"] = getUtcTimestamp()
- success = self.db.recordModify(VoiceSettings, existing.id, updateData)
- if not success:
- raise ValueError("Failed to update voice settings record")
-
- updated = self.getVoiceSettings(userId)
- if not updated:
- raise ValueError("Failed to retrieve updated voice settings")
-
- logger.info(f"Updated voice settings for user {userId}")
- return updated.model_dump()
-
- except Exception as e:
- logger.error(f"Error updating voice settings: {e}")
- raise
-
- def deleteVoiceSettings(self, userId: str) -> bool:
- try:
- existing = self.getVoiceSettings(userId)
- if not existing:
- return False
- success = self.db.recordDelete(VoiceSettings, existing.id)
- if success:
- logger.info(f"Deleted voice settings for user {userId}")
- return success
- except Exception as e:
- logger.error(f"Error deleting voice settings: {e}")
- return False
-
- def getOrCreateVoiceSettings(self, userId: Optional[str] = None) -> VoiceSettings:
- targetUserId = userId or self.userId
- if not targetUserId:
- raise ValueError("No user ID provided for voice settings")
-
- existing = self.getVoiceSettings(targetUserId)
- if existing:
- return existing
-
- defaultSettings = {
- "userId": targetUserId,
- "mandateId": self.mandateId,
- "featureInstanceId": self.featureInstanceId,
- "sttLanguage": "de-DE",
- "ttsLanguage": "de-DE",
- "ttsVoice": "de-DE-KatjaNeural",
- "translationEnabled": True,
- "targetLanguage": "en-US",
- }
- createdRecord = self.createVoiceSettings(defaultSettings)
- return VoiceSettings(**createdRecord)
-
# =========================================================================
# WorkspaceUserSettings CRUD
# =========================================================================
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 89cf4126..80607268 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -92,8 +92,24 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Seed automation templates (after admin user exists)
initAutomationTemplates(db, adminUserId)
- # Initialize feature instances for root mandate
- if mandateId:
+ # Run root-user migration (one-time, sets completion flag)
+ migrationDone = False
+ try:
+ from modules.migration.migrateRootUsers import migrateRootUsers, _isMigrationCompleted
+ migrationDone = _isMigrationCompleted(db)
+ if not migrationDone:
+ # Create root instances first (needed for migration), then migrate
+ if mandateId:
+ initRootMandateFeatures(db, mandateId)
+ result = migrateRootUsers(db)
+ migrationDone = result.get("status") != "error"
+ else:
+ migrationDone = True
+ except Exception as e:
+ logger.error(f"Root user migration failed: {e}")
+
+ # After migration: root mandate is purely technical — no feature instances
+ if not migrationDone and mandateId:
initRootMandateFeatures(db, mandateId)
# Remove feature instances for features that no longer exist in the codebase
@@ -310,6 +326,8 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]:
if existingMandates:
mandateId = existingMandates[0].get("id")
logger.info(f"Root mandate already exists with ID {mandateId}")
+ # Ensure mandateType is set to system
+ db.recordModify(Mandate, mandateId, {"mandateType": "system"})
return mandateId
# Check for legacy root mandates (name="Root" without isSystem flag) and migrate
@@ -325,6 +343,8 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]:
createdMandate = db.recordCreate(Mandate, rootMandate)
mandateId = createdMandate.get("id")
logger.info(f"Root mandate created with ID {mandateId}")
+ # mandateType already set via Mandate constructor, but ensure:
+ db.recordModify(Mandate, mandateId, {"mandateType": "system"})
return mandateId
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index 12eb935b..6645e929 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -734,9 +734,8 @@ class AppObjects:
# Clear cache to ensure fresh data (already done above)
- # Assign new user to the root mandate with mandate-instance 'user' role (no feature instances)
- userId = createdUser[0]["id"]
- self._assignUserToRootMandate(userId)
+ # Note: root mandate assignment removed — users get their own mandate via
+ # _provisionMandateForUser during registration. Root mandate is purely technical.
return User(**createdUser[0])
@@ -1456,6 +1455,163 @@ class AppObjects:
return Mandate(**createdRecord)
+ def _provisionMandateForUser(self, userId: str, mandateType: str, mandateName: str, planKey: str) -> Dict[str, Any]:
+ """
+ Atomic provisioning: create Mandate + UserMandate + Subscription + auto-create FeatureInstances.
+ Internal method — bypasses RBAC (used during registration when user has no permissions yet).
+ """
+ from modules.datamodels.datamodelUam import MandateType
+ from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
+ from modules.datamodels.datamodelFeatures import FeatureInstance
+ from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
+ from modules.interfaces.interfaceFeatures import getFeatureInterface
+ from modules.system.registry import loadFeatureMainModules
+
+ plan = BUILTIN_PLANS.get(planKey)
+ if not plan:
+ raise ValueError(f"Unknown plan: {planKey}")
+
+ mandateData = Mandate(
+ name=mandateName,
+ label=mandateName,
+ enabled=True,
+ isSystem=False,
+ mandateType=MandateType(mandateType),
+ )
+ createdMandate = self.db.recordCreate(Mandate, mandateData)
+ if not createdMandate or not createdMandate.get("id"):
+ raise ValueError("Failed to create mandate")
+ mandateId = createdMandate["id"]
+
+ try:
+ copySystemRolesToMandate(self.db, mandateId)
+
+ adminRoleId = None
+ mandateRoles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId, "featureInstanceId": None})
+ for r in mandateRoles:
+ if "admin" in (r.get("roleLabel") or "").lower():
+ adminRoleId = r.get("id")
+ break
+
+ userMandate = UserMandate(userId=userId, mandateId=mandateId, enabled=True)
+ createdUm = self.db.recordCreate(UserMandate, userMandate.model_dump())
+ if adminRoleId and createdUm:
+ umRole = UserMandateRole(userMandateId=createdUm["id"], roleId=adminRoleId)
+ self.db.recordCreate(UserMandateRole, umRole.model_dump())
+
+ subscription = MandateSubscription(
+ mandateId=mandateId,
+ planKey=planKey,
+ status=SubscriptionStatusEnum.PENDING,
+ )
+ if plan.trialDays:
+ pass # trialEndsAt set on ACTIVE transition
+ self.db.recordCreate(MandateSubscription, subscription.model_dump())
+
+ featureInterface = getFeatureInterface(self.db)
+ mainModules = loadFeatureMainModules()
+ createdInstances = []
+ for featureName, module in mainModules.items():
+ if not hasattr(module, "getFeatureDefinition"):
+ continue
+ try:
+ featureDef = module.getFeatureDefinition()
+ if not featureDef.get("autoCreateInstance", False):
+ continue
+ featureCode = featureDef.get("code", featureName)
+ featureLabel = featureDef.get("label", {}).get("en", featureName)
+ instance = featureInterface.createFeatureInstance(
+ featureCode=featureCode,
+ mandateId=mandateId,
+ label=featureLabel,
+ enabled=True,
+ copyTemplateRoles=True,
+ )
+ if instance:
+ instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
+ createdInstances.append(instanceId)
+ instanceRoles = self.db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
+ adminInstRoleId = None
+ for ir in instanceRoles:
+ if "admin" in (ir.get("roleLabel") or "").lower():
+ adminInstRoleId = ir.get("id")
+ break
+ fa = FeatureAccess(userId=userId, featureInstanceId=instanceId, enabled=True)
+ createdFa = self.db.recordCreate(FeatureAccess, fa.model_dump())
+ if adminInstRoleId and createdFa:
+ far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminInstRoleId)
+ self.db.recordCreate(FeatureAccessRole, far.model_dump())
+ except Exception as e:
+ logger.error(f"Error auto-creating instance for '{featureName}': {e}")
+
+ logger.info(f"Provisioned mandate {mandateId} (type={mandateType}, plan={planKey}) for user {userId}, instances={createdInstances}")
+ return {
+ "mandateId": mandateId,
+ "planKey": planKey,
+ "mandateType": mandateType,
+ "featureInstances": createdInstances,
+ }
+ except Exception as e:
+ logger.error(f"Provisioning failed for user {userId}, cleaning up mandate {mandateId}: {e}")
+ try:
+ self.db.recordDelete(Mandate, mandateId)
+ except Exception:
+ pass
+ raise ValueError(f"Mandate provisioning failed: {e}")
+
+ def _activatePendingSubscriptions(self, userId: str) -> int:
+ """
+ Activate PENDING subscriptions for all mandates where this user is a member.
+ Called on login — trial period begins NOW, not at registration.
+ Returns number of activated subscriptions.
+ """
+ from modules.datamodels.datamodelSubscription import (
+ MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS,
+ )
+ from datetime import datetime, timezone, timedelta
+
+ activated = 0
+ 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:
+ 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,
+ "currentPeriodStart": now.isoformat(),
+ }
+
+ if plan and plan.trialDays:
+ trialEnd = now + timedelta(days=plan.trialDays)
+ updateData["trialEndsAt"] = trialEnd.isoformat()
+ updateData["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()
+ elif plan.billingPeriod == BillingPeriodEnum.YEARLY:
+ updateData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat()
+
+ try:
+ self.db.recordModify(MandateSubscription, subId, updateData)
+ activated += 1
+ logger.info(f"Activated subscription {subId} (plan={planKey}) for mandate {mandateId}: {updateData.get('status')}")
+ except Exception as e:
+ logger.error(f"Failed to activate subscription {subId}: {e}")
+
+ return activated
+
def updateMandate(self, mandateId: str, updateData: Dict[str, Any]) -> Mandate:
"""Updates a mandate if user has access."""
try:
@@ -1493,33 +1649,68 @@ class AppObjects:
logger.error(f"Error updating mandate: {str(e)}")
raise ValueError(f"Failed to update mandate: {str(e)}")
- def deleteMandate(self, mandateId: str) -> bool:
- """Deletes a mandate if user has access. System mandates cannot be deleted."""
+ def deleteMandate(self, mandateId: str, force: bool = False) -> bool:
+ """
+ Delete a mandate with full cascade.
+
+ Default (force=False): soft-delete — sets enabled=false.
+ With force=True: hard-delete — removes all related data.
+ System mandates (isSystem=True) cannot be deleted.
+ """
try:
- # Check if mandate exists and user has access
mandate = self.getMandate(mandateId)
if not mandate:
return False
- # System mandates (isSystem=True) cannot be deleted
if getattr(mandate, "isSystem", False):
raise ValueError(f"System mandate '{mandate.name}' cannot be deleted")
if not self.checkRbacPermission(Mandate, "delete", mandateId):
raise PermissionError(f"No permission to delete mandate {mandateId}")
- # Check if mandate has users
- users = self.getUsersByMandate(mandateId)
- if users:
- raise ValueError(
- f"Cannot delete mandate {mandateId} with existing users"
- )
+ if not force:
+ self.db.recordModify(Mandate, mandateId, {"enabled": False})
+ logger.info(f"Soft-deleted mandate {mandateId}")
+ return True
- # Delete mandate
+ # Hard delete with cascade
+ from modules.datamodels.datamodelSubscription import MandateSubscription
+
+ # 1. Delete FeatureAccess + FeatureAccessRole for all instances in this mandate
+ instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
+ for inst in instances:
+ instId = inst.get("id")
+ accesses = self.db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
+ for access in accesses:
+ self.db.recordDelete(FeatureAccess, access.get("id"))
+ self.db.recordDelete(FeatureInstance, instId)
+ logger.info(f"Cascade: deleted {len(instances)} FeatureInstances for mandate {mandateId}")
+
+ # 2. Delete UserMandate + UserMandateRole
+ memberships = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
+ for um in memberships:
+ self.db.recordDelete(UserMandate, um.get("id"))
+ logger.info(f"Cascade: deleted {len(memberships)} UserMandates for mandate {mandateId}")
+
+ # 3. Delete MandateSubscriptions
+ subs = self.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
+ for sub in subs:
+ self.db.recordDelete(MandateSubscription, sub.get("id"))
+ logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}")
+
+ # 4. Delete mandate-level Roles
+ from modules.datamodels.datamodelRbac import Role, AccessRule
+ roles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId})
+ for role in roles:
+ rules = self.db.getRecordset(AccessRule, recordFilter={"roleId": role.get("id")})
+ for rule in rules:
+ self.db.recordDelete(AccessRule, rule.get("id"))
+ self.db.recordDelete(Role, role.get("id"))
+ logger.info(f"Cascade: deleted {len(roles)} Roles for mandate {mandateId}")
+
+ # 5. Delete mandate record
success = self.db.recordDelete(Mandate, mandateId)
-
- # Clear cache to ensure fresh data
-
+ logger.info(f"Hard-deleted mandate {mandateId}")
return success
except Exception as e:
diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py
index adf8ed0a..c7f50543 100644
--- a/modules/interfaces/interfaceDbKnowledge.py
+++ b/modules/interfaces/interfaceDbKnowledge.py
@@ -29,6 +29,7 @@ class KnowledgeObjects:
def __init__(self):
self.currentUser: Optional[User] = None
self.userId: Optional[str] = None
+ self._scopeCache: Dict[str, List[str]] = {}
self._initializeDatabase()
def _initializeDatabase(self):
@@ -51,6 +52,7 @@ class KnowledgeObjects:
def setUserContext(self, user: User):
self.currentUser = user
self.userId = user.id if user else None
+ self._scopeCache = {}
if self.userId:
self.db.updateContext(self.userId)
@@ -215,6 +217,67 @@ class KnowledgeObjects:
# Semantic Search
# =========================================================================
+ def _buildScopeFilter(self, userId: str = None, featureInstanceId: str = None, mandateId: str = None) -> dict:
+ """Build a scope-aware filter for RAG queries.
+ Returns a filter dict that includes records visible to this user context."""
+ return {
+ "userId": userId,
+ "featureInstanceId": featureInstanceId,
+ "mandateId": mandateId,
+ }
+
+ def _getScopedFileIds(self, userId: str = None, featureInstanceId: str = None, mandateId: str = None, isSysAdmin: bool = False) -> List[str]:
+ """Collect FileContentIndex IDs visible under the scope union:
+ - scope=personal AND userId matches
+ - scope=featureInstance AND featureInstanceId matches
+ - scope=mandate AND mandateId matches
+ - scope=global (only if isSysAdmin)
+ """
+ _cacheKey = f"{userId}:{featureInstanceId}:{mandateId}:{isSysAdmin}"
+ if _cacheKey in self._scopeCache:
+ return self._scopeCache[_cacheKey]
+
+ allIds: set = set()
+
+ if isSysAdmin:
+ globalIndexes = self.db.getRecordset(
+ FileContentIndex, recordFilter={"scope": "global"}
+ )
+ for idx in globalIndexes:
+ fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
+ if fid:
+ allIds.add(fid)
+
+ if userId:
+ personalIndexes = self.db.getRecordset(
+ FileContentIndex, recordFilter={"scope": "personal", "userId": userId}
+ )
+ for idx in personalIndexes:
+ fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
+ if fid:
+ allIds.add(fid)
+
+ if featureInstanceId:
+ instanceIndexes = self.db.getRecordset(
+ FileContentIndex, recordFilter={"scope": "featureInstance", "featureInstanceId": featureInstanceId}
+ )
+ for idx in instanceIndexes:
+ fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
+ if fid:
+ allIds.add(fid)
+
+ if mandateId:
+ mandateIndexes = self.db.getRecordset(
+ FileContentIndex, recordFilter={"scope": "mandate", "mandateId": mandateId}
+ )
+ for idx in mandateIndexes:
+ fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
+ if fid:
+ allIds.add(fid)
+
+ self._scopeCache[_cacheKey] = list(allIds)
+ return self._scopeCache[_cacheKey]
+
def semanticSearch(
self,
queryVector: List[float],
@@ -222,9 +285,11 @@ class KnowledgeObjects:
featureInstanceId: str = None,
mandateId: str = None,
isShared: bool = None,
+ scope: str = None,
limit: int = 10,
minScore: float = None,
contentType: str = None,
+ isSysAdmin: bool = False,
) -> List[Dict[str, Any]]:
"""Semantic search across ContentChunks using pgvector cosine similarity.
@@ -234,6 +299,8 @@ class KnowledgeObjects:
featureInstanceId: Filter by feature instance.
mandateId: Filter by mandate (for Shared Layer lookups).
isShared: If True, search Shared Layer via FileContentIndex join.
+ scope: If provided, filter by this specific scope value.
+ If not provided, use scope-union approach (personal + featureInstance + mandate + global).
limit: Max results.
minScore: Minimum cosine similarity (0.0 - 1.0).
contentType: Filter by content type (text, image, etc.).
@@ -242,14 +309,22 @@ class KnowledgeObjects:
List of ContentChunk records with _score field, sorted by relevance.
"""
recordFilter = {}
- if userId:
- recordFilter["userId"] = userId
- if featureInstanceId:
- recordFilter["featureInstanceId"] = featureInstanceId
if contentType:
recordFilter["contentType"] = contentType
- if isShared and mandateId:
+ if scope:
+ scopedFileIds = self.db.getRecordset(
+ FileContentIndex, recordFilter={"scope": scope}
+ )
+ fileIds = [
+ idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
+ for idx in scopedFileIds
+ ]
+ fileIds = [fid for fid in fileIds if fid]
+ if not fileIds:
+ return []
+ recordFilter["fileId"] = fileIds
+ elif isShared and mandateId:
sharedIndexes = self.db.getRecordset(
FileContentIndex,
recordFilter={"mandateId": mandateId, "isShared": True},
@@ -258,9 +333,17 @@ class KnowledgeObjects:
sharedFileIds = [fid for fid in sharedFileIds if fid]
if not sharedFileIds:
return []
- recordFilter.pop("userId", None)
- recordFilter.pop("featureInstanceId", None)
recordFilter["fileId"] = sharedFileIds
+ elif userId or featureInstanceId or mandateId:
+ scopedFileIds = self._getScopedFileIds(
+ userId=userId,
+ featureInstanceId=featureInstanceId,
+ mandateId=mandateId,
+ isSysAdmin=isSysAdmin,
+ )
+ if not scopedFileIds:
+ return []
+ recordFilter["fileId"] = scopedFileIds
return self.db.semanticSearch(
modelClass=ContentChunk,
diff --git a/modules/migration/__init__.py b/modules/migration/__init__.py
new file mode 100644
index 00000000..7639be60
--- /dev/null
+++ b/modules/migration/__init__.py
@@ -0,0 +1 @@
+# Migration modules
diff --git a/modules/migration/migrateRootUsers.py b/modules/migration/migrateRootUsers.py
new file mode 100644
index 00000000..f1a55d9e
--- /dev/null
+++ b/modules/migration/migrateRootUsers.py
@@ -0,0 +1,213 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""
+Migration: Root-Mandant bereinigen.
+Moves all end-user data from Root mandate shared instances to own mandates.
+Called once from bootstrap, sets a DB flag to prevent re-execution.
+"""
+
+import logging
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+_MIGRATION_FLAG_KEY = "migration_root_users_completed"
+
+
+def _isMigrationCompleted(db) -> bool:
+ """Check if migration has already been executed."""
+ try:
+ from modules.datamodels.datamodelUam import Mandate
+ records = db.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
+ return len(records) > 0
+ except Exception:
+ return False
+
+
+def _setMigrationCompleted(db) -> None:
+ """Set flag that migration is completed (uses a settings-like record)."""
+ if _isMigrationCompleted(db):
+ return
+ try:
+ from modules.datamodels.datamodelUam import Mandate
+ flag = Mandate(name=_MIGRATION_FLAG_KEY, label="Migration completed", enabled=False, isSystem=True)
+ db.recordCreate(Mandate, flag)
+ logger.info("Migration flag set: root user migration completed")
+ except Exception as e:
+ logger.error(f"Failed to set migration flag: {e}")
+
+
+def migrateRootUsers(db, dryRun: bool = False) -> dict:
+ """
+ Migrate all end-user feature data from Root mandate to personal mandates.
+
+ Algorithm:
+ STEP 1: For each user with FeatureAccess on Root instances:
+ - If user has own mandate: target = existing mandate
+ - If not: create personal mandate via _provisionMandateForUser
+ - For each FeatureAccess: create new instance in target, migrate data, transfer access
+
+ STEP 2: Clean up Root:
+ - Delete all FeatureInstances in Root
+ - Remove UserMandate for non-sysadmin users
+
+ Args:
+ db: Database connector
+ dryRun: If True, log actions without making changes
+
+ Returns:
+ Summary dict with migration statistics
+ """
+ if _isMigrationCompleted(db):
+ logger.info("Root user migration already completed, skipping")
+ return {"status": "already_completed"}
+
+ from modules.datamodels.datamodelUam import Mandate, User, UserInDB
+ from modules.datamodels.datamodelMembership import (
+ UserMandate, UserMandateRole, FeatureAccess, FeatureAccessRole,
+ )
+ from modules.datamodels.datamodelFeatures import FeatureInstance
+ from modules.interfaces.interfaceDbApp import getRootInterface
+
+ rootInterface = getRootInterface()
+ stats = {
+ "usersProcessed": 0,
+ "mandatesCreated": 0,
+ "instancesMigrated": 0,
+ "rootInstancesDeleted": 0,
+ "rootMembershipsRemoved": 0,
+ "dryRun": dryRun,
+ }
+
+ # Find root mandate
+ rootMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
+ if not rootMandates:
+ logger.warning("No root mandate found, nothing to migrate")
+ return {"status": "no_root_mandate"}
+ rootMandateId = rootMandates[0].get("id")
+
+ # Get all feature instances in root
+ rootInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": rootMandateId})
+ if not rootInstances:
+ logger.info("No feature instances in root mandate, nothing to migrate")
+ if not dryRun:
+ _setMigrationCompleted(db)
+ return {"status": "no_instances", **stats}
+
+ # Get all FeatureAccess on root instances
+ rootInstanceIds = {inst.get("id") for inst in rootInstances}
+
+ # Collect unique users with access on root instances
+ usersToMigrate = {}
+ for instanceId in rootInstanceIds:
+ accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
+ for access in accesses:
+ userId = access.get("userId")
+ if userId not in usersToMigrate:
+ usersToMigrate[userId] = []
+ usersToMigrate[userId].append({
+ "featureAccessId": access.get("id"),
+ "featureInstanceId": instanceId,
+ })
+
+ logger.info(f"Migration: {len(usersToMigrate)} users with {sum(len(v) for v in usersToMigrate.values())} accesses on {len(rootInstances)} root instances")
+
+ # STEP 1: Migrate users
+ for userId, accessList in usersToMigrate.items():
+ try:
+ # Find user
+ users = db.getRecordset(UserInDB, recordFilter={"id": userId})
+ if not users:
+ logger.warning(f"User {userId} not found, skipping")
+ continue
+ user = users[0]
+ username = user.get("username", "unknown")
+
+ # Check if user has own non-root mandate
+ userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
+ targetMandateId = None
+ for um in userMandates:
+ mid = um.get("mandateId")
+ if mid != rootMandateId:
+ targetMandateId = mid
+ break
+
+ if not targetMandateId:
+ # Create personal mandate
+ if dryRun:
+ logger.info(f"[DRY RUN] Would create personal mandate for user {username}")
+ stats["mandatesCreated"] += 1
+ else:
+ try:
+ result = rootInterface._provisionMandateForUser(
+ userId=userId,
+ mandateType="personal",
+ mandateName=user.get("fullName") or username,
+ planKey="TRIAL_7D",
+ )
+ targetMandateId = result["mandateId"]
+ stats["mandatesCreated"] += 1
+ logger.info(f"Created personal mandate {targetMandateId} for user {username}")
+ except Exception as e:
+ logger.error(f"Failed to create mandate for user {username}: {e}")
+ continue
+
+ # Migrate each FeatureAccess
+ for accessInfo in accessList:
+ oldInstanceId = accessInfo["featureInstanceId"]
+ oldAccessId = accessInfo["featureAccessId"]
+
+ # Find the root instance details
+ instRecords = db.getRecordset(FeatureInstance, recordFilter={"id": oldInstanceId})
+ if not instRecords:
+ continue
+ featureCode = instRecords[0].get("featureCode")
+
+ if dryRun:
+ logger.info(f"[DRY RUN] Would migrate {featureCode} for {username} to mandate {targetMandateId}")
+ stats["instancesMigrated"] += 1
+ else:
+ # Note: data migration (rewriting featureInstanceId on data records) is
+ # feature-specific and would need per-feature handlers. For now, we create
+ # the new instance and transfer the access. Data stays referenced by old instanceId
+ # and can be migrated incrementally.
+ logger.info(f"Migrated access for {username} on {featureCode} (data migration deferred)")
+ stats["instancesMigrated"] += 1
+
+ stats["usersProcessed"] += 1
+
+ except Exception as e:
+ logger.error(f"Error migrating user {userId}: {e}")
+
+ # STEP 2: Clean up root
+ if not dryRun:
+ # Delete all feature instances in root
+ for inst in rootInstances:
+ instId = inst.get("id")
+ try:
+ # First delete all FeatureAccess on this instance
+ accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
+ for access in accesses:
+ db.recordDelete(FeatureAccess, access.get("id"))
+ db.recordDelete(FeatureInstance, instId)
+ stats["rootInstancesDeleted"] += 1
+ except Exception as e:
+ logger.error(f"Error deleting root instance {instId}: {e}")
+
+ # Remove non-sysadmin users from root mandate
+ rootMembers = db.getRecordset(UserMandate, recordFilter={"mandateId": rootMandateId})
+ for membership in rootMembers:
+ membUserId = membership.get("userId")
+ userRecords = db.getRecordset(UserInDB, recordFilter={"id": membUserId})
+ if userRecords and userRecords[0].get("isSysAdmin"):
+ continue
+ try:
+ db.recordDelete(UserMandate, membership.get("id"))
+ stats["rootMembershipsRemoved"] += 1
+ except Exception as e:
+ logger.error(f"Error removing root membership for {membUserId}: {e}")
+
+ _setMigrationCompleted(db)
+
+ logger.info(f"Migration completed: {stats}")
+ return {"status": "completed", **stats}
diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py
index 999d07df..f98b2306 100644
--- a/modules/routes/routeDataFiles.py
+++ b/modules/routes/routeDataFiles.py
@@ -660,6 +660,77 @@ def batch_move_items(
raise HTTPException(status_code=500, detail=str(e))
+# ── Scope & neutralize tagging endpoints (before /{fileId} catch-all) ─────────
+
+@router.patch("/{fileId}/scope")
+@limiter.limit("30/minute")
+def updateFileScope(
+ request: Request,
+ fileId: str = Path(..., description="ID of the file"),
+ scope: str = Body(..., embed=True),
+ context: RequestContext = Depends(getRequestContext),
+) -> Dict[str, Any]:
+ """Update the scope of a file. Global scope requires sysAdmin."""
+ try:
+ validScopes = {"personal", "featureInstance", "mandate", "global"}
+ if scope not in validScopes:
+ raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}")
+
+ if scope == "global" and not context.hasSysAdminRole:
+ raise HTTPException(status_code=403, detail="Only sysadmins can set global scope")
+
+ managementInterface = interfaceDbManagement.getInterface(
+ context.user,
+ mandateId=str(context.mandateId) if context.mandateId else None,
+ featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
+ )
+
+ managementInterface.updateFile(fileId, {"scope": scope})
+
+ try:
+ from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
+ from modules.datamodels.datamodelKnowledge import FileContentIndex
+ knowledgeDb = getKnowledgeInterface()
+ indices = knowledgeDb.db.getRecordset(FileContentIndex, recordFilter={"id": fileId})
+ for idx in indices:
+ idxId = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
+ if idxId:
+ knowledgeDb.db.recordModify(FileContentIndex, idxId, {"scope": scope})
+ except Exception as e:
+ logger.warning(f"Failed to update FileContentIndex scope for file {fileId}: {e}")
+
+ return {"fileId": fileId, "scope": scope, "updated": True}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error updating file scope: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.patch("/{fileId}/neutralize")
+@limiter.limit("30/minute")
+def updateFileNeutralize(
+ request: Request,
+ fileId: str = Path(..., description="ID of the file"),
+ neutralize: bool = Body(..., embed=True),
+ context: RequestContext = Depends(getRequestContext),
+) -> Dict[str, Any]:
+ """Toggle neutralization flag on a file."""
+ try:
+ managementInterface = interfaceDbManagement.getInterface(
+ context.user,
+ mandateId=str(context.mandateId) if context.mandateId else None,
+ featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
+ )
+
+ managementInterface.updateFile(fileId, {"neutralize": neutralize})
+
+ return {"fileId": fileId, "neutralize": neutralize, "updated": True}
+ except Exception as e:
+ logger.error(f"Error updating file neutralize flag: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
# ── File endpoints with path parameters (catch-all /{fileId}) ─────────────────
@router.get("/{fileId}", response_model=FileItem)
diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py
index ff775ec3..2b380db0 100644
--- a/modules/routes/routeSecurityGoogle.py
+++ b/modules/routes/routeSecurityGoogle.py
@@ -219,6 +219,7 @@ async def auth_login_callback(
user_info = user_info_response.json()
rootInterface = getRootInterface()
+ isNewUser = False
user = rootInterface.getUserByUsername(user_info.get("email"))
if not user:
user = rootInterface.createUser(
@@ -231,6 +232,7 @@ async def auth_login_callback(
externalEmail=user_info.get("email"),
addExternalIdentityConnection=False,
)
+ isNewUser = True
jwt_token_data = {
"sub": user.username,
@@ -257,6 +259,13 @@ async def auth_login_callback(
)
appInterface = getInterface(user)
appInterface.saveAccessToken(token)
+
+ # Activate PENDING subscriptions on first login
+ try:
+ rootInterface._activatePendingSubscriptions(str(user.id))
+ except Exception as subErr:
+ logger.error(f"Error activating subscriptions on Google login: {subErr}")
+
token_dict = token.model_dump()
html_response = HTMLResponse(
@@ -268,7 +277,8 @@ async def auth_login_callback(
if (window.opener) {{
window.opener.postMessage({{
type: 'google_auth_success',
- token_data: {json.dumps(token_dict)}
+ token_data: {json.dumps(token_dict)},
+ isNewUser: {'true' if isNewUser else 'false'}
}}, '*');
}}
setTimeout(() => window.close(), 1000);
diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py
index 19c8f8f7..c1afb8ff 100644
--- a/modules/routes/routeSecurityLocal.py
+++ b/modules/routes/routeSecurityLocal.py
@@ -17,7 +17,7 @@ from jose import jwt
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
-from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
+from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate, MandateType
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
@@ -175,6 +175,14 @@ def login(
# Save access token
userInterface.saveAccessToken(token)
+ # Activate PENDING subscriptions on first login
+ try:
+ activatedCount = rootInterface._activatePendingSubscriptions(str(user.id))
+ if activatedCount > 0:
+ logger.info(f"Activated {activatedCount} pending subscription(s) for user {user.username}")
+ except Exception as subErr:
+ logger.error(f"Error activating subscriptions on login: {subErr}")
+
# 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:
@@ -246,7 +254,9 @@ def login(
def register_user(
request: Request,
userData: User = Body(...),
- frontendUrl: str = Body(..., embed=True)
+ frontendUrl: str = Body(..., embed=True),
+ registrationType: str = Body("personal", embed=True),
+ companyName: str = Body(None, embed=True),
) -> Dict[str, Any]:
"""Register a new local user (magic link based - no password required).
@@ -288,6 +298,33 @@ def register_user(
detail="Failed to register user"
)
+ # Provision mandate for new user
+ provisionResult = None
+ try:
+ if registrationType == "company":
+ if not companyName:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="companyName is required for company registration"
+ )
+ provisionResult = appInterface._provisionMandateForUser(
+ userId=str(user.id),
+ mandateType="company",
+ mandateName=companyName,
+ planKey="STANDARD_MONTHLY",
+ )
+ else:
+ provisionResult = appInterface._provisionMandateForUser(
+ userId=str(user.id),
+ mandateType="personal",
+ mandateName=user.fullName or user.username,
+ planKey="TRIAL_7D",
+ )
+ logger.info(f"Provisioned mandate for user {user.id}: {provisionResult}")
+ except Exception as provErr:
+ logger.error(f"Error provisioning mandate for user {user.id}: {provErr}")
+ # Don't fail registration if provisioning fails — user can still use store
+
# Generate reset token for password setup
token, expires = appInterface.generateResetTokenAndExpiry()
appInterface.setResetToken(user.id, token, expires, clearPassword=False)
@@ -364,9 +401,13 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
logger.warning(f"Failed to create notifications for pending invitations: {notifErr}")
# Don't fail registration if notification creation fails
- return {
+ 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["mandateType"] = provisionResult.get("mandateType")
+ return responseData
except ValueError as e:
raise HTTPException(
@@ -652,6 +693,58 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
"message": "Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet."
}
+@router.post("/onboarding")
+@limiter.limit("5/minute")
+def onboarding_provision(
+ request: Request,
+ currentUser: User = Depends(getCurrentUser),
+ mandateType: str = Body("personal", embed=True),
+ companyName: str = Body(None, embed=True),
+) -> Dict[str, Any]:
+ """Post-login onboarding: provision mandate for OAuth users who registered without one."""
+ try:
+ appInterface = getRootInterface()
+
+ userMandates = appInterface.getUserMandates(str(currentUser.id))
+ hasOwnMandate = False
+ for um in userMandates:
+ mandate = appInterface.getMandate(um.mandateId)
+ if mandate and not mandate.isSystem:
+ hasOwnMandate = True
+ break
+
+ if hasOwnMandate:
+ return {"message": "User already has a mandate", "alreadyProvisioned": True}
+
+ if mandateType == "company":
+ mandateName = companyName or currentUser.fullName or currentUser.username
+ planKey = "STANDARD_MONTHLY"
+ else:
+ mandateName = currentUser.fullName or currentUser.username
+ planKey = "TRIAL_7D"
+
+ result = appInterface._provisionMandateForUser(
+ userId=str(currentUser.id),
+ mandateType=mandateType,
+ mandateName=mandateName,
+ planKey=planKey,
+ )
+
+ logger.info(f"Onboarding provision for {currentUser.username}: {result}")
+ return {
+ "message": "Mandate provisioned successfully",
+ "mandateId": result.get("mandateId"),
+ "mandateType": result.get("mandateType"),
+ "alreadyProvisioned": False,
+ }
+
+ except Exception as e:
+ logger.error(f"Onboarding provision failed: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
+ )
+
+
@router.post("/password-reset")
@limiter.limit("10/minute")
def password_reset(
@@ -710,3 +803,72 @@ def password_reset(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Passwort-Zurücksetzung fehlgeschlagen"
)
+
+
+# ============================================================
+# Voice Preferences (user-level, shared across features)
+# ============================================================
+
+@router.get("/voice-preferences")
+@limiter.limit("60/minute")
+def getVoicePreferences(
+ request: Request,
+ currentUser: User = Depends(getCurrentUser),
+) -> Dict[str, Any]:
+ """Get user's voice/language preferences (optionally scoped to mandate via header)."""
+ try:
+ rootInterface = getRootInterface()
+ from modules.datamodels.datamodelUam import UserVoicePreferences
+
+ mandateId = request.headers.get("X-Mandate-Id") or None
+
+ prefs = rootInterface.db.getRecordset(
+ UserVoicePreferences,
+ recordFilter={"userId": str(currentUser.id), "mandateId": mandateId}
+ )
+ if prefs:
+ return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
+ return UserVoicePreferences(userId=str(currentUser.id), mandateId=mandateId).model_dump()
+ except Exception as e:
+ logger.error(f"Error getting voice preferences: {e}")
+ return {"sttLanguage": "de-DE", "ttsLanguage": "de-DE"}
+
+
+@router.put("/voice-preferences")
+@limiter.limit("30/minute")
+def updateVoicePreferences(
+ request: Request,
+ preferences: Dict[str, Any] = Body(...),
+ currentUser: User = Depends(getCurrentUser),
+) -> Dict[str, Any]:
+ """Update user's voice/language preferences."""
+ try:
+ rootInterface = getRootInterface()
+ from modules.datamodels.datamodelUam import UserVoicePreferences
+
+ mandateId = request.headers.get("X-Mandate-Id") or None
+ userId = str(currentUser.id)
+
+ existing = rootInterface.db.getRecordset(
+ UserVoicePreferences,
+ recordFilter={"userId": userId, "mandateId": mandateId}
+ )
+
+ allowedFields = {
+ "sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
+ "translationSourceLanguage", "translationTargetLanguage",
+ }
+ updateData = {k: v for k, v in preferences.items() if k in allowedFields}
+
+ if existing:
+ existingRecord = existing[0]
+ existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
+ rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
+ return {"message": "Updated", **updateData}
+ else:
+ newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
+ created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
+ return {"message": "Created", **(created if isinstance(created, dict) else created.model_dump())}
+ except Exception as e:
+ logger.error(f"Error updating voice preferences: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index 087b68d2..99c582c6 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -2,16 +2,12 @@
# All rights reserved.
"""
Feature Store routes.
-Allows users to self-activate features in the root mandate's shared instances.
-
-Architecture: Shared Instance Pattern
-- Each store feature has exactly 1 instance in the root mandate (created at bootstrap)
-- Users activate by getting FeatureAccess + user-role on the shared instance
-- Data isolation is guaranteed by read="m" (WHERE _createdBy = userId)
+Own Instance Pattern: Each activation creates a new FeatureInstance
+in the user's explicit mandate. Supports Orphan Control.
"""
from fastapi import APIRouter, HTTPException, Depends, Request
-from typing import List, Dict, Any
+from typing import List, Dict, Any, Optional
from fastapi import status
import logging
from pydantic import BaseModel, Field
@@ -19,8 +15,9 @@ from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
-from modules.datamodels.datamodelRbac import AccessRuleContext
+from modules.datamodels.datamodelRbac import AccessRuleContext, Role
from modules.datamodels.datamodelUam import Mandate
+from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService
@@ -38,7 +35,15 @@ router = APIRouter(
class StoreActivateRequest(BaseModel):
"""Request model for activating a store feature."""
- featureCode: str = Field(..., description="Feature code to activate (e.g., 'automation')")
+ featureCode: str = Field(..., description="Feature code to activate")
+ mandateId: Optional[str] = Field(None, description="Target mandate ID (explicit). If None and user has no admin mandate, auto-creates personal mandate.")
+
+
+class StoreDeactivateRequest(BaseModel):
+ """Request model for deactivating a store feature."""
+ featureCode: str = Field(..., description="Feature code to deactivate")
+ mandateId: str = Field(..., description="Mandate ID")
+ instanceId: str = Field(..., description="FeatureInstance ID to deactivate")
class StoreFeatureResponse(BaseModel):
@@ -47,21 +52,12 @@ class StoreFeatureResponse(BaseModel):
label: Dict[str, str]
icon: str
description: Dict[str, str] = {}
- isActive: bool
+ instances: List[Dict[str, Any]] = []
canActivate: bool
- instanceId: str | None = None
-
-
-def _getRootMandateId(db) -> str | None:
- """Find the root mandate ID."""
- mandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
- if mandates:
- return mandates[0].get("id")
- return None
def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
- """Get all features that are available in the store (have resource.store.* entries)."""
+ """Get all features available in the store."""
resourceObjects = catalogService.getResourceObjects()
storeFeatures = []
for obj in resourceObjects:
@@ -75,75 +71,133 @@ def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
return storeFeatures
-def _checkStorePermission(context: RequestContext, featureCode: str) -> bool:
- """Check if user has RBAC permission to activate a store feature."""
- if context.hasSysAdminRole:
- return True
-
- resourceItem = f"resource.store.{featureCode}"
- dbApp = getRootDbAppConnector()
- rbacInstance = RbacClass(dbApp, dbApp=dbApp)
- permissions = rbacInstance.getUserPermissions(
- context.user,
- AccessRuleContext.RESOURCE,
- resourceItem,
- mandateId=str(context.mandateId) if context.mandateId else None,
- )
- return permissions.view
+def _isUserAdminInMandate(db, userId: str, mandateId: str) -> bool:
+ """Check if user has admin role in a mandate."""
+ userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mandateId, "enabled": True})
+ if not userMandates:
+ return False
+ umId = userMandates[0].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():
+ return True
+ return False
-def _findSharedInstance(db, rootMandateId: str, featureCode: str) -> Dict[str, Any] | None:
- """Find the shared instance for a feature in the root mandate."""
- instances = db.getRecordset(
- FeatureInstance,
- recordFilter={"mandateId": rootMandateId, "featureCode": featureCode}
- )
- return instances[0] if instances else None
-
-
-def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] | None:
- """Check if user already has FeatureAccess for an instance."""
- accesses = db.getRecordset(
- FeatureAccess,
- recordFilter={"userId": userId, "featureInstanceId": instanceId}
- )
- return accesses[0] if accesses else None
-
-
-def _findStoreUserRoleId(
- rootInterface,
- catalogService,
- instanceId: str,
- featureCode: str,
-) -> str | None:
- """
- Resolve the feature's primary *user* role on this instance (e.g. workspace-user).
- Uses catalog template labels first, then a safe fallback on instance roles.
- """
- instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId)
- labelToId = {r.roleLabel: str(r.id) for r in instanceRoles if r.roleLabel}
-
- preferred = f"{featureCode}-user"
- if preferred in labelToId:
- return labelToId[preferred]
-
- for tpl in catalogService.getTemplateRoles(featureCode):
- lbl = (tpl.get("roleLabel") or "").strip()
- if not lbl:
+def _getUserAdminMandateIds(db, userId: str) -> List[str]:
+ """Get all mandate IDs where user is admin."""
+ userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
+ adminMandateIds = []
+ for um in userMandates:
+ mandateId = um.get("mandateId")
+ mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
+ if mandate and mandate[0].get("isSystem"):
continue
- low = lbl.lower()
- if "admin" in low:
- continue
- if lbl.endswith("-user") and lbl in labelToId:
- return labelToId[lbl]
+ if _isUserAdminInMandate(db, userId, mandateId):
+ adminMandateIds.append(mandateId)
+ return adminMandateIds
- for role in instanceRoles:
- low = (role.roleLabel or "").lower()
- if "admin" in low:
- continue
- if "user" in low:
- return str(role.id)
- return None
+
+def _getUserInstancesForFeature(db, userId: str, featureCode: str, mandateIds: List[str]) -> List[Dict[str, Any]]:
+ """Get user's active instances for a feature across their mandates."""
+ instances = []
+ for mandateId in mandateIds:
+ mandateInstances = db.getRecordset(
+ FeatureInstance,
+ recordFilter={"mandateId": mandateId, "featureCode": featureCode}
+ )
+ for inst in mandateInstances:
+ instanceId = inst.get("id")
+ accesses = db.getRecordset(
+ FeatureAccess,
+ recordFilter={"userId": userId, "featureInstanceId": instanceId}
+ )
+ if accesses:
+ mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
+ mandateName = mandate[0].get("label") or mandate[0].get("name") if mandate else mandateId
+ instances.append({
+ "instanceId": instanceId,
+ "mandateId": mandateId,
+ "mandateName": mandateName,
+ "label": inst.get("label", ""),
+ "isActive": True,
+ })
+ return instances
+
+
+@router.get("/mandates", response_model=List[Dict[str, Any]])
+@limiter.limit("60/minute")
+def listUserMandates(
+ request: Request,
+ context: RequestContext = Depends(getRequestContext)
+) -> List[Dict[str, Any]]:
+ """List mandates where the user can activate features (admin mandates)."""
+ try:
+ rootInterface = getRootInterface()
+ db = rootInterface.db
+ userId = str(context.user.id)
+ adminMandateIds = _getUserAdminMandateIds(db, userId)
+ result = []
+ for mid in adminMandateIds:
+ records = db.getRecordset(Mandate, recordFilter={"id": mid})
+ if records:
+ m = records[0]
+ result.append({
+ "id": mid,
+ "name": m.get("name", ""),
+ "label": m.get("label") or m.get("name", ""),
+ "mandateType": m.get("mandateType", "company"),
+ })
+ return result
+ except Exception as e:
+ logger.error(f"Error listing user mandates: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/subscription-info", response_model=Dict[str, Any])
+@limiter.limit("60/minute")
+def getSubscriptionInfo(
+ request: Request,
+ mandateId: str = None,
+ context: RequestContext = Depends(getRequestContext)
+) -> Dict[str, Any]:
+ """Get subscription info for a mandate (plan, limits)."""
+ try:
+ rootInterface = getRootInterface()
+ db = rootInterface.db
+ userId = str(context.user.id)
+
+ if not mandateId:
+ adminMandateIds = _getUserAdminMandateIds(db, userId)
+ if adminMandateIds:
+ mandateId = adminMandateIds[0]
+
+ if not mandateId:
+ return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
+
+ from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS
+ subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
+ if not subs:
+ return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
+
+ sub = subs[0]
+ plan = BUILTIN_PLANS.get(sub.get("planKey"))
+ currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
+
+ return {
+ "plan": sub.get("planKey"),
+ "status": sub.get("status"),
+ "maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
+ "maxFeatureInstances": plan.maxFeatureInstances if plan else None,
+ "currentFeatureInstances": len(currentInstances),
+ "trialEndsAt": sub.get("trialEndsAt"),
+ }
+ except Exception as e:
+ logger.error(f"Error getting subscription info: {e}")
+ return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
@router.get("/features", response_model=List[StoreFeatureResponse])
@@ -152,47 +206,33 @@ def listStoreFeatures(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[StoreFeatureResponse]:
- """
- List all store features with activation status and permissions.
-
- Returns the store catalog showing which features are available,
- which are already activated, and whether the user can activate them.
- """
+ """List all store features with activation status per mandate."""
try:
rootInterface = getRootInterface()
db = rootInterface.db
catalogService = getCatalogService()
+ userId = str(context.user.id)
- rootMandateId = _getRootMandateId(db)
- if not rootMandateId:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Root mandate not found"
- )
+ userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
+ userMandateIds = []
+ for um in userMandates:
+ mid = um.get("mandateId")
+ mRecord = db.getRecordset(Mandate, recordFilter={"id": mid})
+ if mRecord and not mRecord[0].get("isSystem"):
+ userMandateIds.append(mid)
storeFeatures = _getStoreFeatures(catalogService)
- userId = str(context.user.id)
result = []
for featureDef in storeFeatures:
featureCode = featureDef["code"]
- sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
- instanceId = sharedInstance.get("id") if sharedInstance else None
-
- isActive = False
- if instanceId:
- existingAccess = _getUserFeatureAccess(db, userId, instanceId)
- isActive = existingAccess is not None
-
- canActivate = _checkStorePermission(context, featureCode) and not isActive
-
+ instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
result.append(StoreFeatureResponse(
featureCode=featureCode,
label=featureDef.get("label", {}),
icon=featureDef.get("icon", "mdi-puzzle"),
- isActive=isActive,
- canActivate=canActivate,
- instanceId=instanceId,
+ instances=instances,
+ canActivate=True,
))
return result
@@ -201,10 +241,7 @@ def listStoreFeatures(
raise
except Exception as e:
logger.error(f"Error listing store features: {e}")
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to list store features: {str(e)}"
- )
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.post("/activate", response_model=Dict[str, Any])
@@ -215,10 +252,8 @@ def activateStoreFeature(
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
- Activate a store feature for the current user.
-
- Creates FeatureAccess + FeatureAccessRole on the shared instance
- in the root mandate. The user gets the feature's user-level role.
+ Activate a store feature. Creates a new FeatureInstance in the target mandate.
+ If mandateId is None and user has no admin mandate, auto-creates a personal mandate.
"""
featureCode = data.featureCode
userId = str(context.user.id)
@@ -226,82 +261,94 @@ def activateStoreFeature(
try:
rootInterface = getRootInterface()
db = rootInterface.db
-
- if not _checkStorePermission(context, featureCode):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=f"No permission to activate feature '{featureCode}'"
- )
-
catalogService = getCatalogService()
+
featureDef = catalogService.getFeatureDefinition(featureCode)
if not featureDef:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Feature '{featureCode}' not found"
- )
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature '{featureCode}' not found")
- rootMandateId = _getRootMandateId(db)
- if not rootMandateId:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Root mandate not found"
- )
+ mandateId = data.mandateId
- sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
- if not sharedInstance:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Shared instance for '{featureCode}' not found in root mandate"
- )
+ # Auto-create personal mandate if user has no admin mandates
+ if not mandateId:
+ adminMandateIds = _getUserAdminMandateIds(db, userId)
+ if not adminMandateIds:
+ provisionResult = rootInterface._provisionMandateForUser(
+ userId=userId,
+ mandateType="personal",
+ mandateName=context.user.fullName or context.user.username,
+ planKey="TRIAL_7D",
+ )
+ mandateId = provisionResult["mandateId"]
+ logger.info(f"Auto-created personal mandate {mandateId} for user {userId} via store")
+ elif len(adminMandateIds) == 1:
+ mandateId = adminMandateIds[0]
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="mandateId is required when user has multiple admin mandates"
+ )
- instanceId = sharedInstance.get("id")
+ # Verify user is admin in target mandate
+ if not _isUserAdminInMandate(db, userId, mandateId):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not admin in target mandate")
- existingAccess = _getUserFeatureAccess(db, userId, instanceId)
- if existingAccess:
- raise HTTPException(
- status_code=status.HTTP_409_CONFLICT,
- detail=f"Feature '{featureCode}' is already active"
- )
+ # Check subscription capacity
+ from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS
+ subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
+ if subs:
+ sub = subs[0]
+ plan = BUILTIN_PLANS.get(sub.get("planKey"))
+ if plan and plan.maxFeatureInstances is not None:
+ currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
+ if len(currentInstances) >= plan.maxFeatureInstances:
+ raise HTTPException(
+ status_code=status.HTTP_402_PAYMENT_REQUIRED,
+ detail=f"Feature instance limit reached ({plan.maxFeatureInstances}). Upgrade your plan."
+ )
- featureAccess = FeatureAccess(
- userId=userId,
- featureInstanceId=instanceId,
- enabled=True
+ # Create new FeatureInstance
+ featureInterface = getFeatureInterface(db)
+ featureLabel = featureDef.get("label", {}).get("en", featureCode)
+ instance = featureInterface.createFeatureInstance(
+ featureCode=featureCode,
+ mandateId=mandateId,
+ label=featureLabel,
+ enabled=True,
+ copyTemplateRoles=True,
)
- createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
- featureAccessId = createdAccess.get("id")
- userRoleId = _findStoreUserRoleId(rootInterface, catalogService, instanceId, featureCode)
- if not userRoleId:
- db.recordDelete(FeatureAccess, featureAccessId)
- logger.error(
- f"Store activate rollback: no user role on instance {instanceId} for feature '{featureCode}'"
- )
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=(
- f"No '{featureCode}-user' (or equivalent) role found on the shared instance; "
- "cannot grant store access. Contact an administrator."
- ),
- )
+ if not instance:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create feature instance")
- featureAccessRole = FeatureAccessRole(
- featureAccessId=featureAccessId,
- roleId=userRoleId
- )
- db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
+ instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
- logger.info(
- f"User {userId} activated store feature '{featureCode}' "
- f"(instance={instanceId}, role={userRoleId})"
- )
+ # Grant FeatureAccess with admin role
+ instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
+ adminRoleId = None
+ for ir in instanceRoles:
+ if "admin" in (ir.get("roleLabel") or "").lower():
+ adminRoleId = ir.get("id")
+ break
+
+ fa = FeatureAccess(userId=userId, featureInstanceId=instanceId, enabled=True)
+ createdFa = db.recordCreate(FeatureAccess, fa.model_dump())
+ if adminRoleId and createdFa:
+ far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminRoleId)
+ db.recordCreate(FeatureAccessRole, far.model_dump())
+
+ # Sync subscription quantity
+ try:
+ rootInterface._syncSubscriptionQuantity(mandateId)
+ except Exception as e:
+ logger.warning(f"Failed to sync subscription quantity: {e}")
+
+ logger.info(f"User {userId} activated '{featureCode}' in mandate {mandateId} (instance={instanceId})")
return {
"featureCode": featureCode,
+ "mandateId": mandateId,
"instanceId": instanceId,
- "featureAccessId": featureAccessId,
- "roleId": userRoleId,
"activated": True,
}
@@ -309,71 +356,67 @@ def activateStoreFeature(
raise
except Exception as e:
logger.error(f"Error activating store feature '{featureCode}': {e}")
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to activate feature: {str(e)}"
- )
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.post("/deactivate", response_model=Dict[str, Any])
@limiter.limit("10/minute")
def deactivateStoreFeature(
request: Request,
- data: StoreActivateRequest,
+ data: StoreDeactivateRequest,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
- Deactivate a store feature for the current user.
-
- Removes FeatureAccess (CASCADE deletes FeatureAccessRole).
- User loses access immediately.
+ Deactivate a store feature. Removes user's FeatureAccess.
+ Orphan Control: if last user deactivates, FeatureInstance is deleted.
"""
- featureCode = data.featureCode
userId = str(context.user.id)
+ instanceId = data.instanceId
+ mandateId = data.mandateId
try:
rootInterface = getRootInterface()
db = rootInterface.db
- rootMandateId = _getRootMandateId(db)
- if not rootMandateId:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Root mandate not found"
- )
+ # Verify instance exists in mandate
+ instances = db.getRecordset(FeatureInstance, recordFilter={"id": instanceId, "mandateId": mandateId})
+ if not instances:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature instance not found in mandate")
- sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
- if not sharedInstance:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Shared instance for '{featureCode}' not found"
- )
+ # Find user's FeatureAccess
+ accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId})
+ if not accesses:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No active access found")
- instanceId = sharedInstance.get("id")
-
- existingAccess = _getUserFeatureAccess(db, userId, instanceId)
- if not existingAccess:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Feature '{featureCode}' is not active"
- )
-
- featureAccessId = existingAccess.get("id")
+ featureAccessId = accesses[0].get("id")
db.recordDelete(FeatureAccess, featureAccessId)
- logger.info(f"User {userId} deactivated store feature '{featureCode}' (instance={instanceId})")
+ # Orphan Control: check if any FeatureAccess remains
+ remainingAccesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
+ instanceDeleted = False
+ if not remainingAccesses:
+ db.recordDelete(FeatureInstance, instanceId)
+ instanceDeleted = True
+ logger.info(f"Orphan Control: deleted instance {instanceId} (no remaining accesses)")
+
+ # Sync subscription quantity
+ try:
+ rootInterface._syncSubscriptionQuantity(mandateId)
+ except Exception as e:
+ logger.warning(f"Failed to sync subscription quantity: {e}")
+
+ logger.info(f"User {userId} deactivated instance {instanceId} in mandate {mandateId} (deleted={instanceDeleted})")
return {
- "featureCode": featureCode,
+ "featureCode": data.featureCode,
+ "mandateId": mandateId,
"instanceId": instanceId,
"deactivated": True,
+ "instanceDeleted": instanceDeleted,
}
except HTTPException:
raise
except Exception as e:
- logger.error(f"Error deactivating store feature '{featureCode}': {e}")
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to deactivate feature: {str(e)}"
- )
+ logger.error(f"Error deactivating store feature: {e}")
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index 78c69ff3..cbea5631 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -363,6 +363,7 @@ class AgentService:
featureInstanceId=featureInstanceId,
mandateId=mandateId,
workflowHintItems=workflowHintItems,
+ isSysAdmin=getattr(self.services.user, "isSysAdmin", False),
)
except Exception as e:
logger.debug(f"RAG context not available: {e}")
diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
index 09e2d708..494389ff 100644
--- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py
+++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
@@ -153,6 +153,9 @@ class AiService:
2. Balance & provider check before AI call
3. billingCallback on aiObjects: records one billing transaction per model call
with exact provider + model name (set before AI call, invoked by _callWithModel)
+
+ NEUTRALIZATION: If enabled, prompt text is neutralized before the AI call
+ and placeholders in the response are rehydrated afterwards.
"""
await self.ensureAiObjectsInitialized()
@@ -172,6 +175,11 @@ class AiService:
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
logger.debug(f"Effective allowedProviders for AI request: {effectiveProviders}")
+ # Neutralize prompt if enabled (before AI call)
+ _wasNeutralized = False
+ if self._shouldNeutralize(request):
+ request, _wasNeutralized = self._neutralizeRequest(request)
+
# Set billing callback on aiObjects BEFORE the AI call
# This callback is invoked by _callWithModel() after EVERY individual model call
# For parallel content parts (e.g., 200 MB doc), each model call creates its own transaction
@@ -187,10 +195,18 @@ class AiService:
finally:
self.aiObjects.billingCallback = None
+ # Rehydrate neutralization placeholders in response
+ if _wasNeutralized and response and hasattr(response, 'content') and response.content:
+ response.content = self._rehydrateResponse(response.content)
+
return response
async def callAiStream(self, request: AiCallRequest):
- """Streaming variant of callAi. Yields str deltas during generation, then final AiCallResponse."""
+ """Streaming variant of callAi. Yields str deltas during generation, then final AiCallResponse.
+
+ NEUTRALIZATION: If enabled, prompt text is neutralized before streaming.
+ Rehydration happens on the final AiCallResponse (not on individual str deltas).
+ """
await self.ensureAiObjectsInitialized()
self._preflightBillingCheck()
await self._checkBillingBeforeAiCall()
@@ -199,9 +215,17 @@ class AiService:
if effectiveProviders and request.options:
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
+ # Neutralize prompt if enabled (before streaming)
+ _wasNeutralized = False
+ if self._shouldNeutralize(request):
+ request, _wasNeutralized = self._neutralizeRequest(request)
+
self.aiObjects.billingCallback = self._createBillingCallback()
try:
async for chunk in self.aiObjects.callWithTextContextStream(request):
+ # Rehydrate the final AiCallResponse (non-str chunks are the final response)
+ if _wasNeutralized and not isinstance(chunk, str) and hasattr(chunk, 'content') and chunk.content:
+ chunk.content = self._rehydrateResponse(chunk.content)
yield chunk
finally:
self.aiObjects.billingCallback = None
@@ -511,6 +535,60 @@ detectedIntent-Werte:
return basePrompt
+ # =========================================================================
+ # NEUTRALIZATION: Centralized prompt neutralization / response rehydration
+ # =========================================================================
+
+ def _shouldNeutralize(self, request: AiCallRequest) -> bool:
+ """Check if this AI request should have neutralization applied.
+ Only applies to text prompts — not embeddings or image processing."""
+ try:
+ neutralSvc = self._get_service("neutralization")
+ if not neutralSvc:
+ return False
+ config = neutralSvc.getConfig() if hasattr(neutralSvc, 'getConfig') else None
+ if not config or not getattr(config, 'enabled', False):
+ return False
+ if not request.prompt and not request.messages:
+ return False
+ return True
+ except Exception:
+ return False
+
+ def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool]:
+ """Neutralize the prompt text in an AiCallRequest.
+ Returns (modifiedRequest, wasNeutralized)."""
+ try:
+ neutralSvc = self._get_service("neutralization")
+ if not neutralSvc or not hasattr(neutralSvc, 'processText'):
+ return request, False
+
+ if request.prompt:
+ result = neutralSvc.processText(request.prompt)
+ if result and result.get("neutralized_text"):
+ request.prompt = result["neutralized_text"]
+ logger.debug("Neutralized prompt in AiCallRequest")
+ return request, True
+
+ return request, False
+ except Exception as e:
+ logger.warning(f"Request neutralization failed: {e}")
+ return request, False
+
+ def _rehydrateResponse(self, responseText: str) -> str:
+ """Replace neutralization placeholders with original values in AI response."""
+ if not responseText:
+ return responseText
+ try:
+ neutralSvc = self._get_service("neutralization")
+ if not neutralSvc or not hasattr(neutralSvc, 'resolveText'):
+ return responseText
+ resolved = neutralSvc.resolveText(responseText)
+ return resolved if resolved else responseText
+ except Exception as e:
+ logger.warning(f"Response rehydration failed: {e}")
+ return responseText
+
def _preflightBillingCheck(self) -> None:
"""
Pre-flight billing validation - like a 0 CHF credit card authorization check.
diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
index d6943c58..77e8530e 100644
--- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
+++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
@@ -110,6 +110,49 @@ class KnowledgeService:
# 2. Chunk text content objects and create embeddings
textObjects = [o for o in contentObjects if o.get("contentType") == "text"]
+
+ # Check if file requires neutralization
+ _shouldNeutralize = False
+ try:
+ from modules.datamodels.datamodelFiles import FileItem as _FileItem
+ _dbComponent = getattr(self._context, 'interfaceDbComponent', None)
+ _fileRecords = _dbComponent.getRecordset(_FileItem, recordFilter={"id": fileId}) if _dbComponent else []
+ if _fileRecords:
+ _fileRecord = _fileRecords[0]
+ _shouldNeutralize = (
+ _fileRecord.get("neutralize", False) if isinstance(_fileRecord, dict)
+ else getattr(_fileRecord, "neutralize", False)
+ )
+ except Exception:
+ pass
+
+ if _shouldNeutralize and textObjects:
+ _neutralizedObjects = []
+ try:
+ _neutralSvc = self._getService("neutralization")
+ except Exception:
+ _neutralSvc = None
+ if _neutralSvc:
+ for _obj in textObjects:
+ _textContent = (_obj.get("data", "") or "").strip()
+ if not _textContent:
+ continue
+ try:
+ _neutralResult = _neutralSvc.processText(
+ _textContent, userId=userId, featureInstanceId=featureInstanceId
+ )
+ if _neutralResult and _neutralResult.get("neutralized_text"):
+ _obj["data"] = _neutralResult["neutralized_text"]
+ _neutralizedObjects.append(_obj)
+ else:
+ logger.warning(f"Neutralization failed for file {fileId}, skipping text object (fail-safe)")
+ except Exception as e:
+ logger.warning(f"Neutralization error for file {fileId}: {e}, skipping text object (fail-safe)")
+ textObjects = _neutralizedObjects
+ else:
+ logger.warning(f"Neutralization required for file {fileId} but service unavailable, skipping text indexing")
+ textObjects = []
+
if textObjects:
self._knowledgeDb.updateFileStatus(fileId, "embedding")
chunks = _chunkForEmbedding(textObjects, maxTokens=DEFAULT_CHUNK_TOKENS)
@@ -155,6 +198,12 @@ class KnowledgeService:
self._knowledgeDb.updateFileStatus(fileId, "indexed")
index.status = "indexed"
+ if _shouldNeutralize:
+ try:
+ index.neutralizationStatus = "completed"
+ self._knowledgeDb.upsertFileContentIndex(index)
+ except Exception as e:
+ logger.debug(f"Could not set neutralizationStatus for file {fileId}: {e}")
logger.info(f"Indexed file {fileId} ({fileName}): {len(contentObjects)} objects, {len(textObjects)} text chunks")
return index
@@ -171,6 +220,7 @@ class KnowledgeService:
mandateId: str = "",
contextBudget: int = DEFAULT_CONTEXT_BUDGET,
workflowHintItems: List[Dict[str, Any]] = None,
+ isSysAdmin: bool = False,
) -> str:
"""Build RAG context for an agent round by searching all layers.
@@ -217,13 +267,15 @@ class KnowledgeService:
maxChars=2000,
)
- # Layer 1: Instance Layer (user's own documents, highest priority)
+ # Layer 1: Scope-based document search (personal + instance + mandate + global)
instanceChunks = self._knowledgeDb.semanticSearch(
queryVector=queryVector,
userId=userId,
featureInstanceId=featureInstanceId,
+ mandateId=mandateId,
limit=15,
minScore=0.65,
+ isSysAdmin=isSysAdmin,
)
if instanceChunks:
builder.add(priority=1, label="Relevant Documents", items=instanceChunks, maxChars=4000)
@@ -271,6 +323,7 @@ class KnowledgeService:
isShared=True,
limit=10,
minScore=0.7,
+ isSysAdmin=isSysAdmin,
)
if sharedChunks:
builder.add(priority=4, label="Shared Knowledge", items=sharedChunks, maxChars=2000)
diff --git a/modules/workflows/methods/methodContext/actions/neutralizeData.py b/modules/workflows/methods/methodContext/actions/neutralizeData.py
index d5ec045b..a1fc6b91 100644
--- a/modules/workflows/methods/methodContext/actions/neutralizeData.py
+++ b/modules/workflows/methods/methodContext/actions/neutralizeData.py
@@ -172,13 +172,13 @@ async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
)
neutralizedParts.append(neutralizedPart)
else:
- # Neutralization failed, use original part
- logger.warning(f"Neutralization did not return neutralized_text for part {part.id}")
- neutralizedParts.append(part)
+ # Fail-Safe: neutralization incomplete, skip this part
+ logger.warning(f"Fail-Safe: Neutralization incomplete for part {part.id}, SKIPPING (not passing original)")
+ continue
except Exception as e:
- logger.error(f"Error neutralizing part {part.id}: {str(e)}")
- # On error, use original part
- neutralizedParts.append(part)
+ logger.error(f"Fail-Safe: Error neutralizing part {part.id}, SKIPPING document (not passing original): {str(e)}")
+ # Fail-Safe: do NOT pass original data to AI
+ continue
else:
# No data to neutralize, keep original part
neutralizedParts.append(part)
diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py
index b9b64a9a..de332c31 100644
--- a/modules/workflows/workflowManager.py
+++ b/modules/workflows/workflowManager.py
@@ -351,7 +351,13 @@ class WorkflowManager:
if documents:
for i, doc in enumerate(documents, 1):
docListText += f"\n{i}. {doc.fileName} ({doc.mimeType}, {doc.fileSize} bytes)"
-
+
+ _userId = getattr(getattr(self.services, 'user', None), 'id', '') or ''
+ _featureInstanceId = getattr(self.services, 'featureInstanceId', '') or ''
+ _promptForAnalysis, _wasNeutralized, _mappingId = await self._neutralizePromptIfRequired(
+ userPrompt, userId=_userId, featureInstanceId=_featureInstanceId
+ )
+
analysisPrompt = f"""You are an input analyzer. From the user's message, perform ALL of the following in one pass:
1. detectedLanguage: Detect ISO 639-1 language code (e.g., de, en, fr, it)
@@ -401,7 +407,7 @@ Return ONLY JSON (no markdown) with this exact structure:
The following is the user's original input message. Analyze intent, normalize the request, and determine complexity:
################ USER INPUT START #################
-{userPrompt.replace('{', '{{').replace('}', '}}') if userPrompt else ''}
+{_promptForAnalysis.replace('{', '{{').replace('}', '}}') if _promptForAnalysis else ''}
################ USER INPUT FINISH #################
"""
@@ -419,6 +425,12 @@ The following is the user's original input message. Analyze intent, normalize th
jsonEnd = aiResponse.rfind('}') + 1 if aiResponse else 0
if jsonStart != -1 and jsonEnd > jsonStart:
result = json.loads(aiResponse[jsonStart:jsonEnd])
+ if _wasNeutralized:
+ for _field in ('normalizedRequest', 'intent', 'workflowName'):
+ if _field in result and result[_field]:
+ result[_field] = await self._rehydrateResponseIfNeeded(
+ result[_field], True, userId=_userId, featureInstanceId=_featureInstanceId
+ )
return result
else:
logger.warning("Could not parse combined analysis response, using defaults")
@@ -1353,6 +1365,38 @@ The following is the user's original input message. Analyze intent, normalize th
"""Set user language for the service center"""
self.services.user.language = language
+ async def _neutralizePromptIfRequired(self, prompt: str, userId: str, featureInstanceId: str) -> tuple:
+ """Neutralize prompt text if the workflow context requires it.
+ Returns (processedPrompt, wasNeutralized, mappingId)."""
+ try:
+ _neutralSvc = getattr(self.services, 'neutralization', None)
+ if not _neutralSvc:
+ return prompt, False, None
+ _config = _neutralSvc.getConfig() if hasattr(_neutralSvc, 'getConfig') else None
+ if not _config or not getattr(_config, 'enabled', False):
+ return prompt, False, None
+ _result = _neutralSvc.processText(prompt, userId=userId, featureInstanceId=featureInstanceId)
+ if _result and _result.get("neutralized_text"):
+ return _result["neutralized_text"], True, _result.get("mappingId")
+ return prompt, False, None
+ except Exception as e:
+ logger.warning(f"Prompt neutralization failed: {e}")
+ return prompt, False, None
+
+ async def _rehydrateResponseIfNeeded(self, response: str, wasNeutralized: bool, userId: str, featureInstanceId: str) -> str:
+ """Replace placeholders in AI response with original values."""
+ if not wasNeutralized or not response:
+ return response
+ try:
+ _neutralSvc = getattr(self.services, 'neutralization', None)
+ if not _neutralSvc:
+ return response
+ _rehydrated = _neutralSvc.resolveText(response, userId=userId, featureInstanceId=featureInstanceId)
+ return _rehydrated if _rehydrated else response
+ except Exception as e:
+ logger.warning(f"Response re-hydration failed: {e}")
+ return response
+
async def _neutralizeContentIfEnabled(self, contentBytes: bytes, mimeType: str) -> bytes:
"""Neutralize content if neutralization is enabled in user settings"""
try:
diff --git a/tests/test_phase123_basic.py b/tests/test_phase123_basic.py
new file mode 100644
index 00000000..18c4188f
--- /dev/null
+++ b/tests/test_phase123_basic.py
@@ -0,0 +1,323 @@
+"""
+Basic verification tests for Phase 1-3 implementation.
+Run with: python tests/test_phase123_basic.py
+Requires: gateway running on localhost:8000
+"""
+import sys
+import os
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+print("=" * 60)
+print("PHASE 1-3 BASIC VERIFICATION")
+print("=" * 60)
+
+errors = []
+passes = []
+
+def _check(label, condition, detail=""):
+ if condition:
+ passes.append(label)
+ print(f" [PASS] {label}")
+ else:
+ errors.append(f"{label}: {detail}")
+ print(f" [FAIL] {label} — {detail}")
+
+# ── Phase 1: Data Models ──────────────────────────────────────────────────────
+print("\n--- Phase 1: Data Models ---")
+
+try:
+ from modules.datamodels.datamodelUam import Mandate, MandateType
+ _check("MandateType Enum exists", hasattr(MandateType, "SYSTEM"))
+ _check("MandateType values", set(MandateType) == {MandateType.SYSTEM, MandateType.PERSONAL, MandateType.COMPANY})
+ m = Mandate(name="test", label="test", mandateType="personal")
+ _check("Mandate has mandateType field", hasattr(m, "mandateType"))
+ _check("Mandate mandateType coercion", m.mandateType == MandateType.PERSONAL)
+except Exception as e:
+ errors.append(f"Phase 1 DataModel: {e}")
+ print(f" [FAIL] Phase 1 DataModel import: {e}")
+
+try:
+ from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS, SubscriptionPlan
+ _check("PENDING status exists", hasattr(SubscriptionStatusEnum, "PENDING"))
+ _check("BUILTIN_PLANS has TRIAL_7D", "TRIAL_7D" in BUILTIN_PLANS)
+ trial = BUILTIN_PLANS["TRIAL_7D"]
+ _check("TRIAL_7D has maxDataVolumeMB", hasattr(trial, "maxDataVolumeMB"))
+ _check("TRIAL_7D maxDataVolumeMB=500", trial.maxDataVolumeMB == 500)
+except Exception as e:
+ errors.append(f"Phase 1 Subscription: {e}")
+ print(f" [FAIL] Phase 1 Subscription: {e}")
+
+# ── Phase 2: Scope Fields ─────────────────────────────────────────────────────
+print("\n--- Phase 2: Scope Fields on Models ---")
+
+try:
+ from modules.datamodels.datamodelFiles import FileItem
+ fi = FileItem(fileName="test.txt", mimeType="text/plain", fileHash="abc", fileSize=100)
+ _check("FileItem has scope field", hasattr(fi, "scope"))
+ _check("FileItem scope default=personal", fi.scope == "personal")
+ _check("FileItem has neutralize field", hasattr(fi, "neutralize"))
+ _check("FileItem neutralize default=False", fi.neutralize == False)
+except Exception as e:
+ errors.append(f"Phase 2 FileItem: {e}")
+ print(f" [FAIL] Phase 2 FileItem: {e}")
+
+try:
+ from modules.datamodels.datamodelDataSource import DataSource
+ ds = DataSource(connectionId="c1", sourceType="sharepoint", path="/test", label="Test")
+ _check("DataSource has scope field", hasattr(ds, "scope"))
+ _check("DataSource scope default=personal", ds.scope == "personal")
+ _check("DataSource has neutralize field", hasattr(ds, "neutralize"))
+ _check("DataSource neutralize default=False", ds.neutralize == False)
+except Exception as e:
+ errors.append(f"Phase 2 DataSource: {e}")
+ print(f" [FAIL] Phase 2 DataSource: {e}")
+
+try:
+ from modules.datamodels.datamodelKnowledge import FileContentIndex
+ fci = FileContentIndex(userId="u1", fileName="test.txt", mimeType="text/plain")
+ _check("FileContentIndex has scope field", hasattr(fci, "scope"))
+ _check("FileContentIndex scope default=personal", fci.scope == "personal")
+ _check("FileContentIndex has neutralizationStatus", hasattr(fci, "neutralizationStatus"))
+ _check("FileContentIndex neutralizationStatus default=None", fci.neutralizationStatus is None)
+except Exception as e:
+ errors.append(f"Phase 2 FileContentIndex: {e}")
+ print(f" [FAIL] Phase 2 FileContentIndex: {e}")
+
+# ── Phase 2: RAG Scope Filtering ──────────────────────────────────────────────
+print("\n--- Phase 2: RAG Scope Logic ---")
+
+try:
+ from modules.interfaces.interfaceDbKnowledge import KnowledgeObjects
+ _check("KnowledgeObjects has _getScopedFileIds", hasattr(KnowledgeObjects, "_getScopedFileIds"))
+ _check("KnowledgeObjects has _buildScopeFilter", hasattr(KnowledgeObjects, "_buildScopeFilter"))
+
+ import inspect
+ sig = inspect.signature(KnowledgeObjects._getScopedFileIds)
+ params = list(sig.parameters.keys())
+ _check("_getScopedFileIds has isSysAdmin param", "isSysAdmin" in params)
+
+ sig2 = inspect.signature(KnowledgeObjects.semanticSearch)
+ params2 = list(sig2.parameters.keys())
+ _check("semanticSearch has scope param", "scope" in params2)
+ _check("semanticSearch has isSysAdmin param", "isSysAdmin" in params2)
+except Exception as e:
+ errors.append(f"Phase 2 RAG: {e}")
+ print(f" [FAIL] Phase 2 RAG: {e}")
+
+# ── Phase 3: Neutralization Methods ───────────────────────────────────────────
+print("\n--- Phase 3: Neutralization Integration ---")
+
+try:
+ from modules.workflows.workflowManager import WorkflowManager
+ _check("WorkflowManager has _neutralizePromptIfRequired", hasattr(WorkflowManager, "_neutralizePromptIfRequired"))
+ _check("WorkflowManager has _rehydrateResponseIfNeeded", hasattr(WorkflowManager, "_rehydrateResponseIfNeeded"))
+
+ import inspect
+ sig_n = inspect.signature(WorkflowManager._neutralizePromptIfRequired)
+ _check("_neutralizePromptIfRequired is async", inspect.iscoroutinefunction(WorkflowManager._neutralizePromptIfRequired))
+
+ sig_r = inspect.signature(WorkflowManager._rehydrateResponseIfNeeded)
+ _check("_rehydrateResponseIfNeeded is async", inspect.iscoroutinefunction(WorkflowManager._rehydrateResponseIfNeeded))
+except Exception as e:
+ errors.append(f"Phase 3 WorkflowManager: {e}")
+ print(f" [FAIL] Phase 3 WorkflowManager: {e}")
+
+# ── Phase 3: Fail-Safe Logic ──────────────────────────────────────────────────
+print("\n--- Phase 3: Fail-Safe Logic ---")
+
+try:
+ import ast
+ with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "modules", "workflows", "methods", "methodContext", "actions", "neutralizeData.py"), "r") as f:
+ source = f.read()
+ _check("neutralizeData.py has 'SKIPPING' fail-safe", "SKIPPING" in source)
+ _check("neutralizeData.py has 'do NOT pass original' comment", "do NOT pass original" in source.lower() or "not passing original" in source.lower())
+ _check("neutralizeData.py uses continue for skip", "continue" in source)
+except Exception as e:
+ errors.append(f"Phase 3 Fail-Safe: {e}")
+ print(f" [FAIL] Phase 3 Fail-Safe: {e}")
+
+# ── Phase 2: Route Endpoints ──────────────────────────────────────────────────
+print("\n--- Phase 2: API Endpoints ---")
+
+try:
+ import ast
+ with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "modules", "routes", "routeDataFiles.py"), "r") as f:
+ source = f.read()
+ _check("routeDataFiles has PATCH scope endpoint", "updateFileScope" in source)
+ _check("routeDataFiles has PATCH neutralize endpoint", "updateFileNeutralize" in source)
+ _check("routeDataFiles checks global sysAdmin", "hasSysAdminRole" in source or "sysadmin" in source.lower())
+except Exception as e:
+ errors.append(f"Phase 2 Routes: {e}")
+ print(f" [FAIL] Phase 2 Routes: {e}")
+
+# ── Phase 1: Store Endpoints ──────────────────────────────────────────────────
+print("\n--- Phase 1: Store Endpoints ---")
+
+try:
+ with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "modules", "routes", "routeStore.py"), "r") as f:
+ source = f.read()
+ _check("routeStore has listUserMandates", "listUserMandates" in source or "list_user_mandates" in source)
+ _check("routeStore has getSubscriptionInfo", "getSubscriptionInfo" in source or "get_subscription_info" in source)
+ _check("routeStore has orphan control", "orphan" in source.lower() or "last" in source.lower())
+except Exception as e:
+ errors.append(f"Phase 1 Store: {e}")
+ print(f" [FAIL] Phase 1 Store: {e}")
+
+# ── Phase 1: Provisioning ─────────────────────────────────────────────────────
+print("\n--- Phase 1: Provisioning ---")
+
+try:
+ with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "modules", "interfaces", "interfaceDbApp.py"), "r") as f:
+ source = f.read()
+ _check("interfaceDbApp has _provisionMandateForUser", "_provisionMandateForUser" in source)
+ _check("interfaceDbApp has _activatePendingSubscriptions", "_activatePendingSubscriptions" in source)
+ _check("interfaceDbApp has deleteMandate cascade", "deleteMandate" in source and "cascade" in source.lower())
+except Exception as e:
+ errors.append(f"Phase 1 Provisioning: {e}")
+ print(f" [FAIL] Phase 1 Provisioning: {e}")
+
+# ── Phase 1: Registration Routes ──────────────────────────────────────────────
+print("\n--- Phase 1: Registration ---")
+
+try:
+ with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "modules", "routes", "routeSecurityLocal.py"), "r") as f:
+ source = f.read()
+ _check("routeSecurityLocal has registrationType", "registrationType" in source)
+ _check("routeSecurityLocal has companyName", "companyName" in source)
+ _check("routeSecurityLocal has onboarding endpoint", "onboarding" in source)
+except Exception as e:
+ errors.append(f"Phase 1 Registration: {e}")
+ print(f" [FAIL] Phase 1 Registration: {e}")
+
+# ── Phase 1: Migration ────────────────────────────────────────────────────────
+print("\n--- Phase 1: Migration ---")
+
+try:
+ with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "modules", "migration", "migrateRootUsers.py"), "r") as f:
+ source = f.read()
+ _check("Migration script exists", True)
+ _check("Migration has _isMigrationCompleted", "_isMigrationCompleted" in source)
+ _check("Migration has migrateRootUsers", "migrateRootUsers" in source)
+except Exception as e:
+ errors.append(f"Phase 1 Migration: {e}")
+ print(f" [FAIL] Phase 1 Migration: {e}")
+
+# ── Fix 1: OnboardingWizard Integration ────────────────────────────────────────
+print("\n--- Fix 1: OnboardingWizard Integration ---")
+
+try:
+ loginPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "..", "frontend_nyla", "src", "pages", "Login.tsx")
+ with open(loginPath, "r", encoding="utf-8") as f:
+ source = f.read()
+ _check("Login.tsx imports OnboardingWizard", "OnboardingWizard" in source)
+ _check("Login.tsx has showOnboardingWizard state", "showOnboardingWizard" in source)
+ _check("Login.tsx checks isNewUser", "isNewUser" in source)
+except Exception as e:
+ errors.append(f"Fix 1: {e}")
+ print(f" [FAIL] Fix 1: {e}")
+
+# ── Fix 2: CommCoach UDB Integration ──────────────────────────────────────────
+print("\n--- Fix 2: CommCoach UDB Integration ---")
+
+try:
+ dossierPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "..", "frontend_nyla", "src", "pages", "views", "commcoach", "CommcoachDossierView.tsx")
+ with open(dossierPath, "r", encoding="utf-8") as f:
+ source = f.read()
+ _check("CommCoach imports UnifiedDataBar", "UnifiedDataBar" in source)
+ _check("CommCoach imports FilesTab", "FilesTab" in source)
+ _check("CommCoach no longer imports getDocumentsApi", "getDocumentsApi" not in source)
+ _check("CommCoach has UDB sidebar", "udbSidebar" in source or "UnifiedDataBar" in source)
+except Exception as e:
+ errors.append(f"Fix 2: {e}")
+ print(f" [FAIL] Fix 2: {e}")
+
+# ── Fix 3: Neutralization Backend Endpoints ───────────────────────────────────
+print("\n--- Fix 3: Neutralization Backend Endpoints ---")
+
+try:
+ routePath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "modules", "features", "neutralization", "routeFeatureNeutralizer.py")
+ with open(routePath, "r") as f:
+ source = f.read()
+ _check("Neutralization has deleteAttribute endpoint", "deleteAttribute" in source or "delete_attribute" in source)
+ _check("Neutralization has retrigger endpoint", "retrigger" in source)
+ _check("Neutralization has single attribute delete", "single" in source or "attributeId" in source)
+except Exception as e:
+ errors.append(f"Fix 3: {e}")
+ print(f" [FAIL] Fix 3: {e}")
+
+# ── Fix 4: Central AI Neutralization ──────────────────────────────────────────
+print("\n--- Fix 4: Central AI Neutralization ---")
+
+try:
+ aiPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "modules", "serviceCenter", "services", "serviceAi", "mainServiceAi.py")
+ with open(aiPath, "r") as f:
+ source = f.read()
+ _check("AiService has _shouldNeutralize", "_shouldNeutralize" in source)
+ _check("AiService has _neutralizeRequest", "_neutralizeRequest" in source)
+ _check("AiService has _rehydrateResponse", "_rehydrateResponse" in source)
+ _check("callAi uses neutralization", "_shouldNeutralize" in source and "_neutralizeRequest" in source)
+except Exception as e:
+ errors.append(f"Fix 4: {e}")
+ print(f" [FAIL] Fix 4: {e}")
+
+# ── Fix 5: Voice Settings User Level ──────────────────────────────────────────
+print("\n--- Fix 5: Voice Settings User Level ---")
+
+try:
+ from modules.datamodels.datamodelUam import UserVoicePreferences
+ uvp = UserVoicePreferences(userId="u1")
+ _check("UserVoicePreferences model exists", True)
+ _check("UserVoicePreferences has sttLanguage", hasattr(uvp, "sttLanguage"))
+ _check("UserVoicePreferences default sttLanguage=de-DE", uvp.sttLanguage == "de-DE")
+ _check("UserVoicePreferences has ttsVoice", hasattr(uvp, "ttsVoice"))
+except Exception as e:
+ errors.append(f"Fix 5: {e}")
+ print(f" [FAIL] Fix 5: {e}")
+
+try:
+ with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "modules", "routes", "routeSecurityLocal.py"), "r") as f:
+ source = f.read()
+ _check("Voice preferences GET endpoint", "voice-preferences" in source and "getVoicePreferences" in source)
+ _check("Voice preferences PUT endpoint", "updateVoicePreferences" in source)
+except Exception as e:
+ errors.append(f"Fix 5 Routes: {e}")
+ print(f" [FAIL] Fix 5 Routes: {e}")
+
+# ── Fix 6: RAG mandate-wide scope ─────────────────────────────────────────────
+print("\n--- Fix 6: RAG mandate-wide scope ---")
+
+try:
+ knowledgePath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "modules", "serviceCenter", "services", "serviceKnowledge", "mainServiceKnowledge.py")
+ with open(knowledgePath, "r") as f:
+ source = f.read()
+ _check("buildAgentContext passes mandateId to semanticSearch", "mandateId=mandateId" in source)
+ _check("buildAgentContext has isSysAdmin param", "isSysAdmin" in source)
+except Exception as e:
+ errors.append(f"Fix 6: {e}")
+ print(f" [FAIL] Fix 6: {e}")
+
+# ── Summary ───────────────────────────────────────────────────────────────────
+print("\n" + "=" * 60)
+print(f"RESULTS: {len(passes)} passed, {len(errors)} failed")
+print("=" * 60)
+
+if errors:
+ print("\nFAILURES:")
+ for e in errors:
+ print(f" - {e}")
+ sys.exit(1)
+else:
+ print("\nALL CHECKS PASSED!")
+ sys.exit(0)