Merge pull request #111 from valueonag/int

Int
This commit is contained in:
Patrick Motsch 2026-03-22 01:27:55 +01:00 committed by GitHub
commit d40afae0a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3103 additions and 1589 deletions

16
app.py
View file

@ -8,7 +8,8 @@ from urllib.parse import quote_plus
os.environ["NUMEXPR_MAX_THREADS"] = "12"
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer
from contextlib import asynccontextmanager
@ -493,6 +494,19 @@ from slowapi import _rate_limit_exceeded_handler
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
async def _insufficientBalanceHandler(request: Request, exc: Exception):
"""HTTP 402 with structured billing hint (PREPAY_USER vs PREPAY_MANDATE)."""
payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)}
return JSONResponse(status_code=402, content={"detail": payload})
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
InsufficientBalanceException,
)
app.add_exception_handler(InsufficientBalanceException, _insufficientBalanceHandler)
# CSRF protection middleware
from modules.auth import CSRFMiddleware
from modules.auth import (

View file

@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# Service Redirects
Service_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback
Service_GOOGLE_REDIRECT_URI = http://localhost:8000/api/google/auth/callback
# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFRE
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
# Microsoft Service Configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=

View file

@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# Service Redirects
Service_MSFT_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/callback
Service_GOOGLE_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/callback
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = 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
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
# Microsoft Service Configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=

View file

@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# Service Redirects
Service_MSFT_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/callback
Service_GOOGLE_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/callback
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = 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
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZ
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
# Microsoft Service Configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=

View file

@ -23,7 +23,7 @@ from modules.shared.configuration import APP_CONFIG
from modules.security.rootAccess import getRootDbAppConnector, getRootUser
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel
from modules.datamodels.datamodelSecurity import Token
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.datamodels.datamodelRbac import AccessRule
# Get Config Data
@ -189,10 +189,46 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}"
)
raise credentialsException
elif token_authority == str(AuthAuthority.GOOGLE.value):
active_token = appInterface.findActiveTokenById(
tokenId=tokenId,
userId=user.id,
authority=AuthAuthority.GOOGLE,
sessionId=sessionId,
mandateId=None,
tokenPurpose=TokenPurpose.AUTH_SESSION.value,
)
if not active_token:
logger.info(
f"Google JWT db record not active/valid: jti={tokenId}, userId={user.id}"
)
raise credentialsException
elif token_authority == str(AuthAuthority.MSFT.value):
active_token = appInterface.findActiveTokenById(
tokenId=tokenId,
userId=user.id,
authority=AuthAuthority.MSFT,
sessionId=sessionId,
mandateId=None,
tokenPurpose=TokenPurpose.AUTH_SESSION.value,
)
if not active_token:
logger.info(
f"Microsoft JWT db record not active/valid: jti={tokenId}, userId={user.id}"
)
raise credentialsException
else:
# No DB record for this token. If the claim says local (or missing/unknown), require DB record.
if normalized_authority in (None, "", str(AuthAuthority.LOCAL.value)):
logger.info("Local JWT without server record or missing authority claim")
if normalized_authority in (
None,
"",
str(AuthAuthority.LOCAL.value),
str(AuthAuthority.GOOGLE.value),
str(AuthAuthority.MSFT.value),
):
logger.info(
"JWT without server record or missing authority claim (local/google/msft require DB row)"
)
raise credentialsException
except HTTPException:
raise

View file

@ -23,11 +23,18 @@ class CSRFMiddleware(BaseHTTPMiddleware):
# Paths that are exempt from CSRF protection
self.exempt_paths = exempt_paths or {
"/api/local/login",
"/api/local/register",
"/api/msft/login",
"/api/google/login",
"/api/msft/callback",
"/api/google/callback",
"/api/local/register",
# OAuth Auth app + Data app (GET redirects / callbacks)
"/api/msft/auth/login",
"/api/msft/auth/login/callback",
"/api/msft/auth/connect",
"/api/msft/auth/connect/callback",
"/api/msft/adminconsent",
"/api/msft/adminconsent/callback",
"/api/google/auth/login",
"/api/google/auth/login/callback",
"/api/google/auth/connect",
"/api/google/auth/connect/callback",
"/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature)
}

View file

