revised state machine for workflow backend and ui

This commit is contained in:
patrick-motsch 2026-02-08 00:25:48 +01:00
parent a054d12d54
commit bbea0ff115
19 changed files with 795 additions and 102 deletions

26
app.py
View file

@ -313,32 +313,18 @@ async def lifespan(app: FastAPI):
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler()
# Ensure billing settings and accounts exist
# Ensure billing settings and accounts exist for all mandates
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum
billingInterface = getBillingRootInterface()
# Ensure root mandate has billing settings
rootMandate = rootInterface.getRootMandate()
if rootMandate:
rootMandateId = rootMandate.get("id") if isinstance(rootMandate, dict) else getattr(rootMandate, "id", None)
if rootMandateId:
existingSettings = billingInterface.getSettings(rootMandateId)
if not existingSettings:
settings = BillingSettings(
mandateId=rootMandateId,
billingModel=BillingModelEnum.PREPAY_USER,
defaultUserCredit=10.0,
warningThresholdPercent=10.0,
blockOnZeroBalance=True,
notifyOnWarning=True
)
billingInterface.createSettings(settings)
logger.info(f"Created billing settings for root mandate: PREPAY_USER with 10 CHF default credit")
# Step 1: Ensure all mandates have billing settings (creates defaults if missing)
settingsCreated = billingInterface.ensureAllMandateSettingsExist()
if settingsCreated > 0:
logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings")
# Efficient bulk check: Ensure all users have billing accounts (3 queries total)
# Step 2: Ensure all users have billing accounts (for PREPAY_USER mandates)
accountsCreated = billingInterface.ensureAllUserAccountsExist()
if accountsCreated > 0:
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")

View file

@ -12,6 +12,8 @@ import uuid
class ChatStat(BaseModel):
"""Statistics for chat operations. User-owned, no mandate context."""
model_config = {"populate_by_name": True, "extra": "allow"} # Allow DB system fields
id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
)
@ -41,7 +43,7 @@ registerModelLabels(
"errorCount": {"en": "Error Count", "fr": "Nombre d'erreurs"},
"process": {"en": "Process", "fr": "Processus"},
"engine": {"en": "Engine", "fr": "Moteur"},
"priceCHF": {"en": "Price USD", "fr": "Prix USD"},
"priceCHF": {"en": "Price CHF", "fr": "Prix CHF"},
},
)

View file

@ -114,6 +114,7 @@ class UserConnection(BaseModel):
{"value": "none", "label": {"en": "None", "fr": "Aucun"}},
]})
tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
grantedScopes: Optional[List[str]] = Field(None, description="OAuth scopes granted for this connection", json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False})
@computed_field
@computed_field
@ -146,6 +147,7 @@ registerModelLabels(
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"},
"tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"grantedScopes": {"en": "Granted Scopes", "de": "Gewährte Berechtigungen", "fr": "Autorisations accordées"},
"connectionReference": {"en": "Connection Reference", "de": "Verbindungsreferenz", "fr": "Référence de connexion"},
"displayLabel": {"en": "Display Label", "de": "Anzeigebezeichnung", "fr": "Libellé d'affichage"},
},

View file

@ -1116,7 +1116,7 @@ class ChatObjects:
# Emit message event for streaming (if event manager is available)
try:
from modules.features.chatbot.eventManager import get_event_manager
from modules.features.chatbot.eventManager import get_event_manager # type: ignore
event_manager = get_event_manager()
message_timestamp = parseTimestamp(chat_message.publishedAt, default=getUtcTimestamp())
# Emit message event in exact chatData format: {type, createdAt, item}
@ -1514,7 +1514,7 @@ class ChatObjects:
# Only emit events for chatbot workflows, not for automation or dynamic workflows
if workflow.workflowMode == WorkflowModeEnum.WORKFLOW_CHATBOT:
try:
from modules.features.chatbot.eventManager import get_event_manager
from modules.features.chatbot.eventManager import get_event_manager # type: ignore
event_manager = get_event_manager()
log_timestamp = parseTimestamp(createdLog.get("timestamp"), default=getUtcTimestamp())
# Emit log event in exact chatData format: {type, createdAt, item}
@ -1563,8 +1563,8 @@ class ChatObjects:
if not stats:
return []
# Return all stats records sorted by creation time
stats.sort(key=lambda x: x.get("created_at", ""))
# Return all stats records sorted by _createdAt (system field from DB)
stats.sort(key=lambda x: x.get("_createdAt", 0))
# Ensure mandateId and featureInstanceId are set for each stat
return [ChatStat(**{**stat, "mandateId": stat.get("mandateId") or self.mandateId or "", "featureInstanceId": stat.get("featureInstanceId") or self.featureInstanceId or ""}) for stat in stats]
@ -1680,11 +1680,12 @@ class ChatObjects:
"item": chatLog
})
# Get stats list
# Get stats - ChatStat model now supports _createdAt via extra="allow"
stats = self.getStats(workflowId)
for stat in stats:
# Apply timestamp filtering in Python
stat_timestamp = stat.createdAt if hasattr(stat, 'createdAt') else getUtcTimestamp()
# Use _createdAt (system field from DB, preserved via model_config extra="allow")
stat_timestamp = getattr(stat, '_createdAt', None) or getUtcTimestamp()
if afterTimestamp is not None and stat_timestamp <= afterTimestamp:
continue