@ -0,0 +1,42 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft)."""
# Google — Auth app only (no Gmail/Drive API scopes)
googleAuthScopes = [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
]
# Google — Data app (Gmail + Drive + identity for token responses)
googleDataScopes = [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/drive.readonly",
]
# Microsoft — Auth app: Graph profile only (MSAL adds openid, profile, offline_access, …)
msftAuthScopes = [
"User.Read",
]
# Microsoft — Data app (delegated; requires admin consent for several)
msftDataScopes = [
"User.Read",
"Mail.ReadWrite",
"Mail.Send",
"Files.ReadWrite.All",
"Sites.ReadWrite.All",
"Team.ReadBasic.All",
"OnlineMeetings.Read",
"Chat.ReadWrite",
"ChatMessage.Send",
]
def msftDataScopesForRefresh() -> str:
"""Space-separated scope string identical to authorization request (Token v2 refresh)."""
return " ".join(msftDataScopes)

View file

@ -9,10 +9,11 @@ import logging
import httpx
from typing import Optional, Dict, Any, Callable
from modules.datamodels.datamodelSecurity import Token
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.datamodels.datamodelUam import AuthAuthority
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp
from modules.auth.oauthProviderConfig import msftDataScopesForRefresh
logger = logging.getLogger(__name__)
@ -20,14 +21,14 @@ class TokenManager:
"""Centralized token management service"""
def __init__(self):
# Microsoft OAuth configuration
self.msft_client_id = APP_CONFIG.get("Service_MSFT_CLIENT_ID")
self.msft_client_secret = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET")
# Microsoft Data-app OAuth (refresh + token exchange for connections)
self.msft_client_id = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_ID")
self.msft_client_secret = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_SECRET")
self.msft_tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common")
# Google OAuth configuration
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_CLIENT_ID")
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_CLIENT_SECRET")
# Google Data-app OAuth
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID")
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET")
def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
"""Refresh Microsoft OAuth token using refresh token"""
@ -49,7 +50,7 @@ class TokenManager:
"client_secret": self.msft_client_secret,
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"scope": "Mail.ReadWrite Mail.Send Mail.ReadWrite.Shared User.Read"
"scope": msftDataScopesForRefresh(),
}
logger.debug(f"refreshMicrosoftToken: Refresh request data prepared (refreshToken length: {len(refreshToken) if refreshToken else 0})")
@ -68,6 +69,7 @@ class TokenManager:
userId=userId,
authority=AuthAuthority.MSFT,
connectionId=oldToken.connectionId, # Preserve connection ID
tokenPurpose=TokenPurpose.DATA_CONNECTION,
tokenAccess=tokenData["access_token"],
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Keep old refresh token if new one not provided
tokenType=tokenData.get("token_type", "bearer"),
@ -128,6 +130,7 @@ class TokenManager:
userId=userId,
authority=AuthAuthority.GOOGLE,
connectionId=oldToken.connectionId, # Preserve connection ID
tokenPurpose=TokenPurpose.DATA_CONNECTION,
tokenAccess=tokenData["access_token"],
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Use new refresh token if provided
tokenType=tokenData.get("token_type", "bearer"),
@ -164,6 +167,15 @@ class TokenManager:
try:
logger.debug(f"refreshToken: Starting refresh for token {oldToken.id}, authority: {oldToken.authority}")
logger.debug(f"refreshToken: Token details: userId={oldToken.userId}, connectionId={oldToken.connectionId}, hasRefreshToken={bool(oldToken.tokenRefresh)}")
_tp = (
oldToken.tokenPurpose.value
if isinstance(oldToken.tokenPurpose, TokenPurpose)
else oldToken.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
logger.warning("refreshToken: skipped — token is not dataConnection")
return None
# Cooldown: avoid refreshing too frequently if a workflow triggers refresh repeatedly
# Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed
@ -266,6 +278,16 @@ class TokenManager:
token = interface.getConnectionToken(connectionId)
if not token:
return None
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
logger.warning(
f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection"
)
return None
return self.ensureFreshToken(
token,
secondsBeforeExpiry=secondsBeforeExpiry,

View file

@ -11,11 +11,13 @@ import uuid
class BillingModelEnum(str, Enum):
"""Billing model types."""
"""Billing model types (prepaid only; legacy UNLIMITED in DB maps to PREPAY_MANDATE)."""
PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
CREDIT_POSTPAY = "CREDIT_POSTPAY" # Credit with monthly invoice (requires billing address)
UNLIMITED = "UNLIMITED" # No cost limitation (internal mandates only)
# Nur fuer initRootMandateBilling (Root-Mandant PREPAY_USER + Startguthaben in Settings).
DEFAULT_USER_CREDIT_CHF = 5.0
class AccountTypeEnum(str, Enum):
@ -46,30 +48,6 @@ class PeriodTypeEnum(str, Enum):
YEAR = "YEAR"
class BillingAddress(BaseModel):
"""Billing address for CREDIT_POSTPAY mandates."""
company: str = Field(..., description="Company name")
street: str = Field(..., description="Street and number")
zip: str = Field(..., description="Postal code")
city: str = Field(..., description="City")
country: str = Field(default="CH", description="Country code")
vatNumber: Optional[str] = Field(None, description="VAT number (optional)")
registerModelLabels(
"BillingAddress",
{"en": "Billing Address", "de": "Rechnungsadresse"},
{
"company": {"en": "Company", "de": "Firma"},
"street": {"en": "Street", "de": "Strasse"},
"zip": {"en": "ZIP", "de": "PLZ"},
"city": {"en": "City", "de": "Ort"},
"country": {"en": "Country", "de": "Land"},
"vatNumber": {"en": "VAT Number", "de": "MwSt-Nummer"},
},
)
class BillingAccount(BaseModel):
"""Billing account for mandate or user-mandate combination."""
id: str = Field(
@ -79,7 +57,6 @@ class BillingAccount(BaseModel):
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
balance: float = Field(default=0.0, description="Current balance in CHF")
creditLimit: Optional[float] = Field(None, description="Credit limit in CHF (only for CREDIT_POSTPAY)")
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
enabled: bool = Field(default=True, description="Account is active")
@ -94,7 +71,6 @@ registerModelLabels(
"userId": {"en": "User ID", "de": "Benutzer-ID"},
"accountType": {"en": "Account Type", "de": "Kontotyp"},
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
"creditLimit": {"en": "Credit Limit (CHF)", "de": "Kreditlimit (CHF)"},
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
"enabled": {"en": "Enabled", "de": "Aktiv"},
@ -161,15 +137,17 @@ class BillingSettings(BaseModel):
billingModel: BillingModelEnum = Field(..., description="Billing model")
# Configuration
defaultUserCredit: float = Field(default=10.0, description="Initial credit in CHF for new users (PREPAY_USER)")
defaultUserCredit: float = Field(
default=0.0,
description="Automatic initial credit (CHF) for PREPAY_USER only when a user is newly added to the root mandate; other mandates use 0 on join.",
)
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
blockOnZeroBalance: bool = Field(default=True, description="Block AI features when balance is zero")
# Billing address (required for CREDIT_POSTPAY)
billingAddress: Optional[BillingAddress] = Field(None, description="Billing address")
# Notifications
notifyEmails: List[str] = Field(default_factory=list, description="Email addresses for billing notifications")
# Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted)
notifyEmails: List[str] = Field(
default_factory=list,
description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)",
)
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
@ -180,11 +158,15 @@ registerModelLabels(
"id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"},
"defaultUserCredit": {"en": "Default User Credit (CHF)", "de": "Standard-Startguthaben (CHF)"},
"defaultUserCredit": {
"en": "Root start credit (CHF)",
"de": "Startguthaben nur Root-Mandant (CHF)",
},
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
"blockOnZeroBalance": {"en": "Block on Zero Balance", "de": "Bei 0 blockieren"},
"billingAddress": {"en": "Billing Address", "de": "Rechnungsadresse"},
"notifyEmails": {"en": "Notification Emails", "de": "Benachrichtigungs-Emails"},
"notifyEmails": {
"en": "Billing notification emails (owner / admin)",
"de": "E-Mails für Billing-Alerts (Inhaber/Admin)",
},
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
},
)
@ -257,7 +239,6 @@ class BillingBalanceResponse(BaseModel):
currency: str = "CHF"
warningThreshold: float
isWarning: bool
creditLimit: Optional[float] = None
class BillingStatisticsChartData(BaseModel):
@ -285,3 +266,16 @@ class BillingCheckResult(BaseModel):
currentBalance: Optional[float] = None
requiredAmount: Optional[float] = None
billingModel: Optional[BillingModelEnum] = None
def parseBillingModelFromStoredValue(raw: Optional[str]) -> BillingModelEnum:
"""Map DB string to enum. Legacy UNLIMITED / unknown values become PREPAY_MANDATE."""
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return BillingModelEnum.PREPAY_MANDATE
s = str(raw).strip().upper()
if s == "UNLIMITED":
return BillingModelEnum.PREPAY_MANDATE
try:
return BillingModelEnum(raw)
except ValueError:
return BillingModelEnum.PREPAY_MANDATE

View file

@ -96,6 +96,51 @@ registerModelLabels(
)
class RoundMemory(BaseModel):
"""Persistent per-round memory for agent tool results, file refs, and decisions.
Stored after each agent round so that RAG can retrieve relevant context
even after the ConversationManager summarises older messages away.
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
workflowId: str = Field(description="FK to the workflow")
roundNumber: int = Field(default=0, description="Agent round that produced this memory")
memoryType: str = Field(
description="Category: file_ref, tool_result, decision, data_source_ref"
)
key: str = Field(description="Dedup key, e.g. 'readFile:<fileId>' or 'plan'")
summary: str = Field(default="", description="Compact summary (max ~2000 chars)")
fullData: Optional[str] = Field(
default=None,
description="Full tool output when small enough (max ~8000 chars)",
)
fileIds: List[str] = Field(default_factory=list, description="Referenced file IDs")
embedding: Optional[List[float]] = Field(
default=None,
description="Embedding of summary for semantic retrieval",
json_schema_extra={"db_type": "vector(1536)"},
)
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
registerModelLabels(
"RoundMemory",
{"en": "Round Memory", "fr": "Mémoire de tour"},
{
"id": {"en": "ID", "fr": "ID"},
"workflowId": {"en": "Workflow ID", "fr": "ID du workflow"},
"roundNumber": {"en": "Round Number", "fr": "Numéro de tour"},
"memoryType": {"en": "Memory Type", "fr": "Type de mémoire"},
"key": {"en": "Key", "fr": "Clé"},
"summary": {"en": "Summary", "fr": "Résumé"},
"fullData": {"en": "Full Data", "fr": "Données complètes"},
"fileIds": {"en": "File IDs", "fr": "IDs de fichier"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
"createdAt": {"en": "Created At", "fr": "Créé le"},
},
)
class WorkflowMemory(BaseModel):
"""Workflow-scoped key-value cache for entities and facts.
Extracted during agent rounds, persisted for cross-round and cross-workflow reuse."""

View file

@ -9,8 +9,8 @@ Multi-Tenant Design:
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
"""
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict, model_validator
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
from .datamodelUam import AuthAuthority
@ -23,6 +23,13 @@ class TokenStatus(str, Enum):
REVOKED = "revoked"
class TokenPurpose(str, Enum):
"""Login/session token vs provider token bound to a UserConnection."""
AUTH_SESSION = "authSession"
DATA_CONNECTION = "dataConnection"
class Token(BaseModel):
"""
Authentication Token model.
@ -38,6 +45,10 @@ class Token(BaseModel):
connectionId: Optional[str] = Field(
None, description="ID of the connection this token belongs to"
)
tokenPurpose: Optional[TokenPurpose] = Field(
default=None,
description="authSession = gateway login JWT; dataConnection = provider OAuth for a connection",
)
tokenAccess: str
tokenType: str = "bearer"
expiresAt: float = Field(
@ -65,6 +76,22 @@ class Token(BaseModel):
model_config = ConfigDict(use_enum_values=True)
@model_validator(mode="before")
@classmethod
def _defaultTokenPurposeFromDb(cls, data: Any) -> Any:
"""Missing tokenPurpose: connection rows → dataConnection; session rows → authSession."""
if isinstance(data, dict):
tp = data.get("tokenPurpose")
if tp is None or tp == "":
cid = data.get("connectionId")
purpose = (
TokenPurpose.DATA_CONNECTION.value
if cid
else TokenPurpose.AUTH_SESSION.value
)
data = {**data, "tokenPurpose": purpose}
return data
registerModelLabels(
"Token",
@ -74,6 +101,7 @@ registerModelLabels(
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
"tokenPurpose": {"en": "Token purpose", "de": "Token-Verwendung", "fr": "Usage du jeton"},
"tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},

View file

@ -1221,10 +1221,28 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option
try:
balanceCheck = billingService.checkBalance(0.01)
if not balanceCheck.allowed:
raise BillingService.InsufficientBalanceException(
currentBalance=balanceCheck.currentBalance or 0.0,
requiredAmount=0.01,
message=f"Ungenuegendes Guthaben. Aktuell: CHF {balanceCheck.currentBalance:.2f}"
mid = str(getattr(services, "mandateId", None) or mandateId or "")
from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted,
)
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
u = getattr(services, "user", None)
ulabel = (
(getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
if u is not None else ""
)
maybeEmailMandatePoolExhausted(
mid,
str(getattr(u, "id", "") if u is not None else ""),
ulabel,
float(balanceCheck.currentBalance or 0.0),
0.01,
)
raise BillingService.InsufficientBalanceException.fromBalanceCheck(
balanceCheck,
mid,
0.01,
)
rbacAllowedProviders = billingService.getallowedProviders()
if not rbacAllowedProviders:

View file

@ -110,14 +110,15 @@ TEMPLATE_ROLES = [
{
"roleLabel": "workspace-admin",
"description": {
"en": "Workspace Admin - Full access to AI workspace",
"de": "Workspace Admin - Vollzugriff auf AI Workspace",
"fr": "Administrateur Workspace - Acces complet au workspace AI"
"en": "Workspace Admin - All UI and API actions; data is always scoped to own records (same privacy as users)",
"de": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)",
"fr": "Administrateur Workspace - Toute l'UI et les API; donnees limitees a ses propres enregistrements"
},
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
# DATA: never ALL in shared instances — every role (including admin) sees only _createdBy = self
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
]
},
]

View file

@ -8,6 +8,7 @@ SSE-based endpoints for the agent-driven AI Workspace.
import logging
import json
import asyncio
import uuid
from typing import Any, Dict, Optional, List
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request, UploadFile, File
@ -15,10 +16,14 @@ from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
InsufficientBalanceException,
)
from modules.interfaces import interfaceDbChat, interfaceDbManagement
from modules.interfaces.interfaceAiObjects import AiObjects
from modules.serviceCenter.core.serviceStreaming import get_event_manager
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit
from modules.shared.timeUtils import parseTimestamp
logger = logging.getLogger(__name__)
@ -242,8 +247,120 @@ def _buildFeatureDataSourceContext(featureDataSourceIds: List[str]) -> str:
return "\n".join(parts) if found else ""
def _workspaceFilesToChatDocuments(dbMgmt, fileIds: List[str]) -> List[Dict[str, Any]]:
"""Build ChatDocument payloads for workspace files referenced on the user message."""
documents: List[Dict[str, Any]] = []
for fid in fileIds or []:
try:
fr = dbMgmt.getFile(fid)
if not fr:
logger.warning(f"Workspace user message: file {fid} not found, skipping attachment record")
continue
fd = fr if isinstance(fr, dict) else fr.model_dump()
documents.append({
"id": str(uuid.uuid4()),
"fileId": fd.get("id") or fid,
"fileName": fd.get("fileName") or "file",
"fileSize": int(fd.get("fileSize") or 0),
"mimeType": fd.get("mimeType") or "application/octet-stream",
"roundNumber": 0,
"taskNumber": 0,
"actionNumber": 0,
})
except Exception as e:
logger.warning(f"Workspace user message: could not load file {fid}: {e}")
return documents
def _buildWorkspaceAttachmentLabel(chatService: Any, dataSourceIds: List[str], featureDataSourceIds: List[str]) -> str:
"""Short human-readable line for non-file attachments (data sources) on the user message."""
parts: List[str] = []
dsLabels: List[str] = []
for dsId in dataSourceIds or []:
try:
ds = chatService.getDataSource(dsId) if chatService and hasattr(chatService, "getDataSource") else None
if ds:
label = ds.get("label") or ds.get("path") or dsId[:8]
dsLabels.append(str(label))
except Exception as e:
logger.debug(f"Label for data source {dsId}: {e}")
if dsLabels:
parts.append("Datenquellen: " + ", ".join(dsLabels))
fdsLabels: List[str] = []
for fdsId in featureDataSourceIds or []:
try:
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId})
if records:
fds = records[0]
tbl = fds.get("tableName") or ""
lbl = fds.get("label") or tbl
fdsLabels.append(f"{tbl} ({lbl})".strip() if tbl else str(lbl))
except Exception as e:
logger.debug(f"Label for feature data source {fdsId}: {e}")
if fdsLabels:
parts.append("Feature-Daten: " + ", ".join(fdsLabels))
return " | ".join(parts)
def _workspaceMessageToClientDict(msg: Any) -> Dict[str, Any]:
"""Serialize ChatMessage (or dict) for workspace GET /messages including documents."""
if isinstance(msg, dict):
raw = dict(msg)
elif hasattr(msg, "model_dump"):
raw = msg.model_dump()
elif hasattr(msg, "dict"):
raw = msg.dict()
else:
raw = {
"id": getattr(msg, "id", None),
"workflowId": getattr(msg, "workflowId", None),
"role": getattr(msg, "role", ""),
"message": getattr(msg, "message", None) or getattr(msg, "content", None),
"publishedAt": getattr(msg, "publishedAt", None),
"sequenceNr": getattr(msg, "sequenceNr", None),
"documentsLabel": getattr(msg, "documentsLabel", None),
"documents": getattr(msg, "documents", None) or [],
}
if raw.get("message") is not None and raw.get("content") is None:
raw["content"] = raw["message"]
docs = raw.get("documents") or []
serialized_docs: List[Dict[str, Any]] = []
for doc in docs:
if isinstance(doc, dict):
serialized_docs.append(doc)
elif hasattr(doc, "model_dump"):
serialized_docs.append(doc.model_dump())
elif hasattr(doc, "dict"):
serialized_docs.append(doc.dict())
else:
serialized_docs.append({
"id": getattr(doc, "id", ""),
"messageId": getattr(doc, "messageId", ""),
"fileId": getattr(doc, "fileId", ""),
"fileName": getattr(doc, "fileName", ""),
"fileSize": getattr(doc, "fileSize", 0),
"mimeType": getattr(doc, "mimeType", ""),
"roundNumber": getattr(doc, "roundNumber", None),
"taskNumber": getattr(doc, "taskNumber", None),
"actionNumber": getattr(doc, "actionNumber", None),
"actionId": getattr(doc, "actionId", None),
})
raw["documents"] = serialized_docs
return raw
def _loadConversationHistory(chatInterface, workflowId: str, currentPrompt: str) -> List[Dict[str, str]]:
"""Load prior messages from DB for follow-up context, excluding the current prompt."""
"""Load prior messages from DB for follow-up context, excluding the current prompt.
File documents attached to user messages are serialized as a short
``[Attached files: ]`` block appended to the message content so the
agent sees which files a previous prompt referred to.
"""
try:
rawMessages = chatInterface.getMessages(workflowId) or []
except Exception as e:
@ -255,17 +372,55 @@ def _loadConversationHistory(chatInterface, workflowId: str, currentPrompt: str)
if isinstance(msg, dict):
role = msg.get("role", "")
content = msg.get("message", "") or msg.get("content", "")
docs = msg.get("documents") or []
docsLabel = msg.get("documentsLabel") or ""
else:
role = getattr(msg, "role", "")
content = getattr(msg, "message", "") or getattr(msg, "content", "")
if role in ("user", "assistant") and content:
history.append({"role": role, "content": content})
docs = getattr(msg, "documents", None) or []
docsLabel = getattr(msg, "documentsLabel", "") or ""
if role not in ("user", "assistant"):
continue
if not content and not docs:
continue
enriched = content or ""
if role == "user" and docs:
fileParts = []
for doc in docs:
if isinstance(doc, dict):
fName = doc.get("fileName", "")
fId = doc.get("fileId", "")
fMime = doc.get("mimeType", "")
fSize = doc.get("fileSize", 0)
elif hasattr(doc, "fileName"):
fName = getattr(doc, "fileName", "")
fId = getattr(doc, "fileId", "")
fMime = getattr(doc, "mimeType", "")
fSize = getattr(doc, "fileSize", 0)
else:
continue
if fId or fName:
fileParts.append(f" - {fName} (id: {fId}, type: {fMime}, size: {fSize} bytes)")
if fileParts:
enriched += "\n\n[Attached files]\n" + "\n".join(fileParts)
if role == "user" and docsLabel:
enriched += f"\n[Attachments: {docsLabel}]"
if enriched.strip():
history.append({"role": role, "content": enriched})
if not history:
return []
# Drop the last user message if it matches the current prompt (already added by the agent loop)
if history[-1]["role"] == "user" and history[-1]["content"].strip() == currentPrompt.strip():
lastContent = history[-1].get("content", "").strip()
currentStripped = currentPrompt.strip()
if history[-1]["role"] == "user" and (
lastContent == currentStripped or lastContent.startswith(currentStripped)
):
history = history[:-1]
if history:
@ -273,6 +428,36 @@ def _loadConversationHistory(chatInterface, workflowId: str, currentPrompt: str)
return history
def _collectPriorFileIds(chatInterface, workflowId: str) -> List[str]:
"""Collect fileIds from all prior user messages in the workflow.
Returns a deduplicated list of file IDs so follow-up prompts
can reference files that were attached to earlier messages.
"""
try:
rawMessages = chatInterface.getMessages(workflowId) or []
except Exception:
return []
seen: set = set()
result: List[str] = []
for msg in rawMessages:
if isinstance(msg, dict):
role = msg.get("role", "")
docs = msg.get("documents") or []
else:
role = getattr(msg, "role", "")
docs = getattr(msg, "documents", None) or []
if role != "user":
continue
for doc in docs:
fid = doc.get("fileId", "") if isinstance(doc, dict) else getattr(doc, "fileId", "")
if fid and fid not in seen:
seen.add(fid)
result.append(fid)
return result
async def _deriveWorkflowName(prompt: str, aiService) -> str:
"""Use AI to generate a concise workflow title from the user prompt."""
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
@ -341,11 +526,36 @@ async def streamWorkspaceStart(
queueId = f"workspace-{workflowId}"
eventManager.create_queue(queueId)
chatInterface.createMessage({
dbMgmt = _getDbManagement(context, featureInstanceId=instanceId)
userDocuments = _workspaceFilesToChatDocuments(dbMgmt, userInput.fileIds or [])
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
svcCtx = ServiceCenterContext(
user=context.user,
mandate_id=mandateId or "",
feature_instance_id=instanceId,
workflow_id=workflowId,
)
chatSvc = getService("chat", svcCtx)
attachmentLabel = _buildWorkspaceAttachmentLabel(
chatSvc,
userInput.dataSourceIds or [],
userInput.featureDataSourceIds or [],
)
userMessageData: Dict[str, Any] = {
"workflowId": workflowId,
"role": "user",
"message": userInput.prompt,
})
}
if userDocuments:
userMessageData["documents"] = userDocuments
if attachmentLabel:
userMessageData["documentsLabel"] = attachmentLabel
chatInterface.createMessage(userMessageData)
agentTask = asyncio.ensure_future(
_runWorkspaceAgent(
@ -469,6 +679,18 @@ async def _runWorkspaceAgent(
conversationHistory = _loadConversationHistory(chatInterface, workflowId, prompt)
priorFileIds = _collectPriorFileIds(chatInterface, workflowId)
currentFileIdSet = set(fileIds or [])
mergedFileIds = list(fileIds or [])
for pf in priorFileIds:
if pf not in currentFileIdSet:
mergedFileIds.append(pf)
if len(mergedFileIds) > len(fileIds or []):
logger.info(
f"Merged {len(mergedFileIds) - len(fileIds or [])} prior file(s) into agent context "
f"(total: {len(mergedFileIds)}) for workflow {workflowId}"
)
accumulatedText = ""
messagePersisted = False
@ -480,7 +702,7 @@ async def _runWorkspaceAgent(
async for event in agentService.runAgent(
prompt=enrichedPrompt,
fileIds=fileIds,
fileIds=mergedFileIds,
workflowId=workflowId,
userLanguage=userLanguage,
conversationHistory=conversationHistory,
@ -581,12 +803,21 @@ async def _runWorkspaceAgent(
})
except Exception as e:
logger.error(f"Workspace agent error: {e}", exc_info=True)
await eventManager.emit_event(queueId, "error", {
"type": "error",
"content": str(e),
"workflowId": workflowId,
})
if isinstance(e, InsufficientBalanceException):
logger.warning(f"Workspace blocked by billing: {e.message}")
await eventManager.emit_event(queueId, "error", {
"type": "error",
"content": e.message,
"workflowId": workflowId,
"item": e.toClientDict(),
})
else:
logger.error(f"Workspace agent error: {e}", exc_info=True)
await eventManager.emit_event(queueId, "error", {
"type": "error",
"content": str(e),
"workflowId": workflowId,
})
finally:
eventManager._unregister_agent_task(queueId)
@ -743,17 +974,14 @@ async def getWorkspaceMessages(
_validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
messages = chatInterface.getMessages(workflowId) or []
items = []
for msg in messages:
if isinstance(msg, dict):
items.append(msg)
else:
items.append({
"id": getattr(msg, "id", None),
"role": getattr(msg, "role", ""),
"content": getattr(msg, "message", "") or getattr(msg, "content", ""),
"createdAt": getattr(msg, "publishedAt", None) or getattr(msg, "createdAt", None),
})
items = [_workspaceMessageToClientDict(m) for m in messages]
items.sort(
key=lambda m: (
parseTimestamp(m.get("publishedAt"), default=0) or 0,
m.get("sequenceNr") or 0,
str(m.get("id") or ""),
)
)
return JSONResponse({"messages": items})

View file

@ -1962,6 +1962,8 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
storeResources = [
"resource.store.automation",
"resource.store.teamsbot",
"resource.store.workspace",
"resource.store.commcoach",
]
storeRules = []
@ -1998,7 +2000,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
def initRootMandateBilling(mandateId: str) -> None:
"""
Initialize billing settings for root mandate.
Root mandate uses PREPAY_USER model with 10 CHF initial credit per user.
Root mandate uses PREPAY_USER model with default initial credit per user in settings (DEFAULT_USER_CREDIT_CHF at bootstrap only).
Creates billing accounts for ALL users regardless of billing model (for audit trail).
Args:
@ -2007,7 +2009,12 @@ def initRootMandateBilling(mandateId: str) -> None:
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum
from modules.datamodels.datamodelBilling import (
BillingSettings,
BillingModelEnum,
DEFAULT_USER_CREDIT_CHF,
parseBillingModelFromStoredValue,
)
billingInterface = _getRootInterface()
appInterface = getAppRootInterface()
@ -2020,27 +2027,28 @@ def initRootMandateBilling(mandateId: str) -> None:
settings = BillingSettings(
mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_USER,
defaultUserCredit=10.0,
defaultUserCredit=DEFAULT_USER_CREDIT_CHF,
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")
logger.info(
f"Created billing settings for root mandate: PREPAY_USER with {DEFAULT_USER_CREDIT_CHF} CHF default credit"
)
existingSettings = billingInterface.getSettings(mandateId)
# Always create user accounts for all users (audit trail)
if existingSettings:
billingModel = existingSettings.get("billingModel", "UNLIMITED")
if billingModel == BillingModelEnum.UNLIMITED.value:
return # No accounts needed for UNLIMITED
billingModel = parseBillingModelFromStoredValue(
existingSettings.get("billingModel")
).value
# Initial balance depends on billing model
if billingModel == BillingModelEnum.PREPAY_USER.value:
initialBalance = existingSettings.get("defaultUserCredit", 10.0)
initialBalance = float(existingSettings.get("defaultUserCredit", 0.0))
else:
initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget on pool
initialBalance = 0.0 # PREPAY_MANDATE: budget on pool account
userMandates = appInterface.getUserMandatesByMandate(mandateId)
accountsCreated = 0

View file

@ -35,7 +35,7 @@ from modules.datamodels.datamodelRbac import (
Role,
)
from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus
from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus, TokenPurpose
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
from modules.datamodels.datamodelMembership import (
UserMandate,
@ -687,11 +687,17 @@ class AppObjects:
externalUsername: str = None,
externalEmail: str = None,
isSysAdmin: bool = False,
addExternalIdentityConnection: bool = True,
) -> User:
"""
Create a new user.
Note: Role assignment is done via createUserMandate(), not via User fields.
Args:
addExternalIdentityConnection: If True (default) and externalId/externalUsername are set,
creates a UserConnection row. OAuth login-only flows should pass False (data connection
is created separately via /auth/connect).
"""
try:
# Ensure username is a string
@ -727,8 +733,9 @@ class AppObjects:
if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create user record")
# Add external connection if provided
if externalId and externalUsername:
# Optional: mirror external IdP identity into UserConnections (data/API OAuth).
# Auth-only login (Google/MSFT JWT) must NOT create a connection — see OAuth split.
if addExternalIdentityConnection and externalId and externalUsername:
self.addUserConnection(
createdRecord["id"],
authenticationAuthority,
@ -746,7 +753,7 @@ class AppObjects:
# Clear cache to ensure fresh data (already done above)
# Assign new user to the root mandate with system 'viewer' role
# Assign new user to the root mandate with mandate-instance 'user' role (no feature instances)
userId = createdUser[0]["id"]
self._assignUserToRootMandate(userId)
@ -815,7 +822,7 @@ class AppObjects:
def _assignUserToRootMandate(self, userId: str) -> None:
"""
Assign a new user to the root mandate with the mandate-instance 'viewer' role.
Assign a new user to the root mandate with the mandate-instance 'user' role.
This ensures every user has a base membership in the system mandate.
Uses the mandate-instance role (mandateId=rootMandateId), not the global template.
@ -839,17 +846,17 @@ class AppObjects:
logger.debug(f"User {userId} already assigned to root mandate")
return
# Find the mandate-instance 'viewer' role (bound to this mandate, not a global template)
mandateViewerRoles = self.db.getRecordset(
# Mandate-instance 'user' role (bound to this mandate, not a global template)
mandateUserRoles = self.db.getRecordset(
Role,
recordFilter={"roleLabel": "viewer", "mandateId": rootMandateId, "featureInstanceId": None}
recordFilter={"roleLabel": "user", "mandateId": rootMandateId, "featureInstanceId": None}
)
viewerRoleId = mandateViewerRoles[0].get("id") if mandateViewerRoles else None
userRoleId = mandateUserRoles[0].get("id") if mandateUserRoles else None
roleIds = [viewerRoleId] if viewerRoleId else []
roleIds = [userRoleId] if userRoleId else []
self.createUserMandate(userId, rootMandateId, roleIds)
logger.info(f"Assigned user {userId} to root mandate with viewer role")
logger.info(f"Assigned user {userId} to root mandate with user role")
except Exception as e:
# Log but don't fail user creation
@ -1641,8 +1648,9 @@ class AppObjects:
Ensure a user has a billing account for the mandate if billing is configured.
User accounts are always created for all billing models (for audit trail).
Initial balance depends on billing model:
- PREPAY_USER: defaultUserCredit from settings
- PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on mandate pool)
- PREPAY_USER: defaultUserCredit from mandate BillingSettings when joining the root mandate (missing key => 0.0);
other mandates get 0.0.
- PREPAY_MANDATE: 0.0 on the user account (shared pool no per-user start credit)
Args:
userId: User ID
@ -1650,7 +1658,7 @@ class AppObjects:
"""
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.datamodels.datamodelBilling import BillingModelEnum, parseBillingModelFromStoredValue
billingInterface = getBillingRootInterface()
settings = billingInterface.getSettings(mandateId)
@ -1658,18 +1666,22 @@ class AppObjects:
if not settings:
return # No billing configured for this mandate
billingModel = settings.get("billingModel", "UNLIMITED")
if billingModel == BillingModelEnum.UNLIMITED.value:
return # No accounts needed for UNLIMITED
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Initial balance depends on billing model
if billingModel == BillingModelEnum.PREPAY_USER.value:
initialBalance = settings.get("defaultUserCredit", 10.0)
# Initial balance depends on billing model (start credit only on root mandate for PREPAY_USER)
rootMandateId = self._getRootMandateId()
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
if billingModel == BillingModelEnum.PREPAY_USER:
initialBalance = (
float(settings.get("defaultUserCredit", 0.0))
if isRootMandate
else 0.0
)
else:
initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget is on pool
initialBalance = 0.0 # PREPAY_MANDATE: budget is on pool
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel}, initial={initialBalance} CHF)")
logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel.value}, initial={initialBalance} CHF)")
except Exception as e:
logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")
@ -1678,6 +1690,8 @@ class AppObjects:
"""
Delete a UserMandate record (remove user from mandate).
CASCADE will delete UserMandateRole entries.
Also removes FeatureAccess rows for any feature instances that belong to this mandate
(FeatureAccessRole rows cascade from FeatureAccess).
Args:
userId: User ID
@ -1690,6 +1704,24 @@ class AppObjects:
existing = self.getUserMandate(userId, mandateId)
if not existing:
return False
# Drop feature-instance memberships for instances under this mandate
instanceRows = self.db.getRecordset(
FeatureInstance,
recordFilter={"mandateId": mandateId}
)
for row in instanceRows:
instId = row.get("id")
if not instId:
continue
accessRows = self.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": instId}
)
for acc in accessRows:
accId = acc.get("id")
if accId:
self.db.recordDelete(FeatureAccess, accId)
return self.db.recordDelete(UserMandate, existing.id)
except Exception as e:
@ -2544,6 +2576,16 @@ class AppObjects:
"Access tokens cannot have connectionId - use saveConnectionToken instead"
)
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.AUTH_SESSION.value:
raise ValueError(
"saveAccessToken requires tokenPurpose=authSession (gateway session JWT)"
)
# Validate user context
if not self.currentUser or not self.currentUser.id:
raise ValueError("No valid user context available for token storage")
@ -2566,6 +2608,7 @@ class AppObjects:
"userId": self.currentUser.id,
"authority": token.authority,
"connectionId": None, # Ensure we only delete access tokens
"tokenPurpose": TokenPurpose.AUTH_SESSION.value,
},
)
deleted_count = 0
@ -2611,6 +2654,16 @@ class AppObjects:
"Connection tokens must have connectionId - use saveAccessToken instead"
)
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
raise ValueError(
"saveConnectionToken requires tokenPurpose=dataConnection (provider OAuth)"
)
# Validate user context
if not self.currentUser or not self.currentUser.id:
raise ValueError("No valid user context available for token storage")
@ -2748,6 +2801,7 @@ class AppObjects:
authority: AuthAuthority,
sessionId: str = None,
mandateId: str = None,
tokenPurpose: str = None,
) -> Optional[Token]:
"""Find an active access token by its id (jti) with optional session/tenant scoping."""
try:
@ -2763,6 +2817,8 @@ class AppObjects:
recordFilter["sessionId"] = sessionId
if mandateId is not None:
recordFilter["mandateId"] = mandateId
if tokenPurpose is not None:
recordFilter["tokenPurpose"] = tokenPurpose
tokens = self.db.getRecordset(Token, recordFilter=recordFilter)
if not tokens:
return None
@ -3405,6 +3461,10 @@ def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjec
instance = AppObjects(currentUser)
instance.setUserContext(currentUser, mandateId=effectiveMandateId)
_gatewayInterfaces[contextKey] = instance
else:
# Re-apply user on every resolve: a prior code path (e.g. legacy logout) may have
# cleared currentUser on this cached singleton; OAuth/login must not see a stale context.
_gatewayInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId)
return _gatewayInterfaces[contextKey]