View file

@ -55,6 +55,9 @@ _gatewayInterfaces = {}
# Root interface instance
_rootAppObjects = None
# Bootstrap completion flag - ensures bootstrap runs only ONCE per application lifecycle
_bootstrapCompleted = False
# Password-Hashing
pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
@ -200,8 +203,28 @@ class AppObjects:
return simpleFields, objectFields
def _initRecords(self):
"""Initialize standard records if they don't exist."""
initBootstrap(self.db)
"""Initialize standard records if they don't exist.
Uses a global flag to ensure bootstrap only runs ONCE per application lifecycle.
The flag is set BEFORE calling bootstrap to prevent recursive calls during bootstrap.
"""
global _bootstrapCompleted
if _bootstrapCompleted:
return
# Set flag BEFORE bootstrap to prevent recursive calls during bootstrap
_bootstrapCompleted = True
logger.info("Starting bootstrap (will only run once)")
try:
initBootstrap(self.db)
logger.info("Bootstrap completed successfully")
except Exception as e:
# Reset flag on failure so bootstrap can be retried
_bootstrapCompleted = False
logger.error(f"Bootstrap failed: {e}")
raise
def checkRbacPermission(

View file

@ -15,7 +15,8 @@ import uuid
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelUam import User, Mandate
from modules.datamodels.datamodelMembership import UserMandate
from modules.datamodels.datamodelBilling import (
BillingAccount,
BillingTransaction,
@ -360,6 +361,60 @@ class BillingObjects:
return created
def ensureAllMandateSettingsExist(self) -> int:
"""
Efficiently ensure all mandates have billing settings.
Creates default settings (PREPAY_USER) for mandates without settings.
Uses bulk queries to minimize database connections.
Returns:
Number of settings created
"""
try:
settingsCreated = 0
# Step 1: Get all existing billing settings in one query (from billing DB)
allSettings = self.db.getRecordset(BillingSettings)
existingMandateIds = set(s.get("mandateId") for s in allSettings if s.get("mandateId"))
# Step 2: Get all mandates from APP database (separate connection)
appDb = DatabaseConnector(
dbDatabase=APP_CONFIG.get('DB_DATABASE', 'poweron_app'),
dbHost=APP_CONFIG.get('DB_HOST', 'localhost'),
dbPort=int(APP_CONFIG.get('DB_PORT', '5432')),
dbUser=APP_CONFIG.get('DB_USER'),
dbPassword=APP_CONFIG.get('DB_PASSWORD_SECRET')
)
allMandates = appDb.getRecordset(Mandate, recordFilter={"enabled": True})
# Step 3: Create settings for mandates that don't have them
for mandate in allMandates:
mandateId = mandate.get("id")
if not mandateId or mandateId in existingMandateIds:
continue
# Create default billing settings
settings = BillingSettings(
mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_USER,
defaultUserCredit=10.0,
warningThresholdPercent=10.0,
blockOnZeroBalance=True,
notifyOnWarning=True
)
self.createSettings(settings)
existingMandateIds.add(mandateId) # Track newly created
settingsCreated += 1
if settingsCreated > 0:
logger.info(f"Created {settingsCreated} missing billing settings for mandates")
return settingsCreated
except Exception as e:
logger.error(f"Error ensuring mandate settings exist: {e}")
return 0
def ensureAllUserAccountsExist(self) -> int:
"""
Efficiently ensure all users across all mandates have billing accounts.
@ -368,10 +423,7 @@ class BillingObjects:
Returns:
Number of accounts created
"""
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
try:
appInterface = getAppRootInterface()
accountsCreated = 0
# Step 1: Get all billing settings in one query (only PREPAY_USER mandates need user accounts)
@ -385,7 +437,7 @@ class BillingObjects:
logger.debug("No PREPAY_USER mandates found, skipping account check")
return 0
# Step 2: Get all existing USER accounts in one query
# Step 2: Get all existing USER accounts in one query (from billing DB)
allAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"accountType": AccountTypeEnum.USER.value}
@ -396,9 +448,16 @@ class BillingObjects:
key = (acc.get("mandateId"), acc.get("userId"))
existingAccountKeys.add(key)
# Step 3: Get all user-mandate combinations in one query
allUserMandates = appInterface.db.getRecordset(
appInterface.db.getModel("UserMandate"),
# Step 3: Get all user-mandate combinations from APP database (separate connection)
appDb = DatabaseConnector(
dbDatabase=APP_CONFIG.get('DB_DATABASE', 'poweron_app'),
dbHost=APP_CONFIG.get('DB_HOST', 'localhost'),
dbPort=int(APP_CONFIG.get('DB_PORT', '5432')),
dbUser=APP_CONFIG.get('DB_USER'),
dbPassword=APP_CONFIG.get('DB_PASSWORD_SECRET')
)
allUserMandates = appDb.getRecordset(
UserMandate,
recordFilter={"enabled": True}
)
@ -855,3 +914,338 @@ class BillingObjects:
logger.error(f"Error getting balances for user: {e}")
return balances
def getTransactionsForUser(self, userId: str, limit: int = 100) -> List[Dict[str, Any]]:
"""
Get all transactions for a user across all mandates they belong to.
Args:
userId: User ID
limit: Maximum number of results
Returns:
List of transaction dicts
"""
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
allTransactions = []
try:
appInterface = getAppInterface(self.currentUser)
userMandates = appInterface.getUserMandates(userId)
for um in userMandates:
# Handle both Pydantic models and dicts
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
if not mandateId:
continue
# Only include mandates with billing settings
settings = self.getSettings(mandateId)
if not settings:
continue
# Get transactions for this mandate
transactions = self.getTransactionsByMandate(mandateId, limit=limit)
# Add mandate context to each transaction
mandate = appInterface.getMandate(mandateId)
mandateName = ""
if mandate:
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
for t in transactions:
t["mandateId"] = mandateId
t["mandateName"] = mandateName
allTransactions.append(t)
except Exception as e:
logger.error(f"Error getting transactions for user: {e}")
# Sort by creation date descending and limit
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
return allTransactions[:limit]
# =========================================================================
# Mandate View Operations (Admin-Level)
# =========================================================================
def getMandateBalances(self, mandateIds: List[str] = None) -> List[Dict[str, Any]]:
"""
Get mandate-level balances.
Args:
mandateIds: Optional list of mandate IDs to filter. If None, returns all.
Returns:
List of mandate balance dicts
"""
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
balances = []
try:
appInterface = getAppInterface(self.currentUser)
# Get settings for filtering
if mandateIds:
allSettings = [self.getSettings(mId) for mId in mandateIds]
allSettings = [s for s in allSettings if s]
else:
allSettings = self.db.getRecordset(BillingSettings)
for settings in allSettings:
mandateId = settings.get("mandateId")
if not mandateId:
continue
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
# Get mandate info
mandate = appInterface.getMandate(mandateId)
mandateName = ""
if mandate:
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
# For PREPAY_MANDATE, get the mandate account balance
# For PREPAY_USER, aggregate all user balances
if billingModel == BillingModelEnum.PREPAY_MANDATE:
account = self.getMandateAccount(mandateId)
totalBalance = account.get("balance", 0.0) if account else 0.0
userCount = 0
elif billingModel == BillingModelEnum.PREPAY_USER:
# Get all user accounts for this mandate
userAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
)
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
userCount = len(userAccounts)
else:
totalBalance = 0.0
userCount = 0
balances.append({
"mandateId": mandateId,
"mandateName": mandateName,
"billingModel": billingModel.value,
"totalBalance": totalBalance,
"userCount": userCount,
"defaultUserCredit": settings.get("defaultUserCredit", 0.0),
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
"blockOnZeroBalance": settings.get("blockOnZeroBalance", True)
})
except Exception as e:
logger.error(f"Error getting mandate balances: {e}")
return balances
def getMandateTransactions(self, mandateIds: List[str] = None, limit: int = 100) -> List[Dict[str, Any]]:
"""
Get all transactions for specified mandates.
Args:
mandateIds: Optional list of mandate IDs to filter. If None, returns all.
limit: Maximum number of results
Returns:
List of transaction dicts with mandate context
"""
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
allTransactions = []
try:
appInterface = getAppInterface(self.currentUser)
# Determine which mandates to query
if mandateIds:
targetMandateIds = mandateIds
else:
allSettings = self.db.getRecordset(BillingSettings)
targetMandateIds = [s.get("mandateId") for s in allSettings if s.get("mandateId")]
for mandateId in targetMandateIds:
transactions = self.getTransactionsByMandate(mandateId, limit=limit)
# Get mandate name
mandate = appInterface.getMandate(mandateId)
mandateName = ""
if mandate:
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
for t in transactions:
t["mandateId"] = mandateId
t["mandateName"] = mandateName
allTransactions.append(t)
except Exception as e:
logger.error(f"Error getting mandate transactions: {e}")
# Sort by creation date descending and limit
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
return allTransactions[:limit]
# =========================================================================
# User View Operations (User-Level with RBAC)
# =========================================================================
def getUserBalancesForMandates(self, mandateIds: List[str] = None) -> List[Dict[str, Any]]:
"""
Get all user-level balances for specified mandates.
Args:
mandateIds: Optional list of mandate IDs to filter. If None, returns all.
Returns:
List of user balance dicts with mandate and user context
"""
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
balances = []
try:
appInterface = getAppInterface(self.currentUser)
# Get all user accounts
accountFilter = {"accountType": AccountTypeEnum.USER.value}
allAccounts = self.db.getRecordset(BillingAccount, recordFilter=accountFilter)
# Filter by mandate if specified
if mandateIds:
allAccounts = [acc for acc in allAccounts if acc.get("mandateId") in mandateIds]
# Get all relevant settings in one query
settingsMap = {}
allSettings = self.db.getRecordset(BillingSettings)
for s in allSettings:
settingsMap[s.get("mandateId")] = s
# Get user info efficiently
userIds = list(set(acc.get("userId") for acc in allAccounts if acc.get("userId")))
userMap = {}
for userId in userIds:
user = appInterface.getUser(userId)
if user:
displayName = getattr(user, 'displayName', None) or (user.get("displayName") if isinstance(user, dict) else None)
email = getattr(user, 'email', None) or (user.get("email") if isinstance(user, dict) else None)
userMap[userId] = displayName or email or userId
# Get mandate info efficiently
mandateMap = {}
mandateIdList = list(set(acc.get("mandateId") for acc in allAccounts if acc.get("mandateId")))
for mandateId in mandateIdList:
mandate = appInterface.getMandate(mandateId)
if mandate:
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
mandateMap[mandateId] = mandateName
for account in allAccounts:
mandateId = account.get("mandateId")
userId = account.get("userId")
if not mandateId or not userId:
continue
settings = settingsMap.get(mandateId)
if not settings:
continue
balance = account.get("balance", 0.0)
warningThreshold = account.get("warningThreshold", 0.0)
balances.append({
"accountId": account.get("id"),
"mandateId": mandateId,
"mandateName": mandateMap.get(mandateId, ""),
"userId": userId,
"userName": userMap.get(userId, userId),
"balance": balance,
"warningThreshold": warningThreshold,
"isWarning": balance <= warningThreshold,
"enabled": account.get("enabled", True)
})
except Exception as e:
logger.error(f"Error getting user balances for mandates: {e}")
return balances
def getUserTransactionsForMandates(self, mandateIds: List[str] = None, limit: int = 100) -> List[Dict[str, Any]]:
"""
Get all user-level transactions for specified mandates.
Args:
mandateIds: Optional list of mandate IDs to filter. If None, returns all.
limit: Maximum number of results
Returns:
List of transaction dicts with mandate and user context
"""
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
allTransactions = []
try:
appInterface = getAppInterface(self.currentUser)
# Get all user accounts
accountFilter = {"accountType": AccountTypeEnum.USER.value}
allAccounts = self.db.getRecordset(BillingAccount, recordFilter=accountFilter)
# Filter by mandate if specified
if mandateIds:
allAccounts = [acc for acc in allAccounts if acc.get("mandateId") in mandateIds]
# Build account to user/mandate mapping
accountMap = {}
for acc in allAccounts:
accountMap[acc.get("id")] = {
"mandateId": acc.get("mandateId"),
"userId": acc.get("userId")
}
# Get user info efficiently
userIds = list(set(acc.get("userId") for acc in allAccounts if acc.get("userId")))
userMap = {}
for userId in userIds:
user = appInterface.getUser(userId)
if user:
displayName = getattr(user, 'displayName', None) or (user.get("displayName") if isinstance(user, dict) else None)
email = getattr(user, 'email', None) or (user.get("email") if isinstance(user, dict) else None)
userMap[userId] = displayName or email or userId
# Get mandate info efficiently
mandateMap = {}
mandateIdList = list(set(acc.get("mandateId") for acc in allAccounts if acc.get("mandateId")))
for mandateId in mandateIdList:
mandate = appInterface.getMandate(mandateId)
if mandate:
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
mandateMap[mandateId] = mandateName
# Get transactions for all accounts
for account in allAccounts:
accountId = account.get("id")
if not accountId:
continue
transactions = self.getTransactions(accountId, limit=limit)
accountInfo = accountMap.get(accountId, {})
mandateId = accountInfo.get("mandateId")
userId = accountInfo.get("userId")
for t in transactions:
t["mandateId"] = mandateId
t["mandateName"] = mandateMap.get(mandateId, "")
t["userId"] = userId
t["userName"] = userMap.get(userId, userId)
allTransactions.append(t)
except Exception as e:
logger.error(f"Error getting user transactions for mandates: {e}")
# Sort by creation date descending and limit
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
return allTransactions[:limit]

View file

@ -1634,11 +1634,12 @@ class ChatObjects:
"item": chatLog
})
# Get stats list
# Get stats - ChatStat model supports _createdAt via model_config extra="allow"
stats = self.getStats(workflowId)
for stat in stats:
# Apply timestamp filtering in Python
stat_timestamp = stat.createdAt if hasattr(stat, 'createdAt') else getUtcTimestamp()
# Use _createdAt (system field from DB, preserved via model_config extra="allow")
stat_timestamp = getattr(stat, '_createdAt', None) or getUtcTimestamp()
if afterTimestamp is not None and stat_timestamp <= afterTimestamp:
continue