View file

@ -23,7 +23,6 @@ from modules.datamodels.datamodelBilling import (
BillingSettings,
StripeWebhookEvent,
UsageStatistics,
BillingAddress,
BillingModelEnum,
AccountTypeEnum,
TransactionTypeEnum,
@ -31,10 +30,49 @@ from modules.datamodels.datamodelBilling import (
PeriodTypeEnum,
BillingBalanceResponse,
BillingCheckResult,
parseBillingModelFromStoredValue,
)
logger = logging.getLogger(__name__)
def _getAppDatabaseConnector() -> DatabaseConnector:
"""App DB connector (same config as UserMandate reads in this module)."""
return 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"),
)
def _getRootMandateIdFromAppDb(appDb: DatabaseConnector) -> Optional[str]:
"""Resolve root mandate id (name='root', isSystem=True) from app database."""
try:
rows = appDb.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
if rows:
rid = rows[0].get("id")
return str(rid) if rid is not None else None
except Exception as e:
logger.warning("Could not resolve root mandate id from app DB: %s", e)
return None
_cachedRootMandateId: Optional[str] = None
_rootMandateIdCacheResolved: bool = False
def _getCachedRootMandateId() -> Optional[str]:
"""Lazy-cached root mandate id (name=root, isSystem=True) for hot paths."""
global _cachedRootMandateId, _rootMandateIdCacheResolved
if not _rootMandateIdCacheResolved:
appDb = _getAppDatabaseConnector()
_cachedRootMandateId = _getRootMandateIdFromAppDb(appDb)
_rootMandateIdCacheResolved = True
return _cachedRootMandateId
# Singleton factory for BillingObjects instances
_billingInterfaces: Dict[str, "BillingObjects"] = {}
@ -121,6 +159,8 @@ class BillingObjects:
"""
Get billing settings for a mandate.
Normalizes billingModel for API (legacy UNLIMITED PREPAY_MANDATE) and persists once.
Args:
mandateId: Mandate ID
@ -132,7 +172,29 @@ class BillingObjects:
BillingSettings,
recordFilter={"mandateId": mandateId}
)
return results[0] if results else None
if not results:
return None
row = dict(results[0])
raw_bm = row.get("billingModel")
parsed = parseBillingModelFromStoredValue(raw_bm)
if str(raw_bm or "").strip().upper() == "UNLIMITED":
try:
self.updateSettings(
row["id"],
{"billingModel": BillingModelEnum.PREPAY_MANDATE.value},
)
logger.info(
"Migrated billing settings for mandate %s: UNLIMITED → PREPAY_MANDATE",
mandateId,
)
except Exception as mig_err:
logger.warning(
"Could not persist billing model migration for mandate %s: %s",
mandateId,
mig_err,
)
row["billingModel"] = parsed.value
return row
except Exception as e:
logger.error(f"Error getting billing settings: {e}")
return None
@ -148,11 +210,6 @@ class BillingObjects:
Created settings dict
"""
settingsDict = settings.model_dump(exclude_none=True)
# Handle nested BillingAddress
if settings.billingAddress:
settingsDict["billingAddress"] = settings.billingAddress.model_dump()
return self.db.recordCreate(BillingSettings, settingsDict)
def updateSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@ -168,7 +225,7 @@ class BillingObjects:
"""
return self.db.recordModify(BillingSettings, settingsId, updates)
def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.UNLIMITED) -> Dict[str, Any]:
def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.PREPAY_MANDATE) -> Dict[str, Any]:
"""
Get or create billing settings for a mandate.
@ -186,10 +243,9 @@ class BillingObjects:
settings = BillingSettings(
mandateId=mandateId,
billingModel=defaultModel,
defaultUserCredit=10.0,
defaultUserCredit=0.0,
warningThresholdPercent=10.0,
blockOnZeroBalance=True,
notifyOnWarning=True
notifyOnWarning=True,
)
return self.createSettings(settings)
@ -365,7 +421,7 @@ class BillingObjects:
def ensureAllMandateSettingsExist(self) -> int:
"""
Efficiently ensure all mandates have billing settings.
Creates default settings (PREPAY_USER) for mandates without settings.
Creates default settings (PREPAY_MANDATE, 0 CHF) for mandates without settings.
Uses bulk queries to minimize database connections.
Returns:
@ -397,11 +453,10 @@ class BillingObjects:
# Create default billing settings
settings = BillingSettings(
mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_USER,
defaultUserCredit=10.0,
billingModel=BillingModelEnum.PREPAY_MANDATE,
defaultUserCredit=0.0,
warningThresholdPercent=10.0,
blockOnZeroBalance=True,
notifyOnWarning=True
notifyOnWarning=True,
)
self.createSettings(settings)
existingMandateIds.add(mandateId) # Track newly created
@ -421,8 +476,8 @@ class BillingObjects:
Ensure all users across all mandates have billing accounts.
User accounts are always created regardless of billing model (for audit trail).
Initial balance depends on billing model:
- PREPAY_USER: defaultUserCredit from settings
- PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on pool)
- PREPAY_USER: defaultUserCredit from settings only for the root mandate; other mandates get 0.0
- PREPAY_MANDATE: 0.0 (budget is on pool)
Uses bulk queries to minimize database connections.
@ -431,16 +486,23 @@ class BillingObjects:
"""
try:
accountsCreated = 0
# Step 1: Get all billing settings (all models except UNLIMITED need user accounts)
appDb = _getAppDatabaseConnector()
rootMandateId = _getCachedRootMandateId()
# Step 1: Get all billing settings (all mandates with settings get user accounts)
allSettings = self.db.getRecordset(BillingSettings)
billingMandates = {} # mandateId -> (billingModel, defaultCredit)
for s in allSettings:
billingModel = s.get("billingModel", BillingModelEnum.UNLIMITED.value)
if billingModel == BillingModelEnum.UNLIMITED.value:
continue
defaultCredit = s.get("defaultUserCredit", 10.0) if billingModel == BillingModelEnum.PREPAY_USER.value else 0.0
billingMandates[s.get("mandateId")] = (billingModel, defaultCredit)
billingModel = parseBillingModelFromStoredValue(s.get("billingModel")).value
mid = s.get("mandateId")
isRoot = rootMandateId is not None and str(mid) == str(rootMandateId)
if billingModel == BillingModelEnum.PREPAY_USER.value:
defaultCredit = (
float(s.get("defaultUserCredit", 0.0) or 0.0) if isRoot else 0.0
)
else:
defaultCredit = 0.0
billingMandates[mid] = (billingModel, defaultCredit)
if not billingMandates:
logger.debug("No billable mandates found, skipping account check")
@ -457,13 +519,6 @@ class BillingObjects:
existingAccountKeys.add(key)
# Step 3: Get all user-mandate combinations from APP database
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}
@ -711,69 +766,44 @@ class BillingObjects:
"""
Check if there's sufficient balance for an operation.
Budget logic:
- PREPAY_USER: check user's own account balance
- PREPAY_MANDATE: check mandate pool balance (shared by all users)
- CREDIT_POSTPAY: check mandate pool credit limit
- UNLIMITED: always allowed
- PREPAY_USER: user.balance >= estimatedCost
- PREPAY_MANDATE: mandate pool balance >= estimatedCost
User accounts are always ensured to exist (for audit trail).
Args:
mandateId: Mandate ID
userId: User ID
estimatedCost: Estimated cost of the operation
Returns:
BillingCheckResult
Root mandate + PREPAY_USER: initial credit from settings.defaultUserCredit on first create.
Missing settings: treated as PREPAY_MANDATE with empty pool (strict).
"""
settings = self.getSettings(mandateId)
if not settings:
return BillingCheckResult(allowed=True, billingModel=BillingModelEnum.UNLIMITED)
billingModel = BillingModelEnum.PREPAY_MANDATE
defaultCredit = 0.0
else:
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
defaultCredit = float(settings.get("defaultUserCredit", 0.0) or 0.0)
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
if billingModel == BillingModelEnum.UNLIMITED:
return BillingCheckResult(allowed=True, billingModel=billingModel)
# Always ensure user account exists (for audit trail)
defaultCredit = settings.get("defaultUserCredit", 10.0)
initialBalance = defaultCredit if billingModel == BillingModelEnum.PREPAY_USER else 0.0
rootMandateId = _getCachedRootMandateId()
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
if billingModel == BillingModelEnum.PREPAY_USER:
initialBalance = defaultCredit if isRootMandate else 0.0
else:
initialBalance = 0.0
self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
# Determine which balance to check based on billing model
if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getUserAccount(mandateId, userId)
currentBalance = account.get("balance", 0.0) if account else 0.0
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
elif billingModel == BillingModelEnum.CREDIT_POSTPAY:
poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
creditLimit = poolAccount.get("creditLimit")
if creditLimit and abs(currentBalance) + estimatedCost > creditLimit:
return BillingCheckResult(
allowed=False,
reason="CREDIT_LIMIT_EXCEEDED",
currentBalance=currentBalance,
requiredAmount=estimatedCost,
billingModel=billingModel
)
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
else:
return BillingCheckResult(allowed=True, billingModel=billingModel)
poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
# PREPAY models - check balance
if currentBalance < estimatedCost:
if settings.get("blockOnZeroBalance", True):
return BillingCheckResult(
allowed=False,
reason="INSUFFICIENT_BALANCE",
currentBalance=currentBalance,
requiredAmount=estimatedCost,
billingModel=billingModel
)
return BillingCheckResult(
allowed=False,
reason="INSUFFICIENT_BALANCE",
currentBalance=currentBalance,
requiredAmount=estimatedCost,
billingModel=billingModel,
)
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
@ -800,7 +830,6 @@ class BillingObjects:
Balance is deducted from the appropriate account based on billing model:
- PREPAY_USER: deduct from user's own balance
- PREPAY_MANDATE: deduct from mandate pool balance
- CREDIT_POSTPAY: deduct from mandate pool balance
"""
if priceCHF <= 0:
return None
@ -810,10 +839,7 @@ class BillingObjects:
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
return None
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
if billingModel == BillingModelEnum.UNLIMITED:
return None
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Transaction is ALWAYS on the user's account (audit trail)
userAccount = self.getOrCreateUserAccount(mandateId, userId)
@ -838,12 +864,11 @@ class BillingObjects:
# Determine where to deduct balance
if billingModel == BillingModelEnum.PREPAY_USER:
# Deduct from user's own balance
return self.createTransaction(transaction)
else:
# PREPAY_MANDATE / CREDIT_POSTPAY: deduct from mandate pool
if billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getOrCreateMandateAccount(mandateId)
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
return None
# =========================================================================
# Workflow Cost Query
@ -865,18 +890,10 @@ class BillingObjects:
def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]:
"""
Switch billing model with automatic budget migration.
Switch billing model with budget migration logged as BillingTransactions.
MANDATE -> USER: pool balance is distributed equally to all user accounts.
USER -> MANDATE: all user balances are consolidated into the pool, user balances set to 0.
Args:
mandateId: Mandate ID
oldModel: Current billing model
newModel: New billing model
Returns:
Migration result dict with details
PREPAY_MANDATE -> PREPAY_USER: pool debited, equal shares credited to user accounts.
PREPAY_USER -> PREPAY_MANDATE: user wallets debited, pool credited with sum.
"""
result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0}
@ -884,47 +901,91 @@ class BillingObjects:
return result
if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER:
# Pool -> distribute equally to users
poolAccount = self.getMandateAccount(mandateId)
if poolAccount and poolAccount.get("balance", 0.0) > 0:
poolBalance = poolAccount["balance"]
userAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
userAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
)
poolBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
n = len(userAccounts)
if poolAccount and poolBalance > 0:
self.createTransaction(
BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=poolBalance,
description="Model switch: distributed from mandate pool to user wallets",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
if userAccounts:
perUser = poolBalance / len(userAccounts)
for acc in userAccounts:
newBalance = acc.get("balance", 0.0) + perUser
self.updateAccountBalance(acc["id"], newBalance)
self.updateAccountBalance(poolAccount["id"], 0.0)
result["migratedAmount"] = poolBalance
result["userCount"] = len(userAccounts)
logger.info(f"Switched {mandateId} MANDATE->USER: distributed {result['migratedAmount']} CHF to {result['userCount']} users")
result["migratedAmount"] = poolBalance
if n > 0:
remaining = poolBalance
for i, acc in enumerate(userAccounts):
if i == n - 1:
share = round(remaining, 4)
else:
share = round(poolBalance / n, 4)
remaining -= share
if share > 0:
self.createTransaction(
BillingTransaction(
accountId=acc["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=share,
description="Model switch: share from mandate pool",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["userCount"] = n
logger.info(
"Switched %s MANDATE->USER: migrated %.4f CHF to %d user account(s) (transactions logged)",
mandateId,
result["migratedAmount"],
result["userCount"],
)
return result
elif oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
# Users -> consolidate into pool
if oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
userAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
)
totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
newPoolBalance = poolAccount.get("balance", 0.0) + totalUserBalance
self.updateAccountBalance(poolAccount["id"], newPoolBalance)
for acc in userAccounts:
self.updateAccountBalance(acc["id"], 0.0)
b = acc.get("balance", 0.0)
if b > 0:
self.createTransaction(
BillingTransaction(
accountId=acc["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=b,
description="Model switch: consolidated to mandate pool",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
if totalUserBalance > 0:
self.createTransaction(
BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=totalUserBalance,
description="Model switch: consolidated from user accounts",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["migratedAmount"] = totalUserBalance
result["userCount"] = len(userAccounts)
logger.info(f"Switched {mandateId} USER->MANDATE: consolidated {totalUserBalance} CHF from {len(userAccounts)} users into pool")
logger.info(
"Switched %s USER->MANDATE: consolidated %.4f CHF from %d users into pool (transactions logged)",
mandateId,
totalUserBalance,
len(userAccounts),
)
return result
elif newModel == BillingModelEnum.PREPAY_MANDATE or newModel == BillingModelEnum.CREDIT_POSTPAY:
# Any -> MANDATE/CREDIT: ensure pool account exists
if newModel == BillingModelEnum.PREPAY_MANDATE:
self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
return result
@ -1027,8 +1088,6 @@ class BillingObjects:
Shows the effective available budget:
- PREPAY_USER: user's own account balance
- PREPAY_MANDATE: mandate pool balance (shared budget visible to user)
- CREDIT_POSTPAY: mandate pool balance
Args:
userId: User ID
@ -1060,25 +1119,20 @@ class BillingObjects:
if not settings:
continue
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
if billingModel == BillingModelEnum.UNLIMITED:
continue
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Determine effective balance based on billing model
if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getOrCreateUserAccount(mandateId, userId)
if not account:
continue
balance = account.get("balance", 0.0)
warningThreshold = account.get("warningThreshold", 0.0)
creditLimit = account.get("creditLimit")
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getOrCreateMandateAccount(mandateId)
if not poolAccount:
continue
balance = poolAccount.get("balance", 0.0)
warningThreshold = poolAccount.get("warningThreshold", 0.0)
creditLimit = poolAccount.get("creditLimit")
else:
continue
@ -1089,7 +1143,6 @@ class BillingObjects:
balance=balance,
warningThreshold=warningThreshold,
isWarning=balance <= warningThreshold,
creditLimit=creditLimit
))
except Exception as e:
logger.error(f"Error getting balances for user: {e}")
@ -1183,7 +1236,7 @@ class BillingObjects:
if not mandateId:
continue
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get mandate info
mandate = appInterface.getMandate(mandateId)
@ -1198,12 +1251,9 @@ class BillingObjects:
)
userCount = len(userAccounts)
# Total balance depends on billing model
if billingModel == BillingModelEnum.PREPAY_USER:
# Budget is distributed across user accounts
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
# Budget is in the mandate pool
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getMandateAccount(mandateId)
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
else:
@ -1215,9 +1265,8 @@ class BillingObjects:
"billingModel": billingModel.value,
"totalBalance": totalBalance,
"userCount": userCount,
"defaultUserCredit": settings.get("defaultUserCredit", 0.0),
"defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0),
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
"blockOnZeroBalance": settings.get("blockOnZeroBalance", True)
})
except Exception as e:

View file

@ -885,7 +885,7 @@ class ChatObjects:
"role": msg.get("role", "assistant"),
"status": msg.get("status", "step"),
"sequenceNr": msg.get("sequenceNr", 0),
"publishedAt": msg.get("publishedAt", msg.get("timestamp", getUtcTimestamp())),
"publishedAt": msg.get("publishedAt") or msg.get("_createdAt") or msg.get("timestamp") or 0,
"success": msg.get("success"),
"actionId": msg.get("actionId"),
"actionMethod": msg.get("actionMethod"),
@ -899,8 +899,15 @@ class ChatObjects:
# Apply default sorting by publishedAt if no sort specified.
# Use parseTimestamp to tolerate mixed DB types (float/string) on INT.
# Tie-break with sequenceNr then id so order matches conversation flow.
if pagination is None or not pagination.sort:
messageDicts.sort(key=lambda x: parseTimestamp(x.get("publishedAt"), default=0))
messageDicts.sort(
key=lambda x: (
parseTimestamp(x.get("publishedAt"), default=0) or 0,
x.get("sequenceNr") or 0,
str(x.get("id") or ""),
)
)
# Apply filtering (if filters provided)
if pagination and pagination.filters:
@ -1039,6 +1046,15 @@ class ChatObjects:
if "actionNumber" not in messageData:
messageData["actionNumber"] = workflow.currentAction
if not messageData.get("publishedAt"):
messageData["publishedAt"] = getUtcTimestamp()
if not messageData.get("sequenceNr"):
existing = self._getRecordset(
ChatMessage, recordFilter={"workflowId": workflowId}
)
messageData["sequenceNr"] = len(existing) + 1
# Note: Chat data is user-owned, no mandate/featureInstance context stored
# mandateId/featureInstanceId removed from ChatMessage model

View file

@ -10,7 +10,7 @@ import logging
from typing import Dict, Any, List, Optional
from modules.connectors.connectorDbPostgre import _get_cached_connector
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk, WorkflowMemory
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk, RoundMemory, WorkflowMemory
from modules.datamodels.datamodelUam import User
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
@ -125,6 +125,58 @@ class KnowledgeObjects:
count += 1
return count
# =========================================================================
# RoundMemory CRUD
# =========================================================================
def storeRoundMemory(self, memory: RoundMemory) -> Dict[str, Any]:
"""Create or update a RoundMemory entry (upsert by id)."""
data = memory.model_dump()
existing = self.db._loadRecord(RoundMemory, memory.id)
if existing:
return self.db.recordModify(RoundMemory, memory.id, data)
return self.db.recordCreate(RoundMemory, data)
def getRoundMemories(self, workflowId: str) -> List[Dict[str, Any]]:
"""Get all RoundMemory entries for a workflow, sorted by roundNumber."""
records = self.db.getRecordset(RoundMemory, recordFilter={"workflowId": workflowId})
records.sort(key=lambda r: r.get("roundNumber", 0))
return records
def getRoundMemoriesByType(
self, workflowId: str, memoryType: str
) -> List[Dict[str, Any]]:
"""Get RoundMemory entries filtered by type (e.g. 'file_ref')."""
return self.db.getRecordset(
RoundMemory, recordFilter={"workflowId": workflowId, "memoryType": memoryType}
)
def semanticSearchRoundMemory(
self,
queryVector: List[float],
workflowId: str,
limit: int = 10,
minScore: float = None,
) -> List[Dict[str, Any]]:
"""Semantic search across RoundMemory entries for a workflow."""
return self.db.semanticSearch(
modelClass=RoundMemory,
vectorColumn="embedding",
queryVector=queryVector,
limit=limit,
recordFilter={"workflowId": workflowId},
minScore=minScore,
)
def deleteRoundMemories(self, workflowId: str) -> int:
"""Delete all RoundMemory entries for a workflow. Returns count."""
entries = self.db.getRecordset(RoundMemory, recordFilter={"workflowId": workflowId})
count = 0
for entry in entries:
if self.db.recordDelete(RoundMemory, entry["id"]):
count += 1
return count
# =========================================================================
# WorkflowMemory CRUD
# =========================================================================

View file

@ -17,7 +17,7 @@ Data Namespace Structure:
GROUP-Berechtigung:
- data.uam.*: GROUP filtert nach Mandant (via UserMandate)
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen, kein Mandantenkontext)
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich _createdBy
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
"""
@ -344,6 +344,20 @@ def buildRbacWhereClause(
# All records within the feature instance - only featureInstanceId filtering
if readLevel == AccessLevel.ALL:
# Chat / AI Workspace: even DATA read ALL must not list other users' rows in a
# shared featureInstance (stale RBAC rules or merged roles). Same as MY.
namespaceAll = TABLE_NAMESPACE.get(table, "system")
if featureInstanceId and namespaceAll == "chat":
userIdFieldAll = "_createdBy"
if table == "UserInDB":
userIdFieldAll = "id"
elif table == "UserConnection":
userIdFieldAll = "userId"
conditionsAll = list(baseConditions)
valuesAll = list(baseValues)
conditionsAll.append(f'"{userIdFieldAll}" = %s')
valuesAll.append(currentUser.id)
return {"condition": " AND ".join(conditionsAll), "values": valuesAll}
if baseConditions:
return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None
@ -379,6 +393,14 @@ def buildRbacWhereClause(
# But still apply featureInstanceId filter if provided
if namespace in USER_OWNED_NAMESPACES:
if baseConditions:
# Shared feature instance: GROUP would otherwise only filter by featureInstanceId
# and expose every user's rows in that instance (e.g. ChatWorkflow).
if featureInstanceId and readLevel == AccessLevel.GROUP:
conditions = list(baseConditions)
values = list(baseValues)
conditions.append('"_createdBy" = %s')
values.append(currentUser.id)
return {"condition": " AND ".join(conditions), "values": values}
return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None

View file

@ -22,9 +22,18 @@ from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService
from modules.routes.routeNotifications import create_access_change_notification
logger = logging.getLogger(__name__)
def _feature_instance_display_name(instance: Any) -> str:
if instance is None:
return ""
if isinstance(instance, dict):
return str(instance.get("label") or instance.get("uiLabel") or instance.get("id", ""))
return str(getattr(instance, "label", None) or getattr(instance, "uiLabel", None) or getattr(instance, "id", ""))
router = APIRouter(
prefix="/api/features",
tags=["Features"],
@ -1024,6 +1033,15 @@ def add_user_to_feature_instance(
f"User {context.user.id} added user {data.userId} to feature instance {instanceId} "
f"with roles {data.roleIds}"
)
iname = _feature_instance_display_name(instance)
create_access_change_notification(
data.userId,
"Feature-Zugriff",
f"Sie haben Zugriff auf die Feature-Instanz «{iname}» erhalten.",
"feature_access",
instanceId,
)
return {
"featureAccessId": featureAccessId,
@ -1104,6 +1122,15 @@ def remove_user_from_feature_instance(
logger.info(
f"User {context.user.id} removed user {userId} from feature instance {instanceId}"
)
iname = _feature_instance_display_name(instance)
create_access_change_notification(
userId,
"Feature-Zugriff",
f"Ihr Zugriff auf die Feature-Instanz «{iname}» wurde entfernt.",
"feature_access",
instanceId,
)
return {
"message": "User access removed",
@ -1197,6 +1224,15 @@ def update_feature_instance_user_roles(
logger.info(
f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {data.roleIds}"
)
iname = _feature_instance_display_name(instance)
create_access_change_notification(
userId,
"Feature-Rollen geändert",
f"Ihre Rollen in der Feature-Instanz «{iname}» wurden angepasst.",
"feature_access",
instanceId,
)
return {
"featureAccessId": featureAccessId,

View file

@ -28,7 +28,6 @@ from modules.datamodels.datamodelBilling import (
BillingAccount,
BillingTransaction,
BillingSettings,
BillingAddress,
BillingModelEnum,
TransactionTypeEnum,
ReferenceTypeEnum,
@ -37,6 +36,7 @@ from modules.datamodels.datamodelBilling import (
BillingStatisticsResponse,
BillingStatisticsChartData,
BillingCheckResult,
parseBillingModelFromStoredValue,
)
# Configure logger
@ -263,10 +263,8 @@ class BillingSettingsUpdate(BaseModel):
billingModel: Optional[BillingModelEnum] = None
defaultUserCredit: Optional[float] = Field(None, ge=0)
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
blockOnZeroBalance: Optional[bool] = None
notifyOnWarning: Optional[bool] = None
notifyEmails: Optional[List[str]] = None
billingAddress: Optional[BillingAddress] = None
class TransactionResponse(BaseModel):
@ -295,7 +293,6 @@ class AccountSummary(BaseModel):
userId: Optional[str]
accountType: str
balance: float
creditLimit: Optional[float]
warningThreshold: float
enabled: bool
@ -323,7 +320,6 @@ class MandateBalanceResponse(BaseModel):
userCount: int
defaultUserCredit: float
warningThresholdPercent: float
blockOnZeroBalance: bool
class UserBalanceResponse(BaseModel):
@ -427,12 +423,12 @@ def _creditStripeSessionIfNeeded(
if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found")
billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billing_model == BillingModelEnum.PREPAY_USER:
if not user_id:
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0)
elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
elif billing_model == BillingModelEnum.PREPAY_MANDATE:
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
else:
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
@ -529,11 +525,10 @@ def getBalanceForMandate(
return BillingBalanceResponse(
mandateId=targetMandateId,
mandateName=mandateName,
billingModel=checkResult.billingModel or BillingModelEnum.UNLIMITED,
billingModel=checkResult.billingModel or BillingModelEnum.PREPAY_MANDATE,
balance=checkResult.currentBalance or 0.0,
warningThreshold=0.0, # TODO: Get from account
isWarning=False,
creditLimit=None
)
except Exception as e:
@ -622,7 +617,7 @@ def getStatistics(
costByFeature={}
)
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Transactions are always on user accounts (audit trail)
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
@ -750,8 +745,12 @@ def createOrUpdateSettings(
if updates:
# Check if billing model is changing - trigger budget migration
if "billingModel" in updates:
oldModel = BillingModelEnum(existingSettings.get("billingModel", BillingModelEnum.UNLIMITED.value))
newModel = BillingModelEnum(updates["billingModel"]) if isinstance(updates["billingModel"], str) else updates["billingModel"]
oldModel = parseBillingModelFromStoredValue(existingSettings.get("billingModel"))
newModel = (
BillingModelEnum(updates["billingModel"])
if isinstance(updates["billingModel"], str)
else updates["billingModel"]
)
if oldModel != newModel:
migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel)
logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}")
@ -764,13 +763,27 @@ def createOrUpdateSettings(
newSettings = BillingSettings(
mandateId=targetMandateId,
billingModel=settingsUpdate.billingModel or BillingModelEnum.UNLIMITED,
defaultUserCredit=settingsUpdate.defaultUserCredit or 10.0,
warningThresholdPercent=settingsUpdate.warningThresholdPercent or 10.0,
blockOnZeroBalance=settingsUpdate.blockOnZeroBalance if settingsUpdate.blockOnZeroBalance is not None else True,
notifyOnWarning=settingsUpdate.notifyOnWarning if settingsUpdate.notifyOnWarning is not None else True,
billingModel=(
settingsUpdate.billingModel
if settingsUpdate.billingModel is not None
else BillingModelEnum.PREPAY_MANDATE
),
defaultUserCredit=(
settingsUpdate.defaultUserCredit
if settingsUpdate.defaultUserCredit is not None
else 0.0
),
warningThresholdPercent=(
settingsUpdate.warningThresholdPercent
if settingsUpdate.warningThresholdPercent is not None
else 10.0
),
notifyOnWarning=(
settingsUpdate.notifyOnWarning
if settingsUpdate.notifyOnWarning is not None
else True
),
notifyEmails=settingsUpdate.notifyEmails or [],
billingAddress=settingsUpdate.billingAddress
)
return billingInterface.createSettings(newSettings)
@ -803,7 +816,7 @@ def addCredit(
if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Validate request based on billing model
if billingModel == BillingModelEnum.PREPAY_USER:
@ -816,7 +829,7 @@ def addCredit(
creditRequest.userId,
initialBalance=0.0
)
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
# Create mandate-level account if needed and add credit
account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
else:
@ -866,7 +879,7 @@ def createCheckoutSession(
if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billingModel == BillingModelEnum.PREPAY_USER:
if not checkoutRequest.userId:
@ -875,7 +888,7 @@ def createCheckoutSession(
raise HTTPException(status_code=403, detail="Users can only load credit to their own account")
if not _isMemberOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=403, detail="User is not a member of this mandate")
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
else:
@ -933,7 +946,7 @@ def confirmCheckoutSession(
if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found")
billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billing_model == BillingModelEnum.PREPAY_USER:
if not user_id:
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
@ -941,7 +954,7 @@ def confirmCheckoutSession(
raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions")
if not _isMemberOfMandate(ctx, mandate_id):
raise HTTPException(status_code=403, detail="User is not a member of this mandate")
elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
elif billing_model == BillingModelEnum.PREPAY_MANDATE:
if not _isAdminOfMandate(ctx, mandate_id):
raise HTTPException(status_code=403, detail="Mandate admin role required")
else:
@ -1041,7 +1054,6 @@ def getAccounts(
userId=acc.get("userId"),
accountType=acc.get("accountType"),
balance=acc.get("balance", 0.0),
creditLimit=acc.get("creditLimit"),
warningThreshold=acc.get("warningThreshold", 0.0),
enabled=acc.get("enabled", True)
))

View file

@ -16,6 +16,7 @@ from fastapi import status
import logging
import json
import math
from urllib.parse import quote
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
from modules.datamodels.datamodelSecurity import Token
@ -445,24 +446,12 @@ def connect_service(
detail="Connection not found"
)
# Initiate OAuth flow with state=connect
# Data-app OAuth (JWT state issued server-side in /auth/connect)
auth_url = None
if connection.authority == AuthAuthority.MSFT:
# Use the same login endpoint with state=connect to ensure account selection
# Include current user ID in state
state_data = {
"type": "connect",
"connectionId": connectionId,
"userId": currentUser.id # Add current user ID
}
auth_url = f"/api/msft/login?state={json.dumps(state_data)}"
auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}"
elif connection.authority == AuthAuthority.GOOGLE:
state_data = {
"type": "connect",
"connectionId": connectionId,
"userId": currentUser.id # Add current user ID
}
auth_url = f"/api/google/login?state={json.dumps(state_data)}"
auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}"
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -21,6 +21,7 @@ from modules.auth import limiter, requireSysAdminRole, getRequestContext, Reques
# Import interfaces
import modules.interfaces.interfaceDbApp as interfaceDbApp
from modules.interfaces.interfaceDbBilling import _getRootInterface as _getBillingRootInterface
from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.auditLogger import audit_logger
@ -29,6 +30,7 @@ from modules.datamodels.datamodelUam import Mandate, User
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeNotifications import create_access_change_notification
# =============================================================================
@ -247,6 +249,15 @@ def create_mandate(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create mandate"
)
try:
billingInterface = _getBillingRootInterface()
billingInterface.getOrCreateSettings(str(newMandate.id))
logger.debug(f"Ensured billing settings for new mandate {newMandate.id}")
except Exception as billingErr:
logger.warning(
f"Could not create default billing settings for mandate {newMandate.id}: {billingErr}"
)
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
@ -612,6 +623,15 @@ def add_user_to_mandate(
f"User {context.user.id} added user {data.targetUserId} to mandate {targetMandateId} "
f"with roles {data.roleIds}"
)
mname = _mandate_display_name(mandate)
create_access_change_notification(
data.targetUserId,
"Mandantenzugriff",
f"Sie wurden dem Mandanten «{mname}» hinzugefügt.",
"mandate_access",
targetMandateId,
)
return UserMandateResponse(
id=str(userMandate.id), # UserMandate ID as primary key
@ -696,6 +716,15 @@ def remove_user_from_mandate(
)
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}")
mname = _mandate_display_name(mandate)
create_access_change_notification(
targetUserId,
"Mandantenzugriff",
f"Sie wurden aus dem Mandanten «{mname}» entfernt.",
"mandate_access",
targetMandateId,
)
return {"message": "User removed from mandate", "userId": targetUserId}
@ -791,6 +820,16 @@ def update_user_roles_in_mandate(
f"User {context.user.id} updated roles for user {targetUserId} "
f"in mandate {targetMandateId} to {roleIds}"
)
mandate_meta = rootInterface.getMandate(targetMandateId)
mname = _mandate_display_name(mandate_meta)
create_access_change_notification(
targetUserId,
"Mandantenrollen geändert",
f"Ihre Rollen im Mandanten «{mname}» wurden angepasst.",
"mandate_access",
targetMandateId,
)
return UserMandateResponse(
id=str(membership.id), # UserMandate ID as primary key
@ -814,6 +853,28 @@ def update_user_roles_in_mandate(
# Helper Functions
# =============================================================================
def _mandate_display_name(mandate: Any) -> str:
"""Human-readable mandate label for notifications."""
if mandate is None:
return ""
if isinstance(mandate, dict):
if mandate.get("label"):
return str(mandate["label"])
name = mandate.get("name")
if isinstance(name, dict):
return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else ""))
return str(name or mandate.get("id", ""))
label = getattr(mandate, "label", None)
if label:
return str(label)
name = getattr(mandate, "name", None)
if isinstance(name, dict):
return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else ""))
if name is not None:
return str(name)
return str(getattr(mandate, "id", ""))
def _getAdminMandateIds(context: RequestContext) -> List[str]:
"""
Get list of mandate IDs where the user has the admin role.

View file

@ -89,6 +89,31 @@ def _createNotification(
return notification
def create_access_change_notification(
userId: str,
title: str,
message: str,
reference_type: str,
reference_id: Optional[str] = None,
) -> None:
"""
In-app notification for mandate/feature access changes (triggers client nav refresh).
Failures are logged only so RBAC mutations still succeed.
"""
try:
_createNotification(
userId=userId,
notificationType=NotificationType.SYSTEM,
title=title,
message=message,
referenceType=reference_type,
referenceId=reference_id,
icon="shield",
)
except Exception as e:
logger.warning(f"Could not create access-change notification for user {userId}: {e}")
def createInvitationNotification(
userId: str,
invitationId: str,

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,7 @@ 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.datamodelSecurity import Token
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
@ -164,6 +164,7 @@ def login(
id=jti,
userId=user.id,
authority=AuthAuthority.LOCAL,
tokenPurpose=TokenPurpose.AUTH_SESSION,
tokenAccess=access_token,
tokenType="bearer",
expiresAt=expires_at.timestamp(),

File diff suppressed because it is too large Load diff

View file

@ -110,15 +110,38 @@ def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] |
return accesses[0] if accesses else None
def _findUserRole(rootInterface, instanceId: str, featureCode: str) -> str | None:
"""Find the user-level role for a feature instance."""
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)
userRoleLabel = f"{featureCode}-user"
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:
continue
low = lbl.lower()
if "admin" in low:
continue
if lbl.endswith("-user") and lbl in labelToId:
return labelToId[lbl]
for role in instanceRoles:
if role.roleLabel == userRoleLabel:
return str(role.id)
for role in instanceRoles:
if "user" in role.roleLabel.lower() and "admin" not in role.roleLabel.lower():
low = (role.roleLabel or "").lower()
if "admin" in low:
continue
if "user" in low:
return str(role.id)
return None
@ -249,13 +272,25 @@ def activateStoreFeature(
createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
featureAccessId = createdAccess.get("id")
userRoleId = _findUserRole(rootInterface, instanceId, featureCode)
if userRoleId:
featureAccessRole = FeatureAccessRole(
featureAccessId=featureAccessId,
roleId=userRoleId
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}'"
)
db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
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."
),
)
featureAccessRole = FeatureAccessRole(
featureAccessId=featureAccessId,
roleId=userRoleId
)
db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
logger.info(
f"User {userId} activated store feature '{featureCode}' "

View file

@ -8,7 +8,7 @@ Core service - not requested by features directly.
import logging
from typing import Optional, Callable, Any
from modules.datamodels.datamodelSecurity import Token
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.auth import TokenManager
logger = logging.getLogger(__name__)
@ -34,6 +34,16 @@ class SecurityService:
token = self._interfaceDbApp.getConnectionToken(connectionId)
if not token:
return None
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
logger.warning(
f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection"
)
return None
return self._tokenManager.ensureFreshToken(
token,
secondsBeforeExpiry=secondsBeforeExpiry,

View file

@ -22,6 +22,9 @@ from modules.serviceCenter.services.serviceAgent.conversationManager import (
)
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.jsonUtils import closeJsonStructures
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
InsufficientBalanceException,
)
logger = logging.getLogger(__name__)
@ -40,6 +43,8 @@ async def runAgentLoop(
aiCallStreamFn: Callable = None,
userLanguage: str = "",
conversationHistory: List[Dict[str, Any]] = None,
persistRoundMemoryFn: Callable[..., Awaitable[None]] = None,
getExternalMemoryKeysFn: Callable[[], List[str]] = None,
) -> AsyncGenerator[AgentEvent, None]:
"""Run the agent loop. Yields AgentEvent for each step (SSE-ready).
@ -56,6 +61,9 @@ async def runAgentLoop(
mandateId: Mandate ID for RAG scoping
userLanguage: ISO 639-1 language code for agent responses
conversationHistory: Prior messages [{role, content/message}] for follow-up context
persistRoundMemoryFn: Optional callback to persist round memories after tool execution
getExternalMemoryKeysFn: Optional callback that returns RoundMemory keys for
this workflow, used by summarization to de-duplicate persisted facts
"""
state = AgentState(workflowId=workflowId, maxRounds=config.maxRounds)
trace = AgentTrace(
@ -76,7 +84,7 @@ async def runAgentLoop(
conversation = ConversationManager(systemPrompt)
if conversationHistory:
conversation.loadHistory(conversationHistory)
conversation.addUserMessage(prompt)
conversation.addUserMessage(prompt, isCurrentPrompt=True)
while state.status == AgentStatusEnum.RUNNING and state.currentRound < state.maxRounds:
await asyncio.sleep(0)
@ -139,7 +147,15 @@ async def runAgentLoop(
state.totalAiCalls += 1
return resp.content
await conversation.summarize(state.currentRound, _summarizeCall)
memKeys: List[str] = []
if getExternalMemoryKeysFn:
try:
memKeys = getExternalMemoryKeysFn()
except Exception:
pass
await conversation.summarize(
state.currentRound, _summarizeCall, externalMemoryKeys=memKeys or None
)
# AI call
aiRequest = AiCallRequest(
@ -175,6 +191,18 @@ async def runAgentLoop(
else:
aiResponse = await aiCallFn(aiRequest)
except InsufficientBalanceException as e:
logger.warning(
f"Insufficient balance in round {state.currentRound} (mandate={mandateId}): {e.message}"
)
state.status = AgentStatusEnum.ERROR
state.abortReason = e.message
yield AgentEvent(
type=AgentEventTypeEnum.ERROR,
content=e.message,
data=e.toClientDict(),
)
break
except Exception as e:
logger.error(f"AI call failed in round {state.currentRound}: {e}", exc_info=True)
state.status = AgentStatusEnum.ERROR
@ -292,6 +320,18 @@ async def runAgentLoop(
]
conversation.addToolResults(toolResultMessages)
# Persist round memories (file refs, tool results, decisions)
if persistRoundMemoryFn:
try:
await persistRoundMemoryFn(
toolCalls=toolCalls,
results=results,
textContent=textContent,
roundNumber=state.currentRound,
)
except Exception as memErr:
logger.warning(f"RoundMemory persist failed (non-blocking): {memErr}")
roundLog.durationMs = int((time.time() - roundStartTime) * 1000)
trace.rounds.append(roundLog)
@ -486,6 +526,80 @@ def _buildProgressSummary(state: AgentState, reason: str) -> str:
)
_FILE_REF_TOOLS = {"readFile", "readContentObjects", "describeImage", "listFiles"}
_DATA_SOURCE_TOOLS = {"browseDataSource", "searchDataSource", "downloadFromDataSource"}
_DECISION_TOOLS = {"writeFile", "replaceInFile"}
def _classifyToolResult(
tc: ToolCallRequest, result: ToolResult
) -> Optional[Dict[str, Any]]:
"""Classify a successful tool result into a RoundMemory dict.
Returns a dict with keys {memoryType, key, summary, fullData, fileIds}
or None if the result is not worth persisting.
"""
name = tc.name
data = result.data or ""
if len(data) < 50:
return None
truncSummary = data[:2000]
fullData = data if len(data) < 8000 else None
fileId = tc.args.get("fileId", "")
fileIds = [fileId] if fileId else []
if name in _FILE_REF_TOOLS:
return {
"memoryType": "file_ref",
"key": f"{name}:{fileId}" if fileId else name,
"summary": truncSummary,
"fullData": fullData,
"fileIds": fileIds,
}
if name in _DATA_SOURCE_TOOLS:
dsId = tc.args.get("dataSourceId", "") or tc.args.get("featureDataSourceId", "")
path = tc.args.get("path", "")
return {
"memoryType": "data_source_ref",
"key": f"{name}:{dsId}:{path}" if dsId else name,
"summary": truncSummary,
"fullData": fullData,
"fileIds": fileIds,
}
if name in _DECISION_TOOLS:
return {
"memoryType": "decision",
"key": f"{name}:{fileId}" if fileId else name,
"summary": truncSummary,
"fullData": None,
"fileIds": fileIds,
}
if name == "queryFeatureInstance":
return {
"memoryType": "tool_result",
"key": f"queryFeatureInstance:{tc.args.get('query', '')[:60]}",
"summary": truncSummary,
"fullData": fullData,
"fileIds": [],
}
if len(data) > 500:
return {
"memoryType": "tool_result",
"key": f"{name}:{tc.id}",
"summary": truncSummary,
"fullData": fullData,
"fileIds": fileIds,
}
return None
_ARTIFACT_TOOLS = {"writeFile", "replaceInFile", "deleteFile", "renameFile", "copyFile",
"createFolder", "deleteFolder", "renderDocument", "generateImage"}

View file

@ -10,9 +10,9 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefin
logger = logging.getLogger(__name__)
FIRST_SUMMARY_ROUND = 4
META_SUMMARY_ROUND = 7
KEEP_RECENT_MESSAGES = 4
FIRST_SUMMARY_ROUND = 6
META_SUMMARY_ROUND = 10
KEEP_RECENT_MESSAGES = 6
MAX_ESTIMATED_TOKENS = 60000
_MAX_HISTORY_MESSAGES = 40
_MAX_HISTORY_MSG_CHARS = 12000
@ -22,9 +22,12 @@ class ConversationManager:
"""Manages the conversation history and context window for agent runs.
Progressive summarization strategy:
- Rounds 1-3: full conversation retained
- Round 4+: older messages compressed into a running summary
- Round 7+: meta-summary replaces prior summaries
- Rounds 1-5: full conversation retained
- Round 6+: older messages compressed into a running summary
- Round 10+: meta-summary replaces prior summaries
Long-term facts (file refs, tool results, decisions) are persisted
externally in RoundMemory and retrieved via RAG, so the summary
can focus on reasoning and relationships.
Supports RAG context injection before each round via injectRagContext."""
def __init__(self, systemPrompt: str):
@ -69,9 +72,19 @@ class ConversationManager:
for msg in self._messages
]
def addUserMessage(self, content: str):
"""Add a user message."""
self._messages.append({"role": "user", "content": content})
def addUserMessage(self, content: str, isCurrentPrompt: bool = False):
"""Add a user message.
Args:
content: Message text.
isCurrentPrompt: If True, this message is the user's current
task prompt and will never be removed by progressive
summarization.
"""
msg: Dict[str, Any] = {"role": "user", "content": content}
if isCurrentPrompt:
msg["_isCurrentPrompt"] = True
self._messages.append(msg)
def addAssistantMessage(self, content: str, toolCalls: List[Dict[str, Any]] = None):
"""Add an assistant message, optionally with tool calls."""
@ -135,8 +148,8 @@ class ConversationManager:
"""Check if progressive summarization should be triggered.
Triggers:
- At round FIRST_SUMMARY_ROUND (4) if not yet summarized
- At round META_SUMMARY_ROUND (7) for meta-summary
- At round FIRST_SUMMARY_ROUND (6) if not yet summarized
- At round META_SUMMARY_ROUND (10) for meta-summary
- Every 5 rounds after that
- When estimated token count exceeds MAX_ESTIMATED_TOKENS
"""
@ -149,12 +162,23 @@ class ConversationManager:
return True
return False
async def summarize(self, currentRound: int, aiCallFn) -> Optional[str]:
async def summarize(
self,
currentRound: int,
aiCallFn,
externalMemoryKeys: List[str] = None,
) -> Optional[str]:
"""Perform progressive summarization of older messages.
Rounds 1-3: full history retained, no summarization.
Round 4+: compress older messages into a running summary.
Round 7+: meta-summary that consolidates prior summaries.
Rounds 1-5: full history retained, no summarization.
Round 6+: compress older messages into a running summary.
Round 10+: meta-summary that consolidates prior summaries.
Args:
currentRound: Current agent round number.
aiCallFn: Async function that takes a prompt string and returns summary text.
externalMemoryKeys: Keys of RoundMemory entries for this workflow,
so the summary prompt can de-duplicate already-persisted facts.
"""
if currentRound < FIRST_SUMMARY_ROUND and self.estimateTokenCount() <= MAX_ESTIMATED_TOKENS:
return None
@ -184,11 +208,25 @@ class ConversationManager:
messagesToSummarize = nonSystemMessages[:splitIdx]
recentMessages = nonSystemMessages[splitIdx:]
# Protect the current user prompt: it must NEVER be summarized away.
promptInRecent = any(m.get("_isCurrentPrompt") for m in recentMessages)
if not promptInRecent:
for i, m in enumerate(messagesToSummarize):
if m.get("_isCurrentPrompt"):
recentMessages = messagesToSummarize[i:] + recentMessages
messagesToSummarize = messagesToSummarize[:i]
break
if not messagesToSummarize:
return None
summaryInput = _formatMessagesForSummary(messagesToSummarize)
previousSummary = self._summaries[-1]["content"] if self._summaries else ""
isMetaSummary = currentRound >= META_SUMMARY_ROUND and len(self._summaries) >= 2
summaryPrompt = _buildSummaryPrompt(summaryInput, previousSummary, isMetaSummary)
summaryPrompt = _buildSummaryPrompt(
summaryInput, previousSummary, isMetaSummary,
externalMemoryKeys=externalMemoryKeys,
)
try:
summaryText = await aiCallFn(summaryPrompt)
@ -241,8 +279,30 @@ def _formatMessagesForSummary(messages: List[Dict[str, Any]]) -> str:
return "\n\n".join(parts)
def _buildSummaryPrompt(messagesText: str, previousSummary: str, isMetaSummary: bool = False) -> str:
"""Build the prompt for progressive summarization."""
def _buildSummaryPrompt(
messagesText: str,
previousSummary: str,
isMetaSummary: bool = False,
externalMemoryKeys: List[str] = None,
) -> str:
"""Build the prompt for progressive summarization.
When externalMemoryKeys is provided, the summary prompt tells the AI
that those facts are preserved in external memory and need not be
repeated verbatim the summary can focus on reasoning, decisions,
and relationships instead.
"""
externalHint = ""
if externalMemoryKeys:
keyList = ", ".join(externalMemoryKeys[:20])
externalHint = (
"NOTE: The following facts are preserved in external persistent memory "
"and do NOT need to be repeated in detail in the summary: "
f"[{keyList}]. "
"Focus on reasoning, decisions, relationships, and anything that is "
"NOT captured by those external memory entries.\n\n"
)
if isMetaSummary:
prompt = (
"Create a comprehensive meta-summary consolidating the previous summary "
@ -251,10 +311,11 @@ def _buildSummaryPrompt(messagesText: str, previousSummary: str, isMetaSummary:
)
else:
prompt = (
"Summarize the following conversation concisely. Preserve all key facts, "
"decisions, entities (names, numbers, dates), and tool results. "
"Summarize the following conversation concisely. Preserve key decisions, "
"reasoning chains, entities (names, numbers, dates), and action outcomes. "
"Do not lose any important information.\n\n"
)
prompt += externalHint
if previousSummary:
prompt += f"Previous Summary:\n{previousSummary}\n\n"
prompt += f"New Messages to Summarize:\n{messagesText}\n\nProvide a concise, factual summary:"

View file

@ -142,6 +142,8 @@ class AgentService:
aiCallStreamFn = self._createAiCallStreamFn()
getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId)
buildRagContextFn = self._createBuildRagContextFn()
persistRoundMemoryFn = self._createPersistRoundMemoryFn(workflowId)
getExternalMemoryKeysFn = self._createGetExternalMemoryKeysFn(workflowId)
async for event in runAgentLoop(
prompt=enrichedPrompt,
@ -157,6 +159,8 @@ class AgentService:
aiCallStreamFn=aiCallStreamFn,
userLanguage=resolvedLanguage,
conversationHistory=conversationHistory,
persistRoundMemoryFn=persistRoundMemoryFn,
getExternalMemoryKeysFn=getExternalMemoryKeysFn,
):
if event.type == AgentEventTypeEnum.AGENT_SUMMARY:
await self._persistTrace(workflowId, event.data or {})
@ -347,18 +351,120 @@ class AgentService:
) -> str:
try:
knowledgeService = self.services.getService("knowledge")
workflowHintItems = _buildWorkflowHintItems(
self.services, workflowId
)
return await knowledgeService.buildAgentContext(
currentPrompt=currentPrompt,
workflowId=workflowId,
userId=userId,
featureInstanceId=featureInstanceId,
mandateId=mandateId,
workflowHintItems=workflowHintItems,
)
except Exception as e:
logger.debug(f"RAG context not available: {e}")
return ""
return _buildRagContext
def _createPersistRoundMemoryFn(self, workflowId: str):
"""Create callback that persists RoundMemory entries after tool execution."""
from modules.serviceCenter.services.serviceAgent.agentLoop import _classifyToolResult
from modules.datamodels.datamodelKnowledge import RoundMemory
async def _persistRoundMemory(
toolCalls, results, textContent: str, roundNumber: int
):
try:
knowledgeService = self.services.getService("knowledge")
except Exception:
return
knowledgeDb = knowledgeService._knowledgeDb
for tc, result in zip(toolCalls, results):
if not result.success:
continue
classified = _classifyToolResult(tc, result)
if not classified:
continue
summary = classified["summary"]
embedding = await knowledgeService._embedSingle(summary[:500]) if summary else []
mem = RoundMemory(
workflowId=workflowId,
roundNumber=roundNumber,
memoryType=classified["memoryType"],
key=classified["key"],
summary=summary,
fullData=classified.get("fullData"),
fileIds=classified.get("fileIds", []),
embedding=embedding if embedding else None,
)
knowledgeDb.storeRoundMemory(mem)
return _persistRoundMemory
def _createGetExternalMemoryKeysFn(self, workflowId: str):
"""Create callback that returns RoundMemory keys for summarization hints."""
def _getKeys() -> List[str]:
try:
knowledgeService = self.services.getService("knowledge")
memories = knowledgeService._knowledgeDb.getRoundMemories(workflowId)
return [m.get("key", "") for m in memories if m.get("key")]
except Exception:
return []
return _getKeys
def _buildWorkflowHintItems(
services, currentWorkflowId: str
) -> List[Dict[str, Any]]:
"""Build a compact list of other workflows for the RAG cross-workflow hint.
Returns key-value items like:
key="Pendenzenliste Excel (3 msgs)" value="last: 2h ago"
Limited to 10 most recent other workflows to keep the hint small.
"""
try:
chatInterface = services.chat.interfaceDbChat
allWorkflows = chatInterface.getWorkflows() or []
except Exception:
return []
others = [w for w in allWorkflows if w.get("id") != currentWorkflowId]
if not others:
return []
import time as _time
now = _time.time()
others.sort(key=lambda w: w.get("_createdAt") or w.get("startedAt") or 0, reverse=True)
others = others[:10]
items = []
for wf in others:
name = wf.get("name") or "(unnamed)"
createdAt = wf.get("_createdAt") or wf.get("startedAt") or 0
ageSec = now - createdAt if createdAt else 0
if ageSec < 3600:
ageStr = f"{int(ageSec / 60)}m ago"
elif ageSec < 86400:
ageStr = f"{int(ageSec / 3600)}h ago"
else:
ageStr = f"{int(ageSec / 86400)}d ago"
wfId = wf.get("id", "")
items.append({
"key": f"{name} (id: {wfId})",
"value": ageStr,
})
countLabel = f"{len(allWorkflows) - 1} other conversation(s)"
if len(allWorkflows) - 1 > 10:
countLabel += f" (showing 10 newest)"
items.insert(0, {"key": countLabel, "value": "use listWorkflowHistory to browse"})
return items
def _getOrCreateTempFolder(chatService) -> Optional[str]:
"""Return the ID of the root-level 'Temp' folder, creating it if it doesn't exist."""
@ -2952,3 +3058,138 @@ def _registerCoreTools(registry: ToolRegistry, services):
},
readOnly=True
)
# ---- Cross-workflow tools ----
async def _listWorkflowHistory(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult:
"""List all chat workflows in this workspace with metadata."""
import json as _json
try:
chatService = services.chat
chatInterface = chatService.interfaceDbChat
allWorkflows = chatInterface.getWorkflows() or []
allWorkflows.sort(
key=lambda w: w.get("_createdAt") or w.get("startedAt") or 0,
reverse=True,
)
allWorkflows = allWorkflows[:50]
items = []
for wf in allWorkflows:
wfId = wf.get("id", "")
name = wf.get("name") or "(unnamed)"
createdAt = wf.get("_createdAt") or wf.get("startedAt") or 0
lastActivity = wf.get("lastActivity") or createdAt
msgs = chatInterface.getMessages(wfId) or []
messageCount = len(msgs)
lastPreview = ""
if msgs:
lastMsg = msgs[-1] if isinstance(msgs[-1], dict) else (
msgs[-1].model_dump() if hasattr(msgs[-1], "model_dump") else {}
)
content = lastMsg.get("message") or lastMsg.get("content") or ""
lastPreview = content[:150]
items.append({
"id": wfId,
"name": name,
"createdAt": createdAt,
"lastActivity": lastActivity,
"messageCount": messageCount,
"lastMessagePreview": lastPreview,
})
return ToolResult(
toolCallId="", toolName="listWorkflowHistory",
success=True, data=_json.dumps(items, ensure_ascii=False),
)
except Exception as e:
return ToolResult(
toolCallId="", toolName="listWorkflowHistory",
success=False, error=str(e),
)
registry.register(
"listWorkflowHistory", _listWorkflowHistory,
description=(
"List all chat conversations/workflows in this workspace. "
"Returns id, name, createdAt, lastActivity, messageCount, and a preview "
"of the last message for each workflow. Use this to discover previous "
"conversations when the user asks about past chats or wants a summary "
"across conversations."
),
parameters={
"type": "object",
"properties": {},
},
readOnly=True,
)
async def _readWorkflowMessages(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult:
"""Read messages from a specific workflow."""
import json as _json
targetWorkflowId = args.get("workflowId", "")
limit = int(args.get("limit", 20))
offset = int(args.get("offset", 0))
if not targetWorkflowId:
return ToolResult(
toolCallId="", toolName="readWorkflowMessages",
success=False, error="workflowId is required",
)
try:
chatService = services.chat
chatInterface = chatService.interfaceDbChat
allMsgs = chatInterface.getMessages(targetWorkflowId) or []
sliced = allMsgs[offset:offset + limit]
items = []
for msg in sliced:
raw = msg if isinstance(msg, dict) else (
msg.model_dump() if hasattr(msg, "model_dump") else {}
)
content = raw.get("message") or raw.get("content") or ""
if len(content) > 2000:
content = content[:2000] + "..."
items.append({
"role": raw.get("role", ""),
"message": content,
"publishedAt": raw.get("publishedAt") or raw.get("_createdAt") or 0,
})
header = f"Workflow {targetWorkflowId}: {len(allMsgs)} total messages"
if offset > 0 or len(allMsgs) > offset + limit:
header += f" (showing {offset + 1}-{offset + len(sliced)})"
return ToolResult(
toolCallId="", toolName="readWorkflowMessages",
success=True,
data=header + "\n" + _json.dumps(items, ensure_ascii=False),
)
except Exception as e:
return ToolResult(
toolCallId="", toolName="readWorkflowMessages",
success=False, error=str(e),
)
registry.register(
"readWorkflowMessages", _readWorkflowMessages,
description=(
"Read messages from a specific chat workflow/conversation. "
"Use this after listWorkflowHistory to read the content of a "
"specific past conversation. Supports pagination via offset/limit."
),
parameters={
"type": "object",
"properties": {
"workflowId": {"type": "string", "description": "ID of the workflow to read messages from"},
"limit": {"type": "integer", "description": "Max messages to return (default 20)"},
"offset": {"type": "integer", "description": "Skip first N messages (default 0)"},
},
"required": ["workflowId"],
},
readOnly=True,
)

View file

@ -17,6 +17,10 @@ from modules.shared.jsonUtils import (
)
from .subJsonResponseHandling import JsonResponseHandler
from modules.datamodels.datamodelAi import JsonAccumulationState
from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted,
)
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
getService as getBillingService,
InsufficientBalanceException,
@ -592,10 +596,19 @@ detectedIntent-Werte:
f"Balance {balance_str} CHF, "
f"Reason: {balanceCheck.reason}"
)
raise InsufficientBalanceException(
currentBalance=balanceCheck.currentBalance or 0.0,
requiredAmount=estimatedCost,
message=f"Ungenugendes Guthaben. Aktuell: CHF {balance_str}"
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
maybeEmailMandatePoolExhausted(
str(mandateId),
str(user.id),
str(ulabel),
float(balanceCheck.currentBalance or 0.0),
float(estimatedCost),
)
raise InsufficientBalanceException.fromBalanceCheck(
balanceCheck,
str(mandateId),
float(estimatedCost),
)
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"

View file

@ -0,0 +1,140 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
When the shared mandate pool (PREPAY_MANDATE) is exhausted, notify billing contacts.
Recipients: BillingSettings.notifyEmails for the mandate (configure as mandate owner / finance).
Emails are throttled per mandate to avoid spam (one notification per cooldown window).
"""
from __future__ import annotations
import html
import logging
import time
from typing import Any, Dict, List, Optional
from modules.datamodels.datamodelMessaging import MessagingChannel
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.security.rootAccess import getRootUser
logger = logging.getLogger(__name__)
# mandate_id -> unix timestamp of last pool-exhausted notification email
_poolExhaustedEmailLastSent: Dict[str, float] = {}
_DEFAULT_COOLDOWN_SEC = 3600
def _normalizeNotifyEmails(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, list):
return [str(e).strip() for e in raw if str(e).strip()]
if isinstance(raw, str):
s = raw.strip()
if not s:
return []
# JSON array string
if s.startswith("["):
try:
import json
parsed = json.loads(s)
if isinstance(parsed, list):
return [str(e).strip() for e in parsed if str(e).strip()]
except Exception:
pass
return [s]
return []
def maybeEmailMandatePoolExhausted(
mandateId: str,
triggeringUserId: str,
triggeringUserLabel: str,
currentBalance: float,
requiredAmount: float,
cooldownSec: float = _DEFAULT_COOLDOWN_SEC,
) -> None:
"""
Send one email per mandate per cooldown to BillingSettings.notifyEmails.
Args:
mandateId: Mandate whose pool is empty.
triggeringUserId: User who hit the block.
triggeringUserLabel: Display (e.g. email or username).
currentBalance: Pool balance (CHF).
requiredAmount: Minimum required (CHF).
cooldownSec: Minimum seconds between emails for this mandate.
"""
if not mandateId:
return
now = time.time()
last = _poolExhaustedEmailLastSent.get(mandateId, 0.0)
if last and (now - last) < cooldownSec:
logger.debug(
"Skip mandate pool exhausted email (cooldown): mandate=%s last=%.0fs ago",
mandateId,
now - last,
)
return
try:
billing = getBillingInterface(getRootUser(), mandateId)
settings = billing.getSettings(mandateId) or {}
recipients = _normalizeNotifyEmails(settings.get("notifyEmails"))
if not recipients:
logger.warning(
"PREPAY_MANDATE pool exhausted for mandate %s but notifyEmails is empty — "
"configure BillingSettings.notifyEmails for owner alerts",
mandateId,
)
return
subject = f"[PowerOn] Mandanten-Budget aufgebraucht (Mandant {mandateId[:8]}…)"
body = (
f"Das gemeinsame Guthaben (PREPAY_MANDATE) für diesen Mandanten ist nicht mehr ausreichend.\n\n"
f"Mandanten-ID: {mandateId}\n"
f"Aktuelles Guthaben (Pool): CHF {currentBalance:.2f}\n"
f"Benötigt (mind.): CHF {requiredAmount:.2f}\n\n"
f"Auslösende/r Benutzer/in: {triggeringUserLabel} (ID: {triggeringUserId})\n\n"
f"Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, "
f"damit Benutzer wieder AI-Funktionen nutzen können.\n"
)
escaped = html.escape(body)
# Cannot use '\\n' inside f-string {…} expression (SyntaxError); build replacement outside.
brWithNl = "<br>" + "\n"
htmlMessage = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
{escaped.replace(chr(10), brWithNl)}
</body></html>"""
messaging = getMessagingInterface()
any_ok = False
for to in recipients:
try:
ok = messaging.send(
channel=MessagingChannel.EMAIL,
recipient=to,
subject=subject,
message=htmlMessage,
)
if ok:
any_ok = True
else:
logger.warning("Pool exhausted email failed for %s", to)
except Exception as send_err:
logger.error("Error sending pool exhausted email to %s: %s", to, send_err)
if any_ok:
_poolExhaustedEmailLastSent[mandateId] = now
logger.info(
"Sent mandate pool exhausted notification for mandate %s to %s recipient(s)",
mandateId,
len(recipients),
)
except Exception as e:
logger.error("maybeEmailMandatePoolExhausted failed: %s", e, exc_info=True)

View file

@ -22,6 +22,7 @@ from modules.datamodels.datamodelBilling import (
ReferenceTypeEnum,
BillingTransaction,
BillingBalanceResponse,
parseBillingModelFromStoredValue,
)
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
@ -333,7 +334,7 @@ class BillingService:
logger.warning(f"No billing settings for mandate {self.mandateId}")
return None
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get or create account
if billingModel == BillingModelEnum.PREPAY_USER:
@ -389,15 +390,127 @@ class BillingService:
# Exception Classes
# ============================================================================
BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF"
BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
def _userActionForBillingModel(bm: BillingModelEnum) -> str:
if bm == BillingModelEnum.PREPAY_USER:
return BILLING_USER_ACTION_TOP_UP_SELF
return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN
def _buildInsufficientBalanceMessages(
bm: BillingModelEnum,
currentBalance: float,
requiredAmount: float,
) -> tuple:
bal_s = f"{currentBalance:.2f}"
req_s = f"{requiredAmount:.2f}"
if bm == BillingModelEnum.PREPAY_USER:
msg_de = (
f"Ihr persönliches Guthaben ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte laden Sie unter „Billing“ Guthaben nach."
)
msg_en = (
f"Your personal balance is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please top up under Billing."
)
else:
msg_de = (
f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
"Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)."
)
msg_en = (
f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please contact your mandate administrator. Billing notification contacts were emailed if configured."
)
return msg_de, msg_en
class InsufficientBalanceException(Exception):
"""Raised when there's insufficient balance for an operation."""
def __init__(self, currentBalance: float, requiredAmount: float, message: str = None):
self.currentBalance = currentBalance
self.requiredAmount = requiredAmount
self.message = message or f"Insufficient balance. Current: {currentBalance:.2f} CHF, Required: {requiredAmount:.2f} CHF"
"""Raised when there's insufficient balance for an operation.
Carries structured fields for API/SSE clients (userAction, billingModel, localized hints).
"""
def __init__(
self,
currentBalance: float,
requiredAmount: float,
message: Optional[str] = None,
*,
billing_model: Optional[BillingModelEnum] = None,
mandate_id: str = "",
user_action: Optional[str] = None,
message_de: Optional[str] = None,
message_en: Optional[str] = None,
):
self.currentBalance = float(currentBalance)
self.requiredAmount = float(requiredAmount)
self.billing_model = billing_model
self.mandate_id = mandate_id or ""
if billing_model is not None:
self.user_action = user_action or _userActionForBillingModel(billing_model)
else:
self.user_action = user_action or BILLING_USER_ACTION_TOP_UP_SELF
if message_de is not None and message_en is not None:
self.message_de = message_de
self.message_en = message_en
self.message = message or message_de
elif message:
self.message = message
self.message_de = message
self.message_en = message
else:
bm = billing_model or BillingModelEnum.PREPAY_USER
md, me = _buildInsufficientBalanceMessages(bm, self.currentBalance, self.requiredAmount)
self.message_de = md
self.message_en = me
self.message = md
super().__init__(self.message)
@classmethod
def fromBalanceCheck(
cls,
check: BillingCheckResult,
mandate_id: str,
required_amount: float,
) -> "InsufficientBalanceException":
bm = check.billingModel or BillingModelEnum.PREPAY_MANDATE
bal = float(check.currentBalance or 0.0)
msg_de, msg_en = _buildInsufficientBalanceMessages(bm, bal, required_amount)
return cls(
bal,
required_amount,
message=msg_de,
billing_model=bm,
mandate_id=mandate_id or "",
message_de=msg_de,
message_en=msg_en,
)
def toClientDict(self) -> Dict[str, Any]:
"""Structured payload for HTTP 402, SSE item, or JSON error details."""
out: Dict[str, Any] = {
"error": "INSUFFICIENT_BALANCE",
"currentBalance": round(self.currentBalance, 4),
"requiredAmount": round(self.requiredAmount, 4),
"message": self.message,
"messageDe": self.message_de,
"messageEn": self.message_en,
"userAction": self.user_action,
}
if self.billing_model is not None:
out["billingModel"] = self.billing_model.value
if self.mandate_id:
out["mandateId"] = self.mandate_id
if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF:
out["billingUiPath"] = "/billing"
return out
class ProviderNotAllowedException(Exception):
"""Raised when a user doesn't have permission to use an AI provider."""

View file

@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
CHARS_PER_TOKEN = 4
DEFAULT_CHUNK_TOKENS = 400
DEFAULT_CONTEXT_BUDGET = 8000
DEFAULT_CONTEXT_BUDGET = 12000
class KnowledgeService:
@ -170,8 +170,18 @@ class KnowledgeService:
featureInstanceId: str = "",
mandateId: str = "",
contextBudget: int = DEFAULT_CONTEXT_BUDGET,
workflowHintItems: List[Dict[str, Any]] = None,
) -> str:
"""Build RAG context for an agent round by searching all 3 layers.
"""Build RAG context for an agent round by searching all layers.
Layer priority:
0 - File refs from RoundMemory (always included so the agent knows
which files exist in this workflow)
1 - Instance documents (user's own indexed files)
1.5 - Semantically relevant RoundMemory entries
2 - Workflow entities
3 - Shared knowledge
4 - Cross-workflow hint (other conversations in this workspace)
Args:
currentPrompt: The current user prompt to find relevant context for.
@ -180,6 +190,8 @@ class KnowledgeService:
featureInstanceId: Feature instance scope.
mandateId: Mandate scope.
contextBudget: Maximum characters for the context string.
workflowHintItems: Optional pre-built list of other workflow summaries
for the cross-workflow hint layer.
Returns:
Formatted context string for injection into the agent's system prompt.
@ -190,6 +202,21 @@ class KnowledgeService:
builder = _ContextBuilder(budget=contextBudget)
# Layer 0: File references from RoundMemory (always included)
fileRefMemories = self._knowledgeDb.getRoundMemoriesByType(workflowId, "file_ref")
if fileRefMemories:
refItems = [
{"key": m.get("key", ""), "value": m.get("summary", "")[:300]}
for m in fileRefMemories
]
builder.add(
priority=0,
label="Known Files",
items=refItems,
isKeyValue=True,
maxChars=2000,
)
# Layer 1: Instance Layer (user's own documents, highest priority)
instanceChunks = self._knowledgeDb.semanticSearch(
queryVector=queryVector,
@ -199,12 +226,43 @@ class KnowledgeService:
minScore=0.65,
)
if instanceChunks:
builder.add(priority=1, label="Relevant Documents", items=instanceChunks)
builder.add(priority=1, label="Relevant Documents", items=instanceChunks, maxChars=4000)
# Layer 1.5: Semantically relevant RoundMemory entries
roundMemories = self._knowledgeDb.semanticSearchRoundMemory(
queryVector=queryVector,
workflowId=workflowId,
limit=10,
minScore=0.55,
)
if roundMemories:
memItems = []
for m in roundMemories:
data = m.get("fullData") or m.get("summary", "")
memItems.append({
"data": data,
"contextRef": {
"type": m.get("memoryType", ""),
"key": m.get("key", ""),
"round": m.get("roundNumber", 0),
},
})
seen = {m.get("key") for m in fileRefMemories} if fileRefMemories else set()
memItems = [
mi for mi in memItems if mi["contextRef"].get("key") not in seen
]
if memItems:
builder.add(
priority=2,
label="Previous Round Context",
items=memItems,
maxChars=4000,
)
# Layer 2: Workflow Layer (current workflow entities & memory)
entities = self._knowledgeDb.getWorkflowEntities(workflowId)
if entities:
builder.add(priority=2, label="Workflow Context", items=entities, isKeyValue=True)
builder.add(priority=3, label="Workflow Context", items=entities, isKeyValue=True, maxChars=2000)
# Layer 3: Shared Layer (mandate-wide shared documents)
sharedChunks = self._knowledgeDb.semanticSearch(
@ -215,7 +273,17 @@ class KnowledgeService:
minScore=0.7,
)
if sharedChunks:
builder.add(priority=3, label="Shared Knowledge", items=sharedChunks)
builder.add(priority=4, label="Shared Knowledge", items=sharedChunks, maxChars=2000)
# Layer 4: Cross-workflow hint (other conversations in this workspace)
if workflowHintItems:
builder.add(
priority=5,
label="Other Conversations",
items=workflowHintItems,
isKeyValue=True,
maxChars=500,
)
return builder.build()
@ -520,12 +588,14 @@ class _ContextBuilder:
label: str,
items: List[Dict[str, Any]],
isKeyValue: bool = False,
maxChars: int = 0,
):
self._sections.append({
"priority": priority,
"label": label,
"items": items,
"isKeyValue": isKeyValue,
"maxChars": maxChars,
})
def build(self) -> str:
@ -537,12 +607,15 @@ class _ContextBuilder:
if remaining <= 0:
break
sectionCap = section.get("maxChars") or remaining
sectionRemaining = min(sectionCap, remaining)
header = f"### {section['label']}\n"
sectionText = header
remaining -= len(header)
sectionRemaining -= len(header)
for item in section["items"]:
if remaining <= 0:
if sectionRemaining <= 0:
break
if section["isKeyValue"]:
@ -550,14 +623,15 @@ class _ContextBuilder:
else:
data = item.get("data", "")
ref = item.get("contextRef", {})
score = item.get("_score", "")
refStr = f" [{ref}]" if ref else ""
line = f"{data}{refStr}\n"
if len(line) <= remaining:
if len(line) <= sectionRemaining:
sectionText += line
remaining -= len(line)
sectionRemaining -= len(line)
consumed = min(sectionCap, remaining) - sectionRemaining
remaining -= consumed
parts.append(sectionText)
return "\n".join(parts).strip()

View file

@ -447,6 +447,11 @@ RESOURCE_OBJECTS = [
"label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI Workspace"},
"meta": {"category": "store", "featureCode": "workspace"}
},
{
"objectKey": "resource.store.commcoach",
"label": {"en": "Store: CommCoach", "de": "Store: CommCoach", "fr": "Store: CommCoach"},
"meta": {"category": "store", "featureCode": "commcoach"}
},
{
"objectKey": "resource.system.api.auth",
"label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"},