View file

@ -74,6 +74,8 @@ class TransactionResponse(BaseModel):
featureCode: Optional[str]
aicoreProvider: Optional[str]
createdAt: Optional[datetime]
mandateId: Optional[str] = None
mandateName: Optional[str] = None
class AccountSummary(BaseModel):
@ -97,6 +99,53 @@ class UsageReportResponse(BaseModel):
costByFeature: Dict[str, float]
# =============================================================================
# Response Models for Mandate/User Views
# =============================================================================
class MandateBalanceResponse(BaseModel):
"""Mandate-level balance summary."""
mandateId: str
mandateName: str
billingModel: str
totalBalance: float
userCount: int
defaultUserCredit: float
warningThresholdPercent: float
blockOnZeroBalance: bool
class UserBalanceResponse(BaseModel):
"""User-level balance summary."""
accountId: str
mandateId: str
mandateName: str
userId: str
userName: str
balance: float
warningThreshold: float
isWarning: bool
enabled: bool
class UserTransactionResponse(BaseModel):
"""User-level transaction with user context."""
id: str
accountId: str
transactionType: TransactionTypeEnum
amount: float
description: str
referenceType: Optional[ReferenceTypeEnum]
workflowId: Optional[str]
featureCode: Optional[str]
aicoreProvider: Optional[str]
createdAt: Optional[datetime]
mandateId: Optional[str] = None
mandateName: Optional[str] = None
userId: Optional[str] = None
userName: Optional[str] = None
# =============================================================================
# Router Setup
# =============================================================================
@ -186,7 +235,7 @@ async def getTransactions(
ctx: RequestContext = Depends(getRequestContext)
):
"""
Get transaction history for the current mandate.
Get transaction history across all mandates the user belongs to.
"""
try:
billingService = getBillingService(
@ -195,7 +244,8 @@ async def getTransactions(
featureCode="billing"
)
transactions = billingService.getTransactionHistory(limit=limit)
# Fetch enough transactions for pagination
transactions = billingService.getTransactionHistory(limit=offset + limit)
# Convert to response model
result = []
@ -210,7 +260,9 @@ async def getTransactions(
workflowId=t.get("workflowId"),
featureCode=t.get("featureCode"),
aicoreProvider=t.get("aicoreProvider"),
createdAt=t.get("_createdAt")
createdAt=t.get("_createdAt"),
mandateId=t.get("mandateId"),
mandateName=t.get("mandateName")
))
return result
@ -607,3 +659,188 @@ async def getTransactionsAdmin(
except Exception as e:
logger.error(f"Error getting billing transactions for mandate {targetMandateId}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# =============================================================================
# Mandate View Endpoints (for Admins)
# =============================================================================
@router.get("/view/mandates/balances", response_model=List[MandateBalanceResponse])
@limiter.limit("30/minute")
async def getMandateViewBalances(
request: Request,
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
):
"""
Get mandate-level balances (SysAdmin only).
Shows aggregated balances per mandate.
"""
try:
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
balances = billingInterface.getMandateBalances()
return [MandateBalanceResponse(**b) for b in balances]
except Exception as e:
logger.error(f"Error getting mandate view balances: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/view/mandates/transactions", response_model=List[TransactionResponse])
@limiter.limit("30/minute")
async def getMandateViewTransactions(
request: Request,
limit: int = Query(default=100, ge=1, le=1000),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
):
"""
Get all transactions across mandates (SysAdmin only).
"""
try:
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
transactions = billingInterface.getMandateTransactions(limit=limit)
result = []
for t in transactions:
result.append(TransactionResponse(
id=t.get("id"),
accountId=t.get("accountId"),
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
amount=t.get("amount", 0.0),
description=t.get("description", ""),
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
workflowId=t.get("workflowId"),
featureCode=t.get("featureCode"),
aicoreProvider=t.get("aicoreProvider"),
createdAt=t.get("_createdAt"),
mandateId=t.get("mandateId"),
mandateName=t.get("mandateName")
))
return result
except Exception as e:
logger.error(f"Error getting mandate view transactions: {e}")
raise HTTPException(status_code=500, detail=str(e))
# =============================================================================
# User View Endpoints (RBAC-based)
# =============================================================================
@router.get("/view/users/balances", response_model=List[UserBalanceResponse])
@limiter.limit("30/minute")
async def getUserViewBalances(
request: Request,
ctx: RequestContext = Depends(getRequestContext)
):
"""
Get user-level balances.
- SysAdmin: sees all user balances across all mandates
- MandateAdmin: sees user balances for mandates they manage
- Regular user: sees only their own balances
"""
try:
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
# Determine which mandates the user has access to
if ctx.user.isSysAdmin:
# SysAdmin sees all
mandateIds = None
else:
# Get mandates where user is admin or has billing access
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
appInterface = getAppInterface(ctx.user)
userMandates = appInterface.getUserMandates(ctx.user.id)
# Filter to only mandates where user has admin role
# For simplicity, we'll check if user is admin in any mandate
mandateIds = []
for um in userMandates:
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
if mandateId:
mandateIds.append(mandateId)
if not mandateIds:
return []
allBalances = billingInterface.getUserBalancesForMandates(mandateIds)
# Non-admin users only see their own balances
if not ctx.user.isSysAdmin:
allBalances = [b for b in allBalances if b.get("userId") == ctx.user.id]
return [UserBalanceResponse(**b) for b in allBalances]
except Exception as e:
logger.error(f"Error getting user view balances: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/view/users/transactions", response_model=List[UserTransactionResponse])
@limiter.limit("30/minute")
async def getUserViewTransactions(
request: Request,
limit: int = Query(default=100, ge=1, le=1000),
ctx: RequestContext = Depends(getRequestContext)
):
"""
Get user-level transactions.
- SysAdmin: sees all user transactions across all mandates
- MandateAdmin: sees user transactions for mandates they manage
- Regular user: sees only their own transactions
"""
try:
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
# Determine which mandates the user has access to
if ctx.user.isSysAdmin:
# SysAdmin sees all
mandateIds = None
else:
# Get mandates where user has access
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
appInterface = getAppInterface(ctx.user)
userMandates = appInterface.getUserMandates(ctx.user.id)
mandateIds = []
for um in userMandates:
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
if mandateId:
mandateIds.append(mandateId)
if not mandateIds:
return []
allTransactions = billingInterface.getUserTransactionsForMandates(mandateIds, limit=limit)
# Non-admin users only see their own transactions
if not ctx.user.isSysAdmin:
allTransactions = [t for t in allTransactions if t.get("userId") == ctx.user.id]
result = []
for t in allTransactions:
result.append(UserTransactionResponse(
id=t.get("id"),
accountId=t.get("accountId"),
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
amount=t.get("amount", 0.0),
description=t.get("description", ""),
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
workflowId=t.get("workflowId"),
featureCode=t.get("featureCode"),
aicoreProvider=t.get("aicoreProvider"),
createdAt=t.get("_createdAt"),
mandateId=t.get("mandateId"),
mandateName=t.get("mandateName"),
userId=t.get("userId"),
userName=t.get("userName")
))
return result
except Exception as e:
logger.error(f"Error getting user view transactions: {e}")
raise HTTPException(status_code=500, detail=str(e))

View file

@ -487,6 +487,10 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
connection.externalId = user_info.get("id")
connection.externalUsername = user_info.get("email")
connection.externalEmail = user_info.get("email")
# Store actually granted scopes for this connection
granted_scopes_list = granted_scopes.split(" ") if granted_scopes else SCOPES
connection.grantedScopes = granted_scopes_list
logger.info(f"Storing granted scopes for connection {connection_id}: {granted_scopes_list}")
# Update connection record directly
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())

View file

@ -498,6 +498,9 @@ async def auth_callback(code: str, state: str, request: Request, response: Respo
connection.externalId = user_info.get("id")
connection.externalUsername = user_info.get("userPrincipalName")
connection.externalEmail = user_info.get("mail")
# Store granted scopes for this connection
connection.grantedScopes = SCOPES
logger.info(f"Storing granted scopes for connection {connection_id}: {SCOPES}")
# Update connection record directly
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())

View file

@ -367,7 +367,7 @@ class BillingService:
def getTransactionHistory(self, limit: int = 100) -> List[Dict[str, Any]]:
"""
Get transaction history for the current mandate.
Get transaction history for the user across all mandates.
Args:
limit: Maximum number of transactions
@ -375,7 +375,7 @@ class BillingService:
Returns:
List of transactions
"""
return self._billingInterface.getTransactionsByMandate(self.mandateId, limit=limit)
return self._billingInterface.getTransactionsForUser(self.currentUser.id, limit=limit)
# ============================================================================

View file

@ -74,6 +74,14 @@ class GenerationService:
document_data_dict = document_data.dict()
elif isinstance(document_data, dict):
document_data_dict = document_data
elif isinstance(document_data, str):
# JSON-String: parsen und als dict speichern (z.B. von outlook.composeAndDraftEmailWithContext)
import json
try:
document_data_dict = json.loads(document_data)
except json.JSONDecodeError:
# Kein valides JSON - als plain text speichern
document_data_dict = {"data": document_data}
else:
document_data_dict = {"data": str(document_data)}

View file

@ -96,21 +96,13 @@ NAVIGATION_SECTIONS = [
"title": {"en": "BILLING", "de": "BILLING", "fr": "FACTURATION"},
"order": 35,
"items": [
{
"id": "billing-dashboard",
"objectKey": "ui.billing.dashboard",
"label": {"en": "Balance", "de": "Guthaben", "fr": "Solde"},
"icon": "FaWallet",
"path": "/billing",
"order": 10,
},
{
"id": "billing-transactions",
"objectKey": "ui.billing.transactions",
"label": {"en": "Transactions", "de": "Transaktionen", "fr": "Transactions"},
"icon": "FaListAlt",
"label": {"en": "Billing", "de": "Billing", "fr": "Facturation"},
"icon": "FaWallet",
"path": "/billing/transactions",
"order": 20,
"order": 10,
},
],
},

View file

@ -13,16 +13,17 @@ logger = logging.getLogger(__name__)
async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> ActionResult:
try:
connectionReference = parameters.get("connectionReference")
to = parameters.get("to")
to = parameters.get("to") or [] # Optional for drafts - can save draft without recipients
context = parameters.get("context")
documentList = parameters.get("documentList", [])
cc = parameters.get("cc", [])
bcc = parameters.get("bcc", [])
emailStyle = parameters.get("emailStyle", "business")
maxLength = parameters.get("maxLength", 1000)
documentList = parameters.get("documentList") or []
cc = parameters.get("cc") or []
bcc = parameters.get("bcc") or []
emailStyle = parameters.get("emailStyle") or "business"
maxLength = parameters.get("maxLength") or 1000
if not connectionReference or not to or not context:
return ActionResult.isFailure(error="connectionReference, to, and context are required")
# Only connectionReference and context are required - to is optional for drafts
if not connectionReference or not context:
return ActionResult.isFailure(error="connectionReference and context are required")
# Convert single values to lists for all recipient parameters
if isinstance(to, str):
@ -82,12 +83,15 @@ async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> A
# Escape only the user-controlled context to prevent prompt injection
escaped_context = context.replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r')
# Build recipients text for prompt
recipients_text = f"Recipients: {to}" if to else "Recipients: (not specified - this is a draft)"
ai_prompt = f"""Compose an email based on this context:
-------
{escaped_context}
-------
Recipients: {to}
{recipients_text}
Style: {emailStyle}
Max length: {maxLength} characters
{doc_list_text}

View file

@ -90,15 +90,20 @@ async def sendDraftEmail(self, parameters: Dict[str, Any]) -> ActionResult:
else:
jsonContent = str(fileData)
# Parse JSON - handle both direct JSON and JSON wrapped in documentData
# Parse JSON - handle ActionDocument format with validationMetadata wrapper
try:
draftEmailData = json.loads(jsonContent)
# If the JSON contains a 'documentData' field, extract it
# ActionDocument format: { "validationMetadata": {...}, "documentData": {...} }
# Extract documentData which contains the actual draft email data
if isinstance(draftEmailData, dict) and 'documentData' in draftEmailData:
documentDataStr = draftEmailData['documentData']
if isinstance(documentDataStr, str):
draftEmailData = json.loads(documentDataStr)
documentDataContent = draftEmailData['documentData']
# documentData should be a dict (parsed from JSON by processSingleDocument)
if isinstance(documentDataContent, dict):
draftEmailData = documentDataContent
elif isinstance(documentDataContent, str):
# Legacy/fallback: parse if still a string
draftEmailData = json.loads(documentDataContent)
# Validate draft email structure
if not isinstance(draftEmailData, dict):

View file

@ -84,6 +84,14 @@ class ConnectionHelper:
elif response.status_code == 403:
logger.error("Permission denied - connection lacks necessary mail permissions")
logger.error("Required scopes: Mail.ReadWrite, Mail.Send, Mail.ReadWrite.Shared")
logger.error("Solution: User must reconnect and grant mail permissions")
return False
elif response.status_code == 404:
# 404 on /me/mailFolders typically means the token lacks mail scopes
# This happens when the connection was created without mail permissions
logger.error("Mail API not accessible (404) - token likely lacks mail scopes")
logger.error("This usually means the connection was created without Mail.ReadWrite permission")
logger.error("Solution: User must delete the connection and reconnect, granting mail permissions")
return False
else:
logger.warning(f"Permission check returned status {response.status_code}")

View file

@ -150,8 +150,8 @@ class MethodOutlook(MethodBase):
name="to",
type="List[str]",
frontendType=FrontendType.MULTISELECT,
required=True,
description="Recipient email addresses"
required=False,
description="Recipient email addresses (optional for drafts)"
),
"context": WorkflowActionParameter(
name="context",

View file

@ -204,29 +204,29 @@ class DynamicMode(BaseMode):
if quality_score is None:
quality_score = 0.0
logger.info(f"Content validation: {validationResult.get('overallSuccess', False)} (quality: {quality_score:.2f})")
# Record validation result for adaptive learning
actionValue = selection.get('action', 'unknown')
actionContext = {
'actionName': actionValue,
'workflowId': context.workflowId
}
self.adaptiveLearningEngine.recordValidationResult(
validationResult,
actionContext,
context.workflowId,
step
)
# Learn from feedback - use taskIntent (task-level), not workflowIntent
feedback = self._collectFeedback(result, validationResult, self.taskIntent)
self.learningEngine.learnFromFeedback(feedback, context, self.taskIntent)
# Update progress - use taskIntent (task-level), not workflowIntent
self.progressTracker.updateOperation(result, validationResult, self.taskIntent)
else:
logger.info("Content validation skipped: no documents to validate")
# NEW: Record validation result for adaptive learning
actionValue = selection.get('action', 'unknown')
actionContext = {
'actionName': actionValue,
'workflowId': context.workflowId
}
self.adaptiveLearningEngine.recordValidationResult(
validationResult,
actionContext,
context.workflowId,
step
)
# NEW: Learn from feedback - use taskIntent (task-level), not workflowIntent
feedback = self._collectFeedback(result, validationResult, self.taskIntent)
self.learningEngine.learnFromFeedback(feedback, context, self.taskIntent)
# NEW: Update progress - use taskIntent (task-level), not workflowIntent
self.progressTracker.updateOperation(result, validationResult, self.taskIntent)
decision = await self._refineDecide(context, observation)

View file

@ -430,11 +430,33 @@ The following is the user's original input message. Analyze intent, normalize th
workflow = self.services.workflow
checkWorkflowStopped(self.services)
# Send "first" message to mark round start (consistent with full workflow)
normalizedRequest = getattr(self.services, 'currentUserPromptNormalized', None) or userInput.prompt
roundNum = workflow.currentRound
contextLabel = f"round{roundNum}_usercontext"
firstMessageData = {
"workflowId": workflow.id,
"role": "user",
"message": normalizedRequest,
"status": "first",
"sequenceNr": len(workflow.messages) + 1,
"publishedAt": self.services.utils.timestampGetUtc(),
"documentsLabel": contextLabel,
"documents": [],
"roundNumber": roundNum,
"taskNumber": 0,
"actionNumber": 0,
"taskProgress": "pending",
"actionProgress": "pending"
}
self.services.chat.storeMessageWithDocuments(workflow, firstMessageData, [])
# Get user language if available
userLanguage = getattr(self.services, 'currentUserLanguage', None)
# Execute fast path - use normalizedRequest if available, otherwise use raw prompt
normalizedPrompt = getattr(self.services, 'currentUserPromptNormalized', None) or userInput.prompt
normalizedPrompt = normalizedRequest
result = await self.workflowProcessor.fastPathExecute(
prompt=normalizedPrompt,
documents=documents,
@ -491,14 +513,6 @@ The following is the user's original input message. Analyze intent, normalize th
}
chatDocuments.append(chatDoc)
# Mark workflow as completed BEFORE storing message (so UI polling stops)
workflow.status = "completed"
workflow.lastActivity = self.services.utils.timestampGetUtc()
self.services.chat.updateWorkflow(workflow.id, {
"status": "completed",
"lastActivity": workflow.lastActivity
})
# Create ChatMessage with fast path response (in user's language)
messageData = {
"workflowId": workflow.id,
@ -518,9 +532,18 @@ The following is the user's original input message. Analyze intent, normalize th
"actionProgress": "success"
}
# Store message with documents
# Store message with documents BEFORE marking workflow as completed
# This ensures UI polling sees the "last" message before status changes
self.services.chat.storeMessageWithDocuments(workflow, messageData, chatDocuments)
# Mark workflow as completed AFTER storing message
workflow.status = "completed"
workflow.lastActivity = self.services.utils.timestampGetUtc()
self.services.chat.updateWorkflow(workflow.id, {
"status": "completed",
"lastActivity": workflow.lastActivity
})
logger.info(f"Fast path completed successfully, response length: {len(responseText)} chars")
except Exception as e: