Compare commits

...

125 commits

Author SHA1 Message Date
d842884ccf Merge remote-tracking branch 'origin/main' into int
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m1s
Deploy Plattform-Core / test (push) Successful in 44s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-06-10 00:42:59 +02:00
fc6de11c37 Remove poweron-center.net references, clean up demo tests
Some checks failed
Deploy Plattform-Core (Int) / deploy (push) Blocked by required conditions
Deploy Plattform-Core (Int) / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 00:42:09 +02:00
12c1d768ac Merge branch 'int'
All checks were successful
Deploy Plattform-Core / test (push) Successful in 52s
Deploy Plattform-Core / deploy (push) Successful in 5s
2026-06-09 23:55:02 +02:00
30db7a310c fix: resolve all deprecation warnings and remove dead test scripts
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m4s
Deploy Plattform-Core (Int) / deploy (push) Successful in 10s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 23:52:50 +02:00
dce41a01ac fix: unit tests and pdf bullet rendering
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m3s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 23:40:43 +02:00
06e68c343b automation fixes
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 1m9s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
2026-06-09 22:59:26 +02:00
ebc4b2a080 cp adapted to 2026 poweron 2
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 12s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
2026-06-09 09:58:05 +02:00
4a60086c80 cp adapted to 2026 poweron
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 15s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
2026-06-09 09:53:31 +02:00
26dd8f6f3f cleanup intra referencings in codebase
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 12s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
2026-06-09 07:05:06 +02:00
4f8473bd70 cleaned servicebag and removed servicehub
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 1m2s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
2026-06-08 23:35:31 +02:00
c1655bdd0a refactor: move billingWebhookHandler to serviceBilling layer
Business logic for Stripe webhooks belongs in serviceCenter/services/serviceBilling/, not in routes/. Updates 3 lazy imports in routeBilling.py accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 21:01:32 +02:00
e0caad0a75 import fixes
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m5s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
2026-06-08 20:45:51 +02:00
ce612ffcfc import referencing fixes
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m0s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
2026-06-08 14:46:52 +02:00
9be2d8aab5 refactory workflowAutomation completed as system component reolacing automation2 and graphEditor
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 16s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
2026-06-08 10:31:17 +02:00
39aba4cca8 before refactory workflowAutomation
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m2s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
2026-06-07 22:26:18 +02:00
2b208ee504 fix(test): update methodTrustee path in signature validator parametrize
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 56s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 08:39:19 +02:00
877f859f6b fix(tests): align test imports with refactored module paths
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 59s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
Fix broken test imports after architecture refactoring:
- mfaService: _buildTotp -> buildTotp, _decryptSecret -> decryptSecret
- _actionSignatureValidator: _validateTypeRef -> validateTypeRef
- fkRegistry: modules.shared -> modules.dbHelpers
- costEstimate/ragLimits: _costEstimate -> costEstimate, _ragLimits -> ragLimits
- udbNodes: _isFeatureAdmin -> isFeatureAdmin
- inheritFlags: _normalisePath -> normalisePath
- methodTrustee: old workflow path -> features/trustee/workflows
- methodDiscovery: fix featuresDir path calculation (4 dirname levels)
- mainGraphicalEditor: wrap template labels with t() for i18n

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 08:25:43 +02:00
cf0233f193 refactor: architecture cleanup + fix scheduler Automation2Workflow error
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 13s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
Fix: add missing Automation2Workflow/Automation2WorkflowRun imports to interfaceFeatureGraphicalEditor.py (caused scheduler crash on boot)
Refactor: gdprDeletion via onUserDelete lifecycle hooks
Refactor: i18nBootSync accounting labels via app.py parameter injection
Refactor: serviceHub moved to serviceCenter/serviceHub.py
Split: teamsbot/service.py, realEstate/main, routeTrustee, routeBilling
Cleanup: remove obsolete methodTrustee, serviceExceptions shim
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 07:59:31 +02:00
bc7c6fe27c elimination of technical issues (imports)
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 13s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
2026-06-06 00:32:45 +02:00
10fad32049 Merge branch 'int'
All checks were successful
Deploy Plattform-Core / test (push) Successful in 46s
Deploy Plattform-Core / deploy (push) Successful in 33s
2026-06-05 10:39:06 +02:00
10f172e950 fix(teamsbot): reuse existing SSE queue in joinMeeting to prevent stale reference
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m6s
Deploy Plattform-Core (Int) / deploy (push) Successful in 8s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 10:38:45 +02:00
e006d85302 Merge branch 'int'
All checks were successful
Deploy Plattform-Core / test (push) Successful in 46s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-06-04 23:57:18 +02:00
74f6f35ad4 fix: resolve datasource labels on reload, faster shutdown
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m3s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 23:56:58 +02:00
4e33cc26dd Merge branch 'int'
All checks were successful
Deploy Plattform-Core / test (push) Successful in 46s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-06-04 23:22:43 +02:00
76753f6037 fix data connector readfile
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m2s
Deploy Plattform-Core (Int) / deploy (push) Successful in 8s
2026-06-04 23:20:28 +02:00
9b674027a0 Merge pull request 'int' (#7) from int into main
All checks were successful
Deploy Plattform-Core / test (push) Successful in 41s
Deploy Plattform-Core / deploy (push) Successful in 6s
Reviewed-on: #7
2026-06-04 19:42:09 +00:00
f766219d4b Merge branch 'int' of ssh://git.poweron.swiss:2222/PowerOn/platform-core into int
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m1s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
2026-06-04 21:38:37 +02:00
5eac61f734 fix infomaniak 2026-06-04 21:38:34 +02:00
Stephan Schellworth
b1d4137935 fix(connectors): resolve browse tokens by connection UUID
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m3s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
ConnectorResolver now loads tokens with UserConnection.id so connection:authority:username references work in browse. ClickUp routes resolve references via getUserConnectionById; graph node defs use list picker only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:41:43 +02:00
2510493891 Merge pull request 'security and mfa' (#6) from int into main
All checks were successful
Deploy Plattform-Core / test (push) Successful in 48s
Deploy Plattform-Core / deploy (push) Successful in 5s
Reviewed-on: #6
2026-06-03 21:25:25 +00:00
08fa70e4e0 security and mfa
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m4s
Deploy Plattform-Core (Int) / deploy (push) Successful in 42s
2026-06-03 23:21:29 +02:00
ae81d27295 Merge pull request 'int' (#5) from int into main
All checks were successful
Deploy Plattform-Core / test (push) Successful in 46s
Deploy Plattform-Core / deploy (push) Successful in 4s
Reviewed-on: #5
2026-06-03 15:18:02 +00:00
b7503e0272 fixes doc generation and renderers 3
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m2s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
2026-06-03 17:15:20 +02:00
60bb771158 fixes doc generation and renderers 2
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 59s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
2026-06-03 17:02:18 +02:00
2eb1a5589d fixes doc generation and renderers
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 18s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
2026-06-03 16:45:17 +02:00
7d207677fd Merge pull request 'int' (#4) from int into main
All checks were successful
Deploy Plattform-Core / test (push) Successful in 44s
Deploy Plattform-Core / deploy (push) Successful in 4s
Reviewed-on: #4
2026-06-03 08:28:55 +00:00
67806e5323 fixes deploy
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m11s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
2026-06-03 10:17:59 +02:00
d61e29bcac fixes private model and udb scoping sources
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 24s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
2026-06-03 09:37:03 +02:00
24899b0cf2 fixes private model and udb scoping sources 2026-06-03 09:34:28 +02:00
a6c89c5159 fix: Calendar adapter uses calendarView with date range, agent shows event summaries inline
All checks were successful
Deploy Plattform-Core / test (push) Successful in 48s
Deploy Plattform-Core (Int) / test (push) Successful in 1m1s
Deploy Plattform-Core / deploy (push) Successful in 11s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 09:29:28 +02:00
33dd694ba1 fix: Outlook email UTF-8 charset for umlauts - add meta charset to HTML body
All checks were successful
Deploy Plattform-Core / test (push) Successful in 44s
Deploy Plattform-Core (Int) / test (push) Successful in 1m11s
Deploy Plattform-Core / deploy (push) Successful in 5s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 00:04:36 +02:00
a7b5192e25 Merge remote-tracking branch 'origin/int'
Some checks failed
Deploy Plattform-Core / deploy (push) Blocked by required conditions
Deploy Plattform-Core (Int) / test (push) Waiting to run
Deploy Plattform-Core (Int) / deploy (push) Blocked by required conditions
Deploy Plattform-Core / test (push) Has been cancelled
2026-06-01 00:02:22 +02:00
92a4c27afe fix: TEXT-to-VECTOR migration, table content normalization, ChatDocument DB fallback, Private-LLM log level
Some checks failed
Deploy Plattform-Core / deploy (push) Blocked by required conditions
Deploy Plattform-Core / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 00:00:57 +02:00
Ida
3b0881b0ca fix: crash upon startup
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 53s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
2026-05-29 07:19:30 +02:00
Ida
3345f65c40 Merge branch 'int' of git.poweron.swiss:PowerOn/platform-core into int
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m2s
Deploy Plattform-Core (Int) / deploy (push) Successful in 10s
2026-05-29 06:41:57 +02:00
cb6b88aa3c fix import token lost across workers: persist token metadata to disk instead of in-memory dict
All checks were successful
Deploy Plattform-Core / test (push) Successful in 47s
Deploy Plattform-Core (Int) / test (push) Successful in 1m15s
Deploy Plattform-Core / deploy (push) Successful in 34s
Deploy Plattform-Core (Int) / deploy (push) Successful in 10s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 01:08:37 +02:00
51ac15e501 fix ragLimits inheritance from connection root to child DataSources
All checks were successful
Deploy Plattform-Core / test (push) Successful in 41s
Deploy Plattform-Core (Int) / test (push) Successful in 1m3s
Deploy Plattform-Core / deploy (push) Successful in 5s
Deploy Plattform-Core (Int) / deploy (push) Successful in 10s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 21:34:57 +02:00
Ida
bf261d6566 feat: billing admin link in exhaust notification email + requestFrontendUrl middleware
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m1s
Deploy Plattform-Core (Int) / deploy (push) Successful in 10s
2026-05-28 15:54:41 +02:00
Ida
14714f3ee2 Merge branch 'int' of git.poweron.swiss:PowerOn/platform-core into int
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m5s
Deploy Plattform-Core (Int) / deploy (push) Successful in 10s
2026-05-28 15:42:03 +02:00
a2c5360364 fix db import
Some checks failed
Deploy Plattform-Core / test (push) Successful in 49s
Deploy Plattform-Core / deploy (push) Successful in 5s
Deploy Plattform-Core (Int) / deploy (push) Blocked by required conditions
Deploy Plattform-Core (Int) / test (push) Has been cancelled
2026-05-28 11:25:10 +02:00
Ida
b04211bed4 bugfix PRM-04: Suchfunktion filtert zuerst, dann sortiert und dann paginiert, sonst fehlerhafte rückgaben 2026-05-28 11:05:43 +02:00
8c2e9d2183 db import streaming
All checks were successful
Deploy Plattform-Core / test (push) Successful in 47s
Deploy Plattform-Core / deploy (push) Successful in 5s
Deploy Plattform-Core (Int) / test (push) Successful in 59s
Deploy Plattform-Core (Int) / deploy (push) Successful in 10s
2026-05-28 10:52:10 +02:00
3e2c07a776 streaming export with log
All checks were successful
Deploy Plattform-Core / test (push) Successful in 46s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-27 23:04:58 +02:00
f24b67ed85 db-export streaming
All checks were successful
Deploy Plattform-Core / test (push) Successful in 49s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-27 19:38:12 +02:00
2b58f7a45d fixed db export
All checks were successful
Deploy Plattform-Core / test (push) Successful in 24s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-27 18:06:59 +02:00
2d796a34ed fix db sync
All checks were successful
Deploy Plattform-Core / test (push) Successful in 50s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-27 17:43:48 +02:00
6ab51cf67e new api ant
All checks were successful
Deploy Plattform-Core / test (push) Successful in 47s
Deploy Plattform-Core / deploy (push) Successful in 4s
Deploy Plattform-Core (Int) / test (push) Successful in 1m0s
Deploy Plattform-Core (Int) / deploy (push) Successful in 10s
2026-05-27 16:48:36 +02:00
19bc4819ee new api openai
All checks were successful
Deploy Plattform-Core / test (push) Successful in 46s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-27 16:17:34 +02:00
51b789b5aa icon toggle
All checks were successful
Deploy Plattform-Core / test (push) Successful in 53s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-27 15:36:03 +02:00
513ded84d5 teams issue
All checks were successful
Deploy Plattform-Core / test (push) Successful in 49s
Deploy Plattform-Core / deploy (push) Successful in 5s
Deploy Plattform-Core (Int) / test (push) Successful in 1m3s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
2026-05-25 23:59:10 +02:00
da1f3f53d0 env
All checks were successful
Deploy Plattform-Core / test (push) Successful in 43s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-25 23:11:09 +02:00
060ca72eb4 fixed db import transactions
All checks were successful
Deploy Plattform-Core / test (push) Successful in 46s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-25 16:53:58 +02:00
6b5e386469 db fixed import
All checks were successful
Deploy Plattform-Core / test (push) Successful in 42s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-25 16:30:27 +02:00
1a1128cc8c fixed shutdown isue
All checks were successful
Deploy Plattform-Core / test (push) Successful in 43s
Deploy Plattform-Core / deploy (push) Successful in 5s
2026-05-25 16:05:15 +02:00
e2d230f2c6 fixed orphan checkker to exclude audit log
All checks were successful
Deploy Plattform-Core / test (push) Successful in 46s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-25 15:58:42 +02:00
0c7ab77728 fixed db poweron reference - not baseclass
All checks were successful
Deploy Plattform-Core / test (push) Successful in 44s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-25 15:49:50 +02:00
1053d0c715 fixed event shutdown
All checks were successful
Deploy Plattform-Core / test (push) Successful in 43s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-25 15:36:47 +02:00
ac85c8e3dc fix identification legacy table
All checks were successful
Deploy Plattform-Core / test (push) Successful in 43s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-25 15:28:38 +02:00
9719a22581 Pydantic FK als Single Source of Truth
All checks were successful
Deploy Plattform-Core / test (push) Successful in 44s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-25 15:14:05 +02:00
c2443a7781 db backup-restore with fk
All checks were successful
Deploy Plattform-Core / test (push) Successful in 55s
Deploy Plattform-Core / deploy (push) Successful in 6s
2026-05-25 14:34:02 +02:00
31955751fb db restore rollback fix
All checks were successful
Deploy Plattform-Core / test (push) Successful in 54s
Deploy Plattform-Core / deploy (push) Successful in 5s
2026-05-25 08:12:37 +02:00
a46e12638e db restore async
All checks were successful
Deploy Plattform-Core / test (push) Successful in 50s
Deploy Plattform-Core / deploy (push) Successful in 6s
2026-05-25 07:46:40 +02:00
afbb8177a3 swap for 2gb upload db
All checks were successful
Deploy Plattform-Core / test (push) Successful in 47s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-24 17:34:24 +02:00
e7874d8e38 fixed db stream upload
All checks were successful
Deploy Plattform-Core / test (push) Successful in 41s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-24 14:59:20 +02:00
c4a9a66c60 db restore with create db if not exitst
All checks were successful
Deploy Plattform-Core / test (push) Successful in 39s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-24 09:18:59 +02:00
59ad6f3849 fixed db download
All checks were successful
Deploy Plattform-Core / test (push) Successful in 41s
Deploy Plattform-Core / deploy (push) Successful in 5s
2026-05-24 09:00:32 +02:00
bc6bb44d6d dbsync
All checks were successful
Deploy Plattform-Core / test (push) Successful in 44s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-24 08:13:54 +02:00
8bc1dd22f1 fix: use printf for SSH key to preserve trailing newline
All checks were successful
Deploy Plattform-Core / test (push) Successful in 38s
Deploy Plattform-Core / deploy (push) Successful in 5s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:22:39 +02:00
c990dd0317 fix: pool test teardown only terminates own connections (not superuser)
All checks were successful
Deploy Plattform-Core / test (push) Successful in 43s
Deploy Plattform-Core / deploy (push) Successful in 4s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:19:51 +02:00
79ec552264 fix: update repo name references plattform-core -> platform-core
Some checks failed
Deploy Plattform-Core / test (push) Failing after 44s
Deploy Plattform-Core / deploy (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:09:53 +02:00
906870faa8 chore: remove update-requirements-lock utility workflow
All checks were successful
Deploy Plattform-Core / test (push) Successful in 42s
Deploy Plattform-Core / deploy (push) Successful in 5s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:38:41 +02:00
a59ee53e3c fix: use env-*.env glob pattern for cleanup in all workflows
All checks were successful
Deploy Plattform-Core / test (push) Successful in 40s
Deploy Plattform-Core / deploy (push) Successful in 5s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:37:01 +02:00
c3530fe2aa feat: add int deployment workflow for porta-int-platform-core
Some checks failed
Deploy Plattform-Core / deploy (push) Blocked by required conditions
Deploy Plattform-Core / test (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:35:18 +02:00
2cfbb41cdf refactor: migrate to Forgejo workflows, normalize env file names, remove GitHub Actions
Some checks are pending
Deploy Plattform-Core / deploy (push) Blocked by required conditions
Deploy Plattform-Core / test (push) Successful in 41s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:34:18 +02:00
ca261c1f5f Update TEAMSBOT_BROWSER_BOT_URL from Azure to Infomaniak VM (179.237.73.4:4100)
All checks were successful
Deploy Plattform-Core / test (push) Successful in 41s
Deploy Plattform-Core / deploy (push) Successful in 5s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:00:47 +02:00
e800bc0b71 Sync: full codebase from GitHub gateway main
Some checks failed
Deploy Plattform-Core / test (push) Failing after 45s
Deploy Plattform-Core / deploy (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 23:54:29 +02:00
Ida
94ce05c443 fix: removed database tests due to network mismatch
All checks were successful
Deploy Plattform-Core / test (push) Successful in 41s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-20 16:47:42 +02:00
Ida
bc8b0288ca fix: tests on github
All checks were successful
Deploy Plattform-Core / test (push) Successful in 41s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-20 16:43:17 +02:00
Ida
d82fc0d955 fix: tests on github
All checks were successful
Deploy Plattform-Core / test (push) Successful in 43s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-20 16:37:14 +02:00
Ida
0c2082896c fix: tests on github
All checks were successful
Deploy Plattform-Core / test (push) Successful in 44s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-20 16:36:15 +02:00
Ida
f67dfb3245 fix: tests on github
All checks were successful
Deploy Plattform-Core / test (push) Successful in 46s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-20 16:34:05 +02:00
Ida
f1cb455ccd fix: tests main github
All checks were successful
Deploy Plattform-Core / test (push) Successful in 40s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-20 16:27:41 +02:00
Ida
56639922e9 fix: postgres connector test
All checks were successful
Deploy Plattform-Core / test (push) Successful in 43s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-20 16:14:20 +02:00
Ida
7624af5b46 fix: grafical editor list parsed incorrectly
Some checks failed
Deploy Plattform-Core / test (push) Failing after 48s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 16:03:21 +02:00
Ida
5a99d73f93 fix: tests
Some checks failed
Deploy Plattform-Core / test (push) Failing after 44s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 15:40:17 +02:00
Ida
c01189ec68 fix: test script
Some checks failed
Deploy Plattform-Core / test (push) Failing after 57s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 13:13:39 +02:00
Ida
149934b730 fix: test script
Some checks failed
Deploy Plattform-Core / test (push) Failing after 2s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 13:12:23 +02:00
Ida
3725ca1a02 trigger: test pipeline
Some checks failed
Deploy Plattform-Core / test (push) Failing after 1m43s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 12:49:45 +02:00
Ida
b888035261 trigger: test pipeline
Some checks failed
Deploy Plattform-Core / test (push) Failing after 21s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 12:48:02 +02:00
Ida
0ea5af4ce8 trigger: test pipeline
Some checks failed
Deploy Plattform-Core / test (push) Failing after 0s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 12:45:08 +02:00
Ida
af383fdfb5 trigger: test pipeline
Some checks failed
Deploy Plattform-Core / test (push) Failing after 7s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 12:43:11 +02:00
Ida
fe4d7afd40 trigger: test pipeline
Some checks failed
Deploy Plattform-Core / test (push) Failing after 4s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 12:26:23 +02:00
Ida
4bd5171644 fix: again test job in deployment file
Some checks failed
Deploy Plattform-Core / test (push) Failing after 3s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 12:20:55 +02:00
Ida
9d3185976b trigger: actions test 2026-05-20 12:18:29 +02:00
Ida
c4883ef22e fix: again test job in deployment file 2026-05-20 12:15:53 +02:00
Ida
76cb841973 fix: test job in deployment file
Some checks failed
Deploy Plattform-Core / test (push) Failing after 3s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 12:14:50 +02:00
Ida
575e5b6fbf added automated testing
Some checks failed
Deploy Plattform-Core / test (push) Failing after 8s
Deploy Plattform-Core / deploy (push) Has been skipped
2026-05-20 12:13:38 +02:00
Ida
f468a377e4 Merge remote-tracking branch 'github/main' 2026-05-20 12:11:33 +02:00
Patrick Motsch
45091dc596
Merge pull request #166 from valueonag/int
Int
2026-05-19 22:35:47 +02:00
ValueOn AG
09c6d33dec fixed expenses workflow 2026-05-19 22:14:00 +02:00
ValueOn AG
a173fab15f fix mandate res 2026-05-19 17:42:24 +02:00
ValueOn AG
9773c00bca trustee budget fix 2026-05-19 17:38:18 +02:00
ValueOn AG
1ed462ad13 fixes rag and workflow 2026-05-19 16:48:01 +02:00
ValueOn AG
4064ac0266 fixed toggle icons udb 2026-05-18 07:56:53 +02:00
26505ba7af .forgejo/workflows/deploy.yml aktualisiert
All checks were successful
Deploy Plattform-Core / deploy (push) Successful in 11s
2026-05-18 04:44:26 +00:00
ValueOn AG
2bb65c2303 db connection pooling and rag limit transparency 2026-05-17 20:38:37 +02:00
Patrick Motsch
a31e0dadc3
Merge pull request #165 from valueonag/int
Some checks failed
Deploy Gateway / deploy (push) Failing after 3s
Int
2026-05-17 00:08:54 +02:00
Patrick Motsch
f5aba4bf99
Merge pull request #164 from valueonag/feat/demo-system-readieness
rag enhancements
2026-05-16 23:02:23 +02:00
ValueOn AG
7c4c5e079a rag enhancements 2026-05-16 22:55:43 +02:00
Ida
2f8abb5ac4 updated all api keys 2026-05-15 12:23:50 +02:00
Patrick Motsch
57a9257047
Merge pull request #159 from valueonag/int
Int
2026-05-10 22:21:28 +02:00
Patrick Motsch
2f87fae44d
Merge pull request #152 from valueonag/int
Int
2026-05-03 22:26:04 +02:00
Patrick Motsch
1369812cef
Merge pull request #148 from valueonag/int
Int
2026-04-29 02:01:06 +02:00
Patrick Motsch
15dbb431ca
Merge pull request #146 from valueonag/int
Int
2026-04-27 08:24:06 +02:00
Patrick Motsch
e499cd47d8
Merge pull request #142 from valueonag/int
Int
2026-04-26 23:13:14 +02:00
873 changed files with 51075 additions and 55598 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,741 +0,0 @@
---
name: Swift iOS App Nachbau
overview: Vollständiger Implementierungsplan für den Nachbau des React-Web-Frontends (frontend_nyla) als native Swift/SwiftUI iOS/iPadOS-App. Die App kommuniziert mit dem bestehenden FastAPI-Gateway-Backend und bildet alle UI-Screens, Navigation und API-Schnittstellen nach.
todos:
- id: phase-0
content: "Phase 0: Xcode-Projekt erstellen, Ordnerstruktur, SPM-Dependencies, Build-Configs (Dev/Int/Prod)"
status: pending
- id: phase-1
content: "Phase 1: Core Networking Layer -- APIClient, SSEClient, WebSocketClient, CSRFManager (analog api.ts + sseClient.ts)"
status: pending
- id: phase-2
content: "Phase 2: Authentication -- LocalAuth, MSAL, Google, Biometrie, Keychain (analog authApi.ts + AuthProvider.tsx)"
status: pending
- id: phase-3
content: "Phase 3: Domain Models + FeatureStore (analog mandate.ts + featureStore.tsx)"
status: pending
- id: phase-4
content: "Phase 4: App Shell -- NavigationSplitView (iPad) / TabView (iPhone), Dashboard, Settings, backend-driven Sidebar"
status: pending
- id: phase-5
content: "Phase 5: i18n String Catalogs (de/en/fr) + Theme System (Light/Dark)"
status: pending
- id: phase-6
content: "Phase 6: Core Pages -- Store, GDPR, Basedata (Prompts/Files/Connections), Billing Transactions"
status: pending
- id: phase-7
content: "Phase 7: Shared UI Components -- FormGenerator, ContentPreview, ChatMessage, AccessRules, NotificationBell"
status: pending
- id: phase-8
content: "Phase 8: Push Notifications (APNs Registration, Deep-Link Handling)"
status: pending
- id: phase-9
content: "Phase 9: Admin Module -- alle 16 Admin-Seiten (Mandates, Users, RBAC, Invitations, Wizards, etc.)"
status: pending
- id: phase-10
content: "Phase 10: Feature Trustee -- Dashboard, Documents, Positions, Roles, Expense-Import, Scan, Accounting"
status: pending
- id: phase-11
content: "Phase 11: Feature Workspace -- Chat-Streaming (SSE), Files, Datasources, Voice"
status: pending
- id: phase-12
content: "Phase 12: Feature Chatbot -- SSE-Streaming Chat, Threads, Conversations"
status: pending
- id: phase-13
content: "Phase 13: Feature Teamsbot -- Sessions, WebSocket Bot-Kommunikation, Voice, MFA"
status: pending
- id: phase-14
content: "Phase 14: Feature CommCoach -- Coaching Sessions, Audio-Streaming, Personas, Dossier"
status: pending
- id: phase-15
content: "Phase 15: Feature ChatPlayground -- Workflows, Playground mit SSE-Stream"
status: pending
- id: phase-16
content: "Phase 16: Feature Automation -- Definitions, Templates, Logs, Execute"
status: pending
- id: phase-17
content: "Phase 17: Feature CodeEditor -- Editor mit SSE-Stream, Code-Anzeige, Apply"
status: pending
- id: phase-18
content: "Phase 18: Feature RealEstate/PEK -- MapKit-Integration, Parcels, Address-Search, BZO"
status: pending
- id: phase-19
content: "Phase 19: Feature Neutralization -- Config, Neutralize Text/File"
status: pending
- id: phase-20
content: "Phase 20: Billing-Erweiterung -- Admin-Views, Stripe Checkout"
status: pending
isProject: false
---
# Nyla iOS/iPadOS App -- Vollständiger Implementierungsplan
## Ausgangslage
Das bestehende Web-Frontend (`frontend_nyla`) ist eine **React 19 + Vite + TypeScript** Anwendung mit:
- **12+ Feature-Module** (Trustee, Workspace, Chatbot, Teamsbot, CommCoach, CodeEditor, Automation, RealEstate, Neutralization, ChatPlayground, Billing, Admin)
- **21 API-Module** unter `src/api/*.ts` mit insgesamt **200+ API-Endpunkten**
- **120+ UI-Komponenten** inkl. dynamischem FormGenerator, ContentPreview, Chat-Streaming, Maps, Charts
- **Multi-Tenant-Architektur**: Mandate > Features > Instanzen > Views/Permissions
- **3 Auth-Provider**: Local, Microsoft MSAL, Google OAuth
- **Echtzeit**: SSE-Streaming (Chat, Workspace, CodeEditor) + WebSockets (Voice)
- **Backend**: FastAPI (Python) auf PostgreSQL, erreichbar unter konfigurierbarer `VITE_API_BASE_URL`
---
## Technische Entscheidungen
| Aspekt | Entscheidung |
| -------------------- | --------------------------------------------------- |
| Plattform | iOS 18+ / iPadOS 18+ |
| UI-Framework | SwiftUI |
| Architektur | **MVVM + Repository Pattern** (s. unten) |
| Networking | URLSession + async/await |
| SSE | Custom SSE-Client auf URLSession-Basis |
| WebSocket | URLSessionWebSocketTask |
| Auth | MSAL SDK, Google Sign-In SDK, Keychain + Local Auth |
| Biometrie | LocalAuthentication (Face ID / Touch ID) |
| State | `@Observable` (Observation Framework, iOS 17+) |
| Navigation | `NavigationStack` + `NavigationSplitView` (iPad) |
| Dependency Injection | Environment-basiert (SwiftUI `@Environment`) |
| Package Manager | Swift Package Manager (SPM) |
| Karten | MapKit (SwiftUI) |
| Charts | Swift Charts |
| i18n | String Catalogs (`.xcstrings`) fuer de/en/fr |
| Push | APNs + UserNotifications Framework |
| PDF-Anzeige | PDFKit |
| Markdown | Native AttributedString (iOS 15+) |
| Persistenz | Keychain (Secrets), UserDefaults (Preferences) |
| Distribution | TestFlight |
### Architektur: MVVM + Repository Pattern
```
Presentation Layer (SwiftUI Views)
|
v
ViewModels (@Observable)
|
v
Repositories (Protokolle)
|
v
API Services (URLSession)
|
v
Gateway Backend (FastAPI)
```
Begründung: SwiftUI ist nativ MVVM-orientiert. Das Repository Pattern kapselt die Datenzugriffe und macht den Code testbar. `@Observable` (iOS 17+) ist leichter als `ObservableObject` und performanter.
### Projektstruktur
```
NylaApp/
NylaApp.swift // App Entry Point
Config/
AppConfig.swift // API URLs, Build Configs
Environment.swift // Dev/Int/Prod Environments
Core/
Networking/
APIClient.swift // Zentraler HTTP-Client (= api.ts)
APIError.swift // Error Types
APIEndpoints.swift // Endpoint Definitionen
SSEClient.swift // Server-Sent Events Client
WebSocketClient.swift // WebSocket Client
CSRFManager.swift // CSRF Token Handling
RequestInterceptor.swift // Auth/Mandate Headers
Auth/
AuthManager.swift // Zentrale Auth-Logik
LocalAuthService.swift // Username/Password
MSALAuthService.swift // Microsoft MSAL
GoogleAuthService.swift // Google Sign-In
BiometricAuthService.swift // Face ID / Touch ID
KeychainService.swift // Secure Storage
Navigation/
AppRouter.swift // Root Navigation
NavigationStore.swift // Backend-driven Nav State
DeepLinkHandler.swift // URL Scheme Handling
Localization/
Localizable.xcstrings // String Catalog
LanguageManager.swift // Sprachauswahl
Theme/
ThemeManager.swift // Light/Dark Mode
DesignTokens.swift // Farben, Spacing, Fonts
Permissions/
PermissionChecker.swift // RBAC Client-Checks
Domain/
Models/ // Shared Domain Models
Mandate.swift // Mandate, Feature, Instance
User.swift // User Model
Permissions.swift // AccessLevel, TablePermission
Pagination.swift // PaginatedResponse<T>
I18nLabel.swift // Mehrsprachige Labels
Repositories/ // Repository Protokolle
AuthRepository.swift
MandateRepository.swift
FeatureRepository.swift
...
Data/
API/ // API-Implementierungen (= src/api/*.ts)
AuthAPI.swift
UserAPI.swift
MandateAPI.swift
FeaturesAPI.swift
BillingAPI.swift
TrusteeAPI.swift
... (21 Module)
Repositories/ // Repository Implementierungen
DefaultAuthRepository.swift
DefaultMandateRepository.swift
...
Features/ // Feature-Module (je Ordner)
Dashboard/
Store/
Settings/
GDPR/
Basedata/
Prompts/
Files/
Connections/
Billing/
Admin/
Mandates/
Users/
Access/
Invitations/
...
Trustee/
Workspace/
Chatbot/
Teamsbot/
CommCoach/
CodeEditor/
ChatPlayground/
Automation/
RealEstate/
Neutralization/
Shared/
Components/ // Wiederverwendbare UI (= src/components/)
FormGenerator/ // Dynamische Formulare
ContentPreview/ // PDF, Bild, JSON Vorschau
ChatMessage/ // Chat-Nachrichten-Rendering
AccessRules/ // Zugriffsregeln-Editor
NotificationBell/ // Notification Badge + Overlay
SearchBar/
LoadingView/
ErrorView/
EmptyStateView/
Extensions/
Utilities/
Resources/
Assets.xcassets
```
---
## Phasen-Plan
### Phase 0: Projekt-Setup (1-2 Tage)
- Xcode-Projekt erstellen (iOS 18+, SwiftUI App Lifecycle)
- Ordnerstruktur nach obigem Schema anlegen
- SPM Dependencies einrichten:
- `MSAL` (Microsoft Authentication Library for iOS)
- `GoogleSignIn` (Google Sign-In SDK)
- Keine weiteren externen Deps noetig (MapKit, Charts, PDFKit sind System-Frameworks)
- Build-Konfigurationen: **Dev** / **Int** / **Prod** mit je eigenem `API_BASE_URL`
- Analog zu den `.env.dev` / `.env.int` / `.env.prod` Dateien im Web-Frontend
- Werte: `http://localhost:8000` (Dev), INT-URL, PROD-URL
- TestFlight-Vorbereitung: App ID, Provisioning Profile, Signing
### Phase 1: Core Networking Layer (3-5 Tage)
**Ziel**: Equivalent zu `[src/api.ts](frontend_nyla/src/api.ts)` + `[src/hooks/useApi.ts](frontend_nyla/src/hooks/useApi.ts)`
**APIClient.swift** -- Zentraler HTTP-Client:
- `URLSession.shared` mit Custom-Configuration
- Cookie-basierte Auth (`httpCookieStorage`)
- Request-Interceptor fuer:
- `Authorization: Bearer` Header (aus Keychain)
- `X-Mandate-Id` / `X-Instance-Id` Header (aus aktuellem Navigation-Context)
- CSRF-Token fuer POST/PUT/PATCH/DELETE
- Response-Handler:
- 401 -> Redirect zu Login (analog Web `api.ts` Zeile 127-151)
- 429 -> Rate-Limit Warning
- Generische Fehlerextraktion (FastAPI `detail` Array/String)
- Generische Request-Methoden: `get<T>()`, `post<T>()`, `put<T>()`, `delete<T>()`, `upload()`
- `Codable`-basierte JSON Serialisierung
**SSEClient.swift** -- Server-Sent Events:
- Analog zu `[src/utils/sseClient.ts](frontend_nyla/src/utils/sseClient.ts)`
- URLSession mit `bytes(for:)` async stream
- Parsing von `data:` Lines
- Callbacks: `onMessage`, `onError`, `onComplete`
- Wird benoetigt fuer: Workspace, Chatbot, CodeEditor, CommCoach Streaming
**WebSocketClient.swift** -- WebSockets:
- `URLSessionWebSocketTask`
- Fuer Voice-Features (Teamsbot: `/api/teamsbot/{instanceId}/bot/ws/{sessionId}`)
- Ping/Pong, Reconnect-Logik
**CSRFManager.swift**:
- Token-Generierung und -Speicherung
- Analog zu `[src/utils/csrfUtils.ts](frontend_nyla/src/utils/csrfUtils.ts)`
### Phase 2: Authentication (3-5 Tage)
**Ziel**: Alle 3 Auth-Provider + Biometrie
**Mapping Web -> Swift:**
| Web (authApi.ts) | Swift |
| ---------------------------------------- | -------------------------------------------- |
| `POST /api/local/login` (form-data) | `LocalAuthService.login(username:password:)` |
| `POST /api/local/register` | `LocalAuthService.register(...)` |
| `POST /api/local/password-reset-request` | `LocalAuthService.requestPasswordReset(...)` |
| `POST /api/local/password-reset` | `LocalAuthService.resetPassword(...)` |
| `GET /api/local/available?username=` | `LocalAuthService.checkAvailability(...)` |
| `GET /api/local/me` | `AuthManager.fetchCurrentUser()` |
| `POST /api/local/logout` | `AuthManager.logout()` |
| MSAL Login/Callback | `MSALAuthService` via MSAL SDK |
| `GET /api/msft/me` | `MSALAuthService.fetchUser()` |
| Google Login/Callback | `GoogleAuthService` via Google Sign-In SDK |
| `GET /api/google/me` | `GoogleAuthService.fetchUser()` |
**AuthManager.swift** (zentral):
- Verwaltet aktiven Auth-Provider (`local` / `msft` / `google`)
- Speichert Auth-State in Keychain (nicht UserDefaults!)
- Published `isAuthenticated`, `currentUser`, `authAuthority`
- Analog zu `[src/providers/auth/AuthProvider.tsx](frontend_nyla/src/providers/auth/AuthProvider.tsx)`
**BiometricAuthService.swift**:
- `LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics)`
- Nach erstem erfolgreichen Login: Credentials in Keychain speichern
- Bei App-Start: Face ID/Touch ID -> Keychain Credentials -> Auto-Login
**Login Screen (SwiftUI)**:
- Username/Password Felder
- "Anmelden mit Microsoft" Button (MSAL)
- "Anmelden mit Google" Button (Google Sign-In)
- "Face ID / Touch ID" Option (wenn verfuegbar)
- Registrierung / Passwort vergessen Links
- Analog zu `[src/pages/Login.tsx](frontend_nyla/src/pages/Login.tsx)`
### Phase 3: Domain Models + Feature Store (2-3 Tage)
**Ziel**: Alle geteilten Datenmodelle + Feature-State
Zentrale Models (analog zu `[src/types/mandate.ts](frontend_nyla/src/types/mandate.ts)`):
```swift
// Mandate.swift
struct I18nLabel: Codable { var de: String; var en: String; var fr: String? }
enum AccessLevel: String, Codable { case none = "n", my = "m", group = "g", all = "a" }
struct TablePermission: Codable { var view: Bool; var read, create, update, delete: AccessLevel }
struct FieldPermission: Codable { var read: Bool; var write: Bool }
struct InstancePermissions: Codable { var tables: [String: TablePermission]; var fields: [String: [String: FieldPermission]]?; var views: [String: Bool]; var isAdmin: Bool? }
struct FeatureInstance: Codable, Identifiable { var id: String; var featureCode, mandateId, mandateName, instanceLabel: String; var userRoles: [String]; var permissions: InstancePermissions }
struct MandateFeature: Codable { var code: String; var label: I18nLabel; var icon: String; var instances: [FeatureInstance] }
struct Mandate: Codable, Identifiable { var id, name: String; var label, code: String?; var features: [MandateFeature] }
struct FeaturesMyResponse: Codable { var mandates: [Mandate] }
```
**FeatureStore.swift** (analog zu `[src/stores/featureStore.tsx](frontend_nyla/src/stores/featureStore.tsx)`):
- `@Observable class FeatureStore`
- `loadFeatures()` -> `GET /api/features/my`
- Cache: `[String: FeatureInstance]` fuer schnellen Zugriff
- Methoden: `getMandateById()`, `getInstanceById()`, `getAllInstances()`, etc.
- Injected via SwiftUI `@Environment`
### Phase 4: App Shell + Navigation (4-6 Tage)
**Ziel**: MainLayout + FeatureLayout + backend-driven Navigation
**Adaptive Layout:**
- **iPad**: `NavigationSplitView` (Sidebar + Detail) -- analog Web-Sidebar
- **iPhone**: `TabView` mit Hauptbereichen + Navigation Stack pro Tab
**Sidebar / Navigation:**
- Backend-driven: `GET /api/navigation?language={lang}` liefert Navigationsbaum
- Analog zu `[src/components/Navigation/MandateNavigation.tsx](frontend_nyla/src/components/Navigation/MandateNavigation.tsx)`
- Hierarchie: Mandate > Feature > Instance > Views
- Icon-Mapping: SF Symbols statt React Icons (Mapping-Tabelle erstellen)
**Screen-Routing:**
- `NavigationStack` mit `NavigationPath` fuer programmatische Navigation
- Deep-Link-Schema: `nyla://mandates/{mandateId}/{featureCode}/{instanceId}/{view}`
- Feature-View-Dispatcher: analog zu `[src/pages/FeatureView.tsx](frontend_nyla/src/pages/FeatureView.tsx)` `VIEW_COMPONENTS`
**Screens in Phase 4:**
- Dashboard (`/`) -- Mandate/Instance-Karten, analog `[src/pages/Dashboard.tsx](frontend_nyla/src/pages/Dashboard.tsx)`
- Settings (`/settings`) -- Theme-Toggle, Sprache (de/en/fr), Profil
- UserSection im Sidebar-Footer
### Phase 5: i18n + Theme (2-3 Tage)
**Internationalisierung:**
- Xcode String Catalog (`.xcstrings`) fuer de/en/fr
- Alle statischen Strings aus den Web-Locales uebernehmen: `[src/locales/de.ts](frontend_nyla/src/locales/de.ts)`, `en.ts`, `fr.ts`
- Dynamische Labels (I18nLabel vom Backend): Helper `label.localized(lang:)` analog `getLabel()` im Web
- `LanguageManager` speichert Praeferenz in UserDefaults
**Theme:**
- SwiftUI `.preferredColorScheme()` fuer System-Integration
- Custom `DesignTokens` fuer konsistente Farben/Spacing
- Analog zu `[src/styles/themes/light.css](frontend_nyla/src/styles/themes/light.css)` + `.dark-theme`
### Phase 6: Core Pages (5-7 Tage)
**Store** (Feature Marketplace):
- `GET /api/store/features` -> Feature-Liste
- `POST /api/store/activate` / `POST /api/store/deactivate`
- Analog `[src/pages/Store.tsx](frontend_nyla/src/pages/Store.tsx)`
**GDPR**:
- `GET /api/user/me/data-export` + `/data-portability`
- `DELETE /api/user/me/`
- Analog `[src/pages/GDPR.tsx](frontend_nyla/src/pages/GDPR.tsx)`
**Basedata - Prompts** (`/basedata/prompts`):
- CRUD auf `/api/prompts` mit FormGenerator
- Analog `[src/pages/PromptsPage.tsx](frontend_nyla/src/pages/PromptsPage.tsx)`
**Basedata - Files** (`/basedata/files`):
- `GET /api/files/list`, Upload, Download, Preview
- Analog `[src/pages/FilesPage.tsx](frontend_nyla/src/pages/FilesPage.tsx)`
- Nutzung von `UIDocumentPickerViewController` (via UIKit-Bridge) fuer File-Upload
- `QuickLook` fuer Dateivorschau
**Basedata - Connections** (`/basedata/connections`):
- CRUD auf `/api/connections/`
- Connect/Disconnect Aktionen
- Analog `[src/pages/ConnectionsPage.tsx](frontend_nyla/src/pages/ConnectionsPage.tsx)`
**Billing** (`/billing/transactions`):
- `GET /api/billing/balance`, `/transactions`, `/statistics/{period}`
- Swift Charts fuer Statistik-Visualisierung
- Analog `[src/pages/billing/BillingDataView.tsx](frontend_nyla/src/pages/billing/BillingDataView.tsx)`
### Phase 7: Shared UI Components (5-8 Tage)
**FormGenerator** (zentral, wird von fast allen Features genutzt):
- Analog zu `[src/components/FormGenerator/](frontend_nyla/src/components/FormGenerator/)`
- Dynamische Formulare basierend auf `AttributeDefinition[]` vom Backend (`GET /api/attributes/{entityType}`)
- Feldtypen: String, Email, Select, Multiselect, Textarea, Checkbox, File, Number, DateTime, Multilingual
- Tabellen-Ansicht (`FormGeneratorTable`) + Listen-Ansicht (`FormGeneratorList`)
- Action Buttons (Edit, Delete, Download, Custom)
- Pagination-Support
**ContentPreview**:
- PDF: `PDFKitView` (UIKit PDFView in UIViewRepresentable)
- Bilder: AsyncImage
- JSON: Syntax-Highlighting
- HTML: WKWebView
- Analog `[src/components/ContentPreview/](frontend_nyla/src/components/ContentPreview/)`
**NotificationBell**:
- `GET /api/notifications/unread-count` (Polling)
- Push Notifications via APNs
- In-App Notification Sheet
- Analog `[src/components/NotificationBell/](frontend_nyla/src/components/NotificationBell/)`
**Chat Message Components**:
- Message-Bubbles mit Markdown-Rendering
- File-Attachments
- Streaming-Indicator (typing animation)
- Auto-Scroll
- Analog `[src/components/UiComponents/Messages/](frontend_nyla/src/components/UiComponents/Messages/)`
**AccessRules Components**:
- Tabelle + Editor fuer RBAC-Regeln
- Analog `[src/components/AccessRules/](frontend_nyla/src/components/AccessRules/)`
### Phase 8: Push Notifications (2-3 Tage)
- APNs-Registrierung in `AppDelegate`
- Device Token an Backend senden (neuer Endpoint oder bestehender `/api/messaging/subscriptions`)
- `UNUserNotificationCenter` fuer lokale + remote Notifications
- Deep-Link Handling aus Notification-Tap
### Phase 9: Admin Module (5-7 Tage)
Alle Admin-Seiten analog zu `[src/pages/admin/](frontend_nyla/src/pages/admin/)`:
| Admin-Seite | API-Endpunkte |
| -------------------- | ------------------------------------------ |
| Mandates | CRUD `/api/mandates/` |
| Users | CRUD `/api/users/` |
| User-Mandates | `/api/mandates/{id}/users` |
| Access Hub | `/api/rbac/permissions`, `/api/rbac/rules` |
| Feature Instances | `/api/features/instances` |
| Feature Roles | `/api/features/templates/roles` |
| Feature Users | `/api/features/instances/{id}/users` |
| Invitations | CRUD `/api/invitations/` |
| Mandate Roles | `/api/rbac/roles` |
| Role Permissions | `/api/rbac/rules/by-role/{roleId}` |
| User Access Overview | `/api/admin/user-access-overview/`* |
| Billing Admin | `/api/billing/admin/`* |
| Automation Events | `/api/admin/automation-events` |
| Logs | `/api/admin/logs` |
| Mandate Wizard | Kombination mehrerer Endpoints |
| Invitation Wizard | Kombination mehrerer Endpoints |
### Phase 10-20: Feature-Module (je 3-7 Tage pro Feature)
Jedes Feature folgt demselben Pattern:
1. **API-Modul** erstellen (alle Endpunkte des Features)
2. **ViewModels** fuer jede View
3. **SwiftUI Views** fuer jede registrierte View
4. **Feature-spezifische Komponenten** wo noetig
---
#### Phase 10: Trustee (5-7 Tage)
Views: Dashboard, Documents, Positions, Instance-Roles, Expense-Import, Scan-Upload, Accounting Settings
API-Basis: `/api/trustee/{instanceId}/`
- Organisations, Roles, Access, Contracts, Documents, Positions CRUD
- Accounting: Connectors, Config, Sync
- Document Upload mit base64-Konvertierung
- Options-Endpoints fuer Dropdowns
Besonderheiten:
- Viele verschachtelte CRUD-Entitaeten (Organisation > Contract > Document > Position)
- Scan-Upload: iOS-Kamera-Integration + VisionKit (OCR)
#### Phase 11: Workspace (5-7 Tage)
Views: Dashboard (Chat-Stream), Settings
API-Basis: `/api/workspace/{instanceId}/`
- SSE-Streaming fuer Chat (`POST .../start/stream`)
- Workflows, Messages, Files, Datasources CRUD
- Voice: Transcribe, Synthesize, Settings
- File Browser mit Ordnerstruktur
Besonderheiten:
- **Zentrales SSE-Streaming** -- das Keep-Alive-Pattern aus dem Web (`WorkspaceKeepAlive`) muss in Swift via Task/Actor geloest werden
- Voice: AVFoundation fuer Audio-Aufnahme, URLSession fuer Upload
#### Phase 12: Chatbot (3-5 Tage)
Views: Conversations, Settings
API-Basis: `/api/chatbot/{instanceId}/`
- `POST .../start/stream` -- SSE-Streaming via fetch (nicht Axios!)
- Threads: List, Get, Delete
- Stop Workflow
Besonderheiten:
- Streaming-Chat mit File-Attachments
- Analog zu `chatbotApi.startChatbotStreamApi` -- Custom SSE via POST
#### Phase 13: Teamsbot (4-6 Tage)
Views: Dashboard, Sessions, Settings
API-Basis: `/api/teamsbot/{instanceId}/`
- Sessions CRUD + Stream (EventSource/SSE)
- Config, System Bots, User Account
- Voice Test
- MFA fuer Sessions
- WebSocket fuer Bot-Kommunikation (`/bot/ws/{sessionId}`)
Besonderheiten:
- **WebSocket** fuer Live-Bot-Interaction
- SSE via EventSource fuer Session-Stream
- Screenshot-Anzeige
#### Phase 14: CommCoach (4-6 Tage)
Views: Dashboard, Coaching, Dossier, Settings
API-Basis: `/api/commcoach/{instanceId}/`
- Contexts CRUD + Archive/Activate
- Sessions: Start, Message-Stream, Audio-Stream, Complete, Cancel
- Tasks CRUD + Status
- Personas CRUD, Documents, Badges, Score History
- Voice: Languages, Voices, TTS
- Export (Dossier, Session)
Besonderheiten:
- **Audio-Streaming**: Mikrofon-Aufnahme -> POST Audio-Stream
- SSE fuer Session-Nachrichten
- Score/Badge-Visualisierung
#### Phase 15: ChatPlayground (3-5 Tage)
Views: Playground, Workflows
API-Basis: `/api/chatplayground/{instanceId}/`
- Start/Stop Workflow (mit SSE-Stream)
- Workflows CRUD + Status/Logs/Messages
- Attributes, Actions
#### Phase 16: Automation (3-5 Tage)
Views: Definitions, Templates, Logs
API-Basis: `/api/automations/`
- Automations CRUD + Execute + Duplicate
- Templates CRUD
- Workflow-Management (gleiche API wie ChatPlayground, anderer Base-Path)
#### Phase 17: CodeEditor (3-5 Tage)
Views: Editor, Workflows
API-Basis: `/api/codeeditor/{instanceId}/`
- Start/Stop/Apply (mit SSE-Stream)
- ChatData, Workflows, Files, File Content
Besonderheiten:
- Code-Darstellung: Syntax-Highlighting (z.B. via `Highlightr` SPM Package oder custom)
- Diff-Ansicht fuer Code-Apply
#### Phase 18: RealEstate / PEK (5-7 Tage)
Views: Dashboard (Map), Instance-Roles
API-Basis: `/api/realestate/{instanceId}/`
- Projects + Parcels CRUD
- Parcel Search, WFS, Selection Summary, Adjacent Parcels
- Address Autocomplete
- BZO Information, Parcel Documents
- Gemeinden
Besonderheiten:
- **MapKit** Integration: Parcel-Visualisierung auf Karte
- Address-Autocomplete: MKLocalSearchCompleter oder Backend-API
- Komplexe Karteninteraktion (Parcel-Selektion, Adjacent Parcels)
#### Phase 19: Neutralization (2-3 Tage)
Views: Dashboard/Playground (gleiche View)
API-Basis: `/api/neutralization/`
- Config GET/POST
- Neutralize File/Text, Resolve Text
- Process SharePoint, Batch Process
- Stats, Attributes
#### Phase 20: Billing View-Erweiterung (1-2 Tage)
Admin-Billing-Views falls in Phase 9 nicht vollstaendig abgedeckt:
- Checkout (Stripe -- SFSafariViewController fuer Redirect)
- Mandate/User Balances und Transaktionen
---
## API-Header-Konvention (fuer alle Requests)
Jeder API-Request muss folgende Header senden (analog `[src/api.ts](frontend_nyla/src/api.ts)`):
| Header | Quelle | Wann |
| -------------------------------- | ------------------ | --------------------- |
| `Authorization: Bearer {token}` | Keychain | Wenn JWT vorhanden |
| `X-Mandate-Id: {mandateId}` | Navigation Context | Bei Feature-Seiten |
| `X-Instance-Id: {instanceId}` | Navigation Context | Bei Feature-Seiten |
| `X-CSRF-Token: {token}` | CSRFManager | POST/PUT/PATCH/DELETE |
| `Content-Type: application/json` | Standard | JSON Bodies |
| Cookie (httpOnly) | URLSession | Automatisch |
---
## Gesamtaufwand-Schaetzung
| Phase | Tage (geschaetzt) |
| ------------------------------- | ----------------- |
| Phase 0: Setup | 1-2 |
| Phase 1: Networking | 3-5 |
| Phase 2: Authentication | 3-5 |
| Phase 3: Domain Models + Store | 2-3 |
| Phase 4: App Shell + Navigation | 4-6 |
| Phase 5: i18n + Theme | 2-3 |
| Phase 6: Core Pages | 5-7 |
| Phase 7: Shared UI Components | 5-8 |
| Phase 8: Push Notifications | 2-3 |
| Phase 9: Admin | 5-7 |
| Phase 10: Trustee | 5-7 |
| Phase 11: Workspace | 5-7 |
| Phase 12: Chatbot | 3-5 |
| Phase 13: Teamsbot | 4-6 |
| Phase 14: CommCoach | 4-6 |
| Phase 15: ChatPlayground | 3-5 |
| Phase 16: Automation | 3-5 |
| Phase 17: CodeEditor | 3-5 |
| Phase 18: RealEstate | 5-7 |
| Phase 19: Neutralization | 2-3 |
| Phase 20: Billing Erweit. | 1-2 |
| **Gesamt** | **~70-105 Tage** |
Hinweis: Dies ist eine Einzelperson-Schaetzung. Mit Team (z.B. 2-3 Devs) kann parallelisiert werden, besonders ab Phase 10+ (Features sind unabhaengig voneinander).
---
## Offene Punkte / Risiken
1. **Backend-Anpassungen**: Das Backend setzt teilweise httpOnly Cookies nach Browser-Redirect (MSAL, Google). Fuer eine native App muss das Backend ggf. alternative Token-Flows unterstuetzen (z.B. Device Code Flow oder Token-Exchange).
2. **Push Notifications**: Das Backend hat aktuell kein APNs-Token-Management. Ein neuer Endpoint `/api/notifications/register-device` muss im Gateway implementiert werden.
3. **SSE ueber POST**: Die Web-App nutzt `fetch` POST + ReadableStream fuer SSE (nicht standard EventSource GET). In Swift muss dies mit `URLSession.bytes(for:)` nachgebaut werden.
4. **Stripe Checkout**: Im Web oeffnet sich ein Stripe-Redirect. In iOS: SFSafariViewController oder Stripe iOS SDK.
5. **SharePoint Integration**: Einige Features nutzen SharePoint-Folder-Picker. In iOS muss eine alternative UI gebaut werden (Liste statt Filepicker).
6. **WebSocket Auth**: Der Web-Client nutzt Cookies fuer WebSocket-Auth. iOS `URLSessionWebSocketTask` unterstuetzt Cookies via URLSession Configuration.

View file

@ -1,30 +0,0 @@
name: Deploy Gateway
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to Infomaniak VM
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
ssh -i ~/.ssh/deploy_key ubuntu@api.poweron.swiss "
cd /srv/gateway/current &&
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/gateway.git &&
git pull &&
cp env-gateway-prod-forgejo.env .env &&
rm -f env-*.env &&
source .venv/bin/activate &&
pip install -r requirements.txt --no-cache-dir &&
sudo systemctl restart gateway
"

View file

@ -0,0 +1,58 @@
name: Deploy Plattform-Core (Int)
on:
push:
branches:
- int
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Tests auf Infomaniak VM
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
ssh -i ~/.ssh/deploy_key ubuntu@api-int.poweron.swiss "
set -e
cd /srv/gateway/current
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
git fetch origin int
git reset --hard origin/int
test -f env-int.env
cp env-int.env .env
rm -f env-*.env
source .venv/bin/activate
pip install -r requirements.txt --no-cache-dir
python -m pytest tests/ --ignore=tests/demo
"
deploy:
runs-on: ubuntu-latest
needs: test
steps:
- name: Deploy to Infomaniak VM
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
ssh -i ~/.ssh/deploy_key ubuntu@api-int.poweron.swiss "
set -e
cd /srv/gateway/current
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
git fetch origin int
git reset --hard origin/int
test -f env-int.env
cp env-int.env .env
rm -f env-*.env
source .venv/bin/activate
pip install -r requirements.txt --no-cache-dir
sudo systemctl restart gateway
"

View file

@ -0,0 +1,58 @@
name: Deploy Plattform-Core
on:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Tests auf Infomaniak VM
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
ssh -i ~/.ssh/deploy_key ubuntu@api.poweron.swiss "
set -e
cd /srv/gateway/current
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
git fetch origin main
git reset --hard origin/main
test -f env-prod.env
cp env-prod.env .env
rm -f env-*.env
source .venv/bin/activate
pip install -r requirements.txt --no-cache-dir
python -m pytest tests/ --ignore=tests/demo
"
deploy:
runs-on: ubuntu-latest
needs: test
steps:
- name: Deploy to Infomaniak VM
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
ssh -i ~/.ssh/deploy_key ubuntu@api.poweron.swiss "
set -e
cd /srv/gateway/current
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
git fetch origin main
git reset --hard origin/main
test -f env-prod.env
cp env-prod.env .env
rm -f env-*.env
source .venv/bin/activate
pip install -r requirements.txt --no-cache-dir
sudo systemctl restart gateway
"

View file

@ -1,151 +0,0 @@
# GitHub Actions workflow for deploying Gateway to Google Cloud Run
# Documentation: https://cloud.google.com/run/docs/deploying
#
# Required GitHub Secrets:
# - GCP_PROJECT_ID: Your Google Cloud Project ID
# - GCP_SA_KEY: Service Account JSON key with Cloud Run Admin and Cloud Build Editor roles
# - GCP_SERVICE_ACCOUNT_EMAIL: Email of the service account to run Cloud Run service as
#
# Required Google Cloud Setup:
# 1. Create a service account with Cloud Run Admin and Cloud Build Editor roles
# 2. Create secret "CONFIG_KEY" in Secret Manager with your master key
# 3. Grant the service account access to Secret Manager secrets
# 4. Create Cloud SQL instance (if not exists)
# 5. Create env-gateway-prod.env and env-gateway-int.env files with your configuration
#
# Environment Selection:
# - Push to 'main' branch → uses env-gateway-prod.env (production)
# - Push to 'int' branch → uses env-gateway-int.env (integration)
# - Manual dispatch → select environment (prod/int) to use corresponding env file
name: Deploy Gateway to Google Cloud Run
on:
push:
branches:
- main
- int
paths:
- 'gateway/**'
workflow_dispatch:
inputs:
environment:
description: 'Environment to deploy to'
required: true
default: 'prod'
type: choice
options:
- prod
- int
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
REGION: europe-west6 # Zurich region
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for Workload Identity Federation
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Determine environment
id: env
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
ENV_TYPE="${{ github.event.inputs.environment }}"
elif [ "${{ github.ref }}" == "refs/heads/int" ]; then
ENV_TYPE="int"
else
ENV_TYPE="prod"
fi
echo "env_type=$ENV_TYPE" >> $GITHUB_OUTPUT
echo "service_name=gateway-$ENV_TYPE" >> $GITHUB_OUTPUT
echo "env_file=env-gateway-${ENV_TYPE}.env" >> $GITHUB_OUTPUT
echo "Determined environment: $ENV_TYPE"
echo "Service name: gateway-$ENV_TYPE"
echo "Env file: env-gateway-${ENV_TYPE}.env"
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
# Alternative: Use Workload Identity Federation (more secure)
# workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
# service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2
- name: Configure Docker for GCR
run: |
gcloud auth configure-docker
- name: Set environment file
run: |
cd gateway
ENV_FILE="${{ steps.env.outputs.env_file }}"
if [ -f "$ENV_FILE" ]; then
echo "Using $ENV_FILE"
cp "$ENV_FILE" .env
else
echo "Warning: $ENV_FILE not found, using env-gateway-prod.env as fallback"
cp env-gateway-prod.env .env
fi
# Clean up other env files (optional, for security)
rm -f env-*.env
- name: Build and push container image
working-directory: ./gateway
run: |
# Build container image using Cloud Build
# If Dockerfile exists, it will be used; otherwise Cloud Buildpacks will be used
SERVICE_NAME="${{ steps.env.outputs.service_name }}"
gcloud builds submit \
--tag gcr.io/${{ env.PROJECT_ID }}/$SERVICE_NAME:${{ github.sha }} \
--tag gcr.io/${{ env.PROJECT_ID }}/$SERVICE_NAME:latest \
--project ${{ env.PROJECT_ID }}
- name: Deploy to Cloud Run
run: |
SERVICE_NAME="${{ steps.env.outputs.service_name }}"
ENV_TYPE="${{ steps.env.outputs.env_type }}"
gcloud run deploy $SERVICE_NAME \
--image gcr.io/${{ env.PROJECT_ID }}/$SERVICE_NAME:${{ github.sha }} \
--region ${{ env.REGION }} \
--platform managed \
--allow-unauthenticated \
--project ${{ env.PROJECT_ID }} \
--set-env-vars "APP_ENV_TYPE=$ENV_TYPE" \
--set-secrets "CONFIG_KEY=CONFIG_KEY:latest" \
--memory 2Gi \
--cpu 2 \
--timeout 300 \
--max-instances 10 \
--min-instances 1 \
--port 8000 \
--service-account ${{ secrets.GCP_SERVICE_ACCOUNT_EMAIL }}
- name: Get service URL
id: service-url
run: |
SERVICE_NAME="${{ steps.env.outputs.service_name }}"
SERVICE_URL=$(gcloud run services describe $SERVICE_NAME \
--region ${{ env.REGION }} \
--project ${{ env.PROJECT_ID }} \
--format 'value(status.url)')
echo "url=$SERVICE_URL" >> $GITHUB_OUTPUT
- name: Output deployment URL
run: |
echo "🚀 Deployment successful!"
echo "Service URL: ${{ steps.service-url.outputs.url }}"

View file

@ -1,88 +0,0 @@
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions
# More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions
name: Build and deploy Python app to Azure Web App - gateway-int
on:
push:
branches:
- int
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read #This is required for actions/checkout
steps:
- uses: actions/checkout@v5
- name: Set up Python version
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Create and start virtual environment
run: |
python -m venv venv
source venv/bin/activate
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.lock ]; then
pip install -r requirements.lock --no-cache-dir
else
pip install -r requirements.txt --no-cache-dir
fi
# Optional: Add step to run tests here (PyTest, Django test suites, etc.)
- name: Zip artifact for deployment
run: zip release.zip ./* -r
- name: Upload artifact for deployment jobs
uses: actions/upload-artifact@v6
with:
name: python-app
path: |
release.zip
!venv/
retention-days: 5
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: 'Production'
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
steps:
- name: Download artifact from build job
uses: actions/download-artifact@v7
with:
name: python-app
- name: Unzip artifact for deployment
run: unzip release.zip
- name: Set productive environment
run: cp env-gateway-int.env .env
- name: Clean up environment files
run: rm -f env-*.env
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
id: deploy-to-webapp
with:
app-name: 'gateway-int'
slot-name: 'Production'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_INT }}

View file

@ -1,88 +0,0 @@
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions
# More info on Python, GitHub Actions, and Azure App Service: https://aka.ms/python-webapps-actions
name: Build and deploy Python app to Azure Web App - gateway-prod
on:
push:
branches:
- main
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read #This is required for actions/checkout
steps:
- uses: actions/checkout@v5
- name: Set up Python version
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Create and start virtual environment
run: |
python -m venv venv
source venv/bin/activate
- name: Install dependencies
run: |
python -m pip install --upgrade pip
if [ -f requirements.lock ]; then
pip install -r requirements.lock --no-cache-dir
else
pip install -r requirements.txt --no-cache-dir
fi
# Optional: Add step to run tests here (PyTest, Django test suites, etc.)
- name: Zip artifact for deployment
run: zip release.zip ./* -r
- name: Upload artifact for deployment jobs
uses: actions/upload-artifact@v6
with:
name: python-app
path: |
release.zip
!venv/
retention-days: 5
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: 'Production'
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
steps:
- name: Download artifact from build job
uses: actions/download-artifact@v7
with:
name: python-app
- name: Unzip artifact for deployment
run: unzip release.zip
- name: Set productive environment
run: cp env-gateway-prod.env .env
- name: Clean up environment files
run: rm -f env-*.env
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
id: deploy-to-webapp
with:
app-name: 'gateway-prod'
slot-name: 'Production'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_PROD }}

View file

@ -1,51 +0,0 @@
# Generates requirements.lock from requirements.txt using Python 3.11 (same as build).
# Run manually (workflow_dispatch) or on changes to requirements.txt.
# After running, commit the generated requirements.lock so builds use it for fast installs.
name: Update requirements.lock
on:
workflow_dispatch:
push:
branches:
- main
- int
paths:
- 'requirements.txt'
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
update-lock:
runs-on: ubuntu-latest
permissions:
contents: write # push requirements.lock
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install pip-tools
run: python -m pip install --upgrade "pip>=24,<26" pip-tools
- name: Generate requirements.lock
run: pip-compile requirements.txt -o requirements.lock
- name: Commit and push requirements.lock
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add requirements.lock
if git diff --staged --quiet; then
echo "No changes to requirements.lock"
else
git commit -m "chore: update requirements.lock"
git push
fi

View file

@ -46,5 +46,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/api/admin/health', timeout=5)" || exit 1
# Run the application
# Cloud Run will set PORT env var, uvicorn reads it automatically
CMD exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} --workers 1
CMD exec gunicorn app:app --bind 0.0.0.0:${PORT:-8000} --timeout 600 --worker-class uvicorn.workers.UvicornWorker --workers 1

281
app.py
View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import os
import sys
@ -61,6 +61,13 @@ class DailyRotatingFileHandler(RotatingFileHandler):
return True
return False
def doRollover(self):
"""Size-based rollover that tolerates Windows file locks."""
try:
super().doRollover()
except PermissionError:
pass
def emit(self, record):
"""Emit a log record, switching files if date has changed"""
# Check if we need to switch to a new file
@ -282,7 +289,7 @@ initLogging()
logger = logging.getLogger(__name__)
instanceLabel = APP_CONFIG.get("APP_ENV_LABEL")
# Pre-warm AI connectors on process load (before lifespan). Critical for chatbot latency.
# Pre-warm AI connectors on process load (before lifespan). Critical for AI/agent latency.
try:
import modules.aicore.aicoreModelRegistry # noqa: F401
logger.info("AI connectors pre-warm (app load) triggered")
@ -295,7 +302,7 @@ async def lifespan(app: FastAPI):
logger.info("Application is starting up")
# Validate FK metadata on all Pydantic models (fail-fast, no silent fallbacks)
from modules.shared.fkRegistry import validateFkTargets
from modules.dbHelpers.fkRegistry import validateFkTargets
fkErrors = validateFkTargets()
if fkErrors:
for err in fkErrors:
@ -304,6 +311,31 @@ async def lifespan(app: FastAPI):
# AI connectors already pre-warmed at module-load via _eager_prewarm() in aicoreModelRegistry.
# Register system-component lifecycle hooks (Composition Root — inverts L4->L5b dependency)
from modules.shared.systemComponentRegistry import registerLifecycleHook
from modules.workflowAutomation.mainWorkflowAutomation import (
onBootstrap as _waOnBootstrap,
onMandateDelete as _waOnMandateDelete,
onInstanceCreate as _waOnInstanceCreate,
)
from modules.interfaces.interfaceDbBilling import (
onMandateDelete as _billingOnMandateDelete,
onMandateProvision as _billingOnMandateProvision,
onStorageChanged as _billingOnStorageChanged,
onUserMandateCreate as _billingOnUserMandateCreate,
onUserMandateDelete as _billingOnUserMandateDelete,
onUserBudgetAdjust as _billingOnUserBudgetAdjust,
)
registerLifecycleHook("onBootstrap", _waOnBootstrap)
registerLifecycleHook("onMandateDelete", _waOnMandateDelete)
registerLifecycleHook("onMandateDelete", _billingOnMandateDelete)
registerLifecycleHook("onMandateProvision", _billingOnMandateProvision)
registerLifecycleHook("onStorageChanged", _billingOnStorageChanged)
registerLifecycleHook("onInstanceCreate", _waOnInstanceCreate)
registerLifecycleHook("onUserMandateCreate", _billingOnUserMandateCreate)
registerLifecycleHook("onUserMandateDelete", _billingOnUserMandateDelete)
registerLifecycleHook("onUserBudgetAdjust", _billingOnUserBudgetAdjust)
# Bootstrap database if needed (creates initial users, mandates, roles, etc.)
# This must happen before getting root interface
from modules.security.rootAccess import getRootDbAppConnector
@ -322,6 +354,14 @@ async def lifespan(app: FastAPI):
catalogService = getCatalogService()
registerAllFeaturesInCatalog(catalogService)
logger.info("Feature catalog registration completed")
# Register service center RBAC objects (Composition Root — avoids system→serviceCenter import)
try:
from modules.serviceCenter import registerServiceObjects
registerServiceObjects(catalogService)
except Exception as e:
logger.warning(f"Service center RBAC registration failed: {e}")
# Persist the in-memory feature registry into the Feature DB-table so
# the FeatureInstance.featureCode FK has real targets. Without this
# every FeatureInstance row would be flagged as orphan by the
@ -335,8 +375,23 @@ async def lifespan(app: FastAPI):
# Sync gateway i18n registry to DB and load translation cache
try:
from modules.shared.i18nRegistry import syncRegistryToDb, loadCache
await syncRegistryToDb()
from modules.system.i18nBootSync import syncRegistryToDb, loadCache
from modules.serviceCenter.registry import IMPORTABLE_SERVICES
serviceLabels = [svc.get("label") for svc in IMPORTABLE_SERVICES.values()]
accountingLabels = []
try:
from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry
registry = getAccountingRegistry()
for connectorType, connector in (registry._connectors or {}).items():
for field in connector.getRequiredConfigFields():
label = getattr(field, "label", "") or ""
if label:
accountingLabels.append({"label": label, "connectorType": connectorType})
except Exception:
pass
await syncRegistryToDb(serviceLabels=serviceLabels, accountingLabels=accountingLabels)
await loadCache()
logger.info("i18n registry sync + cache load completed")
except Exception as e:
@ -369,14 +424,74 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"Could not initialize feature containers: {e}")
# Bootstrap Stripe prices for paid plans (composition root — upward import allowed here)
try:
from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import bootstrapStripePrices
bootstrapStripePrices()
except Exception as e:
logger.error(f"Stripe price bootstrap failed: {e}")
# Bootstrap MIME map into ComponentObjects (composition root — upward import allowed here)
try:
from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry
from modules.interfaces.interfaceDbManagement import ComponentObjects
_mimeRegistry = ExtractorRegistry()
_extensionToMime = _mimeRegistry.getExtensionToMimeMap()
_textMimes: set = set()
_seen: set = set()
for _ext in _mimeRegistry._map.values():
_eid = id(_ext)
if _eid in _seen:
continue
_seen.add(_eid)
_mimes = _ext.getSupportedMimeTypes()
if any(m.startswith("text/") for m in _mimes):
_textMimes.update(_mimes)
_textMimes.update({"application/json", "application/xml", "application/javascript", "application/sql", "application/x-yaml", "application/x-toml"})
ComponentObjects.setMimeMap(_extensionToMime, _textMimes)
except Exception as e:
logger.warning(f"MIME map bootstrap failed: {e}")
# --- Init Managers ---
import asyncio
try:
main_loop = asyncio.get_running_loop()
eventManager.set_event_loop(main_loop)
from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop
from modules.workflowAutomation.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback
setSchedulerMainLoop(main_loop)
# Inject run-failed notification callback (Composition Root — avoids workflows→serviceCenter import)
def _onRunFailed(workflowId, runId, error, mandateId=None, workflowLabel=None):
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelMessaging import MessagingEventParameters
rootInterface = getRootInterface()
if not rootInterface:
return
eventUser = rootInterface.getUserByUsername("event")
if not eventUser:
return
ctx = ServiceCenterContext(
user=eventUser,
mandate_id=mandateId or "",
feature_instance_id="",
feature_code="workflowAutomation",
)
messagingService = getService("messaging", ctx)
subscriptionId = "WorkflowAutomationRunFailed"
eventParams = MessagingEventParameters(triggerData={
"workflowId": workflowId,
"workflowLabel": workflowLabel or workflowId,
"runId": runId,
"error": error,
"mandateId": mandateId or "",
})
messagingService.executeSubscription(subscriptionId, eventParams)
setOnRunFailedCallback(_onRunFailed)
# Suppress noisy ConnectionResetError from ProactorEventLoop on Windows
# when clients (browsers) close connections abruptly. This is a known
# asyncio issue on Windows: https://bugs.python.org/issue39010
@ -386,14 +501,24 @@ async def lifespan(app: FastAPI):
return
if isinstance(exc, ConnectionAbortedError):
return
if exc and "LocalProtocolError" in type(exc).__name__:
return
loop.default_exception_handler(ctx)
main_loop.set_exception_handler(_suppressClientDisconnect)
except RuntimeError:
pass
eventManager.start()
# --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) ---
try:
from modules.workflowAutomation.scheduler.mainScheduler import start as _startWorkflowScheduler
_startWorkflowScheduler(eventUser)
logger.info("WorkflowAutomation scheduler started (system lifespan)")
except Exception as e:
logger.error(f"WorkflowAutomation scheduler failed to start: {e}")
# Register audit log cleanup scheduler
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler()
# Register enterprise subscription auto-renewal scheduler
@ -404,8 +529,10 @@ async def lifespan(app: FastAPI):
try:
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (
recoverInterruptedJobs,
registerZombieKillerScheduler,
)
recoverInterruptedJobs()
registerZombieKillerScheduler(intervalMinutes=5)
except Exception as e:
logger.warning(f"BackgroundJob recovery failed (non-critical): {e}")
@ -416,28 +543,96 @@ async def lifespan(app: FastAPI):
registerKnowledgeIngestionConsumer,
)
registerKnowledgeIngestionConsumer()
# Side-effect import: registers all walker progress message keys
# in the i18n registry so `syncRegistryToDb` picks them up.
from modules.serviceCenter.services.serviceKnowledge import _progressMessages # noqa: F401
except Exception as e:
logger.warning(f"KnowledgeIngestionConsumer registration failed (non-critical): {e}")
# Install force-exit handler AFTER uvicorn has registered its own SIGINT
# handler. Uvicorn's default timeout-graceful-shutdown is None (wait
# forever), so frontend polling keep-alive connections block the process.
# This wraps uvicorn's handler: on Ctrl+C, start a 3s timer that calls
# os._exit() if the graceful shutdown hasn't completed by then.
import signal as _sig
import threading as _thr
_prevSigint = _sig.getsignal(_sig.SIGINT)
def _onSigint(signum, frame):
_t = _thr.Timer(3.0, lambda: os._exit(0))
_t.daemon = True
_t.start()
if callable(_prevSigint) and _prevSigint not in (_sig.SIG_DFL, _sig.SIG_IGN):
_prevSigint(signum, frame)
else:
raise KeyboardInterrupt
_sig.signal(_sig.SIGINT, _onSigint)
yield
# --- Stop Managers ---
eventManager.stop()
# --- Stop Feature Containers (Plug&Play) ---
# --- Shutdown sequence (protected against CancelledError) ---
try:
mainModules = loadFeatureMainModules()
for featureName, module in mainModules.items():
if hasattr(module, "onStop"):
try:
await module.onStop(eventUser)
logger.info(f"Feature '{featureName}' stopped")
except Exception as e:
logger.error(f"Feature '{featureName}' failed to stop: {e}")
except Exception as e:
logger.warning(f"Could not shutdown feature containers: {e}")
logger.info("Application has been shut down")
# 1. Drain SSE queues and cancel agent tasks FIRST so that open
# streaming connections break out of their queue.get() loop
# immediately. Without this, uvicorn waits for the SSE generators
# to finish (up to 120 s keepalive timeout) before the rest of
# the shutdown can proceed.
try:
from modules.shared.eventManager import get_event_manager as _getStreamingEM
_getStreamingEM().shutdown()
except Exception as e:
logger.warning(f"Streaming EventManager shutdown failed: {e}")
# 2. Signal DB layer to abort in-flight borrow waits immediately.
# This MUST happen early so that sync worker threads stuck in
# _acquireConn (30 s poll loop) bail out within one backoff tick
# instead of blocking process exit for the full borrow timeout.
try:
from modules.connectors.connectorDbPostgre import closeAllPools
closeAllPools()
except Exception as e:
logger.warning(f"Closing DB connection pools failed: {e}")
# 3. Stop scheduler (removes all pending cron/interval jobs)
eventManager.stop()
# 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan)
try:
from modules.workflowAutomation.scheduler.mainScheduler import stop as _stopWorkflowScheduler
_stopWorkflowScheduler()
except Exception as e:
logger.warning(f"WorkflowAutomation scheduler stop failed: {e}")
try:
from modules.workflowAutomation.scheduler.emailPoller import stop as _stopEmailPoller
_stopEmailPoller(eventUser)
except Exception as e:
logger.warning(f"Email poller stop failed: {e}")
# 4. Stop Feature Containers (Plug&Play)
try:
mainModules = loadFeatureMainModules()
for featureName, module in mainModules.items():
if hasattr(module, "onStop"):
try:
await module.onStop(eventUser)
logger.info(f"Feature '{featureName}' stopped")
except Exception as e:
logger.error(f"Feature '{featureName}' failed to stop: {e}")
except Exception as e:
logger.warning(f"Could not shutdown feature containers: {e}")
# 5. Close shared HTTP sessions (ResilientHttp) to avoid TCP keepalive hang
try:
from modules.shared.httpResilience import closeAllResilientHttp
await closeAllResilientHttp()
except Exception as e:
logger.warning(f"Closing HTTP sessions failed: {e}")
logger.info("Application has been shut down")
except asyncio.CancelledError:
logger.info("Shutdown interrupted (CancelledError) -- resources released")
# Custom function to generate readable operation IDs for Swagger UI
@ -510,8 +705,8 @@ def getAllowedOrigins():
# CORS origin regex pattern for wildcard subdomain support
# Matches all subdomains of poweron.swiss and poweron-center.net
CORS_ORIGIN_REGEX = r"https://.*\.(poweron\.swiss|poweron-center\.net)"
# Matches all subdomains of poweron.swiss
CORS_ORIGIN_REGEX = r"https://.*\.poweron\.swiss"
# SlowAPI rate limiter initialization
@ -598,6 +793,9 @@ app.include_router(fileRouter)
from modules.routes.routeDataSources import router as dataSourceRouter
app.include_router(dataSourceRouter)
from modules.routes.routeUdb import router as udbRouter
app.include_router(udbRouter)
from modules.routes.routeDataPrompts import router as promptRouter
app.include_router(promptRouter)
@ -613,6 +811,9 @@ app.include_router(tableViewsRouter)
from modules.routes.routeSecurityLocal import router as localRouter
app.include_router(localRouter)
from modules.routes.routeMfa import router as mfaRouter
app.include_router(mfaRouter)
from modules.routes.routeSecurityMsft import router as msftRouter
app.include_router(msftRouter)
@ -689,11 +890,8 @@ from modules.routes.routeSystem import router as systemRouter, navigationRouter
app.include_router(systemRouter)
app.include_router(navigationRouter)
from modules.routes.routeWorkflowDashboard import router as workflowDashboardRouter
app.include_router(workflowDashboardRouter)
from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter
app.include_router(automationWorkspaceRouter)
from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter
app.include_router(workflowAutomationRouter)
# ============================================================================
# PLUG&PLAY FEATURE ROUTERS
@ -702,4 +900,23 @@ app.include_router(automationWorkspaceRouter)
from modules.system.registry import loadFeatureRouters
featureLoadResults = loadFeatureRouters(app)
logger.info(f"Feature router load results: {featureLoadResults}")
logger.info(f"Feature router load results: {featureLoadResults}")
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8000))
try:
import gunicorn.app.wsgiapp # type: ignore[import-untyped] # noqa: F401
import subprocess
import sys
subprocess.run([
sys.executable, "-m", "gunicorn", "app:app",
"--bind", f"0.0.0.0:{port}",
"--timeout", "600",
"--worker-class", "uvicorn.workers.UvicornWorker",
"--workers", "1",
], check=True)
except ImportError:
import uvicorn
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=2)

View file

@ -1,3 +1,5 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Generate tenant-dossier.pdf for neutralization demo. Run: python _generateTenantDossierPdf.py
Uses ReportLab so the PDF opens reliably in all viewers (stdlib-only PDFs are fragile).

View file

@ -1,3 +1,5 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Generate the 3 fictitious PWG scan PDFs used by the pilot demo.
Run: python _generateScans.py

View file

@ -38,7 +38,6 @@
"title": "Pro Scan-Dokument",
"parameters": {
"items": {"type": "ref", "nodeId": "n2", "path": ["files"]},
"level": "auto",
"concurrency": 1
}
},

View file

@ -1,309 +0,0 @@
# Aufwandsschätzung Althaus Bot v2 -- Unabhängige Analyse
**Projekt:** Althaus Bot v2 -- Weiterentwicklung & neue Use Cases
**Kunde:** W. Althaus AG, Aarwangen
**Erstellt:** 13. April 2026
**Basis:** Code-Analyse Gateway-Repository + Offerte v2 vom 14.04.2026
**Methodik:** Bottom-Up-Schätzung auf Basis der bestehenden Implementierung, Dreipunktschätzung (Min / Mitte / Max)
---
## 1. Ist-Zustand der Implementierung
### 1.1 Architekturübersicht
```
┌─────────────────────────────────────────────────────────────────┐
│ React Frontend (SSE-Streaming, Chat-UI) │
└──────────────────────────┬──────────────────────────────────────┘
│ /api/chatbot/*
┌──────────────────────────▼──────────────────────────────────────┐
│ Gateway (Python/FastAPI) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Chatbot Feature (modules/features/chatbot/) │ │
│ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │
│ │ │ Planner │→ │ SQL Plan │→ │ Parse & │→ │Formul. │ │ │
│ │ │ Node │ │ Node │ │ Execute │ │ Node │ │ │
│ │ └────┬────┘ └──────────┘ └────┬─────┘ └────────┘ │ │
│ │ │ │ │ │
│ │ ├→ Tavily (Web Search) │ │ │
│ │ └→ Direct Answer │ │ │
│ └──────────────────────────────────┼──────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────▼──────────────────────┐ │
│ │ PreprocessorConnector (HTTP POST → Azure SQL API) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ KnowledgeService (pgvector/RAG) -- NICHT IM CHATBOT │ │
│ │ Produktiv im AgentService + CommCoach │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────▼──────────────────────────────────────┐
│ Azure Preprocessing Server (deployed, ERP-Daten deaktiviert) │
│ Tabellen: Artikel, Einkaufspreis, Lagerplatz, Lagerplatz_Art. │
│ Repo: github.com/valueonag/gateway_preprocessing │
└─────────────────────────────────────────────────────────────────┘
```
### 1.2 Vorhandene Komponenten (Wiederverwendung)
| Komponente | Datei / Modul | Status | Wiederverwendbar für |
|---|---|---|---|
| LangGraph-Workflow | `chatbot/chatbot.py` | Produktiv (deaktiviert) | Alle Positionen -- Grundgerüst |
| PreprocessorConnector | `connectors/connectorPreprocessor.py` | Produktiv (deaktiviert) | Pos. 1, 2, 3, 4 -- SQL-Abfragen |
| ChatbotConfig | `chatbot/config.py` | Produktiv | Alle -- Konfiguration pro Instanz |
| Streaming-Bridge | `chatbot/service.py` | Produktiv | Alle -- SSE ans Frontend |
| ChatbotDocument | `chatbot/interfaceFeatureChatbot.py` | Implementiert | Pos. 1.4, 2.1, 2.5 -- File-Handling |
| KnowledgeService/RAG | `serviceCenter/services/serviceKnowledge/` | Produktiv (AgentService) | Pos. 5 -- Wiki-Integration |
| Automation-Template | `automation/subAutomationTemplates.py` | Produktiv | Pos. 6 -- Preprocessor-Updates |
| SQL-Sanitize | `chatbot.py``_sanitize_sql_typos` | Produktiv | Pos. 1.1 -- Gesperrte Artikel |
| Markdown-Tabellen | `chatbot.py``_tool_output_to_markdown_table` | Produktiv | Pos. 1.3, 3.3 -- Darstellung |
| File-Upload Backend | `service.py``_convert_file_ids_to_document_references` | Implementiert | Pos. 1.4 -- Upload-Pipeline |
| Excel-Export | `service.py``_create_chat_document_from_action_document` | Implementiert | Pos. 2.5 -- Kalktool-Export |
### 1.3 Fehlende Komponenten (Neuentwicklung)
| Komponente | Benötigt für | Komplexität |
|---|---|---|
| Matching-Engine (exakt → fuzzy → KI) | Pos. 2.2 | Hoch |
| Neuer Planner-Pfad "WIKI" | Pos. 5.2 | Mittel |
| KnowledgeService → Chatbot Integration | Pos. 5.2 | Mittel |
| Wiki-Connector (API/Crawling) | Pos. 5.1 | Unbekannt (Wiki-abhängig) |
| Delta-Sync-Mechanismus | Pos. 5.3 | Mittel |
| Preprocessor: 8-10 neue Tabellen/Views | Pos. 1.5, 3.1, 4.1 | Mittel (Code-Änderung) |
| Frontend: File-Picker, Drag&Drop | Pos. 1.4 | Mittel |
| Frontend: Thread-Liste, Suchfunktion | Pos. 1.2 | Mittel |
| Kalktool-Excel-Format-Export | Pos. 2.5 | Mittel |
| Schwellenwert-Insights | Pos. 4.5 | Mittel |
---
## 2. Detaillierte Aufwandsschätzung
### Position 1: Basics (Plattform-Verbesserungen)
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|---|---|:-:|:-:|:-:|:-:|---|
| 1.1 | Gesperrte Artikel filtern | 4 | 3 | 4 | 4 | System-Prompt + SQL-Sanitize-Regel. Kleine Änderung. |
| 1.2 | Chat-Verlauf speichern | 12 | 12 | 14 | 16 | Backend existiert. Frontend-Aufwand (Thread-Liste, Suche). |
| 1.3 | Längere Antworten | 6 | 4 | 5 | 6 | Streaming-Config + Frontend-Rendering. |
| 1.4 | Datei-Upload | 16 | 16 | 18 | 20 | Full-Stack: Drag&Drop + LangGraph-Integration + Extraktion. |
| 1.5 | Kundenartikelnummern | 8 | 10 | 12 | 14 | Preprocessor-Code + Prompt + Cross-Ref-Queries. ERP-abhängig. |
| 1.6 | Abklärungen & Testing | 8 | 8 | 8 | 8 | Standard. |
| | **Subtotal** | **54** | **53** | **61** | **68** | |
**Delta zur Offerte: +7h (Mitte) / +14h (Max)**
**Haupttreiber:** Preprocessor-Erweiterung für Kundenartikelnummern (Pos. 1.5) erfordert Code-Änderung, nicht nur Config. Frontend-Aufwand bei Upload (Pos. 1.4) eher am oberen Ende.
---
### Position 2: Use Case Kalktool
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|---|---|:-:|:-:|:-:|:-:|---|
| 2.1 | Stücklisten-Upload & Extraktion | 12 | 10 | 12 | 14 | Nutzt Pos. 1.4. serviceExtraction vorhanden. |
| 2.2 | Artikelidentifikation & Matching | 20 | 24 | 28 | 32 | **KRITISCH**: Neue Matching-Engine, 3 Stufen, ERP-abhängig. |
| 2.3 | Automatische Feldergänzung | 16 | 14 | 16 | 18 | Preprocessor + Enrichment-Logik. |
| 2.4 | Alternativartikel-Vorschläge | 12 | 12 | 14 | 16 | KI-Vorschläge + Bestätigungs-Workflow im Chat. |
| 2.5 | Excel-Export (Kalktool-Format) | 12 | 10 | 12 | 14 | Basis existiert. Kalktool-Vorlage-Anpassung. |
| 2.6 | Erweiterbarkeit neue Felder | 8 | 6 | 8 | 10 | Config-gesteuertes Feld-Mapping. |
| 2.7 | Abklärungen & Testing | 12 | 12 | 12 | 12 | Kalktool-Vorlage, Testdaten, UAT. |
| | **Subtotal** | **92** | **88** | **102** | **116** | |
**Delta zur Offerte: +10h (Mitte) / +24h (Max)**
**Haupttreiber:** Die Matching-Engine (Pos. 2.2) ist die komplexeste Neuentwicklung im gesamten Projekt. Mehrstufiges Matching (exakt → fuzzy → KI-gestützt) ohne bestehende Basis. Die Qualität hängt stark von der ERP-Datenqualität und der Vielfalt der Kunden-Stücklisten-Formate ab.
---
### Position 3: Use Case Materialmanagement 1
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|---|---|:-:|:-:|:-:|:-:|---|
| 3.1 | ERP-Daten erweitern | 16 | 16 | 19 | 22 | Preprocessor: Bestellungen, Wareneingänge, Aufträge. Code nötig. |
| 3.2 | System-Prompt Materialmanagement | 8 | 6 | 8 | 10 | Prompt-Engineering + SQL-Templates. |
| 3.3 | Transparente Statusübersicht | 8 | 6 | 7 | 8 | Markdown-Rendering existiert, Erweiterung nötig. |
| 3.4 | Auswirkungsanalyse & Empfehlungen | 12 | 14 | 16 | 18 | Cross-Table-Queries + KI-Analyse. Komplex. |
| 3.5 | Abklärungen & Testing | 8 | 8 | 8 | 8 | Standard. |
| | **Subtotal** | **52** | **50** | **58** | **66** | |
**Delta zur Offerte: +6h (Mitte) / +14h (Max)**
**Haupttreiber:** Auswirkungsanalyse (Pos. 3.4) erfordert Multi-Table-Joins und KI-gestützte Bewertung, was über einfache SQL-Abfragen hinausgeht.
---
### Position 4: Use Case Materialmanagement 2 (KPIs)
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|---|---|:-:|:-:|:-:|:-:|---|
| 4.1 | ERP-Daten erweitern | 16 | 16 | 19 | 22 | Lagerjournal, Preishistorie. Aggregierte Views. |
| 4.2 | System-Prompt KPI-Analyse | 8 | 6 | 8 | 10 | Prompt-Engineering. |
| 4.3 | Liefertermintreue-Analyse | 10 | 10 | 12 | 14 | Zeitreihen, Lieferantenvergleich, komplexe SQL. |
| 4.4 | Preisentwicklungs-Analyse | 10 | 10 | 11 | 12 | Preishistorie, Abweichungsberechnung. |
| 4.5 | Automatisierte Insights | 8 | 10 | 12 | 14 | Schwellenwert-Warnungen, proaktive Erkennung. Neues Konzept. |
| 4.6 | Abklärungen & Testing | 8 | 8 | 8 | 8 | Standard. |
| | **Subtotal** | **60** | **60** | **70** | **80** | |
**Delta zur Offerte: +10h (Mitte) / +20h (Max)**
**Haupttreiber:** Automatisierte Insights (Pos. 4.5) erfordern eine neue Logikschicht, die proaktiv Schwellenwerte überwacht und Empfehlungen generiert. Das ist im aktuellen Chat-Flow nicht vorgesehen.
---
### Position 5: Use Case Wiki-Anbindung
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|---|---|:-:|:-:|:-:|:-:|---|
| 5.1 | Wiki-Anbindung & Indexierung | 16 | 16 | 20 | 24 | KnowledgeService existiert. Wiki-Zugang UNBEKANNT. |
| 5.2 | RAG-Integration im Chatbot | 12 | 12 | 14 | 16 | Pattern existiert (AgentService), muss portiert werden. |
| 5.3 | Inkrementelle Aktualisierung | 8 | 8 | 11 | 14 | Delta-Sync stark Wiki-abhängig. |
| 5.4 | Abklärungen & Testing | 8 | 8 | 9 | 10 | Relevanz-Tuning ist iterativ. |
| | **Subtotal** | **44** | **44** | **54** | **64** | |
**Delta zur Offerte: +10h (Mitte) / +20h (Max)**
**Haupttreiber:** Wiki-System ist unbekannt. Bei Wiki mit guter API (Confluence, SharePoint) sind 44h erreichbar. Bei proprietärem System ohne API steigt der Aufwand erheblich.
**Synergie:** KnowledgeService mit pgvector, Chunking, Embedding und semanticSearch ist bereits produktiv. Die RAG-Pipeline (Ingestion → Embedding → Retrieval) muss nicht neu gebaut werden. Das spart geschätzt 20-30h gegenüber einer Neuentwicklung.
---
### Position 6: Azure-Migration
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|---|---|:-:|:-:|:-:|:-:|---|
| 6.1 | Migration Preprocessor | 6 | 4 | 6 | 8 | Config-Änderungen, Env-Files, Netzwerk. |
| 6.2 | Validierung & Smoke-Tests | 4 | 4 | 4 | 4 | End-to-End-Tests. |
| | **Subtotal** | **10** | **8** | **10** | **12** | |
**Delta zur Offerte: 0h (Mitte)**
**Bewertung:** Realistisch. Einfachste Position.
---
### Position 7: Projektmanagement
| # | Anforderung | Offerte | Min | Mitte | Max | Begründung |
|---|---|:-:|:-:|:-:|:-:|---|
| 7.1 | Kick-off & Workshop | 4 | 4 | 4 | 4 | Standard. |
| 7.2 | Projektmanagement | 8 | 10 | 12 | 14 | 10-14 Wochen, 3 Ansprechpartner, 7 Positionen. |
| 7.3 | Deployment & Go-Live | 6 | 6 | 7 | 8 | Staging + Prod + erste Betriebswoche. |
| | **Subtotal** | **18** | **20** | **23** | **26** | |
**Delta zur Offerte: +5h (Mitte) / +8h (Max)**
**Haupttreiber:** PM-Aufwand bei 3-Monats-Projekt mit mehreren Stakeholdern ist erfahrungsgemäss höher.
---
## 3. Gesamtübersicht
| Pos. | Beschreibung | Offerte (h) | Min (h) | Mitte (h) | Max (h) | Offerte CHF | Mitte CHF |
|---|---|:-:|:-:|:-:|:-:|:-:|:-:|
| 1 | Basics | 54 | 53 | 61 | 68 | 8'100 | 9'150 |
| 2 | Kalktool | 92 | 88 | 102 | 116 | 13'800 | 15'300 |
| 3 | Materialmanagement 1 | 52 | 50 | 58 | 66 | 7'800 | 8'700 |
| 4 | Materialmanagement 2 | 60 | 60 | 70 | 80 | 9'000 | 10'500 |
| 5 | Wiki-Anbindung | 44 | 44 | 54 | 64 | 6'600 | 8'100 |
| 6 | Azure-Migration | 10 | 8 | 10 | 12 | 1'500 | 1'500 |
| 7 | Projektmanagement | 18 | 20 | 23 | 26 | 2'700 | 3'450 |
| | **Gesamt** | **330** | **323** | **378** | **432** | **49'500** | **56'700** |
### Zusammenfassung
| Szenario | Stunden | CHF (à 150/h) | Differenz zur Offerte |
|---|:-:|:-:|:-:|
| Offerte (Kostendach) | 330 | 49'500 | -- |
| Eigene Schätzung (Minimum) | 323 | 48'450 | -2% |
| **Eigene Schätzung (Mitte)** | **378** | **56'700** | **+15%** |
| Eigene Schätzung (Maximum) | 432 | 64'800 | +31% |
---
## 4. Risikobewertung
### Risikomatrix
| # | Risiko | Wahrscheinlichkeit | Auswirkung | Betroffene Pos. | Möglicher Mehraufwand |
|---|---|:-:|:-:|---|:-:|
| R1 | Matching-Engine komplexer als erwartet | Hoch | Hoch | 2.2 | +10-15h |
| R2 | Wiki-System ohne API | Mittel | Hoch | 5.1, 5.3 | +10-20h |
| R3 | ERP-Datenqualität mangelhaft | Mittel | Mittel | 1.5, 2.2, 3.1, 4.1 | +8-16h |
| R4 | Preprocessor-Erweiterung aufwändiger | Mittel | Mittel | 1.5, 3.1, 4.1 | +8-12h |
| R5 | Frontend-Aufwand unterschätzt | Mittel | Gering | 1.2, 1.4 | +4-8h |
| R6 | KI-Modell-Qualität für SQL-Generierung | Gering | Mittel | 3, 4 | +4-8h |
### Synergien (Aufwandsreduktion durch bestehende Komponenten)
| Synergie | Geschätzte Einsparung | Betroffene Pos. |
|---|:-:|---|
| KnowledgeService/RAG existiert produktiv | 20-30h | Pos. 5 |
| ChatbotDocument-Modell existiert | 4-6h | Pos. 1.4, 2.1 |
| LangGraph modular erweiterbar | 6-10h | Pos. 3, 4, 5 |
| Prompt-Engineering über DB-Config | 2-4h | Pos. 1.1, 3.2, 4.2 |
| Excel-Export-Pattern existiert | 2-4h | Pos. 2.5 |
| **Gesamt Einsparung** | **34-54h** | |
---
## 5. Empfehlungen
### 5.1 Zur Offerte
Die Offerte mit 330h als Kostendach ist **ambitioniert, aber bei idealem Verlauf erreichbar**. Die grössten Risiken liegen in:
- Position 2 (Kalktool): Die Matching-Engine ist die komplexeste Neuentwicklung
- Position 5 (Wiki): Komplett abhängig vom Wiki-System, das noch unklärt ist
**Empfehlung:** Offerte bei 330h als Kostendach belassen, aber intern mit 370-380h planen. Die Differenz (~40-50h) als interne Reserve einkalkulieren.
### 5.2 Priorisierung
1. **Must-Have (Prio 1):** Pos. 1 (Basics) + Pos. 6 (Azure-Migration) -- Voraussetzung für alles
2. **High-Value (Prio 2):** Pos. 2 (Kalktool) -- Höchster Kundennutzen, aber auch höchstes Risiko
3. **Quick-Win (Prio 3):** Pos. 3+4 (Materialmanagement) -- Nutzen vorhandene Architektur
4. **Abhängig (Prio 4):** Pos. 5 (Wiki) -- Erst nach Wiki-Klärung starten
### 5.3 Offene Punkte (vor Projektstart zu klären)
| # | Offener Punkt | Verantwortlich | Kritisch für |
|---|---|---|---|
| O1 | Wiki-System und Zugangsart klären | Althaus (Samuel) | Pos. 5 |
| O2 | ERP-System identifizieren und Datenstrukturen dokumentieren | Althaus (Stefan) | Pos. 1.5, 3.1, 4.1 |
| O3 | Preprocessor-Code-Review für Erweiterbarkeit | PowerOn (Entwicklung) | Pos. 1.5, 3.1, 4.1 |
| O4 | Kalktool-Vorlage erhalten und analysieren | Althaus (Reto) | Pos. 2.5 |
| O5 | Muster-Stücklisten für Matching-Test | Althaus (Reto) | Pos. 2.2 |
| O6 | Azure-Subscription-Details | Althaus | Pos. 6 |
---
## 6. Zeitplan (2 Entwickler)
```
Woche 1-2: Kick-off + Azure-Migration (Pos. 6) + Basics 1.1-1.3
Entwickler A: Azure-Migration + 1.1 (Gesperrte Artikel)
Entwickler B: 1.2 (Chat-Verlauf Frontend) + 1.3 (Lange Antworten)
Woche 2-5: Basics 1.4-1.6 (Grundlage für Use Cases)
Entwickler A: 1.4 (File-Upload Full-Stack)
Entwickler B: 1.5 (Kundenartikelnummern + Preprocessor)
Woche 4-9: Kalktool (Pos. 2) -- längster Block, früh starten
Entwickler A: 2.1-2.2 (Upload + Matching-Engine)
Entwickler B: 2.3-2.5 (Feldergänzung + Export)
Woche 6-9: Materialmanagement 1+2 (Pos. 3+4) -- parallel zum Kalktool
Entwickler B: 3.1-3.4 + 4.1-4.5 (Preprocessor + Prompts)
(Entwickler A bleibt auf Kalktool)
Woche 9-12: Wiki-Anbindung (Pos. 5) -- nach Klärung des Wiki-Systems
Entwickler A: 5.1-5.2 (Connector + RAG-Integration)
Entwickler B: 5.3 (Delta-Sync) + Integrationstests
Woche 12-13: Integrationstests, UAT, Go-Live (Pos. 7.3)
Beide Entwickler: E2E-Tests + Deployment + Monitoring
```
**Gesamtdauer:** 12-14 Wochen
**Kritischer Pfad:** Pos. 1 → Pos. 2 (Kalktool braucht Upload + Kundenartikelnummern)
---
*Dokument erstellt auf Basis der Code-Analyse des Gateway-Repository (Stand 13.04.2026)*

View file

@ -1,143 +0,0 @@
# Fragenkatalog Althaus Bot v2 -- Kick-off-Vorbereitung
**Zweck:** Strukturierte Fragen für den Anforderungsworkshop mit W. Althaus AG
**Erstellt:** 13. April 2026
**Zielgruppe:** Projektleitung PowerOn + Ansprechpartner Althaus (Reto, Stefan, Samuel)
---
## A. Wiki-System (Ansprechpartner: Samuel)
> **Kritisch für:** Position 5 (Wiki-Anbindung) -- Aufwandsschätzung schwankt zwischen 44h und 64h je nach Wiki-System.
### A.1 Wiki-Identifikation
| # | Frage | Hintergrund |
|---|---|---|
| A1.1 | Welches Wiki-System wird eingesetzt? (z.B. Confluence, SharePoint Wiki, MediaWiki, DokuWiki, Notion, anderes) | Bestimmt die Anbindungsstrategie (API vs. Export vs. Crawling) |
| A1.2 | Wo wird das Wiki gehostet? (Cloud-SaaS, On-Premise, Azure) | Netzwerk-Zugang und Firewall-Konfiguration |
| A1.3 | Wie viele Seiten/Artikel enthält das Wiki ungefähr? | Dimensionierung der Erstindexierung und Embedding-Kosten |
| A1.4 | In welchen Formaten liegen die Inhalte vor? (reiner Text, HTML, Markdown, eingebettete PDFs/Bilder) | Bestimmt die Extraktions-Komplexität |
### A.2 Technischer Zugang
| # | Frage | Hintergrund |
|---|---|---|
| A2.1 | Gibt es eine REST-API oder ähnliche Schnittstelle zum Lesen der Wiki-Inhalte? | API-Zugang = deutlich weniger Aufwand als Crawling |
| A2.2 | Gibt es eine Export-Funktion? (z.B. XML-Export, PDF-Export, Datenbank-Dump) | Fallback wenn keine API vorhanden |
| A2.3 | Gibt es Authentifizierung (API-Key, OAuth, LDAP)? Welche Credentials werden benötigt? | Konfiguration des Connectors |
| A2.4 | Gibt es eine Change-API oder Webhooks, die bei Änderungen notifizieren? | Bestimmt den Aufwand für inkrementelle Updates (Pos. 5.3) |
| A2.5 | Gibt es Zugriffsbeschränkungen auf bestimmte Wiki-Bereiche? | RBAC-Überlegungen bei der Indexierung |
### A.3 Inhaltliche Abgrenzung
| # | Frage | Hintergrund |
|---|---|---|
| A3.1 | Soll das gesamte Wiki indexiert werden oder nur bestimmte Bereiche? | Scope-Begrenzung für Erstindexierung |
| A3.2 | Gibt es vertrauliche Inhalte, die nicht in den Chatbot einfliessen dürfen? | Datenschutz-/Compliance-Anforderung |
| A3.3 | Wie oft werden Wiki-Inhalte aktualisiert? (täglich, wöchentlich, selten) | Bestimmt die Sync-Frequenz |
| A3.4 | Welche Sprache(n) haben die Wiki-Inhalte? (Deutsch, Englisch, gemischt) | Embedding-Modell-Auswahl |
---
## B. ERP-System & Datenstrukturen (Ansprechpartner: Stefan)
> **Kritisch für:** Positionen 1.5, 2.2-2.3, 3.1, 4.1 -- Preprocessor-Erweiterungen und Matching-Engine.
### B.1 ERP-Identifikation
| # | Frage | Hintergrund |
|---|---|---|
| B1.1 | Welches ERP-System wird eingesetzt? (z.B. Abacus, SAP, Microsoft Dynamics, bexio, Sage) | Bestimmt Datenstruktur und Zugriffsmöglichkeiten |
| B1.2 | Wie werden die Daten aktuell an den Preprocessor geliefert? (direkter DB-Zugriff, API, Export-Datei) | Verständnis der bestehenden Datenpipeline |
| B1.3 | In welchem Rhythmus werden die Daten aktualisiert? (Echtzeit, täglich, wöchentlich) | Aktualität der Chatbot-Antworten |
### B.2 Kundenartikelnummern (Position 1.5)
| # | Frage | Hintergrund |
|---|---|---|
| B2.1 | Gibt es im ERP eine dedizierte Tabelle für Kundenartikelnummern? Wenn ja, wie heisst sie? | Preprocessor-Schema-Erweiterung |
| B2.2 | Wie ist die Zuordnung: 1 Kundenartikel → 1 ERP-Artikel, oder n:m? | Bestimmt die Mapping-Komplexität |
| B2.3 | Wie viele Kundenartikelnummern gibt es ungefähr? | Dimensionierung |
| B2.4 | Welche Felder hat die Kundenartikelnummern-Tabelle? (z.B. KundenNr, KundenArtikelNr, InterneArtikelNr, Bezeichnung) | Schema-Definition für Preprocessor |
### B.3 Bestellwesen & Materialmanagement (Positionen 3 + 4)
| # | Frage | Hintergrund |
|---|---|---|
| B3.1 | Welche ERP-Tabellen/Views gibt es für Bestellungen? (Bestellkopf, Bestellpositionen, Status) | Preprocessor-Erweiterung Pos. 3.1 |
| B3.2 | Gibt es eine Tabelle für Wareneingänge mit Datum und Menge? | Liefertermin-Treue-Berechnung Pos. 4.3 |
| B3.3 | Gibt es eine Preishistorie-Tabelle? Welche Felder enthält sie? (Datum, Preis, Lieferant, Währung) | Preisentwicklungs-Analyse Pos. 4.4 |
| B3.4 | Gibt es ein Lagerjournal mit Buchungsdaten? | KPI-Analyse Pos. 4.1 |
| B3.5 | Gibt es eine Bestandesbedarfsliste oder Dispositions-View? | Material-Analyse Pos. 3.4 |
| B3.6 | Gibt es Felder für "bestätigter Liefertermin" vs. "gewünschter Liefertermin"? | Termintreue-KPI Pos. 4.3 |
| B3.7 | Wie viele offene Bestellungen gibt es typischerweise gleichzeitig? | Performance-Dimensionierung |
### B.4 Datenqualität
| # | Frage | Hintergrund |
|---|---|---|
| B4.1 | Wie konsistent sind Lieferanten-Namen im ERP? (exakt gleich oder Varianten wie "Siemens AG" vs. "Siemens") | Matching-Qualität Pos. 2.2 |
| B4.2 | Gibt es Pflichtfelder die häufig leer sind? | Feldergänzungs-Logik Pos. 2.3 |
| B4.3 | Wie sind Preise gespeichert? (Netto, Brutto, mit/ohne MwSt., Währung) | SQL-Query-Generierung |
| B4.4 | Werden gelöschte/gesperrte Datensätze physisch oder nur logisch gelöscht? | Filter-Logik Pos. 1.1 |
---
## C. Kalktool (Ansprechpartner: Reto)
> **Kritisch für:** Position 2 (Kalktool) -- Höchstes Risiko in der Offerte.
### C.1 Kalktool-Vorlage
| # | Frage | Hintergrund |
|---|---|---|
| C1.1 | Können wir die aktuelle Kalktool-Vorlage (Kalktool_Aktuell_2026_V1.4.xlsx) erhalten? | Zielformat für Excel-Export Pos. 2.5 |
| C1.2 | Welche Spalten/Felder sind Pflicht in der Kalktool-Vorlage? | Feldergänzungs-Priorität Pos. 2.3 |
| C1.3 | Gibt es Formeln in der Vorlage, die erhalten bleiben müssen? | Komplexität des Excel-Exports |
| C1.4 | Welches Format haben die Kunden-Stücklisten typischerweise? (PDF, Excel, CSV) | Extraktions-Strategie Pos. 2.1 |
### C.2 Matching-Anforderungen
| # | Frage | Hintergrund |
|---|---|---|
| C2.1 | Können wir 3-5 Muster-Stücklisten von verschiedenen Kunden erhalten? | Testdaten für Matching-Engine Pos. 2.2 |
| C2.2 | Welche Identifikationsmerkmale haben Kunden-Stücklisten? (Kundenartikelnr., Hersteller-Typ, Beschreibung) | Matching-Stufen definieren |
| C2.3 | Wie hoch ist die erwartete Trefferquote beim exakten Match? (10%? 50%? 90%?) | Gewichtung exakt vs. fuzzy vs. KI |
| C2.4 | Welche Felder sollen bei nicht-eindeutigem Match als "Alternative durch KI" markiert werden? | Bestätigungs-Workflow Pos. 2.4 |
| C2.5 | Gibt es Produktgruppen, die besonders schwierig zu matchen sind? | Risikobewertung |
---
## D. Infrastruktur & Azure (Ansprechpartner: Stefan / IT)
| # | Frage | Hintergrund |
|---|---|---|
| D1 | Details zur neuen Azure-Subscription (Subscription-ID, Region, Resource Group) | Pos. 6 -- Migration |
| D2 | Gibt es Netzwerk-Einschränkungen (VPN, Private Endpoints, Firewall)? | Zugang Preprocessor ↔ ERP |
| D3 | Wer hat Admin-Zugang zur neuen Subscription? | Deployment-Planung |
| D4 | Gibt es Budget-Limits auf der Azure-Subscription? | Betriebskosten-Planung |
---
## E. Priorisierung & Vorgehensweise
| # | Frage | Hintergrund |
|---|---|---|
| E1 | Sollen alle 7 Positionen umgesetzt werden, oder gibt es eine Priorisierung? | Scope-Bestätigung |
| E2 | Gibt es einen gewünschten Go-Live-Termin? | Zeitplanung |
| E3 | Wie soll die UAT organisiert werden? (dedizierte Testphase, laufend, Key-User) | Testplanung |
| E4 | Wer sind die Pilot-User für den reaktivierten Bot? | UAT-Teilnehmer |
| E5 | Sollen Schulungen für Endanwender durchgeführt werden? (nicht in Offerte enthalten) | Ggf. Nachtragsofferte |
---
## Nächste Schritte
1. **Vor dem Kick-off:** Fragenkatalog an Althaus senden, damit Antworten vorbereitet werden können
2. **Im Kick-off:** Fragen durchgehen, fehlende Antworten als Action Items festhalten
3. **Nach dem Kick-off:** Aufwandsschätzung anhand der Antworten finalisieren, insbesondere Pos. 2.2 (Matching) und Pos. 5 (Wiki)
---
*PowerOn AG -- Vorbereitung Anforderungsworkshop Althaus Bot v2*

View file

@ -1,223 +0,0 @@
# Preprocessor Assessment -- Althaus Bot v2
**Zweck:** Technische Analyse des Preprocessing-Servers für die Aufwandsschätzung der Erweiterungen
**Erstellt:** 13. April 2026
**Quellen:** Gateway-Code-Analyse (Repo nicht lokal verfügbar: github.com/valueonag/gateway_preprocessing)
---
## 1. Ist-Zustand (abgeleitet aus Gateway-Code)
### 1.1 Infrastruktur
| Eigenschaft | Wert |
|---|---|
| **Host** | Azure App Service (Switzerland North) |
| **URL (Datenverarbeitung)** | `poweron-althaus-preprocess-prod-*.azurewebsites.net/api/v1/dataprocessor/update-db-with-config` |
| **URL (Abfragen)** | `poweron-althaus-preprocess-prod-*.azurewebsites.net/api/v1/dataquery/query` |
| **Authentifizierung** | `X-PP-API-Key` (Abfragen) / `X-DB-API-Key` (Abfragen) |
| **Status** | Deployed, ERP-Datenanbindung deaktiviert |
| **Quellcode** | `github.com/valueonag/gateway_preprocessing` (separates Repo) |
### 1.2 Aktuelle Tabellen-Konfiguration
Aus dem Automation-Template (`subAutomationTemplates.py`) extrahiert:
```json
{
"tables": [
{
"name": "Artikel",
"powerbi_table_name": "Artikel",
"steps": [
{
"keep": {
"columns": [
"I_ID", "Artikelbeschrieb", "Artikelbezeichnung",
"Artikelgruppe", "Artikelkategorie", "Artikelkürzel",
"Artikelnummer", "Einheit", "Gesperrt",
"Keywords", "Lieferant", "Warengruppe"
]
}
},
{
"fillna": {
"column": "Lieferant",
"value": "Unbekannt"
}
}
]
},
{
"name": "Einkaufspreis",
"powerbi_table_name": "Einkaufspreis",
"steps": [
{
"to_numeric": {
"column": "EP_CHF",
"errors": "coerce"
}
},
{
"dropna": {
"subset": ["EP_CHF"]
}
}
]
}
]
}
```
### 1.3 Zusätzliche Tabellen (im Chatbot referenziert, aber nicht in der Config)
Aus den SQL-Beispielen in `bridges/tools.py` und `chatbot.py`:
| Tabelle | Spalten (referenziert im Code) | Joins |
|---|---|---|
| `Lagerplatz_Artikel` | `R_ARTIKEL`, `R_LAGERPLATZ`, `S_IST_BESTAND`, `S_RESERVIERTER__BESTAND` | ON `Artikel.I_ID = Lagerplatz_Artikel.R_ARTIKEL` |
| `Lagerplatz` | `I_ID`, `Lagerplatz` (Name) | ON `Lagerplatz_Artikel.R_LAGERPLATZ = Lagerplatz.I_ID` |
Diese Tabellen sind vermutlich in einer älteren Config-Version oder direkt im Preprocessor konfiguriert.
### 1.4 API-Schnittstellen
**Abfrage-API** (genutzt vom `PreprocessorConnector`):
- Methode: `POST`
- Payload: `{"query": "SELECT ..."}`
- Header: `X-DB-API-Key: <api_key>`
- Response: `{"success": true/false, "data": [...], "row_count": N, "message": "..."}`
- Einschränkung: Nur SELECT-Queries (validiert im Gateway)
**Update-API** (genutzt vom Automation-Template):
- Methode: `POST`
- Payload: `configJson` (Tabellendefinitionen + Transformationsschritte)
- Header: `X-PP-API-Key: <secret>`
- Zweck: Datenbank mit neuer Konfiguration aktualisieren
### 1.5 Transformation-Steps (bekannte Operationen)
Aus der Config-JSON abgeleitet:
| Operation | Parameter | Beschreibung |
|---|---|---|
| `keep` | `columns: [...]` | Nur angegebene Spalten behalten |
| `fillna` | `column`, `value` | NULL-Werte ersetzen |
| `to_numeric` | `column`, `errors` | Spalte in numerischen Typ konvertieren |
| `dropna` | `subset: [...]` | Zeilen mit NULL in angegebenen Spalten entfernen |
---
## 2. Benötigte Erweiterungen (nach Position)
### 2.1 Position 1.5: Kundenartikelnummern
**Neue Tabelle: `Kundenartikelnummer`**
| Spalte (geschätzt) | Typ | Beschreibung |
|---|---|---|
| `I_ID` | INT | Primary Key |
| `R_ARTIKEL` | INT | FK auf Artikel.I_ID |
| `Kundenummer` | VARCHAR | Kundennummer |
| `Kundenartikelnummer` | VARCHAR | Kunden-eigene Artikelnummer |
| `Bezeichnung` | VARCHAR | Kundenbezeichnung (optional) |
**Config-Erweiterung:**
```json
{
"name": "Kundenartikelnummer",
"powerbi_table_name": "Kundenartikelnummer",
"steps": [
{"keep": {"columns": ["I_ID", "R_ARTIKEL", "Kundenummer", "Kundenartikelnummer", "Bezeichnung"]}}
]
}
```
**Aufwand-Bewertung:** Falls der Preprocessor neue Tabellen per Config akzeptiert: ~2-3h Config + Test. Falls neuer Code nötig: ~6-8h.
### 2.2 Position 3.1: Bestellwesen (Materialmanagement 1)
**Neue Tabellen (geschätzt 3-4 Tabellen):**
| Tabelle | Wichtige Spalten | Zweck |
|---|---|---|
| `Bestellkopf` | ID, Bestellnummer, Lieferant, Bestelldatum, Status, Wunschtermin | Bestellübersicht |
| `Bestellposition` | ID, R_Bestellung, R_Artikel, Menge, Preis, Status, Bestätigter_Termin | Positionsdetails |
| `Wareneingang` | ID, R_Bestellung, R_Position, Eingangsdatum, Menge, Qualität | Lieferverfolgung |
| `Auftrag` | ID, Auftragsnummer, Kunde, R_Artikel, Menge, Termin | Betroffene Aufträge |
**Aufwand-Bewertung:** 4 Tabellen × ~4h pro Tabelle (Config + Code + Transformationen + Test) = ~16h. Bei komplexen Transformationen (Joins, Aggregationen): +4-6h.
### 2.3 Position 4.1: KPI-Daten (Materialmanagement 2)
**Neue Tabellen/Views (geschätzt 3-4):**
| Tabelle/View | Wichtige Spalten | Zweck |
|---|---|---|
| `Lagerjournal` | ID, R_Artikel, Buchungsdatum, Menge, Typ | Lagerbewegungen |
| `Preishistorie` | ID, R_Artikel, R_Lieferant, Datum, Preis, Währung | Preisentwicklung |
| `Bestandesbedarfsliste` | R_Artikel, Bedarf, Bestand, Fehlmenge, Datum | Dispositionsplanung |
| `View_Termintreue` | R_Lieferant, Wunschtermin, Bestätigt, Geliefert, Abweichung_Tage | Aggregierte KPIs |
**Aufwand-Bewertung:** 4 Tabellen/Views × ~4h = ~16h. Aggregierte Views (Termintreue): +4-6h für Berechnungslogik im Preprocessor.
---
## 3. Gesamtbewertung Preprocessor-Erweiterungen
### 3.1 Zusammenfassung
| Position | Neue Tabellen | Config-Aufwand | Code-Aufwand | Test | Gesamt |
|---|:-:|:-:|:-:|:-:|:-:|
| 1.5 (Kundenartikelnummern) | 1 | 1h | 3-5h | 2h | **6-8h** |
| 3.1 (Bestellwesen) | 3-4 | 2h | 8-12h | 4h | **14-18h** |
| 4.1 (KPIs) | 3-4 | 2h | 8-12h | 4h | **14-18h** |
| **Gesamt** | **7-9** | **5h** | **19-29h** | **10h** | **34-44h** |
### 3.2 Offene Fragen (Code-Review des Preprocessor-Repos erforderlich)
| # | Frage | Auswirkung |
|---|---|---|
| P1 | Unterstützt der Preprocessor neue Tabellen per Config-Erweiterung, oder muss für jede Tabelle Code geschrieben werden? | Bestimmt ob Config-only (~2h/Tabelle) oder Code (~4h/Tabelle) |
| P2 | Können aggregierte Views/Berechnungen im Preprocessor definiert werden? | Termintreue-KPI, Bestandsreichweite |
| P3 | Wie werden Joins zwischen Tabellen gehandhabt? (SQLite-seitig oder Preprocessor-seitig) | Komplexität der Cross-Table-Queries |
| P4 | Gibt es Rate-Limits oder Grössen-Limits bei der Query-API? | Performance bei komplexen KPI-Abfragen |
| P5 | Wie gross ist die aktuelle SQLite-Datenbank? Wie viele Artikel? | Dimensionierung für 8-10 neue Tabellen |
### 3.3 Empfehlung
**Vor Projektstart sollte ein Code-Review des Preprocessor-Repos durchgeführt werden** (geschätzter Aufwand: 2-4h). Dabei klären:
1. Erweiterbarkeit: Kann der Preprocessor neue Tabellen per Config akzeptieren?
2. Transformationen: Welche Operationen sind neben `keep`, `fillna`, `to_numeric`, `dropna` verfügbar?
3. Performance: Wie skaliert die SQLite-DB mit 8-10 zusätzlichen Tabellen?
4. Deployment: Wie wird der Preprocessor deployed? (CI/CD, manuell, Azure DevOps)
Das Ergebnis dieses Reviews kann die Aufwandsschätzung für Pos. 1.5, 3.1 und 4.1 um jeweils 4-6h nach oben oder unten korrigieren.
---
## 4. Aktueller Datenfluss (zur Referenz)
```
ERP (Althaus)
▼ (Power BI Export / API / DB-Zugriff -- Mechanismus unklar)
Preprocessor Server (Azure)
├── /api/v1/dataprocessor/update-db-with-config ← Automation-Template
│ (Tabellen laden, transformieren, in SQLite schreiben)
└── /api/v1/dataquery/query ← PreprocessorConnector (Gateway)
(SQL SELECT auf SQLite ausführen)
Gateway (Chatbot LangGraph)
React Frontend (Chat-UI)
```
---
*Assessment erstellt auf Basis der Gateway-Code-Analyse. Für eine genauere Schätzung ist ein Code-Review des Preprocessor-Repos erforderlich.*

97
env-dev.env Normal file
View file

@ -0,0 +1,97 @@
# Development Environment Configuration
# System Configuration
APP_ENV_TYPE = dev
APP_ENV_LABEL = Development Instance Patrick
APP_API_URL = http://localhost:8000
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron-swiss/local/notes/key.txt
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
# PostgreSQL DB Host
DB_HOST=localhost
DB_USER=poweron_dev
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
APP_TOKEN_EXPIRY=300
# MFA Configuration
MFA_REQUIRE_ADMINS = False
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron-swiss/local/logs
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# 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 = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kxaG9WY1FJaWdCbVFVaTllUlJfU3Y3MmJkRmkzMDVDWUNtZEhlNVhISzJPcy00ZUVZcklYLXFMV0dIODV3NXNSSFBKQ0ZsZllES3diTEgySDF0T1ZCbFZHREZtcXFGSWNZN1NJbzJzczRRQWxoeVNsNzlsa0VzMHJPWHUydjBBclo=
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyUW96aXFVOVJlLUdyRlVvT1hVU09ILWtMZnV2M19mVUxGMnFPV3FzNTdQa3dTbHVGTDBHTk01ZThLcjh6QUR5VldVZUpfcDlZNTh5YldtLWtjTll6VzJNQ3JCQ3ZubHdmd2JvaExDOXdvQ1pjWDVQTUtFWVAtUHhwS1lFQnJXWk4=
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyd1hPd09vcVFtbVg0Sm5Nd1VYVEEtWjZMZkFndmFVS0ZlcTU0dzJnYVYzRkZWbjh0QldyZkhseDV2cUgxYkNHTzF6MXhqQlZ2N0UtbmhPeWRKUHBVdzV0Q1ROaWNuN2xjMmVzMjNZQ2ZYZ3dOTHgxaU5sTGRjVHpfakhYeWF0ZGU=
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kySXoyd1BmTnhOd1owTUJOWm53WlZMMjFHNGJhSUwyd2NDUW9BanlRWVJPLU5jYzRlcm5QeW96d0JYUkVWVWd2dGNBVEpJbElZY2lWb0o5S0gyNnhoV1pnNXhpSFEyaklZZjcwX2lVU0ktMEJGN01DMDhXQ3k4R1BXc1Q3ejFjOEg=
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
# AI configuration
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnFGdnVHZlpWWWVaV1dERUItVjRfNWFMRXZUVjY5Ulp1ZXkyMmVZWUJPNzJ5anRucGNlOUFYSVNzZ1FaWlZLemVNbk5pbDgwOEZxbkxxM3U3UU1EdDJzczBaYmRDbld1N0hHdjROQUFmMUJmbDRMS1JWc3c4ZTFPMHY3OWVublBsUjFxNjhDSGRCaE9PR1JUN29iQjRqVFRINHB5dnJXeGxiQ2FTdnNnN283b3o1MnV3X09uc1pXeXZUclNTbjN4YWZyb2tGVmtGVmRnQmNlczI0WlZKRGZSYWgycnB0R2RfR1Fvbkt5bXN1UVBwS202SkZyRmJGTEU3MkxpcTk2d0QtOGxRckNLaTFLRnJBYlVSZDAydlpjMDVqYmktdHhuV3FLa2xrYkh4cGc3S3FGcnM9
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQnFGd0hadGRhRFBOaXJSN2E1UnpLcHlkUHhoQS1FWFJBQlJ5cGsxcHNNMDlRM1JVbEJkWWE1ZXQzTThFQkRmQTBOeVNvUERXTVRTQ3Y4ekt5NU1XR3E2cWw4UlFNUXlGSmZmZU1JZXlwT3lUVGw0aWF4V1d6cVR6LTFsbGRjWWx5dVlodWNEUFJkZ0tUa3hSbjk3WjZ1Z3RPczZMYzA5QTlMbkVudFNVcG1xaTJuM3g3dDdSSFczbWJnODQ1S1J2djBnS3lQc2NFd0ttUThRVnFma3NOVlFIZm1ZZz09
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlNV9felVPcHVyMU9kVGhGZEt0MG9iRzRrTVM4TFJvSHhGOVo0U1ROWkdEMzRSWjhtMnFrZUhHTHNXelpLZ014RzRkMlIxZDJwcjEwc1dRamY5ekJMR1VLb2w4eEZqZENBRnFaZlRhb1h5VE05Tml1ZlVBWHBaTkJaZUE5NWprVklva0ZFZnB4cFFudGdkalpmTlBhdV9nPT0=
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlY1R2WGpuazk5M05SeDIyLWd3bHpKN3lUdlVFdjhvZEJXdlM4bGlBdTB1TjRia051YllDQ2lwM0V3R3dPd2lKVWxoSm9BNWl1ZFFlVkZ5cXh4TFRVU0Z4NVU5WVRjSUJPc01La3JyaVZSNkhYWU9PR00yMENEb0dRT3l5enEwSFlWZVVzTVR0UWQ4eUxvRmZvWHl0c0xRPT0=
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlelh2T2hqNGcxV0hMV1FKbmFDZjVHUWF6T2FXbGlCSnQzSzNXLWJHeXBFWE1nUlh1b1NHY1JRSEVtTVEtc1MtUnZrX2ZCcURqQ2FYNmFWa2xudGJtS3g2eVo4MFZMd09nZTBNMmo1ZHU0bzBJdFRqLVhHSVZNb2Zrc0VkUXI0SVk=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQnFIc3YtU0x4LTlHbTY1NUVGY2V2bUdmck85dDh1ZWVKa2ktR0N6NjdlTGFrUHMybVQ2bVRLN01XNFRZR2lyN0ZNSHhzWVVGNnVtZjRjV2hhR0ViTDYwT25lSmxJY0pSTkl3OUEyT0JxMFVYRndfUFJudExMajdTYUNXS01JU2lhQzZmNWFYdXA4aVZ5Zkh4Zko1Z00tcEE5ZFEwQkFVa1oyR296YXozRFI2WUdXN0ZSREFFclFNaTd6OUVlSmFxS1BTSlNJbnlWNHNfbkk4QzVOUGlkMzdfQUZxUlJOVEZzUlN1aWRWY01JZmlRM0JNZE1EZ3BmbW10c3BDdERpa2FMakstQUlqVEVlRC1hUmZoeFVoQ3pYNXRlRFVSTlI3ekJrU0QwSHBSaWxiSGU0akFGMXUtY2Q0RnUzS0tPOEQtcTdVdWhQeHFDM1hRRVVMcUxCeklvWHNWRUN2bjVHZUUwLTVtaGpUbWdPUnJabWlIcHZ5UjNtN0NMTUNRN29ZRGVXU28xQmhJTVg2eEZnaUdrcW9UVklHMHJycm1nT0JkdGJReVVHeV8tYm12UDlOU0lpNHFidXBQbUFSSVVmWUl1M1BVMFFncm0xSldkVzBrb2poRFMyaVUwcUZvMHl0QlZIZ1h1MjZwR3AtZWhqdzN4UVhtT2hUa1lQU3VudzNXdW1FcVY3VnQ3RmpkQnFQemlrQlF3WGhBNWxOZXJ6Zm9KVFlEZExUXzlqODhYaFNNMzVWTzFNMmVTcWdodDZoRmZTUzlhLVlOSU5fYW1vNXctaFpFMC1pUllRZW11d1JQN25sbldHVjI1anc2UC1ycndjTGtxWk55WmpJeU1wOVR0RnlTdFpad1dkRmlUNDE0d240TDlKc3JFUXdOYzd5UTFYSXUzLTQ2Y1ZGcWE3R2RyQ0I1WDMtMHBScEFzZDV4UEkyanh4ckJZUjdTYnJGZjAxQkU3MEJ6OXdybGRaWHNod1hZZEhVOXRpMWRLbVJsRGd0UDRDN3JsRzF4T0RpcnczRU5TM0RKVjVkWTRqNTl6bmhQdmdvaEg1U2kya0QtQ0l4ZHVUcGxkNi1vNVVVOEcyWXhxZWc5N1lKMk4tT0o3ZFVzYjJtT3NVZFJiSTFNUnpaSmFOeDZaLWVpZlc0VUhZRHdXOUMyQ3cwaXBQUDRJN1g1YkwzaTFiRVRxRFY5UTdZU1dSaGR6NUw3aEtac2RENXF3WEpVN0dXVTlQR0F6MFlpWl83MU44NVR1ZUtPVUNlZ205YUIwOFoxUDBvTlI0SU52emVvQ3VZXy1jTlFXRWZXQ0d5RHJ0eV9JeE5wMHl0b3FVSjNoVzg2d21hYVNYY3Q0dkFaVEZwa09tRnFBbEtoOUlGY2xkeVJoZGYzQUxYNFZfb0ZiaU5VRjJPbGhieXYtWTFKckZwenVCUGFva1IwVVFORVQ4SDMxWHVuRWhBRGd0cVlsc3kyQ0RyY2ZIVDlwcGh5ampySV9uOVpsVmlWbGoxMEg3SXh6NzRJbmZXRlhMMWc0RXhzeWtnQlJ0VnZSdENkbEpOdENwUzItUjZhZWFYRFhzbDM1WDBxaGFPX19CSG1KZjRTTU5JemcxZzJRSFY5bkx4TTlIZFNHOW1USWxBYWhEZ1FSNVdSSDJETUZwMi1Hd0RESkF2cVA1TVJGTEtPUl9oN3gzVEIwSzZOVzlOWXhNa2I1Vzc1SV9tdENfRy1rQTNzRlZGSTYwQmJIaGswZUNWSnRDVXFfdWFCckZZcnJOT2Rfb3FrcWI4S1lVRTMyRnZJQTRZV1VsU0xobGRjekhtbG9LamR2d1hfVklsM3JBeW9SRzJnWVdiWDRzN1ltcXdSVGoxRVBvczViVXNjMUxBazZUdS1WbkRQX0h1MzdNd3ltVDUzd2FGdi1XeUMybV9ia1YxQVBPdnUxY1dfT2M5eEpZR2JHMkdZbWdDZTRERXRYOWxodndkTXltVW40c0t0bVA5YWxuRzM3LWlCdmJiYmF5dkNBY3ozbUw1Zm5zRmpBdk5ORmFZRWJKM3Q2UDdKNl9zaUV5eVVGbkF0QmZSZzk5dGo3UjNIQWxwcjRlVTdUT2s1VGFjdndvX2c3d1VmaHRMZU10M1ZKVk9Ma3dZb1kwYVV5Z2NlTjUxdUYtZXRnRTRzQlp1aFp0OUF5TVBwN1gzU21kRmJ6OUlOeUFOOEhEOU5WSENNZndvLXdoVUFJYVFDTWEyakJEcTVSVDhJOWJscU8taThqNUZkdThCOUlXcldndFBTZk9QVnlMaUphUU5sUktpb1plZDZOQnFzNFNMUzRWbWFVQWhUWmJfem96X0cxWXVTcUxCeDhOc3E2OEpFa2lzWHFIV0p3eGdBZmN1aXBhYjExZTZqaUY4S0ZudTNhcUx2WlpuTU9lNUk2ZmNyN0JCODdYMGNEU2JsZkZXYlRFaTJQUTI5RU5SMmtkV1NHQTVTTjEyZGZLYnhTNTg2Nl9aaWJqX2Q1U1NwQ3pRTGRBSUw0N3FNQ0ItMks1QVZmbURYVWdHMWFZTWhGNURVOUg0bGVuMUozanlxTnRwbVlGX2RnN2FBVTZlZjhDaXVzZEtVR1Z5azhzWHRrS1dYSG9rYkowTjQ1N0hyRWdNVWMya1ZmWmZvSnVTdHNiMHFDODNLckpjQ081SFlieGxuM0picGhKMnNQRURwY2hpQzF3dHRnNEFWcUlPYjVxZEhod0JDbWZhU01Ob21UWmRwd0NQRlpjOE5CUFBOT004U2JKNkFSUlFzRklYZGJobUoxQzZzT2wzZ3J1Z05aYThRVVNzcFktMGJDcXFfSkxVS2hhajI3dTdrR2poa21ZM3Z4UzFRblFsOFlOZVVUM0YxaFRuNjFWQ2E4ZlhvZjZpMWFtOGRuaGx0MTZxZE9TY1dsTTMyMHhsNXJ2MkduaGRkZXpYUWJ3cEt1U3YwMC1IRzM5eWRCb0lvaUhTQ2R4XzhEZl9zRk5GeHhCSWx2X3BkUkJ4NFZLVzdVRFZkbnpNNkpjUTFHY1pDV0ZOMFBaNTVpLUlmSnFrX1N5X05MTjRUeTVERUs5MG9kMFJ3di03U3BpMUM4YXNwaG1fangwYURIVjBpSVdCUkt4UW5HbWtGOUh3TUdPZjMxYXpVZDcwTmlDcTR6WldZb3VzbHRpRUgyN2lFTjlpUV85T0M4blJxMWx0cC1iU0FDOHhueDBLYjdLZGhNbjFPbE1RdmhhNlEzX3ZpT2ZsYllwNkU5TE9fZWFabDE4RWRoRWxiMk5aVFZrWmxjaW5MX1VrUGhUN29vbU1tWldESnczYTNBQ1RPd1VTNGNJdjdJU3p3QXZQLVlDNkQ1cTh4Rk1WNnRMUi1DT3VGREFPa28xejc2NUl1dzJSa2hCTlJublBRNGkydlJVRjlFbFotOWtraWFqQkNNTXBpT1hZM0NXNEpObGMxQUNuS29rOExMSnMxT3NLbjNfLTdpQW1BcDMxR1RZdVRvbElGbENWbHJqRlVrTXhYbFdiMmItUzlxR2ZxT2FCWXpMVVJYZXBfSFVwNTczU3JHUVhET3hSWm80Ry1KcE9mV3FYejVHSEVSS0pxOUtCc3V2VHNFVkRqYk5Od20tM0ttdFQ1eGdsc091WGFYNFgybzNVd3ZvbzEwUDJ0T0hvTVd3YnlHNnpNWC0wbkJOQTIwQ3VYdlUzaXY5NFhDNlNOOW9UdGZNUk4zZ0VJakpwS21SZlJtQjVWLUxfejFYZFc1cjRwR3ZUOGdZb2VJaTdJUS1MYlRJb0ZFYW9uYzM3MDd4b09BR1pnTEh3RFpnaGhxZURQamllNUhqTHg0cHJfN08wMkdGSVQwQUlqWDhLVGViY3J5NlVFTzY3RGhGQ0R6aXNsb2w4dnBVYndTd1Jhd3IwS1BxY0h1X05RcGsySzVNbXR5YlBVQi1IOGFUNkh5QjhRZk5BQmZvcGF6ZTNXenZkdy1GRjFGdE1saGdMSnotUkIyX1VqTlZFWnJER1YyNGQtMFZHU3hmRVNPUWFCdXV3QUxzOGVSbF9EdEZGUFNxbTdiYm5oWHdYak5qa3Zoem5WY1ZUdDREVUxGX0VQeS1jckhqS2lRLXQ1Y2tyOFRjYnVhajNUZmZOUE9kbU9PYXdqdk5DYUtEOVFiMW9yZTYxMFNUaDdvUTExUFZ1bklYSkRKTnJ1RURvOTR3ODREcWdWeHpRS2RETjZqeXpvbUpxMW5lWl84RzVocmJFQ3JfZlpMd3RCZEo5RWZ0MzIxNWV6bHlwdWJJWXhoaWxlM2FHSjBhWG14Sk94ZV96cXFvU1JwWDdKZldmZWdvdWVKdXVfaS1jZjdENXQzSzNyb1d3eWhUMU53QzgxemRiTTlkdFRxZU1OdEN5c1kxOEd2MTJMcnBJWEE0eXdJdFpOYVNMQTNLR292UFlGb0Ztdz0=
# Teamsbot Browser Bot Service
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
# The bot will connect back to localhost:8000 via WebSocket
TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron-swiss/local/debug
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron-swiss/local/debug/sync
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a

View file

@ -1,97 +0,0 @@
# Development Environment Configuration
# System Configuration
APP_ENV_TYPE = dev
APP_ENV_LABEL = Development Instance Patrick
APP_API_URL = http://localhost:8000
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
# PostgreSQL DB Host
DB_HOST=localhost
DB_USER=poweron_dev
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron/local/logs
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# 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 = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kxaG9WY1FJaWdCbVFVaTllUlJfU3Y3MmJkRmkzMDVDWUNtZEhlNVhISzJPcy00ZUVZcklYLXFMV0dIODV3NXNSSFBKQ0ZsZllES3diTEgySDF0T1ZCbFZHREZtcXFGSWNZN1NJbzJzczRRQWxoeVNsNzlsa0VzMHJPWHUydjBBclo=
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyUW96aXFVOVJlLUdyRlVvT1hVU09ILWtMZnV2M19mVUxGMnFPV3FzNTdQa3dTbHVGTDBHTk01ZThLcjh6QUR5VldVZUpfcDlZNTh5YldtLWtjTll6VzJNQ3JCQ3ZubHdmd2JvaExDOXdvQ1pjWDVQTUtFWVAtUHhwS1lFQnJXWk4=
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyd1hPd09vcVFtbVg0Sm5Nd1VYVEEtWjZMZkFndmFVS0ZlcTU0dzJnYVYzRkZWbjh0QldyZkhseDV2cUgxYkNHTzF6MXhqQlZ2N0UtbmhPeWRKUHBVdzV0Q1ROaWNuN2xjMmVzMjNZQ2ZYZ3dOTHgxaU5sTGRjVHpfakhYeWF0ZGU=
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kySXoyd1BmTnhOd1owTUJOWm53WlZMMjFHNGJhSUwyd2NDUW9BanlRWVJPLU5jYzRlcm5QeW96d0JYUkVWVWd2dGNBVEpJbElZY2lWb0o5S0gyNnhoV1pnNXhpSFEyaklZZjcwX2lVU0ktMEJGN01DMDhXQ3k4R1BXc1Q3ejFjOEg=
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
# AI configuration
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5ZmdDZ3hrSElrMnQzNFAtel9wX191VjVzN2g1LWZoa0V1YklubEdmMEJDdEZiR1RWeVZrM3V3enBHX3p6WUtTS0kwYkFyVEF0Nm8zX05CelVQcFJUc0lwVW5iNFczc1p1WWJ2WFBmd0lpLUxxWndEeUh0b2hGUHVpN19vb19nMTBnV1A1VmNpWERVX05lQ29VS20wTjZ3PT0=
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFREd0pyQXRKb1F5eGtHSnkyOGZiUnlBOFc0b3Vzcndrc3ViRm1nMDJIOEZKYWxqdWNkZGh5N0Z4R0JlQmxXSG5pVnJUR2VYckZhMWNMZ1FNeXJ3enJLVlpiblhOZTNleUg3ZzZyUzRZanFSeDlVMkI=
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0=
# Teamsbot Browser Bot Service
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
# The bot will connect back to localhost:8000 via WebSocket
TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron/local/debug
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a

View file

@ -1,92 +0,0 @@
# Integration Environment Configuration
# System Configuration
APP_ENV_TYPE = int
APP_ENV_LABEL = Integration Instance
APP_API_URL = https://gateway-int.poweron.swiss
# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https://
APP_COOKIE_SECURE = true
APP_KEY_SYSVAR = CONFIG_KEY
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
# PostgreSQL DB Host
DB_HOST=gateway-int-server.postgres.database.azure.com
DB_USER=heeshkdlby
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kydlVubld1d1h6SUNSWW1aZ3p4X3Zod1NDTjhZVnVYS2lqOERGTFp2OXJ4TGRiNlRLVFpzLUVDTUhkZGhGUWdxa1djdEV5UWkyblN1UHZoaFBjaExNTEpGMG1PRGJEbDdHVll0Ungwcl9JemZ4ZXFzZUNFQmFlZi1DZFlCekU1S3E=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyV1FRVjF0c0d3d0dyWU1TdW9HdXVkdHdsVWZKYTJjbGZPRDhMRjA2M0FkaUZIVmhIUmFKNjg2ekFodHd6NG80VTI3TC1icW1LZ01jWVZuQ1pKRm5nMW5UREJEaGp2Wl9oRDRCSmZVT0JpTnkwXzgwY0pkV29yczQ5akF2d1ZGcVY=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI=
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
# AI configuration
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnB5dkd6UkhtU3lhYmZMSlo0bklQZ2s3UTFBSkprZTNwWkg5Q2lVa0wtenhxWXpva21xVDVMRjdKSmhpTmxWS05IUTRoRHdCbktSRVVjcVFnY1RfV0N2S2dyV0dTMlhxQlRFVm41RkFTWVQzQThuVkZwdlNuVC05QlVRVXB6Qjk3akNpYmY1MFR6R1ByMzlIMllRZlRRYVVRN2ZBPT0=
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1cDZoOWEzSktneHZEV2JndTNmWlNSMV9KbFNIZmQzeVlrNE5qUEIwcUlBSGM1a0hOZ3J6djIyOVhnZzI3M1dIUkdicl9FVXF3RGktMmlEYmhnaHJfWTdGUkktSXVUSGdQMC1vSEV6VE8zR2F1SVk=
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkTUNsWm4wX0p6eXFDZmJ4dFdHNEs1MV9MUzdrb3RzeC1jVWVYZ0REWHRyZkFiaGZLcUQtTXFBZzZkNzRmQ0gxbEhGbUNlVVFfR1JEQTc0aldkZkgyWnBOcjdlUlZxR0tDTEdKRExULXAyUEtsVmNTMkRKU1BJNnFiM0hlMXo4YndMcHlRMExtZDQ3Zm9vNFhMcEZCcHpBPT0=
# Teamsbot Browser Bot Service
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss

View file

@ -1,91 +0,0 @@
# Production Environment Configuration
# System Configuration
APP_ENV_TYPE = prod
APP_ENV_LABEL = Production Instance Forgejo
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://api.poweron.swiss
# PostgreSQL DB Host
DB_HOST=10.20.0.21
DB_USER=poweron_dev
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnA4UXZiMnRoUzVlbVRLX3JTRl94cVpMaURtMndZVmFBYXdvdnIxLV81dWwxWmhmcUlCMUFZbDhRT2NsQmNqSl9ZMmRWRVN1Y2JqNlVwOXRJY1VBTm1oSjNiaFE9PQ==
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=https://porta.poweron.swiss
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = srv/gateway/shared/logs
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyeUZORDYxOFdlNHk1N25kV3pSQVJMUVFwLUFlMzlzQjQ1eVljOTlzX184RndsTmtTV1FjdWkyQlBiUkdCbGt5S2ltZjJxa2I2dHBMdnJqZnhFSnBCampHYjB3RG5URDM1YzZSLVd6TGdaRXRVcEdadE5zM2thNV9SZy1KZDdLSHY=
Service_MSFT_AUTH_REDIRECT_URI=https://api.poweron.swiss/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySk5uMmlWczBWTE00MHBIcWlBbVJmVmc3MlBWbDA1YTFaS3psZjVLd3d1X2FvRHV0X0c5blpLV0FpY05aMTJMMzUtcG8wakF2TlM3SGQ2VjFZM3JLT1MwTlZ0bm9BRlpkbHVPQTFNaXJvazlQRzN4M2ZZNEVhV1JHV190dWluSUk=
Service_MSFT_DATA_REDIRECT_URI = https://api.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kybjVVZ0FldUE1NTJiY2U1N0I0aVU0Z2hfeWlYc2tTdmlxTS1NdGxsRnFHdjZVcW5RRHZkUFhzUTVyX2RaZHlrQThRdTdCRmVBelBOcDlsbFQyd19SZExuWEM5aTcwQ0FvY3ctMUlWU1pndDE0MkdzeTZZRHkwLWU3aW56LW1jS20=
Service_GOOGLE_AUTH_REDIRECT_URI = https://api.poweron.swiss/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyMnFma3VPOVJtTFFrNDRLN0NkWHY2dUZDWlJzdDVMd3p3N19IY0tWdURRRzExOGZCMjJOYmpKT1E0cTVwYlgtcVJINTY0anZPc1VoTW00cHl6NVh3ZHVTek1oT1RqWUhtamRkZ1dENWlwNTlZSU1oNWczeGdEOC1Gbk5XU2RBcmI=
Service_GOOGLE_DATA_REDIRECT_URI = https://api.poweron.swiss/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
# AI configuration
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
# Teamsbot Browser Bot Service
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss

View file

@ -1,92 +0,0 @@
# Production Environment Configuration
# System Configuration
APP_ENV_TYPE = prod
APP_ENV_LABEL = Production Instance
APP_KEY_SYSVAR = CONFIG_KEY
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://gateway-prod.poweron.swiss
APP_COOKIE_SECURE = true
# PostgreSQL DB Host
DB_HOST=gateway-prod-server.postgres.database.azure.com
DB_USER=gzxxmcrdhn
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySFR2NjBKM084QTNpeUlyUmM4R0N0SU1BZ2x4MmVTZTVHQkVzRE9GdmFkV041MzhudFhobjU0RWNnd3lqeXpKUXA5aGtNZkhtYU12QjBtX0NjemVmdEZBdC1TbXVBSXJTcF9vMlJXd0ZNRTRKRFBMUXNjTF85eTBxakR4RVNfYmU=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyNVU4cVRIZFdjS3l2S1RJVTVlc1ozQ1liZXZDX1VwdFZQUzFtS0N6UWYyeGxkNGNmY1hoaWxEUDBXVU5QR2t3Vi1ZV1A2QkxqbnpobzJwOXdzYTBZaFZYdnNkeDE1VVl0bm4weHFiLXdON2gtZzAwMTkxNWRoZldFM2djSkNHVS0=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyUmJleVpTOF9OaFV3NGVfcWVBX2oxSjUwMWRGOFZRWFRIN1FZRzZ6U3VQMlg5a21RY1drTHh3U254LW4zM1A1cXQ1TTFWYlNoek9hSHJIeE4tbm1wU1lKRXlKNU5HVWI4VGZwTVE0VnJGaV8wZmNvdkVrMjJGeXdmZ3UyNmVXN1E=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyY2pxMDh0U0RqWERianBMTTNtSUZPSzhKUzh4S0RTenR2MmxnRDlvQzJjbDVTczRWLUJtVnhxWTE2MmUxQjJia2xJcVUzVlFlUnpma040NFdHRzVNRUt0OXR0c2JkTkRmQ1RIYllXbXFFaExIQWNycFVHbUxHbmtYOVhOVUV2MFY=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
# AI configuration
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
# Teamsbot Browser Bot Service
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss

92
env-int.env Normal file
View file

@ -0,0 +1,92 @@
# Integration Environment Configuration
# System Configuration
APP_ENV_TYPE = int
APP_ENV_LABEL = Integration Instance
APP_API_URL = https://api-int.poweron.swiss
# Force SameSite=None+Secure for auth cookies. Optional if APP_API_URL is https://
APP_COOKIE_SECURE = true
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
# PostgreSQL DB Host (porta-int-db on Infomaniak Public Cloud)
DB_HOST=db-int.poweron.swiss
DB_USER=poweron_dev
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQnFGTFJneVFGQ09JYVgwVWRGXzRSQjJ2RnlGYS05WllIMURpUUNBS0poQS1yLUJDaFFQS2IyLTNTSTUtRTBfekF1R1U5dUhiOXdYdi1WSVF4bUltczVQUVJQN2Q0Mng3cHFWVndZVDJxc2ZicXRXVnc9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
APP_TOKEN_EXPIRY=300
# MFA Configuration
MFA_REQUIRE_ADMINS = True
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = srv/gateway/shared/logs
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kydlVubld1d1h6SUNSWW1aZ3p4X3Zod1NDTjhZVnVYS2lqOERGTFp2OXJ4TGRiNlRLVFpzLUVDTUhkZGhGUWdxa1djdEV5UWkyblN1UHZoaFBjaExNTEpGMG1PRGJEbDdHVll0Ungwcl9JemZ4ZXFzZUNFQmFlZi1DZFlCekU1S3E=
Service_MSFT_AUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
Service_MSFT_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
Service_GOOGLE_AUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyV1FRVjF0c0d3d0dyWU1TdW9HdXVkdHdsVWZKYTJjbGZPRDhMRjA2M0FkaUZIVmhIUmFKNjg2ekFodHd6NG80VTI3TC1icW1LZ01jWVZuQ1pKRm5nMW5UREJEaGp2Wl9oRDRCSmZVT0JpTnkwXzgwY0pkV29yczQ5akF2d1ZGcVY=
Service_GOOGLE_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI=
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
# AI configuration
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnFGdnVIcUxPOUVXT0NlUlJNVENjRi1iLXdsR1ZuOXU3Sk1qbmVZOThYdUZrNGlDREJmMkttRVNyWlNsMHlDc2pnQ1VyZ0lzYXVkc0hHNm95bjFrejNRWVVGUWZOVTVYOGpKcF9QNGttc001TE9VdFdKa2FyUEYxY1VYOE1RenBObmNMbVFHeTdHbGpORVAwOWc3Rng1dWtlUEphZmVKV1otSE03a2FTVHVvMlNONWZ3N3hMR2FmdTEtdkdWOHV5d0RYWlZ3dVV1SEpKNHBjWG1QTEZ6SE5oS1VESEJ2MmVSRmh4azd3d1RmQ3FjMDVsbWxNc2EzZDdWMXYyaFBOMTZFSUltUkk2ZTEtZ0FHRW5pZkhON1Rna3lYX1Z5TWRQNkZEX3NXVHlPYkVuMW5zcE09
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFGd0hhbGxMRUlZc1A2d1RvOWdFX3NkQXM0RG5LOTZQYWpOc21tTTJWU09nbS12M29YLVVzVk8zWGdrWXIzb05meW56dkRtTElVN1ZndkZ5eHdWMGxGVjBPTlRvTGxpTzVzcFlzdnVhTTh0R0gtM2M2Mk9ac3dnc0RYYkx2c3BDdnoxVXJLX2tPMTVpZXdmQll3cHF3dEhGWGRlb3JLZjlNTVJpZTN1TzFtMU5yZmdXTnZuZ1lXN0p5VUdsVXBDUXJoY1Y3aFBkUW1HbmJJZmZaR1cwTVNQR0VZUT09
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFla1h1R1M3QlQ5XzJhS0x4eXFpTkZ3WHpLMWVZZldRMGpMX2psMFZ2RmpETTZMZ3ZXblo2MnhyemxYWXRsMHN1LXdZU3k5ampEMjMtdzcyb1J4Ri1rTmxPOWhJMF9MMEtzZ3d5dFZxSFY3TjNac3ZpTVJxUFFmUVpXeHEtbVBTUmtiR0lhQjhVcjM3U1NNX1ZHY1NxUFJ3PT0=
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlbmRSZVRjTzVKRklFbFgwdVZJaE5jNVoyX3dVTVlRUFVUenc4X1JOX2laOHRoTU9mN1lTUVRzb2xNZjJXVjhEYnVIaXdkSWN4NEpJbTFJZFN2cmkwUkJ0ZXNKT2NidktjdDFJX1BkZ3QwU3dQRzg0aG9aNmtxc1FZZ1ZBRjQyM3lOSS1EYkpqWmxoV0xWWE1Fc01uN3RnPT0=
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlU2tMLTFnQWhET2Nia2pTcVpBakRaSVFDdUpHRzZ1bkhGVVhMeEVlSnFZU3F3UFRBUkNMMU4tQU92OUdTeDlpM2VZbXJzLURQZ1lPLVB3azgxSDZabkhkSHJ5Y005aWhtcDJzajk3a2JDQUxCZlNKRGw5elJuSzJMUUpTZ2hiSlU=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQnFIc3YtSjhlcklrU2JCOW5mdHFHd0dLTUZZZk9PT3o5RWt5RjAxX2s3ekJRLUUzU0dNSnNseTE4bUpNTnZSTWg0QV9mWm5iX19aWjV4YnRXU1JBSm1INVB5dXNRT2JiYk1tLWRSS29pdTRMdS1lMDZxMkx4VTh3bU5aVWh3cEwyOE1QcXVockgtZWh5bzdNVXQyemFuSmZqRzZZYmNGN21JdjNwNWpPRXB6WU1qSU5rZUVSb3JBS0lhcThvakkwbTRUUHhBdjRZdWNsZ1Z1RmFaNGZLcEpaNVNLdFAxYzFXdTJydU9COWJ0bkNyYUF2X2FNc1BfT05teEs1SE9PeGhPd3VJSFY2VFJ5VEl6V3R3bzd6OTVKTEVRcmt5ZzdBMXBFY1A5dUFJRFJONFBlaDlJcjNBQnBraC0wMTBhNW8wYWZaeHNWclVTOVotLTdWSmVuYzJKcUZSUkdrdXB3VEVESzd4UTI0bGd6SzdCajdoazZXVTVCaGRiaWJaOHg5Z2thSWItcS05U25DbUdrT2M1QV81WEg2dlJfMlBtZU9Bc3V5bmtBWHRoRUVLR2lWNHY3M3hHcU1raFRFOWQwSEtUU1RDWDFRNFlkNHVnTkZDbk5zS3RZeGR2Z015RnRGc3NndFVEQjc4bVpNeE81bXc1MnQ2QjNZeHZCbUJJZVJ2TE5xWEd4M3hHT2hJWW5DOWMxQlNmZE9uMVRGVnRwTUlXZjZCRUZBLU9GWVZGWFpZbUE3WVlpZU1DX1Z0bWQ0bjlaRThHOE9WR3VOVzlYWS1JampTNmxkNmFxWG54WDJjallIT3UyT0tGSzJpeG1tX0JoQjZxbEpESHBhMWZFa205bjdvTVFwSVVidnVzdURZVDAzVVpkekJ2SVZTZmhxQVJ2OWpuRGR2WFE3elMtb3B2ZzhpQVNvRmkzbzRrY1BuamVzM0E2eVM0bXBHTHgtYmhsVG5jNlB1Q1JHZU9HUlNfaTJSQkcwS2FSZnZSOW9oZzdXa1RUVTVTZTgwY01GYXQyQ0xWX1Fnb0xaOTRQY3hTclgweVJ5clc5OVpRWWlDb0JQVXoxVDA0bW8zUE55aGowb1ZZNEpBN2UtSTZTY2llRGhISFFkYWFYVlVBQ0IzbGxzVTQ2V2dsUGV1Y2I5bEZLRnlwdXRHMWZVcnBaTXNzNzNkUVFqR2xnSEQ1VlpTdXpwMFVVYjQ0enFlUnk0d3dDQUtSS1dUVnNyYnBKQW9TRjJxN2JNY2NhRWNONWRpWU5RbzNNZVJBS3EzN2ZMZ1E5VXQtMDFTZklLY1JiSDNYRlFuOF9VYUktS0xoY2IyR0xkT19qTEpIV1p6RFExUWNCQTdqN1kyS0Jaa2lyMDluenc1MS1vdmhPVlE5OUphWEY2dXFYNE04Z3lBUG5DNGZjTUVnYzEzYWhzTHpMdVBzT0dzRGJaT2x5b0pVbWJtUzJxdEd2VGtrc01kTlNPNURoVHhwZzU1d3pTZGJiTUZIME5tQ0xqNWJ2QS1QSEJHV2FEOExHWDByV19rVnc2R2pibnNENEo1cTh4bGNMX2ZpSTBMcjRvQWRhbW5xYVBiZkZzWTRERlVESEU2aHpvdzNMTjlCazRYeEJhMmZwdXY5T25IYkFTaUM3SmdIV1FCX2xxRXctWHZQOHgxLXI1c1JkWmcydkFTUmxFSU03cGtnallnTXplOElQbEJRSEE2aW5KREU0YUxwX25wOFhuS2RIbms1dXNIRHBtNjFtb3B3UGVGb0hwOENKM1hMclBwa3NBa2pFYnZYbEtFbUF0Y3pmeFRmMDNMaTZrR1BZWnBrNUQ1WlU1NVZQSWUxN3dwcXhhcjdXNTl4LVVpYVF3Y0wtRmFyNXZRNTE3UUc2cHVaVVNpaVdHbXRqQVJNZWZmNjdQQ2lwTGd6RFFZN2tSY2NEdmxvaXk4MTZMcmg0VGo3MTN2R2V6cmV3YjdQVlNEZTQySUpaY2pkTHZzUzdJLVJ2WnlOQ3Vmem5FZXRaWjBMWjF4ZEF3ZHJ4VF8tMVNsRnljejVsaEpGOU5JbnhydjNVdzNMOENrWUVsbXp0ZEhuVE1Vd0RJcnp2N0RXUGFuNDM2OXBPbV9LRDUwTWk1NHYwaDhlVEhKUmtEa09INURwNjV5ZE1VWmpRSGdjeXJNc3FqcjZDdmx5WXluNWZ2VlpsWmR2TXVXVnBubEFmQlRfaGRwRndCVXVkMjkyLWVhaDQtZDN1cmFZLUoybGRwbGQ5MTExU2NnZ2lueVNfSjFDQ2NkWGtNX2M1T2I4YnVJOUFueGIxbG1EYlZOcFYtQlE3cm90SE40X0ZjalhLdXM5S2l5aW84ZUJPMlR4MU9EVkhZcHdrX1Zqc0NhWEJacDZHMzQwSzdkdi1Rd2s4Y1dfLS1ES0NfYTNxYl84UTN1S0lIM0pVTTNEYlJ0YW55Tk4yVjBONXNTQWtVZTJ2V3B5eHBJcG9IWGRMMklob0hMbVVZZzJKbTFMUExOQm5HSEZzWHU0VGVIWlJMVzFLeFB0NkkyWFkwWk0wdjdHRmxSWFFoSkJ2Vm5NUWNQQlp6YWlIc2NKLUdhOVVycHd5N3NFMDNVWlAxZGQ1NzRGbm9LcWxEb2tKR1RnVEtvRUc1d3l4aU1IOUQ5RldUT3Z0a3lpRHpVSWJ4MjU4RWY5MEpCQ0VFdHNMbnkxOGswcE44QzJwNXFCVGpIa0VGc2VNXy1qdzVNRU9DaXg2MW9VX3FjUk41QVFVLURwVGFLRTkyNWlENy1IcGZjNW9wY0Y5Q3d5eFg5emVUUF9hV3ZTQWNaNEN0VzdJRlFBR0picXJoUERacWNLbDZhTE8wdWlfZ3kxd2QzOXBOZV9uaUNGMkNJbGhNd3k0S2t3dTRGWVVxTTFRRlg3Ui1zLW1FLU1Mai1yaURjb2Fob2c4MDUyRHN5aldUVWMxLTVNbm5VQTdrYy0zLVFyOHRkNzZ3dGdhbXZXN3JHNkdfZ2RuRXFDM3R2TVB1cDNOdWZGTmpFNnNFTmMxTmFuZDdJUld5bERyQkJ0TGZXRk54NEdqN09hSmVMYV91NXUwNXFvMl9KV0hBNlB4bklNQ2U5WGZLUTdlX2dJenVGcDYwWHBsdTNpbE5mWGhWeXFuUkFPV0puR2h0RkhrR2MwTzJGUmp4bUR6UFlUWTlNbTJLa19hTUZZR0dscVpBbFBReTBRMDNseXo4SXNnZWt4VFdpOERqLV9ZczRkR0QwRFJQM0pqdHluWktDUlp6WU9XSjVNZi1tYnNzcVlGTDRFMzNlSmRTazFfTkNxSjAwM0wxNk9Sd2h1SWpfOW5MVWMtVXYyYlVZR0VuaHRpN1pnNnpHME5raVBMd2h2dDRyMV8yZGFJNnlkcmhtSWdmNlpLN19NcjNkc002dXFxQzhTaDZzRlgzNUJ1SzVpVnp6NVU1Y2luUlM4UEJoajNTOUJadnE1MlhzV0kxSzBObXkteVhNM3RKYW9heDVWWFJ1NGlDM0l0elRPbThwUU9oYkVkbC1PZFNLSHY3WHJiZWpEamNIVC00MlNNWV9qcHdjNDRjRlVhZXlrLTlicVBNaDlDeXdRb0Fwc3RmUGFvbURQZ29yckliaS1VUDNxcXVlYTJJRUhXNUVobk1KUDhHZE16UzBLeDViYVRwZWY3d2w0d253eEZYcExKRGpsaGlBUElaTzB3eUVadnROX1dabENGb3R4ZF9aS05KY0dHTVZaYzRFc1Z4TlZGbFd2NjdYRzJMTzVwU2NaN1Y3MzQ2Z2pzV2RSMzJBbjg0MEhaZmhoREloY0oxOFdjNDZNdVZfYlRKU1Q1M2hYdHgwUjVsTV9USjZCZXlQTTdNRWc3bUxOcXRDVkpTdnJxR0hkWWpaRUdrOEFyNHk4MENwVzdob0hUSkJvam4zZW1kcGxZUjg0RXFRNnBxSUg1MDVHdHRwVlFkWWhHM0ZyZVFvMF96R2V5YjBuMnVZTU5CQ3pVci16SGJlQTQtbnFLa1E2eHFncUg3UmYyYlZvOF82a3d2ZE4tbmxIUlNYYjlrck9QYk5CcV9faXludS1yem1JNjFBdVYyb21RQWFMMFkxX0s1TjQ4czZ2WXI3X0FzRWdNTlZndHl4bnVOTHl2YlZfaURQV053dHl4N1czRFdzaVFnRHB0MWRDV2ZuU2lzX1NZZkRQYzhsT3ItZWw0dVJlVmtFWUM5cEppOGxuYVdpQkN5dV9hQ2dodTJvV3REVkw2dVVDaGtvc0Zqd0V2dldLZEVNRVRRNVRUVmw5aHZmZEpHdk1wS0xwRFc5Vmx4dTdfdGZDRUtCU29qdEVIOW5VdjBmeGpFMFZHSUthamtVN1E2bDZqaEFackVSQnZMN0tyaUhIcUs1ZHMzMzl2TnhadGIwZW5QNS1BM3pSODY3WVFsLU1jeUpCMG1PWmhPVT0=
# Teamsbot Browser Bot Service (service-main-teams-browser-bot on Infomaniak)
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss

91
env-prod.env Normal file
View file

@ -0,0 +1,91 @@
# Production Environment Configuration
# System Configuration
APP_ENV_TYPE = prod
APP_ENV_LABEL = Production Instance Forgejo
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://api.poweron.swiss
# PostgreSQL DB Host (porta-main-db on Infomaniak Public Cloud)
DB_HOST=db.poweron.swiss
DB_USER=poweron_dev
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnA4UXZiMnRoUzVlbVRLX3JTRl94cVpMaURtMndZVmFBYXdvdnIxLV81dWwxWmhmcUlCMUFZbDhRT2NsQmNqSl9ZMmRWRVN1Y2JqNlVwOXRJY1VBTm1oSjNiaFE9PQ==
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
APP_TOKEN_EXPIRY=300
# MFA Configuration
MFA_REQUIRE_ADMINS = True
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = srv/gateway/shared/logs
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True
APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyeUZORDYxOFdlNHk1N25kV3pSQVJMUVFwLUFlMzlzQjQ1eVljOTlzX184RndsTmtTV1FjdWkyQlBiUkdCbGt5S2ltZjJxa2I2dHBMdnJqZnhFSnBCampHYjB3RG5URDM1YzZSLVd6TGdaRXRVcEdadE5zM2thNV9SZy1KZDdLSHY=
Service_MSFT_AUTH_REDIRECT_URI=https://api.poweron.swiss/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySk5uMmlWczBWTE00MHBIcWlBbVJmVmc3MlBWbDA1YTFaS3psZjVLd3d1X2FvRHV0X0c5blpLV0FpY05aMTJMMzUtcG8wakF2TlM3SGQ2VjFZM3JLT1MwTlZ0bm9BRlpkbHVPQTFNaXJvazlQRzN4M2ZZNEVhV1JHV190dWluSUk=
Service_MSFT_DATA_REDIRECT_URI = https://api.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kybjVVZ0FldUE1NTJiY2U1N0I0aVU0Z2hfeWlYc2tTdmlxTS1NdGxsRnFHdjZVcW5RRHZkUFhzUTVyX2RaZHlrQThRdTdCRmVBelBOcDlsbFQyd19SZExuWEM5aTcwQ0FvY3ctMUlWU1pndDE0MkdzeTZZRHkwLWU3aW56LW1jS20=
Service_GOOGLE_AUTH_REDIRECT_URI = https://api.poweron.swiss/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyMnFma3VPOVJtTFFrNDRLN0NkWHY2dUZDWlJzdDVMd3p3N19IY0tWdURRRzExOGZCMjJOYmpKT1E0cTVwYlgtcVJINTY0anZPc1VoTW00cHl6NVh3ZHVTek1oT1RqWUhtamRkZ1dENWlwNTlZSU1oNWczeGdEOC1Gbk5XU2RBcmI=
Service_GOOGLE_DATA_REDIRECT_URI = https://api.poweron.swiss/api/google/auth/connect/callback
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
# AI configuration
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVKZ2Z0U2s4cnpUN01mRVkzQUYyVm43NzZLOWJBODlvRlNFdTNGbzZHblNzUFJ2X0I3SXRFQTRXWlFYZjY1aUVVOTgxSU1KemZ4Wkl1NzFQb2JIcnM3bjg5bkRDYmpNVjVjTG55QmtSUVpZejEtckZTd20xRzd2NmhVSkJUQTFUZWk0dzhrUnJuNWZPa2NPSDR6QnQ1a0RCbWM4Y1h3Mmh2NmQ5SHFOR2FISndEMzF4Y29YcVlKaVNyNGM2VWFINUg4MjVMcHZJTUxVWXJNNUZVdW9GUkx0ZkZlZTJqRGI4ZkVuaklHMEotb3FyOEFka1c2WC1VclJZeHFucmJlRjhlUUhLNWdFX0xaRFp0ODNFNFZaWEdSTU5QbDcxbUxlclN0X2t0c1dpWXVJeFE9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFGd0hjRzFXSXhjMVZWOW40ZFRRREItclVxODVDdFdSa2tzVVJ2TWVZaVl5UE40YzgxR2d4RVdhVUFaQy1VZVRRMzFnZW1NcjlNY1h6ZVJta3F5STI5Y2taRVlXbFREb2paMTZpRVZpdEVBVnJrSjlvS1lSMzB3V3FkWW56WlNhQUFiby1Mb2RCb0VHQ2NYYmNOUGZ5UEdseGJic2ZSQk1ReXlTRnJITVY3SEdPb296eGNIdXNRME5LOTlZUlRvclJRX2R3ZHlxM0puXzlWRzY2eHliY1FUNmxSZz09
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLODRmYXo5T3BxSDJnZXgzRlFfR0oyWXVkeVRZbk14VkdDV3pTaWVfV3Y3R21LaWJpSC1laTg1T3NYREI2RzBBWWtraFJud0U2ZnhVQzJ0bnViVzJtOWh4dDZ3VUdoZUxaUzdhSkM4N3ZOOTFINmV1TGNmRE9RRmtfeTduVEV6QnYyRTZJaGxGb3ZFSmZmZ1JxUDdFSVBRPT0=
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLcTlLSFJ5b0gwRmJLMFB5MzA3S3FYbmhKV2VzbHI0ZFUzOUJNdHVYQlQ0ckdicW1WWG5CNEkyWVlrR0gwQ0ZramJ1c19JS290MmlvWVhYWW92cEhIdmRTRXdPQzZpVFdDaU9MQzFlMEdPYUVnYy1HZlM1ODVuYnZGRnVZVFZpYzZBcUNRekVBZFFzVExQV254OUZ0aHVBPT0=
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLRHplbzNheDhIdndsU0xUeGlBYVVXWDRzOF9Tek41WjEtSmNqbnVHRXFaZ0dramlfZWlQelpJWVh5T0F2azBaQWU3ajU0TWljaGpMeTlra0g0LVhKeTRKNGxKY0ZqSkxwdTJLdWM5cWdMVC1TVkpLb2lPdHhyeWtieFJFOHdkVy0=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnFIc3YtNDZzenJuZEZiQnVMOWRmZjl3R29QOWZRaGlPdk56WG1DR0FSZU5DM3dENWdoMmRpaks1U1VDNDJkZ3d3UXhSbXlkZ2h3SGZfdk54WXVidF82VkdJQXZiRTk0UlhZaUY1b2kwNzNPSm52VFdsdkwtaHJBb2dpRDBVLXRwd19Bb0dUZDkyV1VWZDJ1TG5mZ0ktYXpuS3U1U0JkZUk5TXpMdnhOaUtMN3BIb0pEZ1N0SlpFN3NNby15VTRfWWtxaF9DYjlJcnVKb0ZualVMTUx2aVNGY0JJdE1oZy1xSVBUZDF1aDM0TGVlTzVrNkFHcjlhcEk0SmRIMTFGdDFTMVUxX1dERk9NTXZMb0tVTFRoc20xME1uRkdVV0Z5N200ZTQzSjVsVExoa2VRZmFBU21ZczF0Vm9Ib3BZM2ZneDkwak12UmFyWWd0eng3ZVVFTUFLVzNOazcxeUhLVWUxcEFIZWtNRi1mT29kM1pqNGJJUUh3UVBlNGY3SlotOWZFUk5aQXFXcUFVdnUzc0Z5bERXYUNPbG14VnBNenFvb2tiQ3lZeHNHUVBlQTdTdVdXOEkxaGxCX016WWktWmN2WFcwM0VmVHdvMHVnY212VFE2cjJwUjdENkFCZF9GcUktWWpmWlNXNWVTMHBPdzVxRi15d3FSRDFra2k0NEFmTmpUeVh3SHRuZWE3WGJ4eUNIcE5tdnRqX2NCZnJoMEI2emU4U0ZYN1Nmdlhva1NacFo3UFh3WnpSdGw5ZmNpSGhicFo0ZThReXl3LW9vUzZaMkFHX2lJalFEMWtjZVdqbVpIZGk0cEdEU01TMl9xQkdSNDllTS1GV3lXS0xROTJvSlhaTjlXenJhQ3lOd2p0VjR5ZjEyektUZGJ3UThJOVJuMzhsTTVBVW9BcDFtcjk5Y0pVeW0zX3R0Nk81R3VDRWEzZnRqSXhFUW5ONHFTSWlwQU4yazlDb01KYlFQRjBFVTljdEJIY29WdF9hUkRJOThVTVFfWlJQUXI0Z3RzWFlzR1ZxUWFBd2I1SW1EMWlKdVprT3dKYTlaREp6TkZEZmVsZGEyalZGc3dHaUkyamdmQWtUT2czNzBCZEg0Vk1HSHFpRnhRYzBRNnN3TFkyaE9uMTVXN1VJTmJwbTNUMTdZbVRyc2d6Yl9aaVBXNmFvanROQVhfbWpXTDRlR1RfbklnYnJUQTZPX2JfNnlrWDVDUWJ4Z3YwNXVsTkJFQlRhTG5DVHpwejdsMGl1bzRfRXRTU2dmb3BVMUo4VkQwa0hsTmFBZnVjVzRrQmNzS2R0ZHNGV24yQnktWENtMUp6eG1MQW1ENE1vWFpFUF9PMEpWZVlxX05hSW1QUGlVT1l3MFp4bDBDZVVldHlEUlVCY1VvVlBNTlBhWFlmcVRobDNqRHo0QjZvNDBqVUVKN3JOb2dtYXQxSWw5NERSeEVRdHNUWndzUkY5RjdBOG1FZFRiVTNVSzl5bDNwdTl2SVd5aW5Ub2Q1YlBDRnpBUDkteU44YnV5X05ONmNndm9teUpqaFZVcVlHdGVRcXRpZkJLVnRuMTJSUFhGWndibExqRW03YUJTWXZXUXJ5WXlvd01ISDFuUFpaMFJzNFVQbWRUb2h1Zi1rcXJXMkRQSUFPeWFJN3lzOFc1d3BjWG1kbWlQWGUwelNiSnJXbUpnajdlQTlQR19XNTF0Q3JYcUMzaGp3eU0yZGhKa3FtX0tleHBfekZaWlRJRlZlSzNDVU56cml0TnFJeUc3b09uYVlwbGxFVFR6WFJVMzRmak5yWjBhcjl5ZmJpQ3hpajRXV1dwbDF5N25tNnI2bWtFem1TS08yV3JybUF0enYxRXpkUVdTNVp4WVB0aldJUUN3TnhHcHdMczh5MTFETzNWLXZFSktsdU1vM1JSNXhraDlJRDl0MEhvR1NOQWRaQW1NdzhpZnFVa1hvdXNwY2FvaThHQjVMOXdySnNIcWJlWERfLXVOcHhpN2ZZOW4yVzB3VTI2a3hvVmFkc29aX2ZUZkY5bi04WEV4MTlxNXQ4cTcwaHE4X3hDWkQxelRwSUl2amZOQ0JXRlJjRFhJNVhjNjRmaXp5eG15LTN1MFRvN3BHTFRZQ1ZFVFYyNUxleFpKTHlIVzRnVHk1Y3ZUbV9RUDdqN1Z2M2ZqVG8wa2RoVHJPeENFRDNHV0wwdi1DbEdOVDFJZnRiZGEydlZyM2tQVExOVlo3LXhIUnhZUnB6a2UzZXNtTjR0S2NzUmFNOWNiSHhHTnJDWHowWk1tbVFKUC14M25aQ1hyYjhJM2pxOEtZY0J1WTZrU3l6cDJOdk5iSXpBUk41MFFVellVZFU4UWVDZXFkQnJFbGxQX2J0S3pReU8zZUdsZUgtTnJuSlpfTjdxR3UxWTBEV0JaRV93eE9qa2dNa2tVTHRxMWNyeUh2VWNrYkdKM3BZOURkUlBxUDA3R2M4NnlMTVR2dmNMZi1lZlhzalRJWlFocGRleVRJYXBBY2hCXzFGZEU4ZVFxbHNic3RDV2FYN1dNaWpkaGdwYTEzRkZYRlEtRXR1cERHdnJKX1Zzb1Q0MnVYZkVhb0VYU1JPdFhoV29TMlhTaEppR1lTTURLYmZnNS1pSzl4T1k5MXJ0YV9qX0ZyQ1R6RFFzRndrTW9IUVlxcG5jcTEyYVU3dkpIR0tZZTZiOXNIRFpIalRtUDFBLVNyd1NfNUMtLW52NVpFZGpQenJCOGw0UlJZNlZVT1ZXTm92R3k4c3hTQXFoNFE3TUFHcjRWc01zT082anJZT0laakl5VUk1WDdDaWlubjIwS3RNcjBjTTdpbUNxSmxNR05JaWtEQURlS1h6N2h0NE9CcW5rQ3NXWkwyNXVBUU5mLTU5MG8xX29xZ0t6Z2pKWmhMNG1BNXBhYWkzY0loSmluUXNKdURwQWRIV2laM2dHQTFxV19lbkZXWmdfWEdiWEZsMGVIWDdoMnJ5dzM0ZGtBM3BSRVp2QzFNbFJSWXBManN5WmFVMlp6aUpWMF9jMTRPbWptM1lsTE41NG1kUW4tT0ZqTzNaZnZ5ZzBLZzNNc1N1X2FMMVJ0N3o4a25LMkxKVUE0dTNhU3hZX3RFMUtKcEgtX1B0cTdEMmYyMzdPaEhoeWhaUGRITC11NzRWYTJnZldiUkFvdG95a1RwWnNKaERkT0kxN1RJMzZQZzFiSjl1SlJieTJjaHBMYmZDUlhTT2hvQnRPaTNhS3NzaVc1Tms0X0FyUHRsSXdCLW1OUWk1RkRKc3pqSjVQTFFROEN5M3pxUGVjZHI4SVM3Qmx1S1A2bEEzNWlVWkFndGpUSm4wcV9jRjQ5T0l1c3ZqN0w3Z1dMV2ZtbU9MbTVSOXphX3VLMko2ZEs3U0NIaFFIMVFIcnN0OGIxSjdxNGlHUHRnOEJDaGwzcXJYNFBnOGdFSVFuSGUyOWJ3WmtlVGhGQWk0THdZd1hUbGRydk83SWVzWUJrb21tSlNvVkJjdWYtcWo0aEc1Ri1XNTZoSENaRWJISmp3UlJNMU9vSnNzZ0VudXpxMDA3aGdfSDBNZlA0Y1gybkF4dGl6SzFOc1VMN0dzVkQxVllkSDhyby12SWNxTFRYdThJUm13S3p3cGFYc05TbVc2YVNtZEdCOFBCUXhadkIzNmdkbXpnc1pLYUhzOEtsY2kxVmNYZm9wOS1LOERLRHJhY2VhanNjaThUZW1rS01wUW05SFJxOGd1VF9STlJZWDRiTV92dXlQTkdxN3BYYTN1SUhRSjRNTy1PZWpGd0xhUlVES0hiWE5LUkM5dHNvenR3TVMySC1ueUZXUkxFY2VyRmhISGc2U2ZxeXY2VkJULV9pOTU1QkI5VUNndnVQcVItTW96VTBqRTdzem1IQ1UxVWtWdjhvTERFeGJ6M3dJNERUV1BTeUlRcG1fbUVjQ0lNREF5QkpLeHJHRkFxQS1kZEE4bXJ2aVVSckVoTkZwNGtoRElIcUktQjA1bkNRclM4dWlqUVRXXzdlQ0VjQWZGSTZlR01NQmU5bHQ3bGNtZWU1eHVvRVdQRVU4Rmx0OFRTaWF3cGgyeFJoM25sRk1GNXJtdEpfcEJmYVFrZXd4eXl0c0ZKVjQ3MkFNRjh5bDBTbFZNd256dmxpQlo5Z1FRM1ZmVTJSb3VrZTk3cXVQYmZ6SnNUWGhlSUhrUjVWUHFwemNmbW1scWVxTkcxT1p5dVlvUjhCSVJaSnBjU0dpc3YzVkt1WUtrd2xoQlVNQXh1eDhmTXNISWMyUnBUMmIwamxlS0tjMVRiWDlBcE03b1BHR1FmdmlsX2ZlMTNCaFNvNG1TeTNiQXRNZ2Y1eE1IaFAxTUZGZ1YyZjEzTG9PaGRCdHJzVlB5Mm12T1NiX2RyT2d2RERCRWFHT0dadW5DZjNtdXE4cHhEQlpub2l3bz0=
# Teamsbot Browser Bot Service
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
# Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Base connector interface for AI connectors.
@ -11,15 +11,15 @@ IMPORTANT: Model Registration Requirements
- If duplicate displayNames are detected during registration, an error will be raised
"""
import re as _re
import re
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional, AsyncGenerator, Union
from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse
_RETRY_AFTER_PATTERN = _re.compile(
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", _re.IGNORECASE
_RETRY_AFTER_PATTERN = re.compile(
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", re.IGNORECASE
)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Dynamic model registry that collects models from all AI connectors.
@ -12,10 +12,9 @@ import time
import threading
from typing import Dict, List, Optional, Any, Tuple
from modules.datamodels.datamodelAi import AiModel
from modules.datamodels.datamodelRbac import AccessRuleContext, RbacProtocol
from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelUam import User
from modules.security.rbacHelpers import checkResourceAccess
from modules.security.rbac import RbacClass
from modules.connectors.connectorDbPostgre import DatabaseConnector
logger = logging.getLogger(__name__)
@ -186,7 +185,7 @@ class ModelRegistry:
def getAvailableModels(
self,
currentUser: Optional[User] = None,
rbacInstance: Optional[RbacClass] = None,
rbacInstance: Optional[RbacProtocol] = None,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> List[AiModel]:
@ -237,7 +236,7 @@ class ModelRegistry:
self,
models: List[AiModel],
currentUser: User,
rbacInstance: RbacClass,
rbacInstance: RbacProtocol,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None
) -> List[AiModel]:
@ -262,7 +261,7 @@ class ModelRegistry:
logger.debug(f"User {currentUser.username} does not have access to model {model.displayName} (connector: {model.connectorType})")
return filteredModels
def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacClass] = None) -> Optional[AiModel]:
def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacProtocol] = None) -> Optional[AiModel]:
"""Get a specific model by displayName, optionally checking RBAC permissions.
Args:
@ -284,8 +283,15 @@ class ModelRegistry:
connectorResourcePath = f"ai.model.{model.connectorType}"
modelResourcePath = f"ai.model.{model.connectorType}.{model.displayName}"
hasConnectorAccess = checkResourceAccess(rbacInstance, currentUser, connectorResourcePath)
hasModelAccess = checkResourceAccess(rbacInstance, currentUser, modelResourcePath)
try:
connPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, connectorResourcePath)
modelPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, modelResourcePath)
hasConnectorAccess = connPerms.view if connPerms else False
hasModelAccess = modelPerms.view if modelPerms else False
except Exception as e:
logger.error(f"Error checking resource access for {modelResourcePath}: {e}")
hasConnectorAccess = False
hasModelAccess = False
if not (hasConnectorAccess or hasModelAccess):
logger.warning(f"User {currentUser.username} does not have access to model {displayName}")
@ -341,8 +347,8 @@ class ModelRegistry:
modelRegistry = ModelRegistry()
# Eager pre-warm on first import: ensures connectors are ready in this process.
# Critical for chatbot performance — avoids 48 s latency on first request.
# Runs when this module is first imported (lifespan or first chatbot request).
# Critical for AI/agent performance — avoids 48 s latency on first request.
# Runs when this module is first imported (lifespan or first AI request).
def _eager_prewarm() -> None:
try:
modelRegistry.ensureConnectorsRegistered()

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Simplified model selection based on model properties and priority-based sorting.
@ -140,11 +140,10 @@ class ModelSelector:
promptFiltered.append(model)
else:
maxAllowedTokens = model.contextLength * 0.8
# Compare prompt tokens (not bytes) with model's token limit
if promptTokens <= maxAllowedTokens:
if totalTokens <= maxAllowedTokens:
promptFiltered.append(model)
else:
logger.debug(f"Model {model.name} filtered out: promptSize={promptTokens:.0f} tokens > maxAllowed={maxAllowedTokens:.0f} tokens (80% of {model.contextLength} tokens)")
logger.debug(f"Model {model.name} filtered out: totalTokens={totalTokens:.0f} > maxAllowed={maxAllowedTokens:.0f} tokens (80% of {model.contextLength} tokens)")
logger.debug(f"After prompt size filtering: {len(promptFiltered)} models")
@ -324,4 +323,4 @@ class ModelSelector:
# Global model selector instance
modelSelector = ModelSelector()
modelSelector = ModelSelector()

View file

@ -1,5 +1,6 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import base64
import json
import logging
import httpx
@ -30,6 +31,8 @@ def _supportsCustomTemperature(modelName: str) -> bool:
if not modelName:
return True
name = modelName.lower()
if name.startswith("claude-opus-4-8"):
return False
if name.startswith("claude-opus-4-7"):
return False
if name.startswith("claude-sonnet-4-7"):
@ -78,6 +81,54 @@ class AiAnthropic(BaseConnectorAi):
def getModels(self) -> List[AiModel]:
# Get all available Anthropic models.
return [
AiModel(
name="claude-opus-4-8",
displayName="Anthropic Claude Opus 4.8",
connectorType="anthropic",
apiUrl="https://api.anthropic.com/v1/messages",
temperature=0.2,
maxTokens=128000,
contextLength=1000000,
costPer1kTokensInput=0.005, # $5/M tokens (Anthropic API, 2026-05)
costPer1kTokensOutput=0.025, # $25/M tokens
speedRating=5,
qualityRating=10,
functionCall=self.callAiBasic,
functionCallStream=self.callAiBasicStream,
priority=PriorityEnum.QUALITY,
processingMode=ProcessingModeEnum.DETAILED,
operationTypes=createOperationTypeRatings(
(OperationTypeEnum.PLAN, 10),
(OperationTypeEnum.DATA_ANALYSE, 9),
(OperationTypeEnum.DATA_GENERATE, 10),
(OperationTypeEnum.DATA_EXTRACT, 9),
(OperationTypeEnum.AGENT, 10),
(OperationTypeEnum.DATA_QUERY, 3),
),
version="claude-opus-4-8",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.005 + (bytesReceived / 4 / 1000) * 0.025
),
AiModel(
name="claude-opus-4-8",
displayName="Anthropic Claude Opus 4.8 Vision",
connectorType="anthropic",
apiUrl="https://api.anthropic.com/v1/messages",
temperature=0.2,
maxTokens=128000,
contextLength=1000000,
costPer1kTokensInput=0.005,
costPer1kTokensOutput=0.025,
speedRating=5,
qualityRating=10,
functionCall=self.callAiImage,
priority=PriorityEnum.QUALITY,
processingMode=ProcessingModeEnum.DETAILED,
operationTypes=createOperationTypeRatings(
(OperationTypeEnum.IMAGE_ANALYSE, 10)
),
version="claude-opus-4-8",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.005 + (bytesReceived / 4 / 1000) * 0.025
),
AiModel(
name="claude-opus-4-7",
displayName="Anthropic Claude Opus 4.7",
@ -604,9 +655,9 @@ class AiAnthropic(BaseConnectorAi):
mimeType = parts[0].replace("data:", "")
base64Data = parts[1]
import base64 as _b64
_SUPPORTED = {"image/jpeg", "image/png", "image/gif", "image/webp"}
try:
rawHead = _b64.b64decode(base64Data[:32])
rawHead = base64.b64decode(base64Data[:32])
if rawHead[:3] == b"\xff\xd8\xff":
mimeType = "image/jpeg"
elif rawHead[:8] == b"\x89PNG\r\n\x1a\n":
@ -617,6 +668,9 @@ class AiAnthropic(BaseConnectorAi):
mimeType = "image/webp"
except Exception:
pass
if mimeType not in _SUPPORTED:
raise ValueError(f"Unsupported image media_type '{mimeType}' for Anthropic (supported: {', '.join(sorted(_SUPPORTED))})")
# Convert to Anthropic's vision format
anthropicMessages = [{
@ -808,4 +862,4 @@ def _convertToolsToAnthropicFormat(openaiTools: List[Dict[str, Any]]) -> List[Di
"description": fn.get("description", ""),
"input_schema": fn.get("parameters", {"type": "object", "properties": {}})
})
return anthropicTools
return anthropicTools

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
from typing import List

View file

@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import json as _json
import json
import httpx
from typing import List, Dict, Any, AsyncGenerator, Union
from fastapi import HTTPException
@ -274,7 +274,7 @@ class AiMistral(BaseConnectorAi):
bodyStr = body.decode()
if response.status_code == 429:
try:
errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
errorMsg = json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
except (ValueError, KeyError):
errorMsg = f"Rate limit exceeded for {model.name}"
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
@ -287,8 +287,8 @@ class AiMistral(BaseConnectorAi):
if data.strip() == "[DONE]":
break
try:
chunk = _json.loads(data)
except _json.JSONDecodeError:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
delta = chunk.get("choices", [{}])[0].get("delta", {})

View file

@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import json as _json
import json
import httpx
from typing import List, Dict, Any, AsyncGenerator, Union
from fastapi import HTTPException
@ -319,25 +319,24 @@ class AiOpenai(BaseConnectorAi):
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.00013
),
AiModel(
name="dall-e-3",
displayName="OpenAI DALL-E 3",
name="gpt-image-1",
displayName="OpenAI GPT Image",
connectorType="openai",
apiUrl="https://api.openai.com/v1/images/generations",
temperature=0.0, # Image generation doesn't use temperature
maxTokens=0, # Image generation doesn't use tokens
temperature=0.0,
maxTokens=0,
contextLength=0,
costPer1kTokensInput=0.04,
costPer1kTokensOutput=0.0,
speedRating=5, # Slow for image generation
qualityRating=9, # High quality art generation
# capabilities removed (not used in business logic)
speedRating=5,
qualityRating=9,
functionCall=self.generateImage,
priority=PriorityEnum.QUALITY,
processingMode=ProcessingModeEnum.DETAILED,
operationTypes=createOperationTypeRatings(
(OperationTypeEnum.IMAGE_GENERATE, 10)
),
version="dall-e-3",
version="gpt-image-1",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.04
)
]
@ -478,7 +477,7 @@ class AiOpenai(BaseConnectorAi):
bodyStr = body.decode()
if response.status_code == 429:
try:
errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
errorMsg = json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
except (ValueError, KeyError):
errorMsg = f"Rate limit exceeded for {model.name}"
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
@ -491,8 +490,8 @@ class AiOpenai(BaseConnectorAi):
if data.strip() == "[DONE]":
break
try:
chunk = _json.loads(data)
except _json.JSONDecodeError:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
delta = chunk.get("choices", [{}])[0].get("delta", {})
@ -653,105 +652,82 @@ class AiOpenai(BaseConnectorAi):
)
async def generateImage(self, modelCall: AiModelCall) -> AiModelResponse:
"""
Generate an image using DALL-E 3 using standardized pattern.
Args:
modelCall: AiModelCall with messages and generation options
Returns:
AiModelResponse with generated image data
"""
"""Generate an image using GPT Image model (gpt-image-1)."""
try:
# Extract parameters from modelCall
messages = modelCall.messages
model = modelCall.model
options = modelCall.options
# Get prompt from messages
promptContent = messages[0]["content"] if messages else ""
# Parse prompt using AiCallPromptImage model
import json
messages = modelCall.messages
options = modelCall.options
promptContent = messages[0]["content"] if messages else ""
try:
# Try to parse as JSON
promptData = json.loads(promptContent)
promptModel = AiCallPromptImage(**promptData)
except:
# If not JSON, use plain text prompt
except Exception:
promptModel = AiCallPromptImage(
prompt=promptContent,
size=options.size if options and hasattr(options, 'size') else "1024x1024",
quality=options.quality if options and hasattr(options, 'quality') else "standard",
style=options.style if options and hasattr(options, 'style') else "vivid"
size=options.size if options and hasattr(options, "size") else "1024x1024",
quality=options.quality if options and hasattr(options, "quality") else "auto",
)
# Extract parameters from Pydantic model
prompt = promptModel.prompt
size = promptModel.size or "1024x1024"
quality = promptModel.quality or "standard"
style = promptModel.style or "vivid"
rawQuality = promptModel.quality or "auto"
quality = {"standard": "auto", "hd": "high"}.get(rawQuality, rawQuality)
logger.debug(f"Starting image generation with prompt: '{prompt[:100]}...'")
# DALL-E 3 API endpoint
dalle_url = "https://api.openai.com/v1/images/generations"
payload = {
"model": "dall-e-3",
"model": "gpt-image-1",
"prompt": prompt,
"size": size,
"quality": quality,
"style": style,
"n": 1,
"response_format": "b64_json" # Get base64 data directly instead of URLs
}
# Use existing httpClient to benefit from connection pooling
# This avoids TLS connection issues that can occur with fresh clients
response = await self.httpClient.post(
dalle_url,
json=payload
"https://api.openai.com/v1/images/generations",
json=payload,
)
if response.status_code != 200:
logger.error(f"DALL-E API error: {response.status_code} - {response.text}")
logger.error(f"Image generation API error: {response.status_code} - {response.text}")
return AiModelResponse(
content="",
success=False,
error=f"DALL-E API error: {response.status_code} - {response.text}"
error=f"Image generation API error: {response.status_code} - {response.text}",
)
responseJson = response.json()
if "data" in responseJson and len(responseJson["data"]) > 0:
image_data = responseJson["data"][0]["b64_json"]
logger.info(f"Successfully generated image: {len(image_data)} characters")
imageData = responseJson["data"][0].get("b64_json", "")
if not imageData:
imageData = responseJson["data"][0].get("url", "")
logger.info(f"Successfully generated image: {len(imageData)} characters")
return AiModelResponse(
content=image_data,
content=imageData,
success=True,
modelId="dall-e-3",
modelId="gpt-image-1",
metadata={
"size": size,
"quality": quality,
"style": style,
"response_id": responseJson.get("id", "")
}
"response_id": responseJson.get("id", ""),
},
)
else:
logger.error("No image data in DALL-E response")
logger.error("No image data in generation response")
return AiModelResponse(
content="",
success=False,
error="No image data in DALL-E response"
error="No image data in generation response",
)
except Exception as e:
logger.error(f"Error during image generation: {str(e)}", exc_info=True)
return AiModelResponse(
content="",
success=False,
error=f"Error during image generation: {str(e)}"
)
error=f"Error during image generation: {str(e)}",
)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
import logging
import httpx

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
AI Connector for PowerOn Private-LLM Service.
@ -6,14 +6,17 @@ AI Connector for PowerOn Private-LLM Service.
Connects to the private-llm service running on-premise with Ollama backend.
Provides OCR and Vision capabilities via local AI models.
Models:
- poweron-text-general: Text (qwen2.5); NEUTRALIZATION_TEXT + data/plan ops
- poweron-vision-general: Vision (qwen2.5vl); IMAGE_ANALYSE + NEUTRALIZATION_IMAGE
Models (current L4 24 GB):
- poweron-text-general: Text (qwen2.5:7b); NEUTRALIZATION_TEXT + data/plan ops
- poweron-vision-general: Vision (qwen2.5vl:7b); IMAGE_ANALYSE + NEUTRALIZATION_IMAGE
- poweron-vision-deep: Vision (granite3.2); IMAGE_ANALYSE + NEUTRALIZATION_IMAGE
Pricing (CHF per call):
- Text models: CHF 0.010
- Vision models: CHF 0.100
Models (next-gen RTX PRO 6000 96 GB, auto-activated when pulled in Ollama):
- poweron-text-reasoning: Reasoning (deepseek-r1:70b); complex logic, math, planning
- poweron-vision-general: Vision (llama4:scout); multimodal, long-context documents
- poweron-embed: Embedding (nomic-embed-text); local RAG embedding
Pricing: byte-based (~per-token via bytes/4), configured via the PRICE_* constants below.
"""
import logging
@ -36,9 +39,20 @@ from modules.datamodels.datamodelAi import (
# Configure logger
logger = logging.getLogger(__name__)
# Pricing constants (CHF)
PRICE_TEXT_PER_CALL = 0.01 # CHF 0.010 per text model call
PRICE_VISION_PER_CALL = 0.10 # CHF 0.100 per vision model call
# Pricing constants (CHF per 1k tokens; billed byte-based via bytes/4 ~ 1 token)
PRICE_INPUT_PER_1K = 0.0075
PRICE_OUTPUT_PER_1K = 0.0375
PRICE_EMBED_PER_1K = 0.0005
def _calcPrivatePriceCHF(processingTime, bytesSent, bytesReceived):
"""Byte-based price for private text/vision/reasoning models."""
return (bytesSent / 4 / 1000) * PRICE_INPUT_PER_1K + (bytesReceived / 4 / 1000) * PRICE_OUTPUT_PER_1K
def _calcPrivateEmbedPriceCHF(processingTime, bytesSent, bytesReceived):
"""Byte-based price for private embedding (input only)."""
return (bytesSent / 4 / 1000) * PRICE_EMBED_PER_1K
# Private-LLM Service URL (fix, nicht via env konfigurierbar)
@ -233,8 +247,8 @@ class AiPrivateLlm(BaseConnectorAi):
temperature=0.1,
maxTokens=4096,
contextLength=8192, # Reduced for RAM constraints
costPer1kTokensInput=0.0, # Flat rate pricing
costPer1kTokensOutput=0.0, # Flat rate pricing
costPer1kTokensInput=PRICE_INPUT_PER_1K,
costPer1kTokensOutput=PRICE_OUTPUT_PER_1K,
speedRating=8, # Fast and efficient
qualityRating=9, # High quality text model
functionCall=self.callAiText,
@ -250,7 +264,7 @@ class AiPrivateLlm(BaseConnectorAi):
(OperationTypeEnum.AGENT, 8),
),
version="qwen2.5:7b",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_TEXT_PER_CALL
calculatepriceCHF=_calcPrivatePriceCHF
),
"ollamaModel": "qwen2.5:7b"
},
@ -264,8 +278,8 @@ class AiPrivateLlm(BaseConnectorAi):
temperature=0.2,
maxTokens=2048,
contextLength=4096, # Reduced for RAM constraints (vision needs more)
costPer1kTokensInput=0.0, # Flat rate pricing
costPer1kTokensOutput=0.0, # Flat rate pricing
costPer1kTokensInput=PRICE_INPUT_PER_1K,
costPer1kTokensOutput=PRICE_OUTPUT_PER_1K,
speedRating=7,
qualityRating=9,
functionCall=self.callAiVision,
@ -276,7 +290,7 @@ class AiPrivateLlm(BaseConnectorAi):
(OperationTypeEnum.NEUTRALIZATION_IMAGE, 9),
),
version="qwen2.5vl:7b",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL
calculatepriceCHF=_calcPrivatePriceCHF
),
"ollamaModel": "qwen2.5vl:7b"
},
@ -290,8 +304,8 @@ class AiPrivateLlm(BaseConnectorAi):
temperature=0.1,
maxTokens=2048,
contextLength=4096, # Reduced for RAM constraints
costPer1kTokensInput=0.0, # Flat rate pricing
costPer1kTokensOutput=0.0, # Flat rate pricing
costPer1kTokensInput=PRICE_INPUT_PER_1K,
costPer1kTokensOutput=PRICE_OUTPUT_PER_1K,
speedRating=9, # Fast due to small 2B model
qualityRating=8, # Good for document understanding
functionCall=self.callAiVision,
@ -302,10 +316,92 @@ class AiPrivateLlm(BaseConnectorAi):
(OperationTypeEnum.NEUTRALIZATION_IMAGE, 9),
),
version="granite3.2-vision",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL
calculatepriceCHF=_calcPrivatePriceCHF
),
"ollamaModel": "granite3.2-vision"
},
# --- Next-gen models (auto-activated when available in Ollama) ---
# Reasoning Model (deepseek-r1:70b — chain-of-thought, math, logic)
{
"model": AiModel(
name="poweron-text-reasoning",
displayName="PowerOn Reasoning",
connectorType="privatellm",
apiUrl=f"{self.baseUrl}/api/analyze",
temperature=0.1,
maxTokens=8192,
contextLength=65536,
costPer1kTokensInput=PRICE_INPUT_PER_1K,
costPer1kTokensOutput=PRICE_OUTPUT_PER_1K,
speedRating=5,
qualityRating=10,
functionCall=self.callAiText,
priority=PriorityEnum.QUALITY,
processingMode=ProcessingModeEnum.DETAILED,
operationTypes=createOperationTypeRatings(
(OperationTypeEnum.PLAN, 10),
(OperationTypeEnum.DATA_ANALYSE, 10),
(OperationTypeEnum.DATA_GENERATE, 9),
(OperationTypeEnum.DATA_EXTRACT, 9),
(OperationTypeEnum.NEUTRALIZATION_TEXT, 10),
(OperationTypeEnum.AGENT, 9),
),
version="deepseek-r1:70b",
calculatepriceCHF=_calcPrivatePriceCHF
),
"ollamaModel": "deepseek-r1:70b"
},
# Vision Multimodal (llama4:scout — native vision, 10M context)
{
"model": AiModel(
name="poweron-vision-multimodal",
displayName="PowerOn Vision Multimodal",
connectorType="privatellm",
apiUrl=f"{self.baseUrl}/api/analyze",
temperature=0.2,
maxTokens=4096,
contextLength=131072,
costPer1kTokensInput=PRICE_INPUT_PER_1K,
costPer1kTokensOutput=PRICE_OUTPUT_PER_1K,
speedRating=7,
qualityRating=10,
functionCall=self.callAiVision,
priority=PriorityEnum.QUALITY,
processingMode=ProcessingModeEnum.DETAILED,
operationTypes=createOperationTypeRatings(
(OperationTypeEnum.IMAGE_ANALYSE, 10),
(OperationTypeEnum.NEUTRALIZATION_IMAGE, 10),
),
version="llama4:scout",
calculatepriceCHF=_calcPrivatePriceCHF
),
"ollamaModel": "llama4:scout"
},
# Local Embedding (nomic-embed-text — replaces OpenAI text-embedding-3-small)
{
"model": AiModel(
name="poweron-embed",
displayName="PowerOn Embedding",
connectorType="privatellm",
apiUrl=f"{self.baseUrl}/v1/embeddings",
temperature=0.0,
maxTokens=0,
contextLength=8192,
costPer1kTokensInput=PRICE_EMBED_PER_1K,
costPer1kTokensOutput=0.0,
speedRating=10,
qualityRating=8,
functionCall=self.callAiText,
priority=PriorityEnum.COST,
processingMode=ProcessingModeEnum.BASIC,
operationTypes=createOperationTypeRatings(
(OperationTypeEnum.EMBEDDING, 9),
),
version="nomic-embed-text",
calculatepriceCHF=_calcPrivateEmbedPriceCHF
),
"ollamaModel": "nomic-embed-text"
},
]
# Filter models by Ollama availability
@ -320,7 +416,7 @@ class AiPrivateLlm(BaseConnectorAi):
unavailableModels.append(modelDef["model"].name)
if unavailableModels:
logger.warning(
logger.info(
f"Private-LLM: {len(unavailableModels)} models not available in Ollama: {', '.join(unavailableModels)}. "
f"Install with: ollama pull <model-name>"
)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Tavily web search class.
"""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Authentication and authorization modules for routes and services.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Authentication module for backend API.
@ -437,7 +437,7 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
# Audit for all SysAdmin actions
try:
from modules.shared.auditLogger import audit_logger
from modules.dbHelpers.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",
@ -483,7 +483,7 @@ def requirePlatformAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
# Audit for all Platform-Admin actions
try:
from modules.shared.auditLogger import audit_logger
from modules.dbHelpers.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
CSRF Protection Middleware for PowerOn Gateway

View file

@ -1,11 +1,11 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
JWT Service
Centralizes local JWT creation and cookie helpers.
"""
from datetime import timedelta
from datetime import datetime, timedelta
from typing import Optional, Tuple
from fastapi import Response
from jose import jwt

132
modules/auth/mfaService.py Normal file
View file

@ -0,0 +1,132 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
MFA (Multi-Factor Authentication) Service.
TOTP-based MFA using pyotp. Secrets are encrypted at rest via
encryptValue/decryptValue from the configuration module.
MFA obligation is resolved by three OR-linked rules:
1. Any mandate the user belongs to has ``mfaRequired=True``.
2. User is sysAdmin OR platformAdmin AND config key ``MFA_REQUIRE_ADMINS``
is truthy.
3. User has opted in (``mfaEnabled=True`` without any mandate/admin rule).
"""
import logging
from typing import Optional
import pyotp
from modules.shared.configuration import APP_CONFIG, encryptValue, decryptValue
logger = logging.getLogger(__name__)
_MFA_DIGITS = 6
_MFA_INTERVAL = 30
_MFA_VALID_WINDOW = 1
def getMfaIssuer() -> str:
"""Build the TOTP issuer name, e.g. 'PowerOn' or 'PowerOn (Dev)'."""
envType = (APP_CONFIG.get("APP_ENV_TYPE") or "").strip().lower()
if envType in ("prod", ""):
return "PowerOn"
return f"PowerOn ({envType.upper()})"
def _generateSecret() -> str:
"""Generate a fresh base32-encoded TOTP secret."""
return pyotp.random_base32()
def _encryptSecret(plainSecret: str, userId: str = "system") -> str:
return encryptValue(plainSecret, userId=userId, keyName="mfa_secret")
def decryptSecret(encryptedSecret: str, userId: str = "system") -> str:
return decryptValue(encryptedSecret, userId=userId, keyName="mfa_secret")
def buildTotp(plainSecret: str) -> pyotp.TOTP:
return pyotp.TOTP(plainSecret, digits=_MFA_DIGITS, interval=_MFA_INTERVAL)
def generateSetup(userId: str, username: str) -> dict:
"""Start MFA enrolment: return secret + provisioning URI (for QR code).
Returns dict with keys ``secret`` (encrypted for DB storage) and
``provisioningUri`` (otpauth:// URI the frontend renders as QR).
The plaintext secret is NOT returned -- the URI already contains it.
"""
plain = _generateSecret()
encrypted = _encryptSecret(plain, userId=userId)
totp = buildTotp(plain)
uri = totp.provisioning_uri(name=username, issuer_name=getMfaIssuer())
return {
"encryptedSecret": encrypted,
"provisioningUri": uri,
}
def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> bool:
"""Verify a TOTP code against an encrypted secret (enrolment confirmation)."""
try:
plain = decryptSecret(encryptedSecret, userId=userId)
totp = buildTotp(plain)
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
except Exception:
logger.exception("MFA confirmSetup failed for userId=%s", userId)
return False
def verifyCode(encryptedSecret: str, code: str, userId: str = "system") -> bool:
"""Verify a TOTP code during login."""
try:
plain = decryptSecret(encryptedSecret, userId=userId)
totp = buildTotp(plain)
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
except Exception:
logger.exception("MFA verifyCode failed for userId=%s", userId)
return False
def _isMfaRequireAdminsEnabled() -> bool:
"""Read ``MFA_REQUIRE_ADMINS`` from config / env."""
raw = (APP_CONFIG.get("MFA_REQUIRE_ADMINS") or "").strip().lower()
return raw in ("1", "true", "yes")
def isMfaRequired(user, userMandates=None, mandates=None) -> bool:
"""Resolve whether MFA is mandatory for *user*.
Rules (OR):
1. At least one of the user's mandates has ``mfaRequired=True``.
2. User is sysAdmin or platformAdmin AND ``MFA_REQUIRE_ADMINS`` config
key is truthy.
3. User already opted in (``mfaEnabled=True``).
Parameters
----------
user : User | UserInDB
The user object.
userMandates : list | None
List of UserMandate records for the user (each has ``mandateId``).
mandates : list | None
List of Mandate objects the user has access to. If provided directly
this avoids a second lookup.
"""
if getattr(user, "mfaEnabled", False):
return True
isSys = getattr(user, "isSysAdmin", False)
isPlat = getattr(user, "isPlatformAdmin", False)
if (isSys or isPlat) and _isMfaRequireAdminsEnabled():
return True
if mandates:
for m in mandates:
if getattr(m, "mfaRequired", False):
return True
return False

View file

@ -0,0 +1,101 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Short-lived signed tickets for OAuth data-connection popups.
The UI authenticates API calls with a Bearer token in localStorage, but
``window.open(authUrl)`` cannot send that header. Cross-origin httpOnly cookies
are unreliable in cross-origin setups (UI and API on different subdomains).
Login popups work without a session because ``/auth/login`` is public; connect
popups hit ``/auth/connect``, which used to require ``getCurrentUser``.
Flow: POST ``/api/connections/{id}/connect`` (Bearer-authenticated) issues a
ticket; the popup opens ``/auth/connect?connectTicket=...`` which validates the
ticket instead of cookies.
"""
import time
from typing import Any, Dict, Tuple
from fastapi import HTTPException, status
from jose import JWTError, jwt as jose_jwt
from modules.auth.jwtService import ALGORITHM, SECRET_KEY
from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.shared.i18nRegistry import apiRouteContext
_msg = apiRouteContext("oauthConnectTicket")
_CONNECT_TICKET_TTL_SEC = 600
def issue_connect_ticket(flow: str, connection_id: str, user_id: str) -> str:
"""Issue a short-lived JWT for starting a data-connection OAuth popup."""
body = {
"flow": flow,
"connectionId": connection_id,
"userId": str(user_id),
"exp": int(time.time()) + _CONNECT_TICKET_TTL_SEC,
}
return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM)
def parse_connect_ticket(ticket: str, expected_flow: str) -> Dict[str, Any]:
"""Validate connect ticket signature, expiry, and flow."""
try:
data = jose_jwt.decode(ticket, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=_msg("Invalid or expired connect ticket"),
) from e
if data.get("flow") != expected_flow:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=_msg("Invalid connect ticket flow"),
)
connection_id = data.get("connectionId")
user_id = data.get("userId")
if not connection_id or not user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=_msg("Incomplete connect ticket"),
)
return data
def resolve_connect_context(
connect_ticket: str,
connection_id: str,
expected_flow: str,
authority: AuthAuthority,
) -> Tuple[User, UserConnection]:
"""Validate ticket and return the user + connection for OAuth redirect."""
state = parse_connect_ticket(connect_ticket, expected_flow)
if state.get("connectionId") != connection_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=_msg("Connection ID does not match connect ticket"),
)
root = getRootInterface()
user = root.getUser(state["userId"])
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=_msg("User not found"),
)
interface = getInterface(user)
connection = None
for conn in interface.getUserConnections(user.id):
if conn.id == connection_id and conn.authority == authority:
connection = conn
break
if not connection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=_msg("Connection not found"),
)
return user, connection

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft)."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Token Manager Service

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Token Refresh Middleware for PowerOn Gateway

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Token Refresh Service for PowerOn Gateway
@ -12,7 +12,7 @@ import logging
from typing import Dict, Any
from modules.datamodels.datamodelUam import UserConnection, AuthAuthority
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.auditLogger import audit_logger
from modules.dbHelpers.auditLogger import audit_logger
logger = logging.getLogger(__name__)

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Azure Communication Services Email Connector

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Twilio SMS Connector

View file

@ -1,3 +1,5 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
ÖREB WFS Connector

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Preprocessor connector for executing SQL queries via HTTP API.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Abstract base classes for the Provider-Connector architecture (1:n).

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows).
@ -13,10 +13,13 @@ Path convention (leading slash, no trailing slash except root):
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Union
import aiohttp
from modules.connectors.connectorProviderBase import (
ProviderConnector,
@ -24,11 +27,11 @@ from modules.connectors.connectorProviderBase import (
DownloadResult,
)
from modules.datamodels.datamodelDataSource import ExternalEntry
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import ClickupService
logger = logging.getLogger(__name__)
# type metadata for ExternalEntry.metadata["cuType"]
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
_CU_TEAM = "team"
_CU_SPACE = "space"
_CU_FOLDER = "folder"
@ -45,14 +48,118 @@ def _norm(path: str) -> str:
return p
def clickupAuthorizationHeader(token: str) -> str:
"""ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer."""
t = (token or "").strip()
if t.startswith("pk_"):
return t
return f"Bearer {t}"
class ClickupApiClient:
"""Low-level ClickUp REST API v2 client. Pure HTTP — no service dependencies."""
def __init__(self, accessToken: str):
self.accessToken = accessToken
async def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
data: Optional[aiohttp.FormData] = None,
) -> Union[Dict[str, Any], List[Any], bytes, None]:
if not self.accessToken:
return {"error": "Access token is not set."}
url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}"
headers: Dict[str, str] = {
"Authorization": clickupAuthorizationHeader(self.accessToken),
}
if json_body is not None:
headers["Content-Type"] = "application/json"
timeout = aiohttp.ClientTimeout(total=60)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
kwargs: Dict[str, Any] = {"headers": headers, "params": params}
if json_body is not None:
kwargs["json"] = json_body
if data is not None:
kwargs["data"] = data
async with session.request(method.upper(), url, **kwargs) as resp:
if resp.status == 204:
return {}
text = await resp.text()
if resp.status >= 400:
log = logger.warning if resp.status == 404 else logger.error
log(f"ClickUp API {method} {url} -> {resp.status}: {text[:500]}")
return {"error": f"HTTP {resp.status}", "body": text}
if not text:
return {}
try:
return json.loads(text)
except Exception:
return {"raw": text}
except asyncio.TimeoutError:
return {"error": f"ClickUp API timeout: {path}"}
except Exception as e:
logger.error(f"ClickUp API error: {e}")
return {"error": str(e)}
async def getAuthorizedTeams(self) -> Dict[str, Any]:
return await self._request("GET", "/team")
async def getSpaces(self, teamId: str) -> Dict[str, Any]:
return await self._request("GET", f"/team/{teamId}/space")
async def getFolders(self, spaceId: str) -> Dict[str, Any]:
return await self._request("GET", f"/space/{spaceId}/folder")
async def getFolderlessLists(self, spaceId: str) -> Dict[str, Any]:
return await self._request("GET", f"/space/{spaceId}/list")
async def getListsInFolder(self, folderId: str) -> Dict[str, Any]:
return await self._request("GET", f"/folder/{folderId}/list")
async def getTasksInList(self, listId: str, *, page: int = 0) -> Dict[str, Any]:
params: Dict[str, Any] = {"page": page, "subtasks": "true", "include_closed": "false"}
return await self._request("GET", f"/list/{listId}/task", params=params)
async def getTask(self, taskId: str) -> Dict[str, Any]:
params = {"include_subtasks": "true"}
return await self._request("GET", f"/task/{taskId}", params=params)
async def searchTeamTasks(self, teamId: str, *, query: str, page: int = 0) -> Dict[str, Any]:
params = {"query": query, "page": page}
return await self._request("GET", f"/team/{teamId}/task", params=params)
async def uploadTaskAttachment(self, taskId: str, fileBytes: bytes, fileName: str) -> Dict[str, Any]:
if not self.accessToken:
return {"error": "Access token is not set."}
url = f"{_CLICKUP_API_BASE}/task/{taskId}/attachment"
headers = {"Authorization": clickupAuthorizationHeader(self.accessToken)}
formData = aiohttp.FormData()
formData.add_field("attachment", fileBytes, filename=fileName, content_type="application/octet-stream")
timeout = aiohttp.ClientTimeout(total=120)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, headers=headers, data=formData) as resp:
text = await resp.text()
if resp.status >= 400:
return {"error": f"HTTP {resp.status}", "body": text}
return json.loads(text) if text else {}
except Exception as e:
return {"error": str(e)}
class ClickupListsAdapter(ServiceAdapter):
"""Maps ClickUp hierarchy + list tasks to browse/download/upload/search."""
def __init__(self, access_token: str):
self._token = access_token
# Minimal service instance for API calls (no ServiceCenter context)
self._svc = ClickupService(context=None, get_service=lambda _: None)
self._svc.setAccessToken(access_token)
self._svc = ClickupApiClient(access_token)
async def browse(
self,

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""FTP/SFTP ProviderConnector stub.

View file

@ -1,36 +1,78 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Google ProviderConnector -- Drive and Gmail via Google OAuth."""
import asyncio
import base64
import logging
import re
import urllib.parse
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
import aiohttp
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
from modules.shared.httpResilience import ResilientHttp
from modules.datamodels.datamodelDataSource import ExternalEntry
logger = logging.getLogger(__name__)
_http = ResilientHttp("Google", maxConcurrent=8, defaultTimeoutS=20)
_DRIVE_BASE = "https://www.googleapis.com/drive/v3"
_GMAIL_BASE = "https://gmail.googleapis.com/gmail/v1"
_CALENDAR_BASE = "https://www.googleapis.com/calendar/v3"
_PEOPLE_BASE = "https://people.googleapis.com/v1"
async def _googleGet(token: str, url: str) -> Dict[str, Any]:
def _parseGoogleDateRange(text: Optional[str]) -> tuple:
"""Parse a date range from a filter/query string for Calendar timeMin/timeMax.
Supports two ISO dates, a single ISO date (~31 day window) or a YYYY-MM
month pattern. Returns RFC3339 UTC strings (timeMin, timeMax) or (None, None).
"""
if not text:
return (None, None)
def _toRfc3339(value: str) -> str:
value = value.strip().rstrip("Z")
if "T" not in value:
value = f"{value}T00:00:00"
return f"{value}Z"
isoMatch = re.findall(r'\d{4}-\d{2}-\d{2}(?:T[\d:]+)?', text)
if len(isoMatch) >= 2:
return (_toRfc3339(isoMatch[0]), _toRfc3339(isoMatch[1]))
if len(isoMatch) == 1:
try:
dt = datetime.fromisoformat(isoMatch[0])
return (_toRfc3339(isoMatch[0]), _toRfc3339((dt + timedelta(days=31)).strftime('%Y-%m-%dT00:00:00')))
except ValueError:
pass
monthMatch = re.match(r'^(\d{4})-(\d{2})$', text.strip())
if monthMatch:
year, month = int(monthMatch.group(1)), int(monthMatch.group(2))
start = f"{year}-{month:02d}-01T00:00:00"
end = f"{year + 1}-01-01T00:00:00" if month == 12 else f"{year}-{month + 1:02d}-01T00:00:00"
return (_toRfc3339(start), _toRfc3339(end))
return (None, None)
async def googleGet(token: str, url: str) -> Dict[str, Any]:
headers = {"Authorization": f"Bearer {token}"}
timeout = aiohttp.ClientTimeout(total=20)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as resp:
if resp.status in (200, 201):
return await resp.json()
errorText = await resp.text()
logger.warning(f"Google API {resp.status}: {errorText[:300]}")
return {"error": f"{resp.status}: {errorText[:200]}"}
except Exception as e:
return {"error": str(e)}
return await _http.getJson(url, headers=headers)
def _raiseGoogleError(result: Dict[str, Any], ctx: str) -> None:
"""Raise a clear error for a failed Google API response.
Browse/search must NOT swallow API failures into an empty result list, which
masks a real error as 'empty'. Callers wrap these in try/except.
"""
err = result.get("error") if isinstance(result, dict) else None
logger.warning("Google error (%s): %s", ctx, err or result)
raise RuntimeError(f"Google error ({ctx}): {err or result}")
class DriveAdapter(ServiceAdapter):
@ -51,10 +93,9 @@ class DriveAdapter(ServiceAdapter):
pageSize = max(1, min(int(limit or 100), 1000))
url = f"{_DRIVE_BASE}/files?q={query}&fields={fields}&pageSize={pageSize}&orderBy=folder,name"
result = await _googleGet(self._token, url)
result = await googleGet(self._token, url)
if "error" in result:
logger.warning(f"Google Drive browse failed: {result['error']}")
return []
_raiseGoogleError(result, "Google Drive browse")
entries = []
for f in result.get("files", []):
@ -81,37 +122,33 @@ class DriveAdapter(ServiceAdapter):
if not fileId:
return b""
headers = {"Authorization": f"Bearer {self._token}"}
timeout = aiohttp.ClientTimeout(total=60)
dlTimeout = aiohttp.ClientTimeout(total=60)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
# Try direct download first
url = f"{_DRIVE_BASE}/files/{fileId}?alt=media"
async with session.get(url, headers=headers) as resp:
if resp.status == 200:
return await resp.read()
logger.debug(f"Google Drive direct download returned {resp.status} for {fileId}")
url = f"{_DRIVE_BASE}/files/{fileId}?alt=media"
data = await _http.getBytes(url, headers=headers, timeout=dlTimeout)
if data is not None:
return data
logger.debug(f"Google Drive direct download returned None for {fileId}")
# If 403/404, check if it's a native Google file that needs export
metaUrl = f"{_DRIVE_BASE}/files/{fileId}?fields=mimeType,name"
async with session.get(metaUrl, headers=headers) as metaResp:
if metaResp.status != 200:
logger.warning(f"Google Drive metadata fetch failed ({metaResp.status}) for {fileId}")
return b""
meta = await metaResp.json()
fileMime = meta.get("mimeType", "")
fileName = meta.get("name", fileId)
metaUrl = f"{_DRIVE_BASE}/files/{fileId}?fields=mimeType,name"
meta = await _http.getJson(metaUrl, headers=headers)
if "error" in meta:
logger.warning(f"Google Drive metadata fetch failed for {fileId}: {meta['error']}")
return b""
fileMime = meta.get("mimeType", "")
fileName = meta.get("name", fileId)
exportMime = self._EXPORT_MIME_MAP.get(fileMime)
if not exportMime:
logger.warning(f"Google Drive: unsupported mimeType '{fileMime}' for file '{fileName}' ({fileId})")
return b""
exportMime = self._EXPORT_MIME_MAP.get(fileMime)
if not exportMime:
logger.warning(f"Google Drive: unsupported mimeType '{fileMime}' for file '{fileName}' ({fileId})")
return b""
exportUrl = f"{_DRIVE_BASE}/files/{fileId}/export?mimeType={exportMime}"
logger.info(f"Google Drive: exporting '{fileName}' as {exportMime}")
async with session.get(exportUrl, headers=headers) as exportResp:
if exportResp.status == 200:
return await exportResp.read()
logger.warning(f"Google Drive export failed ({exportResp.status}) for '{fileName}'")
exportUrl = f"{_DRIVE_BASE}/files/{fileId}/export?mimeType={exportMime}"
logger.info(f"Google Drive: exporting '{fileName}' as {exportMime}")
exported = await _http.getBytes(exportUrl, headers=headers, timeout=dlTimeout)
if exported is not None:
return exported
logger.warning(f"Google Drive export failed for '{fileName}'")
except Exception as e:
logger.error(f"Google Drive download failed for {fileId}: {e}")
return b""
@ -125,27 +162,51 @@ class DriveAdapter(ServiceAdapter):
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
safeQuery = query.replace("'", "\\'")
safeQuery = query.replace("\\", "\\\\").replace("'", "\\'")
folderId = (path or "").strip("/")
qParts = [f"name contains '{safeQuery}'", "trashed=false"]
# `fullText contains` matches file name AND content (and some metadata),
# which is what users expect from a search -- not just the file name.
qParts = [f"fullText contains '{safeQuery}'", "trashed=false"]
if folderId:
qParts.append(f"'{folderId}' in parents")
qStr = " and ".join(qParts)
pageSize = max(1, min(int(limit or 100), 1000))
url = f"{_DRIVE_BASE}/files?q={qStr}&fields=files(id,name,mimeType,size)&pageSize={pageSize}"
effectiveLimit = max(1, int(limit)) if limit is not None else None
pageSize = min(effectiveLimit or 100, 1000)
logger.debug(f"Google Drive search: q={qStr}")
result = await _googleGet(self._token, url)
if "error" in result:
return []
return [
ExternalEntry(
name=f.get("name", ""),
path=f"/{f.get('id', '')}",
isFolder=f.get("mimeType") == "application/vnd.google-apps.folder",
size=int(f.get("size", 0)) if f.get("size") else None,
)
for f in result.get("files", [])
]
entries: List[ExternalEntry] = []
pageToken: Optional[str] = None
hardCap = effectiveLimit or 1000
while len(entries) < hardCap:
params = {
"q": qStr,
"fields": "nextPageToken,files(id,name,mimeType,size,modifiedTime)",
"pageSize": str(pageSize),
}
if pageToken:
params["pageToken"] = pageToken
url = f"{_DRIVE_BASE}/files?{urllib.parse.urlencode(params)}"
result = await googleGet(self._token, url)
if "error" in result:
if not entries:
_raiseGoogleError(result, "Google Drive search")
break
for f in result.get("files", []):
entries.append(ExternalEntry(
name=f.get("name", ""),
path=f"/{f.get('id', '')}",
isFolder=f.get("mimeType") == "application/vnd.google-apps.folder",
size=int(f.get("size", 0)) if f.get("size") else None,
mimeType=f.get("mimeType"),
metadata={"id": f.get("id"), "modifiedTime": f.get("modifiedTime")},
))
if len(entries) >= hardCap:
break
pageToken = result.get("nextPageToken")
if not pageToken:
break
if effectiveLimit is not None:
entries = entries[:effectiveLimit]
return entries
class GmailAdapter(ServiceAdapter):
@ -155,7 +216,8 @@ class GmailAdapter(ServiceAdapter):
self._token = accessToken
_DEFAULT_MESSAGE_LIMIT = 100
_MAX_MESSAGE_LIMIT = 500
_MAX_MESSAGE_LIMIT = 1000
_METADATA_FETCH_CAP = 200
async def browse(
self,
@ -167,10 +229,9 @@ class GmailAdapter(ServiceAdapter):
if not cleanPath:
url = f"{_GMAIL_BASE}/users/me/labels"
result = await _googleGet(self._token, url)
result = await googleGet(self._token, url)
if "error" in result:
logger.warning(f"Gmail labels failed: {result['error']}")
return []
_raiseGoogleError(result, "Gmail labels")
_SYSTEM_LABELS = {"INBOX", "SENT", "DRAFT", "TRASH", "SPAM", "STARRED", "IMPORTANT"}
labels = []
for lbl in result.get("labels", []):
@ -188,23 +249,116 @@ class GmailAdapter(ServiceAdapter):
return labels
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
url = f"{_GMAIL_BASE}/users/me/messages?labelIds={cleanPath}&maxResults={effectiveLimit}"
result = await _googleGet(self._token, url)
if "error" in result:
return []
entries = []
for msg in result.get("messages", [])[:effectiveLimit]:
msgId = msg.get("id", "")
detailUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date"
detail = await _googleGet(self._token, detailUrl)
if "error" in detail:
entries.append(ExternalEntry(name=f"Message {msgId}", path=f"/{cleanPath}/{msgId}", isFolder=False))
continue
headers = {h.get("name", ""): h.get("value", "") for h in detail.get("payload", {}).get("headers", [])}
labelId = await self._resolveLabelId(cleanPath)
if not labelId:
raise ValueError(
f"Gmail label not found: '{cleanPath}'. Browse the mailbox root ('/') "
f"to list available labels."
)
msgIds, totalEstimate = await self._listMessageIds(
params={"labelIds": labelId}, limit=effectiveLimit,
)
entries = await self._fetchMessageEntries(
msgIds[:self._METADATA_FETCH_CAP], labelPath=labelId,
)
if totalEstimate and totalEstimate > len(msgIds):
entries.append(ExternalEntry(
name=f"(~{totalEstimate} total messages estimated, {len(msgIds)} listed)",
path=f"/{labelId}/_count", isFolder=False,
metadata={"totalEstimate": totalEstimate, "listed": len(msgIds)},
))
elif len(msgIds) > self._METADATA_FETCH_CAP:
entries.append(ExternalEntry(
name=f"({len(msgIds)} messages listed, metadata shown for first {self._METADATA_FETCH_CAP})",
path=f"/{labelId}/_count", isFolder=False,
metadata={"listed": len(msgIds), "metadataShown": self._METADATA_FETCH_CAP},
))
return entries
async def _resolveLabelId(self, ref: str) -> Optional[str]:
"""Resolve a Gmail label reference (display name / system name / id) to a
label id. Returns None if nothing matches so the caller can raise a clear
error instead of querying with an invalid label."""
if not ref:
return None
r = ref.strip()
result = await googleGet(self._token, f"{_GMAIL_BASE}/users/me/labels")
if "error" in result:
_raiseGoogleError(result, "Gmail labels")
labels = result.get("labels", [])
# 1) exact id match (already-resolved id passes through)
for lbl in labels:
if lbl.get("id") == r:
return r
# 2) case-insensitive display-name match
for lbl in labels:
if (lbl.get("name") or "").strip().lower() == r.lower():
return lbl.get("id")
# 3) system label by uppercased name (INBOX, SENT, ...)
up = r.upper()
for lbl in labels:
if lbl.get("id") == up:
return up
return None
async def _listMessageIds(
self, params: Dict[str, str], limit: int,
) -> tuple:
"""Page through ``messages.list`` and return (msgIds, totalEstimate).
Gmail's ``maxResults`` caps at 500 per page, so we follow
``nextPageToken`` until we have ``limit`` ids or there are no more pages.
``resultSizeEstimate`` from the first page gives the agent an approximate
total count without having to download every message.
"""
msgIds: List[str] = []
totalEstimate: Optional[int] = None
pageToken: Optional[str] = None
pageSize = min(limit, 500)
while len(msgIds) < limit:
p = {**params, "maxResults": str(pageSize)}
if pageToken:
p["pageToken"] = pageToken
url = f"{_GMAIL_BASE}/users/me/messages?{urllib.parse.urlencode(p)}"
result = await googleGet(self._token, url)
if "error" in result:
if not msgIds:
_raiseGoogleError(result, "Gmail list messages")
break
if totalEstimate is None:
totalEstimate = result.get("resultSizeEstimate")
for m in result.get("messages", []):
mid = m.get("id", "")
if mid:
msgIds.append(mid)
if len(msgIds) >= limit:
break
pageToken = result.get("nextPageToken")
if not pageToken:
break
return msgIds, totalEstimate
async def _fetchMessageEntries(self, msgIds: List[str], labelPath: str = "") -> List[ExternalEntry]:
"""Resolve a list of Gmail message ids into ExternalEntries with
Subject/From/Date metadata. Detail fetches run concurrently to avoid a
slow sequential N+1 round-trip per message."""
if not msgIds:
return []
pathPrefix = f"/{labelPath}" if labelPath else ""
async def _one(msgId: str) -> ExternalEntry:
detailUrl = (
f"{_GMAIL_BASE}/users/me/messages/{msgId}"
f"?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date"
)
detail = await googleGet(self._token, detailUrl)
if "error" in detail:
return ExternalEntry(name=f"Message {msgId}", path=f"{pathPrefix}/{msgId}", isFolder=False,
metadata={"id": msgId})
headers = {h.get("name", ""): h.get("value", "") for h in detail.get("payload", {}).get("headers", [])}
return ExternalEntry(
name=headers.get("Subject", "(no subject)"),
path=f"/{cleanPath}/{msgId}",
path=f"{pathPrefix}/{msgId}",
isFolder=False,
metadata={
"id": msgId,
@ -212,20 +366,19 @@ class GmailAdapter(ServiceAdapter):
"date": headers.get("Date", ""),
"snippet": detail.get("snippet", ""),
},
))
return entries
)
return list(await asyncio.gather(*[_one(mid) for mid in msgIds]))
async def download(self, path: str) -> DownloadResult:
"""Download a Gmail message as RFC 822 EML via format=raw."""
import base64
import re
cleanPath = (path or "").strip("/")
msgId = cleanPath.split("/")[-1] if cleanPath else ""
if not msgId:
return DownloadResult()
url = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=raw"
result = await _googleGet(self._token, url)
result = await googleGet(self._token, url)
if "error" in result:
return DownloadResult()
@ -236,7 +389,7 @@ class GmailAdapter(ServiceAdapter):
emlBytes = base64.urlsafe_b64decode(rawB64)
metaUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject"
meta = await _googleGet(self._token, metaUrl)
meta = await googleGet(self._token, metaUrl)
subject = msgId
if "error" not in meta:
for h in meta.get("payload", {}).get("headers", []):
@ -261,19 +414,34 @@ class GmailAdapter(ServiceAdapter):
limit: Optional[int] = None,
) -> list:
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
url = f"{_GMAIL_BASE}/users/me/messages?q={query}&maxResults={effectiveLimit}"
result = await _googleGet(self._token, url)
if "error" in result:
return []
return [
ExternalEntry(
name=f"Message {m.get('id', '')}",
path=f"/{m.get('id', '')}",
isFolder=False,
metadata={"id": m.get("id")},
)
for m in result.get("messages", [])
]
params: Dict[str, str] = {"q": query}
labelPath = (path or "").strip("/")
if labelPath:
labelId = await self._resolveLabelId(labelPath)
if not labelId:
raise ValueError(
f"Gmail label not found: '{labelPath}'. Browse the mailbox root ('/') "
f"to list available labels, or search without a label scope."
)
labelPath = labelId
params["labelIds"] = labelId
msgIds, totalEstimate = await self._listMessageIds(params, limit=effectiveLimit)
entries = await self._fetchMessageEntries(
msgIds[:self._METADATA_FETCH_CAP], labelPath=labelPath,
)
if totalEstimate and totalEstimate > len(msgIds):
entries.append(ExternalEntry(
name=f"(~{totalEstimate} total results estimated, {len(msgIds)} listed)",
path=f"/{labelPath or 'search'}/_count", isFolder=False,
metadata={"totalEstimate": totalEstimate, "listed": len(msgIds)},
))
elif len(msgIds) > self._METADATA_FETCH_CAP:
entries.append(ExternalEntry(
name=f"({len(msgIds)} results listed, metadata shown for first {self._METADATA_FETCH_CAP})",
path=f"/{labelPath or 'search'}/_count", isFolder=False,
metadata={"listed": len(msgIds), "metadataShown": self._METADATA_FETCH_CAP},
))
return entries
class CalendarAdapter(ServiceAdapter):
@ -300,10 +468,9 @@ class CalendarAdapter(ServiceAdapter):
cleanPath = (path or "").strip("/")
if not cleanPath:
url = f"{_CALENDAR_BASE}/users/me/calendarList?maxResults=250"
result = await _googleGet(self._token, url)
result = await googleGet(self._token, url)
if "error" in result:
logger.warning(f"Google Calendar list failed: {result['error']}")
return []
_raiseGoogleError(result, "Google Calendar list")
calendars = result.get("items", [])
if filter:
f = filter.lower()
@ -331,10 +498,14 @@ class CalendarAdapter(ServiceAdapter):
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
f"?maxResults={effectiveLimit}&orderBy=startTime&singleEvents=true"
)
result = await _googleGet(self._token, url)
# Restrict to a date window when the filter is a date range, so large
# multi-year calendars only return the relevant period.
timeMin, timeMax = _parseGoogleDateRange(filter)
if timeMin and timeMax:
url += f"&timeMin={quote(timeMin, safe='')}&timeMax={quote(timeMax, safe='')}"
result = await googleGet(self._token, url)
if "error" in result:
logger.warning(f"Google Calendar events failed: {result['error']}")
return []
_raiseGoogleError(result, "Google Calendar events")
events = result.get("items", [])
return [
ExternalEntry(
@ -362,7 +533,7 @@ class CalendarAdapter(ServiceAdapter):
return DownloadResult()
calendarId, eventId = cleanPath.split("/", 1)
url = f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events/{quote(eventId, safe='')}"
ev = await _googleGet(self._token, url)
ev = await googleGet(self._token, url)
if "error" in ev:
logger.warning(f"Google Calendar event fetch failed: {ev['error']}")
return DownloadResult()
@ -387,13 +558,23 @@ class CalendarAdapter(ServiceAdapter):
from urllib.parse import quote
calendarId = (path or "").strip("/").split("/", 1)[0] or "primary"
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
url = (
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true"
)
result = await _googleGet(self._token, url)
# A date-range query maps to timeMin/timeMax (efficient window fetch);
# otherwise fall back to the free-text q parameter.
timeMin, timeMax = _parseGoogleDateRange(query)
if timeMin and timeMax:
url = (
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
f"?timeMin={quote(timeMin, safe='')}&timeMax={quote(timeMax, safe='')}"
f"&maxResults={effectiveLimit}&orderBy=startTime&singleEvents=true"
)
else:
url = (
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true"
)
result = await googleGet(self._token, url)
if "error" in result:
return []
_raiseGoogleError(result, "Google Calendar search")
return [
ExternalEntry(
name=ev.get("summary", "(no title)"),
@ -447,7 +628,7 @@ class ContactsAdapter(ServiceAdapter):
),
]
url = f"{_PEOPLE_BASE}/contactGroups?pageSize=200"
result = await _googleGet(self._token, url)
result = await googleGet(self._token, url)
if "error" not in result:
for grp in result.get("contactGroups", []):
name = grp.get("formattedName") or grp.get("name") or ""
@ -477,10 +658,9 @@ class ContactsAdapter(ServiceAdapter):
f"{_PEOPLE_BASE}/people/me/connections"
f"?pageSize={min(effectiveLimit, 1000)}&personFields={self._PERSON_FIELDS}"
)
result = await _googleGet(self._token, url)
result = await googleGet(self._token, url)
if "error" in result:
logger.warning(f"Google People connections failed: {result['error']}")
return []
_raiseGoogleError(result, "Google People connections")
people = result.get("connections", [])
else:
groupResource = groupRef
@ -488,10 +668,9 @@ class ContactsAdapter(ServiceAdapter):
f"{_PEOPLE_BASE}/{quote(groupResource, safe='/')}"
f"?maxMembers={min(effectiveLimit, 1000)}"
)
grpResult = await _googleGet(self._token, grpUrl)
grpResult = await googleGet(self._token, grpUrl)
if "error" in grpResult:
logger.warning(f"Google contactGroup detail failed: {grpResult['error']}")
return []
_raiseGoogleError(grpResult, "Google contactGroup detail")
memberResourceNames = grpResult.get("memberResourceNames") or []
if not memberResourceNames:
return []
@ -501,7 +680,7 @@ class ContactsAdapter(ServiceAdapter):
chunk = memberResourceNames[i : i + chunkSize]
params = "&".join(f"resourceNames={quote(rn, safe='/')}" for rn in chunk)
batchUrl = f"{_PEOPLE_BASE}/people:batchGet?{params}&personFields={self._PERSON_FIELDS}"
batchResult = await _googleGet(self._token, batchUrl)
batchResult = await googleGet(self._token, batchUrl)
if "error" in batchResult:
logger.warning(f"Google People batchGet failed: {batchResult['error']}")
continue
@ -537,7 +716,7 @@ class ContactsAdapter(ServiceAdapter):
if not personSuffix:
return DownloadResult()
url = f"{_PEOPLE_BASE}/people/{quote(personSuffix, safe='')}?personFields={self._PERSON_FIELDS}"
person = await _googleGet(self._token, url)
person = await googleGet(self._token, url)
if "error" in person:
logger.warning(f"Google People fetch failed: {person['error']}")
return DownloadResult()
@ -566,9 +745,9 @@ class ContactsAdapter(ServiceAdapter):
f"?query={quote(query, safe='')}&pageSize={min(effectiveLimit, 30)}"
f"&readMask={self._PERSON_FIELDS}"
)
result = await _googleGet(self._token, url)
result = await googleGet(self._token, url)
if "error" in result:
return []
_raiseGoogleError(result, "Google Contacts search")
entries: List[ExternalEntry] = []
for r in result.get("results", []):
p = r.get("person") or {}
@ -581,6 +760,8 @@ class ContactsAdapter(ServiceAdapter):
metadata={
"id": p.get("resourceName"),
"emails": [e.get("value") for e in (p.get("emailAddresses") or []) if e.get("value")],
"phones": [pn.get("value") for pn in (p.get("phoneNumbers") or []) if pn.get("value")],
"organization": (p.get("organizations") or [{}])[0].get("name") if p.get("organizations") else None,
},
)
)
@ -588,7 +769,6 @@ class ContactsAdapter(ServiceAdapter):
def _googleSafeFileName(name: str) -> str:
import re
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
@ -608,7 +788,6 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
"""Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC)."""
if not value:
return None
from datetime import datetime, timezone
try:
if "T" not in value:
dt = datetime.strptime(value, "%Y-%m-%d")
@ -624,7 +803,6 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
def _googleEventToIcs(event: Dict[str, Any]) -> bytes:
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Google Calendar event."""
from datetime import datetime, timezone
uid = event.get("iCalUID") or event.get("id") or "unknown@poweron"
summary = _googleIcsEscape(event.get("summary") or "")
location = _googleIcsEscape(event.get("location") or "")

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT.
@ -31,6 +31,7 @@ Path conventions (leading slash, ``ServiceAdapter`` paths always start with
/{addressBookId}/{contactId} -- single contact (.vcf download)
"""
import json
import logging
import re
from datetime import datetime, timedelta, timezone
@ -44,10 +45,13 @@ from modules.connectors.connectorProviderBase import (
ServiceAdapter,
DownloadResult,
)
from modules.shared.httpResilience import ResilientHttp
from modules.datamodels.datamodelDataSource import ExternalEntry
logger = logging.getLogger(__name__)
_http = ResilientHttp("Infomaniak", maxConcurrent=6, defaultTimeoutS=20)
_API_BASE = "https://api.infomaniak.com"
_CALENDAR_BASE = "https://calendar.infomaniak.com"
_CONTACTS_BASE = "https://contacts.infomaniak.com"
@ -82,18 +86,18 @@ async def _infomaniakGet(
"""
url = f"{baseUrl.rstrip('/')}/{endpoint.lstrip('/')}"
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
timeout = aiohttp.ClientTimeout(total=20)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers, allow_redirects=False) as resp:
if resp.status in (200, 201):
return await resp.json()
errorText = await resp.text()
logger.warning(f"Infomaniak GET {url} -> {resp.status}: {errorText[:300]}")
return {"error": f"{resp.status}: {errorText[:200]}"}
except Exception as e:
logger.error(f"Infomaniak GET {url} crashed: {e}")
return {"error": str(e)}
return await _http.getJson(url, headers=headers, allowRedirects=False)
def _raiseInfomaniakError(result: Dict[str, Any], ctx: str) -> None:
"""Raise a clear error for a failed Infomaniak API response.
Browse/search must NOT swallow API failures into an empty result list, which
masks a real error as 'empty'. Callers wrap these in try/except.
"""
err = result.get("error") if isinstance(result, dict) else None
logger.warning("Infomaniak error (%s): %s", ctx, err or result)
raise RuntimeError(f"Infomaniak error ({ctx}): {err or result}")
async def _infomaniakDownload(
@ -113,20 +117,7 @@ async def _infomaniakDownload(
"""
url = f"{baseUrl.rstrip('/')}/{endpoint.lstrip('/')}"
headers = {"Authorization": f"Bearer {token}"}
timeout = aiohttp.ClientTimeout(total=120)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers, allow_redirects=True) as resp:
if resp.status == 200:
return await resp.read()
logger.warning(
f"Infomaniak download {url} -> {resp.status}: "
f"{(await resp.text())[:300]}"
)
return None
except Exception as e:
logger.error(f"Infomaniak download {url} crashed: {e}")
return None
return await _http.getBytes(url, headers=headers, timeout=aiohttp.ClientTimeout(total=120))
def _unwrapData(payload: Any) -> Any:
@ -358,10 +349,7 @@ class KdriveAdapter(ServiceAdapter):
result = await _infomaniakGet(self._token, endpoint)
if isinstance(result, dict) and result.get("error"):
logger.warning(
f"kDrive list-children {driveId}/{fileId or 'root'} failed: {result['error']}"
)
return []
_raiseInfomaniakError(result, f"kDrive list-children {driveId}/{fileId or 'root'}")
data = _unwrapData(result)
items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else []
@ -404,8 +392,115 @@ class KdriveAdapter(ServiceAdapter):
return DownloadResult()
return DownloadResult(data=content, fileName=fileName, mimeType=mimeType)
async def _createDirectory(self, driveId: str, parentId: str, name: str) -> Optional[str]:
"""Create a single directory and return its ID.
If the directory already exists (409), lists the parent to find
the existing folder's ID -- kDrive directory creation is not
idempotent.
"""
url = f"{_API_BASE}/3/drive/{driveId}/files/{parentId}/directory"
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
body = json.dumps({"name": name})
result = await _http.request("POST", url, headers=headers, data=body)
if isinstance(result, dict) and not result.get("error"):
data = _unwrapData(result)
if isinstance(data, dict) and data.get("id"):
return str(data["id"])
errorStr = str(result.get("error", "")) if isinstance(result, dict) else ""
if "already_exists" in errorStr or "409" in errorStr:
children = await self._listChildren(driveId, fileId=parentId, limit=1000)
for child in children:
if child.isFolder and child.name == name:
return (child.metadata or {}).get("id") or child.path.strip("/").split("/")[-1]
logger.warning("kDrive mkdir %s/%s in %s failed: %s", driveId, name, parentId, result)
return None
async def _ensureDirectoryPath(self, driveId: str, parentId: str, pathSegments: List[str]) -> Optional[str]:
"""Walk *pathSegments* and create each level that does not exist yet.
Returns the numeric folder ID of the deepest directory, or
``None`` if any step fails.
"""
currentId = parentId
for segment in pathSegments:
folderId = await self._createDirectory(driveId, currentId, segment)
if not folderId:
return None
currentId = folderId
return currentId
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
return {"error": "kDrive upload not yet implemented"}
"""Upload a file to kDrive.
Path formats:
/{driveId} -> upload to drive root
/{driveId}/{folderId} -> upload into folder by numeric ID
/{driveId}/{folderId}/Sub/Path -> create Sub/Path under folderId, then upload
/{driveId}/Some/Human/Path -> create path from drive root (id 1), then upload
Directories are created step-by-step via the v3 mkdir endpoint;
existing directories are reused (idempotent). File upload uses
the v3 upload endpoint (max 1 GB).
"""
segments = [s for s in (path or "").strip("/").split("/") if s]
if not segments:
return {"error": "Upload path must include at least a drive ID"}
driveId = segments[0]
targetDirId: Optional[str] = None
if len(segments) > 1:
subSegments = segments[1:]
numericPrefix: List[str] = []
nameSegments: List[str] = []
for i, seg in enumerate(subSegments):
if seg.isdigit() and not nameSegments:
numericPrefix.append(seg)
else:
nameSegments = subSegments[i:]
break
parentId = numericPrefix[-1] if numericPrefix else "1"
if nameSegments and nameSegments[-1] == fileName:
nameSegments = nameSegments[:-1]
if nameSegments:
targetDirId = await self._ensureDirectoryPath(driveId, parentId, nameSegments)
if not targetDirId:
return {"error": f"Failed to create directory path: {'/'.join(nameSegments)}"}
else:
targetDirId = parentId
params = [
f"file_name={quote(fileName)}",
f"total_size={len(data)}",
"conflict=version",
]
if targetDirId:
params.append(f"directory_id={targetDirId}")
endpoint = f"/3/drive/{driveId}/upload?{'&'.join(params)}"
url = f"{_API_BASE.rstrip('/')}/{endpoint.lstrip('/')}"
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/octet-stream",
}
result = await _http.request(
"POST", url, headers=headers, data=data,
timeout=aiohttp.ClientTimeout(total=120),
)
if isinstance(result, dict) and result.get("error"):
return result
unwrapped = _unwrapData(result) if isinstance(result, dict) else result
return unwrapped if isinstance(unwrapped, dict) else {"data": unwrapped}
async def search(
self,
@ -426,7 +521,7 @@ class KdriveAdapter(ServiceAdapter):
endpoint = f"/2/drive/{driveId}/files/search?query={query}&per_page={pageSize}"
result = await _infomaniakGet(self._token, endpoint)
if isinstance(result, dict) and result.get("error"):
return []
_raiseInfomaniakError(result, "kDrive search")
data = _unwrapData(result)
items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else []
@ -495,7 +590,7 @@ class CalendarAdapter(ServiceAdapter):
if not segments:
return await self._listCalendars()
if len(segments) == 1:
return await self._listEvents(segments[0], limit=limit)
return await self._listEvents(segments[0], limit=limit, filter=filter)
return []
async def _listCalendars(self) -> List[ExternalEntry]:
@ -503,8 +598,7 @@ class CalendarAdapter(ServiceAdapter):
self._token, f"{_PIM_PREFIX}/calendar", baseUrl=_CALENDAR_BASE
)
if isinstance(result, dict) and result.get("error"):
logger.warning(f"Calendar list-calendars failed: {result['error']}")
return []
_raiseInfomaniakError(result, "Calendar list-calendars")
data = _unwrapData(result)
calendars = data.get("calendars", []) if isinstance(data, dict) else []
entries: List[ExternalEntry] = []
@ -527,18 +621,64 @@ class CalendarAdapter(ServiceAdapter):
))
return entries
def _eventWindow(self) -> tuple:
def _eventWindow(self, filter: Optional[str] = None) -> tuple:
# Honour an explicit date range from the agent (e.g. "2026-06" or
# "2026-06-01 2026-06-30"), clamped to the vendor's <3 month limit.
# Otherwise fall back to the default 90-day browsing window.
rng = self._parseFilterWindow(filter)
if rng:
return rng
now = datetime.now(timezone.utc)
fromStr = (now - timedelta(days=self._PAST_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
toStr = (now + timedelta(days=self._FUTURE_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
return fromStr, toStr
@staticmethod
def _parseFilterWindow(filter: Optional[str]) -> Optional[tuple]:
"""Parse a date range from a filter string into Infomaniak's
'Y-m-d H:i:s' from/to window, clamped to <3 months. Returns None when
the filter is not a parseable date range."""
if not filter:
return None
iso = re.findall(r'\d{4}-\d{2}-\d{2}', filter)
start = end = None
if len(iso) >= 2:
start, end = iso[0], iso[1]
elif len(iso) == 1:
start = iso[0]
else:
month = re.match(r'^(\d{4})-(\d{2})$', filter.strip())
if not month:
return None
year, mon = int(month.group(1)), int(month.group(2))
start = f"{year}-{mon:02d}-01"
end = f"{year + 1}-01-01" if mon == 12 else f"{year}-{mon + 1:02d}-01"
try:
startDt = datetime.fromisoformat(start)
except ValueError:
return None
if end:
try:
endDt = datetime.fromisoformat(end)
except ValueError:
endDt = startDt + timedelta(days=31)
else:
endDt = startDt + timedelta(days=31)
# Clamp to vendor limit (<3 months).
if endDt - startDt > timedelta(days=85):
endDt = startDt + timedelta(days=85)
return (
startDt.strftime("%Y-%m-%d %H:%M:%S"),
endDt.strftime("%Y-%m-%d %H:%M:%S"),
)
async def _listEvents(
self,
calendarId: str,
limit: Optional[int],
filter: Optional[str] = None,
) -> List[ExternalEntry]:
fromStr, toStr = self._eventWindow()
fromStr, toStr = self._eventWindow(filter)
endpoint = (
f"{_PIM_PREFIX}/event"
f"?calendar_id={calendarId}"
@ -547,8 +687,7 @@ class CalendarAdapter(ServiceAdapter):
)
result = await _infomaniakGet(self._token, endpoint, baseUrl=_CALENDAR_BASE)
if isinstance(result, dict) and result.get("error"):
logger.warning(f"Calendar list-events {calendarId} failed: {result['error']}")
return []
_raiseInfomaniakError(result, f"Calendar list-events {calendarId}")
data = _unwrapData(result)
events = data if isinstance(data, list) else data.get("events", []) if isinstance(data, dict) else []
entries: List[ExternalEntry] = []
@ -626,11 +765,14 @@ class CalendarAdapter(ServiceAdapter):
)
if not calendars:
return []
needle = (query or "").strip().lower()
# A date-range query maps directly to the event window; a free-text
# query keeps the default window and filters on title/location.
dateWindow = self._parseFilterWindow(query)
needle = "" if dateWindow else (query or "").strip().lower()
results: List[ExternalEntry] = []
for cal in calendars:
calId = (cal.metadata or {}).get("id") or cal.path.strip("/")
for ev in await self._listEvents(calId, limit=limit):
for ev in await self._listEvents(calId, limit=limit, filter=query if dateWindow else None):
hay = " ".join(
str(v) for v in (
ev.name,
@ -768,8 +910,7 @@ class ContactAdapter(ServiceAdapter):
self._token, f"{_PIM_PREFIX}/addressbook", baseUrl=_CONTACTS_BASE
)
if isinstance(result, dict) and result.get("error"):
logger.warning(f"Contacts list-addressbooks failed: {result['error']}")
return []
_raiseInfomaniakError(result, "Contacts list-addressbooks")
data = _unwrapData(result)
books = data.get("addressbooks", []) if isinstance(data, dict) else []
entries: List[ExternalEntry] = []
@ -809,10 +950,7 @@ class ContactAdapter(ServiceAdapter):
)
result = await _infomaniakGet(self._token, endpoint, baseUrl=_CONTACTS_BASE)
if isinstance(result, dict) and result.get("error"):
logger.warning(
f"Contacts list-contacts {addressBookId} failed: {result['error']}"
)
return []
_raiseInfomaniakError(result, f"Contacts list-contacts {addressBookId}")
data = _unwrapData(result)
if isinstance(data, list):
return [c for c in data if isinstance(c, dict)]

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Microsoft ProviderConnector -- one MSFT connection serves SharePoint, Outlook, Teams, OneDrive.
@ -6,17 +6,23 @@ All ServiceAdapters share the same OAuth access token obtained from the
UserConnection (authority=msft).
"""
import json
import logging
import re
import aiohttp
import asyncio
import urllib.parse
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, List, Optional
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
from modules.shared.httpResilience import ResilientHttp
from modules.datamodels.datamodelDataSource import ExternalEntry
logger = logging.getLogger(__name__)
_GRAPH_BASE = "https://graph.microsoft.com/v1.0"
_http = ResilientHttp("Graph", maxConcurrent=10, defaultTimeoutS=30)
class _GraphApiMixin:
@ -43,63 +49,25 @@ class _GraphApiMixin:
async def _graphDownload(self, endpoint: str) -> Optional[bytes]:
"""Download binary content from Graph API."""
headers = {"Authorization": f"Bearer {self._accessToken}"}
timeout = aiohttp.ClientTimeout(total=60)
url = f"{_GRAPH_BASE}/{endpoint.lstrip('/')}"
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as resp:
if resp.status == 200:
return await resp.read()
logger.error(f"Download failed {resp.status}: {await resp.text()}")
return None
except Exception as e:
logger.error(f"Graph download error: {e}")
return None
return await _http.getBytes(url, headers=headers, timeout=aiohttp.ClientTimeout(total=60))
async def _makeGraphCall(
token: str, endpoint: str, method: str = "GET", data: Any = None
) -> Dict[str, Any]:
"""Execute a single Microsoft Graph API call."""
"""Execute a single Microsoft Graph API call via shared resilient HTTP client."""
url = f"{_GRAPH_BASE}/{endpoint.lstrip('/')}"
contentType = "application/json"
contentType = "application/json; charset=utf-8"
if method == "PUT" and isinstance(data, bytes):
contentType = "application/octet-stream"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": contentType,
}
timeout = aiohttp.ClientTimeout(total=30)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
kwargs: Dict[str, Any] = {"headers": headers}
if data is not None:
kwargs["data"] = data
if method == "GET":
async with session.get(url, **kwargs) as resp:
return await _handleResponse(resp)
elif method == "POST":
async with session.post(url, **kwargs) as resp:
return await _handleResponse(resp)
elif method == "PUT":
async with session.put(url, **kwargs) as resp:
return await _handleResponse(resp)
elif method == "PATCH":
async with session.patch(url, **kwargs) as resp:
return await _handleResponse(resp)
elif method == "DELETE":
async with session.delete(url, **kwargs) as resp:
if resp.status in (200, 204):
return {}
return await _handleResponse(resp)
except asyncio.TimeoutError:
return {"error": f"Graph API timeout: {endpoint}"}
except Exception as e:
return {"error": f"Graph API error: {e}"}
return {"error": f"Unsupported method: {method}"}
if "$count=true" in endpoint:
headers["ConsistencyLevel"] = "eventual"
return await _http.request(method, url, headers=headers, data=data)
async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]:
@ -114,7 +82,7 @@ async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]:
return {"error": f"{resp.status}: {errorText}"}
def _stripGraphBase(url: str) -> str:
def stripGraphBase(url: str) -> str:
"""Convert an absolute Graph URL (used by @odata.nextLink) into the
relative endpoint that ``_makeGraphCall`` expects."""
if not url:
@ -124,6 +92,18 @@ def _stripGraphBase(url: str) -> str:
return url
def _raiseGraphError(result: Dict[str, Any], ctx: str) -> None:
"""Raise a clear error for a failed Graph response.
Browse/search must NOT swallow API failures into an empty result list, which
makes a real error look like 'empty directory'. Callers (data-source tools,
tree-builder, sync jobs) already wrap these in try/except.
"""
err = result.get("error") if isinstance(result, dict) else None
logger.warning("Graph error (%s): %s", ctx, err or result)
raise RuntimeError(f"Graph error ({ctx}): {err or result}")
def _graphItemToExternalEntry(item: Dict[str, Any], basePath: str = "") -> ExternalEntry:
isFolder = "folder" in item
# Graph exposes the driveItem content hash as ``eTag`` (quoted) or
@ -189,7 +169,8 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
while endpoint and len(items) < hardCap:
result = await self._graphGet(endpoint)
if "error" in result:
logger.warning(f"SharePoint browse failed: {result['error']}")
if not items:
_raiseGraphError(result, "SharePoint browse")
break
for raw in result.get("value", []) or []:
items.append(raw)
@ -198,7 +179,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
if effectiveLimit is not None and len(items) >= effectiveLimit:
break
nextLink = result.get("@odata.nextLink")
endpoint = _stripGraphBase(nextLink) if nextLink else None
endpoint = stripGraphBase(nextLink) if nextLink else None
entries = [_graphItemToExternalEntry(item, path) for item in items]
if filter:
@ -211,8 +192,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
"""Discover accessible SharePoint sites."""
result = await self._graphGet("sites?search=*&$top=50")
if "error" in result:
logger.warning(f"SharePoint site discovery failed: {result['error']}")
return []
_raiseGraphError(result, "SharePoint site discovery")
return [
ExternalEntry(
name=s.get("displayName") or s.get("name", ""),
@ -253,17 +233,37 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
siteId, _ = _parseSharepointPath(path or "")
siteId, folderPath = _parseSharepointPath(path or "")
if not siteId:
return []
safeQuery = query.replace("'", "''")
endpoint = f"sites/{siteId}/drive/root/search(q='{safeQuery}')"
result = await self._graphGet(endpoint)
if "error" in result:
return []
entries = [_graphItemToExternalEntry(item) for item in result.get("value", [])]
if limit is not None:
entries = entries[: max(1, int(limit))]
cleanFolder = (folderPath or "").strip("/")
# Scope the search to the attached folder when one is given, so the agent
# does not get hits from unrelated parts of the site drive.
if cleanFolder:
endpoint: Optional[str] = f"sites/{siteId}/drive/root:/{cleanFolder}:/search(q='{safeQuery}')?$top=200"
else:
endpoint = f"sites/{siteId}/drive/root/search(q='{safeQuery}')?$top=200"
effectiveLimit = int(limit) if limit is not None else None
items: List[Dict[str, Any]] = []
hardCap = 1000
while endpoint and len(items) < hardCap:
result = await self._graphGet(endpoint)
if "error" in result:
if not items:
_raiseGraphError(result, "SharePoint search")
break
for raw in result.get("value", []) or []:
items.append(raw)
if effectiveLimit is not None and len(items) >= effectiveLimit:
break
if effectiveLimit is not None and len(items) >= effectiveLimit:
break
nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None
entries = [_graphItemToExternalEntry(item) for item in items]
if effectiveLimit is not None:
entries = entries[: max(1, effectiveLimit)]
return entries
@ -271,6 +271,59 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
# Outlook Adapter
# ---------------------------------------------------------------------------
_CHARSET_META = '<meta charset="utf-8">'
def _parseDateRange(filterStr: Optional[str]) -> tuple:
"""Parse a date range from a filter/query string.
Supports two ISO dates ("2026-06-01 2026-06-30"), a single ISO date
(treated as a ~31 day window), or a YYYY-MM month pattern. Returns
(startDateTime, endDateTime) ISO strings, or (None, None) if not parseable.
"""
if not filterStr:
return (None, None)
isoMatch = re.findall(r'\d{4}-\d{2}-\d{2}(?:T[\d:]+)?', filterStr)
if len(isoMatch) >= 2:
return (isoMatch[0], isoMatch[1])
if len(isoMatch) == 1:
try:
dt = datetime.fromisoformat(isoMatch[0])
return (isoMatch[0], (dt + timedelta(days=31)).strftime('%Y-%m-%dT00:00:00'))
except ValueError:
pass
monthMatch = re.match(r'^(\d{4})-(\d{2})$', filterStr.strip())
if monthMatch:
year, month = int(monthMatch.group(1)), int(monthMatch.group(2))
start = f"{year}-{month:02d}-01T00:00:00"
if month == 12:
end = f"{year + 1}-01-01T00:00:00"
else:
end = f"{year}-{month + 1:02d}-01T00:00:00"
return (start, end)
return (None, None)
def _toGraphUtc(isoStr: str) -> str:
"""Normalise an ISO date/datetime to a Graph-compatible UTC string
(always 'YYYY-MM-DDTHH:MM:SSZ')."""
if not isoStr:
return isoStr
value = isoStr.strip().rstrip("Z")
if "T" not in value:
value = f"{value}T00:00:00"
return f"{value}Z"
def _ensureHtmlCharset(html: str) -> str:
"""Ensure HTML body has a charset meta tag so Outlook renders UTF-8 correctly."""
if "charset" in html.lower():
return html
if html.strip().lower().startswith("<html"):
return html.replace("<html>", f"<html><head>{_CHARSET_META}</head>", 1)
return f"<html><head>{_CHARSET_META}</head><body>{html}</body></html>"
class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
"""ServiceAdapter for Outlook (mail, calendar) via Microsoft Graph."""
@ -316,7 +369,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
if not nextLink:
endpoint = None
else:
endpoint = _stripGraphBase(nextLink)
endpoint = stripGraphBase(nextLink)
# Guarantee Inbox is present (well-known name, locale-independent)
if not any((f.get("displayName") or "").lower() in ("inbox", "posteingang") for f in folders):
@ -339,25 +392,62 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
for f in folders
]
folderId = path.strip("/")
# The incoming path segment may be a display name ("MGB-Ablage"), a
# well-known shortcut ("inbox") or an already-resolved Graph folder id.
# Resolve it to a real id first; otherwise Graph rejects the URL with
# 400 ErrorInvalidIdMalformed.
folderRef = path.strip("/")
folderId = await self._resolveFolderId(folderRef)
if not folderId:
raise ValueError(
f"Outlook folder not found: '{folderRef}'. Browse the mailbox root "
f"(path '/') or call listMailFolders to obtain a valid folder id."
)
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
pageSize = min(self._PAGE_SIZE, effectiveLimit)
endpoint: Optional[str] = (
f"me/mailFolders/{folderId}/messages"
f"?$top={pageSize}&$orderby=receivedDateTime desc"
)
# Optional date-range filter (e.g. "2026-06" or "2026-06-01 2026-06-30")
# so only that period is fetched server-side instead of paging the whole
# folder. Falls back to a plain newest-first listing otherwise.
startDateTime, endDateTime = _parseDateRange(filter)
countParam = "&$count=true"
if startDateTime and endDateTime:
dateFilter = (
f"receivedDateTime ge {_toGraphUtc(startDateTime)} and "
f"receivedDateTime lt {_toGraphUtc(endDateTime)}"
)
endpoint: Optional[str] = (
f"me/mailFolders/{folderId}/messages"
f"?$top={pageSize}&$orderby=receivedDateTime desc"
f"&$filter={urllib.parse.quote(dateFilter)}{countParam}"
)
else:
endpoint = (
f"me/mailFolders/{folderId}/messages"
f"?$top={pageSize}&$orderby=receivedDateTime desc{countParam}"
)
messages: List[Dict[str, Any]] = []
totalCount: Optional[int] = None
firstPage = True
while endpoint and len(messages) < effectiveLimit:
result = await self._graphGet(endpoint)
if "error" in result:
if firstPage:
err = result.get("error") or {}
raise RuntimeError(
f"Graph error listing messages in folder '{folderRef}': "
f"{err.get('message') or err}"
)
break
if firstPage and "@odata.count" in result:
totalCount = result["@odata.count"]
firstPage = False
for m in result.get("value", []):
messages.append(m)
if len(messages) >= effectiveLimit:
break
nextLink = result.get("@odata.nextLink")
endpoint = _stripGraphBase(nextLink) if nextLink else None
return [
endpoint = stripGraphBase(nextLink) if nextLink else None
entries = [
ExternalEntry(
name=m.get("subject", "(no subject)"),
path=f"{path}/{m.get('id', '')}",
@ -371,10 +461,16 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
)
for m in messages
]
if totalCount is not None and totalCount > len(entries):
entries.append(ExternalEntry(
name=f"({totalCount} total messages in folder, {len(entries)} listed)",
path=f"{path}/_count", isFolder=False,
metadata={"totalCount": totalCount, "listed": len(entries)},
))
return entries
async def download(self, path: str) -> DownloadResult:
"""Download a mail message as RFC 822 EML via Graph API $value endpoint."""
import re
messageId = path.strip("/").split("/")[-1]
meta = await self._graphGet(f"me/messages/{messageId}?$select=subject")
@ -401,14 +497,28 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
safeQuery = query.replace("'", "''")
safeQuery = query.replace('"', '\\"')
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(1, min(int(limit), self._MAX_MESSAGE_LIMIT))
# Scope the search to the attached folder when one is given, so the agent
# gets hits only from e.g. the Inbox instead of the whole mailbox. Resolve
# the folder reference (display name / well-known / id) to a real id first.
folderRef = (path or "").strip("/")
base = "me/messages"
if folderRef:
folderId = await self._resolveFolderId(folderRef)
if not folderId:
raise ValueError(
f"Outlook folder not found: '{folderRef}'. Call listMailFolders "
f"to obtain a valid folder id, or search without a folder scope."
)
base = f"me/mailFolders/{folderId}/messages"
# NOTE: Graph $search does not support $orderby and may return a single
# page (no @odata.nextLink). We still pass $top to lift the implicit 25.
endpoint = f"me/messages?$search=\"{safeQuery}\"&$top={effectiveLimit}"
endpoint = f"{base}?$search=\"{safeQuery}\"&$top={effectiveLimit}"
result = await self._graphGet(endpoint)
if "error" in result:
return []
err = result.get("error") or {}
raise RuntimeError(f"Graph error searching mail: {err.get('message') or err}")
return [
ExternalEntry(
name=m.get("subject", "(no subject)"),
@ -433,9 +543,12 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
attachments: list of {"name": str, "contentBytes": str (base64), "contentType": str}
"""
content = body
if bodyType.upper() == "HTML":
content = _ensureHtmlCharset(body)
message: Dict[str, Any] = {
"subject": subject,
"body": {"contentType": bodyType, "content": body},
"body": {"contentType": bodyType, "content": content},
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
}
if cc:
@ -459,7 +572,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
attachments: Optional[List[Dict]] = None,
) -> Dict[str, Any]:
"""Send an email via Microsoft Graph. bodyType: 'Text' or 'HTML'."""
import json
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8")
result = await self._graphPost("me/sendMail", payload)
@ -474,7 +586,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
attachments: Optional[List[Dict]] = None,
) -> Dict[str, Any]:
"""Create a draft email in the user's Drafts folder via Microsoft Graph."""
import json
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
payload = json.dumps(message).encode("utf-8")
result = await self._graphPost("me/messages", payload)
@ -504,7 +615,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
Preserves the conversation thread and the ``AW:`` prefix in Outlook --
unlike sendMail() which creates a brand-new conversation.
"""
import json
endpointAction = "replyAll" if replyAll else "reply"
payload = json.dumps({"comment": comment}).encode("utf-8")
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
@ -516,7 +626,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
self, messageId: str, to: List[str], comment: str = "",
) -> Dict[str, Any]:
"""Forward an existing message to new recipients."""
import json
payload = json.dumps({
"comment": comment,
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
@ -531,7 +640,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
replyAll: bool = False,
) -> Dict[str, Any]:
"""Create a reply-draft (in the Drafts folder) that the user can edit before sending."""
import json
endpointAction = "createReplyAll" if replyAll else "createReply"
payload = json.dumps({"comment": comment}).encode("utf-8") if comment else b"{}"
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
@ -543,7 +651,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
self, messageId: str, to: Optional[List[str]] = None, comment: str = "",
) -> Dict[str, Any]:
"""Create a forward-draft (in the Drafts folder) that the user can edit before sending."""
import json
body: Dict[str, Any] = {}
if comment:
body["comment"] = comment
@ -614,7 +721,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
"childFolderCount": f.get("childFolderCount", 0),
})
nextLink = result.get("@odata.nextLink")
endpoint = _stripGraphBase(nextLink) if nextLink else None
endpoint = stripGraphBase(nextLink) if nextLink else None
return folders
async def _resolveFolderId(self, folderRef: str) -> Optional[str]:
@ -651,7 +758,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
self, messageId: str, destinationFolder: str,
) -> Dict[str, Any]:
"""Move a message to another folder (well-known name, displayName, or folder id)."""
import json
destId = await self._resolveFolderId(destinationFolder)
if not destId:
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
@ -665,7 +771,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
self, messageId: str, destinationFolder: str,
) -> Dict[str, Any]:
"""Copy a message into another folder (original stays in place)."""
import json
destId = await self._resolveFolderId(destinationFolder)
if not destId:
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
@ -705,7 +810,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
async def markMailAsRead(self, messageId: str) -> Dict[str, Any]:
"""Mark a message as read (sets ``isRead=true``)."""
import json
payload = json.dumps({"isRead": True}).encode("utf-8")
result = await self._graphPatch(f"me/messages/{messageId}", payload)
if "error" in result:
@ -714,7 +818,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
async def markMailAsUnread(self, messageId: str) -> Dict[str, Any]:
"""Mark a message as unread (sets ``isRead=false``)."""
import json
payload = json.dumps({"isRead": False}).encode("utf-8")
result = await self._graphPatch(f"me/messages/{messageId}", payload)
if "error" in result:
@ -732,7 +835,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
``"notFlagged"`` -- the three values Microsoft Graph recognises for
``followupFlag.flagStatus``.
"""
import json
if flagStatus not in ("flagged", "complete", "notFlagged"):
return {"error": f"Invalid flagStatus '{flagStatus}'. Use one of: flagged, complete, notFlagged."}
payload = json.dumps({"flag": {"flagStatus": flagStatus}}).encode("utf-8")
@ -760,8 +862,7 @@ class TeamsAdapter(_GraphApiMixin, ServiceAdapter):
if not cleanPath:
result = await self._graphGet("me/joinedTeams")
if "error" in result:
logger.warning(f"Teams browse failed: {result['error']}")
return []
_raiseGraphError(result, "Teams browse")
return [
ExternalEntry(
name=t.get("displayName", ""),
@ -777,7 +878,7 @@ class TeamsAdapter(_GraphApiMixin, ServiceAdapter):
if len(parts) == 1:
result = await self._graphGet(f"teams/{teamId}/channels")
if "error" in result:
return []
_raiseGraphError(result, "Teams channels")
return [
ExternalEntry(
name=ch.get("displayName", ""),
@ -820,18 +921,33 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
) -> List[ExternalEntry]:
cleanPath = (path or "").strip("/")
if not cleanPath:
endpoint = "me/drive/root/children"
endpoint: Optional[str] = "me/drive/root/children?$top=200"
else:
endpoint = f"me/drive/root:/{cleanPath}:/children"
endpoint = f"me/drive/root:/{cleanPath}:/children?$top=200"
result = await self._graphGet(endpoint)
if "error" in result:
return []
entries = [_graphItemToExternalEntry(item, path) for item in result.get("value", [])]
effectiveLimit = int(limit) if limit is not None else None
items: List[Dict[str, Any]] = []
hardCap = 5000
while endpoint and len(items) < hardCap:
result = await self._graphGet(endpoint)
if "error" in result:
if not items:
_raiseGraphError(result, "OneDrive browse")
break
for raw in result.get("value", []) or []:
items.append(raw)
if effectiveLimit is not None and len(items) >= effectiveLimit:
break
if effectiveLimit is not None and len(items) >= effectiveLimit:
break
nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None
entries = [_graphItemToExternalEntry(item, path) for item in items]
if filter:
entries = [e for e in entries if _matchFilter(e, filter)]
if limit is not None:
entries = entries[: max(1, int(limit))]
if effectiveLimit is not None:
entries = entries[: max(1, effectiveLimit)]
return entries
async def download(self, path: str) -> bytes:
@ -854,13 +970,32 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
limit: Optional[int] = None,
) -> List[ExternalEntry]:
safeQuery = query.replace("'", "''")
endpoint = f"me/drive/root/search(q='{safeQuery}')"
result = await self._graphGet(endpoint)
if "error" in result:
return []
entries = [_graphItemToExternalEntry(item) for item in result.get("value", [])]
if limit is not None:
entries = entries[: max(1, int(limit))]
cleanPath = (path or "").strip("/")
# Scope to the attached folder if given, otherwise search the whole drive.
if cleanPath:
endpoint: Optional[str] = f"me/drive/root:/{cleanPath}:/search(q='{safeQuery}')?$top=200"
else:
endpoint = f"me/drive/root/search(q='{safeQuery}')?$top=200"
effectiveLimit = int(limit) if limit is not None else None
items: List[Dict[str, Any]] = []
hardCap = 1000
while endpoint and len(items) < hardCap:
result = await self._graphGet(endpoint)
if "error" in result:
if not items:
_raiseGraphError(result, "OneDrive search")
break
for raw in result.get("value", []) or []:
items.append(raw)
if effectiveLimit is not None and len(items) >= effectiveLimit:
break
if effectiveLimit is not None and len(items) >= effectiveLimit:
break
nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None
entries = [_graphItemToExternalEntry(item) for item in items]
if effectiveLimit is not None:
entries = entries[: max(1, effectiveLimit)]
return entries
@ -894,8 +1029,7 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
if not cleanPath:
result = await self._graphGet("me/calendars?$top=100")
if "error" in result:
logger.warning(f"MSFT Calendar list failed: {result['error']}")
return []
_raiseGraphError(result, "MSFT Calendar list")
calendars = result.get("value", [])
if filter:
calendars = [c for c in calendars if filter.lower() in (c.get("name") or "").lower()]
@ -915,25 +1049,46 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
for c in calendars
]
calendarId = cleanPath.split("/", 1)[0]
# The path segment may be a calendar display name or an already-resolved
# calendar id; resolve first so a name does not produce a malformed URL.
calendarRef = cleanPath.split("/", 1)[0]
calendarId = await self._resolveCalendarId(calendarRef)
if not calendarId:
raise ValueError(
f"Calendar not found: '{calendarRef}'. Browse the root ('/') to list "
f"calendars and use the returned id."
)
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
pageSize = min(self._PAGE_SIZE, effectiveLimit)
endpoint: Optional[str] = (
f"me/calendars/{calendarId}/events"
f"?$top={pageSize}&$orderby=start/dateTime desc"
)
startDateTime, endDateTime = self._parseDateRange(filter)
if startDateTime and endDateTime:
endpoint: Optional[str] = (
f"me/calendars/{calendarId}/calendarView"
f"?startDateTime={startDateTime}&endDateTime={endDateTime}"
f"&$top={pageSize}&$orderby=start/dateTime"
f"&$select=id,subject,start,end,location,organizer,isAllDay,webLink"
)
else:
endpoint = (
f"me/calendars/{calendarId}/events"
f"?$top={pageSize}&$orderby=start/dateTime desc"
f"&$select=id,subject,start,end,location,organizer,isAllDay,webLink"
)
events: List[Dict[str, Any]] = []
while endpoint and len(events) < effectiveLimit:
result = await self._graphGet(endpoint)
if "error" in result:
logger.warning(f"MSFT Calendar events failed: {result['error']}")
if not events:
_raiseGraphError(result, "MSFT Calendar events")
break
for ev in result.get("value", []):
events.append(ev)
if len(events) >= effectiveLimit:
break
nextLink = result.get("@odata.nextLink")
endpoint = _stripGraphBase(nextLink) if nextLink else None
endpoint = stripGraphBase(nextLink) if nextLink else None
return [
ExternalEntry(
@ -954,6 +1109,35 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
for ev in events
]
async def _resolveCalendarId(self, ref: str) -> Optional[str]:
"""Resolve a calendar reference (display name / 'default' / id) to a Graph
calendar id. Returns None if nothing matches."""
if not ref:
return None
r = ref.strip()
# Heuristic: Graph ids are long URL-safe strings without spaces.
if len(r) > 60 and " " not in r:
return r
result = await self._graphGet("me/calendars?$top=100")
if "error" in result:
_raiseGraphError(result, "MSFT Calendar list")
cals = result.get("value", [])
for c in cals:
if c.get("id") == r:
return r
if r.lower() in ("default", "primary", "calendar", "kalender"):
for c in cals:
if c.get("isDefaultCalendar"):
return c.get("id")
for c in cals:
if (c.get("name") or "").strip().lower() == r.lower():
return c.get("id")
return None
@staticmethod
def _parseDateRange(filterStr: Optional[str]) -> tuple:
return _parseDateRange(filterStr)
async def download(self, path: str) -> DownloadResult:
cleanPath = (path or "").strip("/")
if "/" not in cleanPath:
@ -981,22 +1165,37 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
safeQuery = query.replace("'", "''")
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
endpoint = f"me/events?$search=\"{safeQuery}\"&$top={effectiveLimit}"
startDateTime, endDateTime = self._parseDateRange(query)
if startDateTime and endDateTime:
endpoint = (
f"me/calendarView"
f"?startDateTime={startDateTime}&endDateTime={endDateTime}"
f"&$top={effectiveLimit}&$orderby=start/dateTime"
f"&$select=id,subject,start,end,location,organizer,isAllDay"
)
else:
safeQuery = query.replace("'", "''").replace('"', '\\"')
endpoint = f'me/events?$search="{safeQuery}"&$top={effectiveLimit}&$select=id,subject,start,end,location,organizer,isAllDay'
result = await self._graphGet(endpoint)
if "error" in result:
return []
_raiseGraphError(result, "MSFT Calendar search")
calendarId = (path or "").strip("/").split("/")[0] if path else "search"
return [
ExternalEntry(
name=ev.get("subject", "(no subject)"),
path=f"/search/{ev.get('id', '')}",
path=f"/{calendarId}/{ev.get('id', '')}",
isFolder=False,
mimeType="text/calendar",
metadata={
"id": ev.get("id"),
"start": (ev.get("start") or {}).get("dateTime"),
"end": (ev.get("end") or {}).get("dateTime"),
"location": (ev.get("location") or {}).get("displayName"),
"organizer": (ev.get("organizer") or {}).get("emailAddress", {}).get("address"),
"isAllDay": ev.get("isAllDay", False),
},
)
for ev in result.get("value", [])
@ -1058,7 +1257,15 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
logger.warning(f"MSFT contactFolders list failed: {result['error']}")
return folders
folderId = cleanPath.split("/", 1)[0]
# The path segment may be a contact-folder display name or an already-
# resolved folder id (or the virtual 'default'); resolve first.
folderRef = cleanPath.split("/", 1)[0]
folderId = await self._resolveContactFolderId(folderRef)
if not folderId:
raise ValueError(
f"Contact folder not found: '{folderRef}'. Browse the root ('/') to "
f"list folders and use the returned id."
)
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
pageSize = min(self._PAGE_SIZE, effectiveLimit)
if folderId == self._DEFAULT_FOLDER_ID:
@ -1070,14 +1277,15 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
while endpoint and len(contacts) < effectiveLimit:
result = await self._graphGet(endpoint)
if "error" in result:
logger.warning(f"MSFT contacts list failed: {result['error']}")
if not contacts:
_raiseGraphError(result, "MSFT contacts list")
break
for c in result.get("value", []):
contacts.append(c)
if len(contacts) >= effectiveLimit:
break
nextLink = result.get("@odata.nextLink")
endpoint = _stripGraphBase(nextLink) if nextLink else None
endpoint = stripGraphBase(nextLink) if nextLink else None
return [
ExternalEntry(
@ -1098,6 +1306,28 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
for c in contacts
]
async def _resolveContactFolderId(self, ref: str) -> Optional[str]:
"""Resolve a contact-folder reference (display name / 'default' / id) to a
folder id. Returns None if nothing matches."""
if not ref:
return None
r = ref.strip()
if r == self._DEFAULT_FOLDER_ID or r.lower() in ("kontakte", "contacts", "default"):
return self._DEFAULT_FOLDER_ID
# Heuristic: Graph ids are long URL-safe strings without spaces.
if len(r) > 60 and " " not in r:
return r
result = await self._graphGet("me/contactFolders?$top=100")
if "error" in result:
_raiseGraphError(result, "MSFT contactFolders list")
for f in result.get("value", []):
if f.get("id") == r:
return r
for f in result.get("value", []):
if (f.get("displayName") or "").strip().lower() == r.lower():
return f.get("id")
return None
async def download(self, path: str) -> DownloadResult:
cleanPath = (path or "").strip("/")
if "/" not in cleanPath:
@ -1125,19 +1355,27 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
safeQuery = query.replace("'", "''")
safeQuery = query.replace('"', '\\"')
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
endpoint = f"me/contacts?$search=\"{safeQuery}\"&$top={effectiveLimit}"
result = await self._graphGet(endpoint)
if "error" in result:
return []
_raiseGraphError(result, "MSFT contacts search")
return [
ExternalEntry(
name=c.get("displayName") or _personLabel(c) or "(no name)",
path=f"/search/{c.get('id', '')}",
isFolder=False,
mimeType="text/vcard",
metadata={"id": c.get("id")},
metadata={
"id": c.get("id"),
"givenName": c.get("givenName"),
"surname": c.get("surname"),
"companyName": c.get("companyName"),
"emailAddresses": [e.get("address") for e in (c.get("emailAddresses") or []) if e.get("address")],
"businessPhones": c.get("businessPhones") or [],
"mobilePhone": c.get("mobilePhone"),
},
)
for c in result.get("value", [])
]
@ -1199,7 +1437,6 @@ def _matchFilter(entry: ExternalEntry, pattern: str) -> bool:
def _safeFileName(name: str) -> str:
"""Strip path-unsafe characters and trim length so the result is a usable file name."""
import re
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
@ -1229,7 +1466,6 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]:
"""Convert an ISO datetime string to an RFC 5545 DATE-TIME value (UTC)."""
if not value:
return None
from datetime import datetime, timezone
try:
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
dt = datetime.fromisoformat(normalized)
@ -1242,7 +1478,6 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]:
def _eventToIcs(event: Dict[str, Any]) -> bytes:
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Graph event payload."""
from datetime import datetime, timezone
uid = event.get("iCalUId") or event.get("id") or "unknown@poweron"
summary = _icsEscape(event.get("subject") or "")
location = _icsEscape((event.get("location") or {}).get("displayName") or "")

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ConnectorResolver -- resolves a connectionId to the correct ProviderConnector and ServiceAdapter.
@ -15,6 +15,15 @@ from modules.connectors.connectorProviderBase import ProviderConnector, ServiceA
logger = logging.getLogger(__name__)
def _connection_uuid(connection: Any) -> str:
"""Resolve UserConnection primary key (tokens are stored by UUID, not reference string)."""
if connection is None:
return ""
if isinstance(connection, dict):
return str(connection.get("id") or "").strip()
return str(getattr(connection, "id", None) or "").strip()
class ConnectorResolver:
"""Resolves connectionId → ProviderConnector (with fresh token) → ServiceAdapter."""
@ -35,31 +44,31 @@ class ConnectorResolver:
if ConnectorResolver._providerRegistry:
return
try:
from modules.connectors.providerMsft.connectorMsft import MsftConnector
from modules.connectors.connectorProviderMsft import MsftConnector
ConnectorResolver._providerRegistry["msft"] = MsftConnector
except ImportError:
logger.warning("MsftConnector not available")
try:
from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector
from modules.connectors.connectorProviderGoogle import GoogleConnector
ConnectorResolver._providerRegistry["google"] = GoogleConnector
except ImportError:
logger.debug("GoogleConnector not available (stub)")
try:
from modules.connectors.providerFtp.connectorFtp import FtpConnector
from modules.connectors.connectorProviderFtp import FtpConnector
ConnectorResolver._providerRegistry["local:ftp"] = FtpConnector
except ImportError:
logger.debug("FtpConnector not available (stub)")
try:
from modules.connectors.providerClickup.connectorClickup import ClickupConnector
from modules.connectors.connectorProviderClickup import ClickupConnector
ConnectorResolver._providerRegistry["clickup"] = ClickupConnector
except ImportError:
logger.warning("ClickupConnector not available")
try:
from modules.connectors.providerInfomaniak.connectorInfomaniak import InfomaniakConnector
from modules.connectors.connectorProviderInfomaniak import InfomaniakConnector
ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector
except ImportError:
logger.warning("InfomaniakConnector not available")
@ -79,9 +88,16 @@ class ConnectorResolver:
if not providerClass:
raise ValueError(f"No ProviderConnector registered for authority: {authorityStr}")
token = self._security.getFreshToken(connectionId)
resolved_id = _connection_uuid(connection)
if not resolved_id:
raise ValueError(f"Connection {connectionId} has no id")
token = self._security.getFreshToken(resolved_id)
if not token or not token.tokenAccess:
raise ValueError(f"No valid token for connection {connectionId}")
raise ValueError(
f"No valid token for connection {resolved_id}"
+ (f" (ref: {connectionId})" if connectionId != resolved_id else "")
)
return providerClass(connection, token.tokenAccess)

View file

@ -1,3 +1,5 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Swiss Topo MapServer Connector (Simplified)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""ClickUp connector for CRUD operations (compatible with TicketInterface).
@ -9,7 +9,7 @@ from typing import Optional
import logging
import aiohttp
from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import clickup_authorization_header
from modules.connectors.connectorProviderClickup import clickupAuthorizationHeader
logger = logging.getLogger(__name__)
@ -31,7 +31,7 @@ class ConnectorTicketClickup(TicketBase):
def _headers(self) -> dict:
return {
"Authorization": clickup_authorization_header(self.apiToken),
"Authorization": clickupAuthorizationHeader(self.apiToken),
"Content-Type": "application/json",
}

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Jira connector for CRUD operations (neutralized to generic ticket interface).

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Redmine REST connector.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Google Cloud Speech-to-Text and Translation Connector
@ -15,7 +15,7 @@ from google.cloud import speech
from google.cloud import translate_v2 as translate
from google.cloud import texttospeech
from modules.shared.configuration import APP_CONFIG
from modules.shared.voiceCatalog import getDefaultVoice as _catalogDefaultVoice
from modules.shared.voiceCatalog import getDefaultVoice
logger = logging.getLogger(__name__)
@ -1097,7 +1097,7 @@ class ConnectorGoogleSpeech:
voice exists, in which case the caller omits `name` and Google
auto-selects based on languageCode + ssml_gender.
"""
return _catalogDefaultVoice(languageCode)
return getDefaultVoice(languageCode)
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
"""

View file

@ -1,3 +1,5 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Swiss Parcel (Liegenschaften) Connector

View file

@ -1,7 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""ClickUp provider connector."""
from .connectorClickup import ClickupConnector
__all__ = ["ClickupConnector"]

View file

@ -1,3 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""FTP/SFTP Provider Connector stub."""

View file

@ -1,3 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Google Provider Connector -- 1 Connection : n Services (Drive, Gmail)."""

View file

@ -1,3 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Infomaniak Provider Connector -- 1 Connection : n Services (kDrive, Mail)."""

View file

@ -1,3 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Microsoft Provider Connector -- 1 Connection : n Services (SharePoint, Outlook, Teams, OneDrive)."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unified modules.datamodels package.
@ -13,4 +13,5 @@ from . import datamodelSecurity as security
from . import datamodelChat as chat
from . import datamodelFiles as files
from . import datamodelVoice as voice
from . import datamodelUtils as utils
from . import datamodelUtils as utils
from . import jsonContinuation

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Optional, List, Dict, Any, Callable, TYPE_CHECKING, Tuple
from pydantic import BaseModel, Field, ConfigDict
@ -245,11 +245,10 @@ class AiCallPromptWebCrawl(BaseModel):
class AiCallPromptImage(BaseModel):
"""Structured prompt format for image generation."""
prompt: str = Field(description="Text description of the image to generate")
size: Optional[str] = Field(default="1024x1024", description="Image size (1024x1024, 1792x1024, 1024x1792)")
quality: Optional[str] = Field(default="standard", description="Image quality (standard, hd)")
style: Optional[str] = Field(default="vivid", description="Image style (vivid, natural)")
size: Optional[str] = Field(default="1024x1024", description="Image size (1024x1024, 1536x1024, 1024x1536)")
quality: Optional[str] = Field(default="auto", description="Image quality (auto, high, medium, low)")
class AiProcessParameters(BaseModel):
@ -352,4 +351,4 @@ class CodeContentPromptArgs(BaseModel):
class CodeStructurePromptArgs(BaseModel):
"""Type-safe arguments for code structure prompt builder."""
userPrompt: str
contentParts: List[ContentPart] = Field(default_factory=list)
contentParts: List[ContentPart] = Field(default_factory=list)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""AI Audit Log data model for Compliance & AI-Datenfluss tracking.
@ -9,14 +9,15 @@ for compliance, audit, and data-protection reporting.
import uuid
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
from modules.shared.timeUtils import getUtcTimestamp
@i18nModel("AI-Audit-Eintrag")
class AiAuditLogEntry(BaseModel):
class AiAuditLogEntry(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
@ -34,7 +35,7 @@ class AiAuditLogEntry(BaseModel):
userId: str = Field(
description="ID of the user who triggered the AI call",
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True}},
)
username: Optional[str] = Field(
default=None,
@ -43,17 +44,17 @@ class AiAuditLogEntry(BaseModel):
)
mandateId: str = Field(
description="Mandate context of the call",
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label", "softFk": True}},
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature instance context",
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True}},
)
featureCode: Optional[str] = Field(
default=None,
description="Feature code (e.g. workspace, trustee)",
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code", "softFk": True}},
)
instanceLabel: Optional[str] = Field(
default=None,

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Audit Log Data Model for database-based audit logging.
@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
from enum import Enum
import uuid
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.i18nRegistry import i18nModel
@ -83,7 +84,7 @@ class AuditAction(str, Enum):
@i18nModel("Audit-Log-Eintrag")
class AuditLogEntry(BaseModel):
class AuditLogEntry(PowerOnModel):
"""
Audit log entry for database storage.
@ -111,7 +112,7 @@ class AuditLogEntry(BaseModel):
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True},
},
)
@ -130,7 +131,7 @@ class AuditLogEntry(BaseModel):
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label", "softFk": True},
},
)
@ -142,7 +143,7 @@ class AuditLogEntry(BaseModel):
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True},
},
)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Background job models: generic, reusable infrastructure for long-running tasks.
@ -96,6 +96,17 @@ class BackgroundJob(PowerOnModel):
description="Human-readable current step (e.g. 'Importing journal entries...')",
json_schema_extra={"label": "Fortschritts-Nachricht"},
)
progressMessageData: Optional[Dict[str, Any]] = Field(
None,
description=(
"Structured i18n payload for `progressMessage`. Shape: "
"{'key': '<de-text-with-{placeholders}>', 'params': {...}}. "
"Frontend renders via `t(key, params)`; older clients fall back "
"to `progressMessage`. Single source of truth — keep `progressMessage` "
"as the rendered fallback in the producing language."
),
json_schema_extra={"label": "Fortschritts-Nachricht (i18n)"},
)
payload: Dict[str, Any] = Field(
default_factory=dict,

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Base Pydantic model with system-managed fields (DB + API + UI metadata)."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics."""
@ -123,7 +123,7 @@ class BillingTransaction(PowerOnModel):
@i18nModel("Abrechnungseinstellungen")
class BillingSettings(BaseModel):
class BillingSettings(PowerOnModel):
"""Billing settings per mandate. Only PREPAY_MANDATE model."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@ -186,7 +186,7 @@ class BillingSettings(BaseModel):
)
class StripeWebhookEvent(BaseModel):
class StripeWebhookEvent(PowerOnModel):
"""Stores processed Stripe webhook event IDs for idempotency."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@ -201,7 +201,7 @@ class StripeWebhookEvent(BaseModel):
@i18nModel("Nutzungsstatistik")
class UsageStatistics(BaseModel):
class UsageStatistics(PowerOnModel):
"""Aggregated usage statistics for quick retrieval."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Chat models: ChatWorkflow, ChatMessage, ChatLog, ChatDocument."""
@ -111,7 +111,6 @@ class ChatMessage(PowerOnModel):
class WorkflowModeEnum(str, Enum):
WORKFLOW_DYNAMIC = "Dynamic"
WORKFLOW_AUTOMATION = "Automation"
WORKFLOW_CHATBOT = "Chatbot"
@i18nModel("Chat-Workflow")
class ChatWorkflow(PowerOnModel):
@ -132,7 +131,7 @@ class ChatWorkflow(PowerOnModel):
None,
description=(
"Optional foreign key linking this chat to an entity outside the "
"ChatWorkflow table (e.g. an Automation2Workflow in the GraphicalEditor "
"ChatWorkflow table (e.g. an Automation2Workflow in WorkflowAutomation "
"AI editor chat). NULL for the default workspace chats. Combined with "
"featureInstanceId this gives a 1:1 relation entity ↔ chat per feature."
),
@ -169,10 +168,6 @@ class ChatWorkflow(PowerOnModel):
"value": WorkflowModeEnum.WORKFLOW_AUTOMATION.value,
"label": "Automation",
},
{
"value": WorkflowModeEnum.WORKFLOW_CHATBOT.value,
"label": "Chatbot",
},
]})
maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"label": "Max. Schritte", "frontend_type": "integer", "frontend_readonly": False, "frontend_required": False})
expectedFormats: Optional[List[str]] = Field(None, description="List of expected file format extensions from user request (e.g., ['xlsx', 'pdf']). Extracted during intent analysis.", json_schema_extra={"label": "Erwartete Formate", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
@ -319,7 +314,7 @@ class DocumentExchange(BaseModel):
documents: List[str] = Field(default_factory=list, description="List of document references", json_schema_extra={"label": "Dokumente"})
@i18nModel("Aufgaben-Aktion")
class ActionItem(BaseModel):
class ActionItem(PowerOnModel):
id: str = Field(..., description="Action ID", json_schema_extra={"label": "Aktions-ID"})
execMethod: str = Field(..., description="Method to execute", json_schema_extra={"label": "Methode"})
execAction: str = Field(..., description="Action to perform", json_schema_extra={"label": "Aktion"})

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Content Object data models for the container and content extraction pipeline.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""DataSource and ExternalEntry models for external data integration.
@ -62,9 +62,14 @@ class DataSource(PowerOnModel):
description="Owner user ID",
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
ragIndexEnabled: bool = Field(
default=False,
description="When true this tree element is indexed into the RAG knowledge store",
ragIndexEnabled: Optional[bool] = Field(
default=None,
description=(
"Three-state RAG indexing flag with cascade-inherit semantics. "
"None = inherit from nearest ancestor DataSource (path-traversal); "
"True/False = explicit override that propagates to descendants. "
"Walker computes effective value via getEffectiveFlag()."
),
json_schema_extra={"label": "Im RAG indexieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
lastIndexed: Optional[float] = Field(
@ -72,21 +77,34 @@ class DataSource(PowerOnModel):
description="Timestamp of last successful RAG indexing run",
json_schema_extra={"label": "Letzte Indexierung", "frontend_type": "timestamp"},
)
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": "Persönlich"},
{"value": "featureInstance", "label": "Feature-Instanz"},
{"value": "mandate", "label": "Mandant"},
{"value": "global", "label": "Global"},
]},
# scope was removed (privacy, 2026-06). Personal sources must not be
# shared across scopes. Only Files (folder-files) retain scope.
# The DB column is kept as deprecated-nullable to avoid a migration;
# it is never read or written by UDB/ingest/knowledge anymore.
scope: Optional[str] = Field(
default=None,
description="DEPRECATED (2026-06, privacy). Always None. Use Files scope instead.",
json_schema_extra={"frontend_readonly": True, "frontend_hidden": True},
)
neutralize: bool = Field(
default=False,
description="Whether this data source should be neutralized before AI processing",
neutralize: Optional[bool] = Field(
default=None,
description=(
"Three-state neutralization flag with cascade-inherit semantics. "
"None = inherit from nearest ancestor DataSource (path-traversal); "
"True/False = explicit override that propagates to descendants."
),
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
settings: Optional[Dict[str, Any]] = Field(
default=None,
description=(
"DataSource-scoped settings (JSON). Currently used keys: "
"ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. "
"Walker reads these directly; missing keys fall back to RAG_LIMITS_DEFAULT "
"and are lazily persisted on next bootstrap."
),
json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False},
)
class ExternalEntry(BaseModel):

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Document reference models for typed document references in workflows.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List, Optional, Literal, Union
from pydantic import BaseModel, Field, field_serializer

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
from typing import Any, Dict, List, Optional, Literal
from pydantic import BaseModel, Field
@ -112,4 +112,4 @@ class ExtractionOptions(BaseModel):
# Additional processing options
enableParallelProcessing: bool = Field(default=True, description="Enable parallel processing of chunks")
maxConcurrentChunks: int = Field(default=5, ge=1, le=20, description="Maximum number of chunks to process concurrently")
maxConcurrentChunks: int = Field(default=5, ge=1, le=20, description="Maximum number of chunks to process concurrently")

View file

@ -1,82 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""FeatureDataSource model for exposing feature instance data to the AI workspace.
A FeatureDataSource links a FeatureInstance table (DATA_OBJECT) to a workspace
so the agent can query structured feature data (e.g. TrusteePosition rows).
"""
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Feature-Datenquelle")
class FeatureDataSource(PowerOnModel):
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
featureInstanceId: str = Field(
description="FK to FeatureInstance",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
featureCode: str = Field(
description="Feature code (e.g. trustee, commcoach)",
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
)
tableName: str = Field(
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
json_schema_extra={"label": "Tabelle"},
)
objectKey: str = Field(
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
json_schema_extra={"label": "Objekt-Schluessel"},
)
label: str = Field(
description="User-visible label",
json_schema_extra={"label": "Bezeichnung"},
)
mandateId: str = Field(
default="",
description="Mandate scope",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
userId: str = Field(
default="",
description="Owner user ID",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
workspaceInstanceId: str = Field(
description="Workspace feature instance where this source is used",
json_schema_extra={"label": "Workspace", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": "Persönlich"},
{"value": "featureInstance", "label": "Feature-Instanz"},
{"value": "mandate", "label": "Mandant"},
{"value": "global", "label": "Global"},
]},
)
neutralize: bool = Field(
default=False,
description="Whether this data source should be neutralized before AI processing",
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
neutralizeFields: Optional[List[str]] = Field(
default=None,
description="Column names whose values are replaced with placeholders before AI processing",
json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False},
)
recordFilter: Optional[Dict[str, str]] = Field(
default=None,
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
json_schema_extra={"label": "Datensatzfilter"},
)

View file

@ -1,20 +1,24 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Feature models: Feature, FeatureInstance."""
"""Feature models: Feature definitions, instances, data sources, and shared feature types."""
import uuid
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
from modules.datamodels.datamodelUtils import TextMultilingual
# ---------------------------------------------------------------------------
# Feature & FeatureInstance
# ---------------------------------------------------------------------------
@i18nModel("Feature")
class Feature(PowerOnModel):
"""Feature-Definition (global, z.B. 'trustee', 'chatbot'). Verfuegbare Funktionalitaeten der Plattform."""
"""Feature-Definition (global, z.B. 'trustee', 'commcoach'). Verfuegbare Funktionalitaeten der Plattform."""
code: str = Field(
description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'",
description="Unique feature code (Primary Key), z.B. 'trustee', 'commcoach'",
json_schema_extra={"label": "Code", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
label: TextMultilingual = Field(
@ -71,3 +75,147 @@ class FeatureInstance(PowerOnModel):
description="Instance-specific configuration (JSONB). Structure depends on featureCode.",
json_schema_extra={"label": "Konfiguration", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
)
# ---------------------------------------------------------------------------
# FeatureDataSource
# ---------------------------------------------------------------------------
@i18nModel("Feature-Datenquelle")
class FeatureDataSource(PowerOnModel):
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
featureInstanceId: str = Field(
description="FK to FeatureInstance",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
featureCode: str = Field(
description="Feature code (e.g. trustee, commcoach)",
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
)
tableName: str = Field(
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
json_schema_extra={"label": "Tabelle"},
)
objectKey: str = Field(
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
json_schema_extra={"label": "Objekt-Schluessel"},
)
label: str = Field(
description="User-visible label",
json_schema_extra={"label": "Bezeichnung"},
)
mandateId: str = Field(
default="",
description="Mandate scope (set automatically from featureInstance.mandateId on create).",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
neutralize: Optional[bool] = Field(
default=None,
description=(
"Three-state neutralization flag with cascade-inherit semantics. "
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
),
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
ragIndexEnabled: Optional[bool] = Field(
default=None,
description=(
"Three-state RAG-indexing flag with cascade-inherit semantics. "
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
),
json_schema_extra={"label": "RAG-Indexierung", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
neutralizeFields: Optional[List[str]] = Field(
default=None,
description="Column names whose values are replaced with placeholders before AI processing",
json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False},
)
recordFilter: Optional[Dict[str, str]] = Field(
default=None,
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
json_schema_extra={"label": "Datensatzfilter"},
)
settings: Optional[Dict[str, Any]] = Field(
default=None,
description=(
"FeatureDataSource-scoped settings (JSON). Currently used keys: "
"ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. "
"Mirror of DataSource.settings so the UDB settings modal can target both."
),
json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False},
)
# ---------------------------------------------------------------------------
# DataNeutralizerAttributes
# ---------------------------------------------------------------------------
@i18nModel("Neutralisiertes Datenattribut")
class DataNeutralizerAttributes(PowerOnModel):
"""Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the attribute mapping (used as UID in neutralized files)",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
mandateId: str = Field(
description="ID of the mandate this attribute belongs to",
json_schema_extra={
"label": "Mandanten-ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
},
)
featureInstanceId: str = Field(
description="ID of the feature instance this attribute belongs to",
json_schema_extra={
"label": "Feature-Instanz-ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
},
)
userId: str = Field(
description="ID of the user who created this attribute",
json_schema_extra={
"label": "Benutzer-ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
},
)
originalText: str = Field(
description="Original text that was neutralized",
json_schema_extra={"label": "Originaltext", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
fileId: Optional[str] = Field(
default=None,
description="ID of the file this attribute belongs to",
json_schema_extra={
"label": "Datei-ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"},
},
)
patternType: str = Field(
description="Type of pattern that matched (email, phone, name, etc.)",
json_schema_extra={"label": "Mustertyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
# ---------------------------------------------------------------------------
# AutoWorkflow — re-exported from canonical location (datamodelWorkflowAutomation)
# ---------------------------------------------------------------------------
from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow # noqa: F401

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""File-related datamodels: FileItem, FilePreview, FileData."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Invitation model for self-service onboarding.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Unified JSON document schema and helpers used by both generation prompts and renderers.
@ -16,6 +16,9 @@ supportedSectionTypes: List[str] = [
"paragraph",
"code_block",
"image",
# Layout primitives (A3): type-specific document layout.
"cover_page", # centered title page (subtitle/author/date/logo), ends with page break
"image_grid", # N-column arrangement of images (marketing-style layouts)
]
class InlineRun(TypedDict, total=False):

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory.
@ -98,7 +98,7 @@ class FileContentIndex(PowerOnModel):
connectionId: Optional[str] = Field(
default=None,
description="UserConnection ID if this index entry originates from an external connector",
json_schema_extra={"label": "Connection-ID"},
json_schema_extra={"label": "Connection-ID", "fk_target": {"db": "poweron_app", "table": "UserConnection", "labelField": "authority"}},
)
neutralizationStatus: Optional[str] = Field(
default=None,
@ -122,7 +122,7 @@ class ContentChunk(PowerOnModel):
)
contentObjectId: str = Field(
description="Reference to the content object within FileContentIndex",
json_schema_extra={"label": "Inhaltsobjekt-ID"},
json_schema_extra={"label": "Inhaltsobjekt-ID", "fk_target": {"db": "poweron_knowledge", "table": "FileContentIndex", "labelField": "fileName"}},
)
fileId: str = Field(
description="FK to the source file",
@ -177,7 +177,7 @@ class RoundMemory(PowerOnModel):
)
workflowId: str = Field(
description="FK to the workflow",
json_schema_extra={"label": "Workflow-ID"},
json_schema_extra={"label": "Workflow-ID", "fk_target": {"db": "poweron_chat", "table": "ChatWorkflow", "labelField": "name"}},
)
roundNumber: int = Field(
default=0,

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Membership models: UserMandate, FeatureAccess, and Junction Tables.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Messaging models: MessagingSubscription, MessagingSubscriptionRegistration, MessagingDelivery."""
@ -112,7 +112,7 @@ class MessagingSubscription(PowerOnModel):
@i18nModel("Messaging-Registrierung")
class MessagingSubscriptionRegistration(BaseModel):
class MessagingSubscriptionRegistration(PowerOnModel):
"""Data model for user registrations to messaging subscriptions"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@ -203,7 +203,7 @@ class MessagingSubscriptionRegistration(BaseModel):
@i18nModel("Messaging-Zustellung")
class MessagingDelivery(BaseModel):
class MessagingDelivery(PowerOnModel):
"""Data model for individual message deliveries"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),

View file

@ -0,0 +1,357 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Navigation structure data (Layer L1 - datamodels).
Single source of truth for UI navigation sections used by RBAC and frontend.
"""
from modules.shared.i18nRegistry import t
# =============================================================================
# Navigation Structure (Single Source of Truth)
# =============================================================================
#
# Block Order (gemaess Navigation-API-Konzept):
# - System: 10
# - <dynamic/features>: 15 (wird in routeSystem.py eingefuegt)
# - Basisdaten: 30
# - Administration: 200
#
# NOTE: Workflows and Migrate sections removed - now handled as features
#
# Item Order: Default-Abstand 10 pro Item
# uiComponent: Abgeleitet von objectKey (ui.system.home -> page.system.home)
# icon: Wird intern gehalten aber NICHT in der API Response zurueckgegeben
NAVIGATION_SECTIONS = [
# --- Meine Sicht (with top-level item + subgroups) ---
{
"id": "system",
"title": t("Meine Sicht"),
"order": 10,
"items": [
{
"id": "home",
"objectKey": "ui.system.home",
"label": t("Start"),
"icon": "FaHome",
"path": "/",
"order": 10,
"public": True,
},
],
"subgroups": [
{
"id": "system-overviews",
"title": t("Übersichten"),
"order": 15,
"items": [
{
"id": "integrations",
"objectKey": "ui.system.integrations",
"label": t("Integrationen"),
"icon": "FaProjectDiagram",
"path": "/integrations",
"order": 10,
"public": True,
},
{
"id": "compliance-audit",
"objectKey": "ui.system.complianceAudit",
"label": t("Compliance & Audit"),
"icon": "FaShieldAlt",
"path": "/compliance-audit",
"order": 20,
},
],
},
{
"id": "system-basedata",
"title": t("Basisdaten"),
"order": 20,
"items": [
{
"id": "connections",
"objectKey": "ui.system.connections",
"label": t("Verbindungen"),
"icon": "FaLink",
"path": "/basedata/connections",
"order": 10,
"public": True,
},
{
"id": "files",
"objectKey": "ui.system.files",
"label": t("Dateien"),
"icon": "FaRegFileAlt",
"path": "/basedata/files",
"order": 20,
"public": True,
},
{
"id": "prompts",
"objectKey": "ui.system.prompts",
"label": t("Prompts"),
"icon": "FaLightbulb",
"path": "/basedata/prompts",
"order": 30,
"public": True,
},
],
},
{
"id": "system-usage",
"title": t("Nutzung"),
"order": 30,
"items": [
{
"id": "billing-admin",
"objectKey": "ui.system.billingAdmin",
"label": t("Abrechnung"),
"icon": "FaMoneyBillAlt",
"path": "/billing/admin",
"order": 10,
},
{
"id": "statistics",
"objectKey": "ui.system.statistics",
"label": t("Statistiken"),
"icon": "FaChartBar",
"path": "/billing/transactions",
"order": 20,
},
{
"id": "rag-inventory",
"objectKey": "ui.system.ragInventory",
"label": t("RAG-Inventar"),
"icon": "FaDatabase",
"path": "/rag-inventory",
"order": 35,
},
{
"id": "store",
"objectKey": "ui.system.store",
"label": t("Store"),
"icon": "FaStore",
"path": "/store",
"order": 40,
"public": True,
},
{
"id": "settings",
"objectKey": "ui.system.settings",
"label": t("Einstellungen"),
"icon": "FaCog",
"path": "/settings",
"order": 50,
"public": True,
},
],
},
],
},
# --- Solution Design (System-Komponente, cross-mandate) ---
# Single nav entry; tabs are managed internally by WorkflowAutomationHubPage.
{
"id": "workflowAutomation",
"title": t("Lösungsdesign"),
"order": 25,
"items": [
{
"id": "wa-hub",
"objectKey": "ui.system.workflowAutomation",
"label": t("Workflow-Automation"),
"icon": "FaSitemap",
"path": "/workflow-automation",
"order": 10,
},
],
},
# --- Administration (with subgroups) ---
{
"id": "admin",
"title": t("Administration"),
"order": 200,
"subgroups": [
{
"id": "admin-wizards",
"title": t("Wizards"),
"order": 10,
"items": [
{
"id": "admin-mandate-wizard",
"objectKey": "ui.admin.mandateWizard",
"label": t("Mandanten-Wizard"),
"icon": "FaMagic",
"path": "/admin/mandate-wizard",
"order": 10,
"adminOnly": True,
},
{
"id": "admin-invitation-wizard",
"objectKey": "ui.admin.invitationWizard",
"label": t("Einladungs-Wizard"),
"icon": "FaEnvelopeOpenText",
"path": "/admin/invitation-wizard",
"order": 20,
"adminOnly": True,
},
],
},
{
"id": "admin-users-group",
"title": t("Benutzer"),
"order": 20,
"items": [
{
"id": "admin-users",
"objectKey": "ui.admin.users",
"label": t("Übersicht"),
"icon": "FaUsers",
"path": "/admin/users",
"order": 10,
"adminOnly": True,
},
{
"id": "admin-invitations",
"objectKey": "ui.admin.invitations",
"label": t("Einladungen"),
"icon": "FaEnvelopeOpenText",
"path": "/admin/invitations",
"order": 20,
"adminOnly": True,
},
{
"id": "admin-user-access-overview",
"objectKey": "ui.admin.userAccessOverview",
"label": t("Zugriffe"),
"icon": "FaClipboardList",
"path": "/admin/user-access-overview",
"order": 30,
"adminOnly": True,
},
{
"id": "admin-subscriptions",
"objectKey": "ui.admin.subscriptions",
"label": t("Abonnements"),
"icon": "FaFileContract",
"path": "/admin/subscriptions",
"order": 40,
"adminOnly": True,
},
],
},
{
"id": "admin-system-group",
"title": t("System"),
"order": 30,
"items": [
{
"id": "admin-roles",
"objectKey": "ui.admin.roles",
"label": t("Rollen"),
"icon": "FaUserTag",
"path": "/admin/mandate-roles",
"order": 10,
"adminOnly": True,
},
{
"id": "admin-mandate-role-permissions",
"objectKey": "ui.admin.mandateRolePermissions",
"label": t("Rollen-Berechtigungen"),
"icon": "FaKey",
"path": "/admin/mandate-role-permissions",
"order": 20,
"adminOnly": True,
},
{
"id": "admin-mandates",
"objectKey": "ui.admin.mandates",
"label": t("Mandanten"),
"icon": "FaBuilding",
"path": "/admin/mandates",
"order": 30,
"adminOnly": True,
},
{
"id": "admin-user-mandates",
"objectKey": "ui.admin.userMandates",
"label": t("Mandanten-Mitglieder"),
"icon": "FaUserFriends",
"path": "/admin/user-mandates",
"order": 40,
"adminOnly": True,
},
{
"id": "admin-access",
"objectKey": "ui.admin.access",
"label": t("Zugriffsverwaltung"),
"icon": "FaBuilding",
"path": "/admin/access",
"order": 50,
"adminOnly": True,
},
{
"id": "admin-feature-instances",
"objectKey": "ui.admin.featureInstances",
"label": t("Feature-Instanzen"),
"icon": "FaCubes",
"path": "/admin/feature-instances",
"order": 60,
"adminOnly": True,
},
{
"id": "admin-feature-roles",
"objectKey": "ui.admin.featureRoles",
"label": t("Features Rollen-Vorlagen"),
"icon": "FaShieldAlt",
"path": "/admin/feature-roles",
"order": 70,
"adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-logs",
"objectKey": "ui.admin.logs",
"label": t("Logs"),
"icon": "FaFileAlt",
"path": "/admin/logs",
"order": 90,
"adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-languages",
"objectKey": "ui.admin.languages",
"label": t("UI-Sprachen"),
"icon": "FaGlobe",
"path": "/admin/languages",
"order": 95,
"adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-database-health",
"objectKey": "ui.admin.databaseHealth",
"label": t("Datenbank-Gesundheit"),
"icon": "FaDatabase",
"path": "/admin/database-health",
"order": 98,
"adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-demo-config",
"objectKey": "ui.admin.demoConfig",
"label": t("Demo Config"),
"icon": "FaCubes",
"path": "/admin/demo-config",
"order": 100,
"adminOnly": True,
"sysAdminOnly": True,
},
],
},
],
},
]

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Notification model for in-app notifications.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Pagination models for server-side pagination, sorting, and filtering.

View file

@ -1,30 +1,17 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Typed Port System for the Graphical Editor.
"""Port type catalog and primitive types for the Graphical Editor workflow system."""
Defines PortSchema, PORT_TYPE_CATALOG, SYSTEM_VARIABLES,
output normalizers, and Transit helpers.
from typing import Dict, List, Optional
"""
from pydantic import BaseModel, ConfigDict, Field
import logging
import time
import uuid
from typing import Any, Dict, List, Optional
from modules.shared.i18nRegistry import t
from pydantic import BaseModel, Field
from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class PortField(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str
type: str # str, int, bool, List[str], List[Document], Dict[str,Any], ConnectionRef, …
description: str = ""
@ -36,28 +23,19 @@ class PortField(BaseModel):
discriminator: bool = False
# Surfaces this field at the top of the DataPicker list as the most common pick.
recommended: bool = False
# Human DataPicker title (camelCase JSON for frontend). Omit for technical paths-only.
picker_label: Optional[str] = Field(default=None, serialization_alias="pickerLabel")
# For List[T] fields: segment between parent and inner field (iteration / one list item).
picker_item_label: Optional[str] = Field(default=None, serialization_alias="pickerItemLabel")
class PortSchema(BaseModel):
name: str # e.g. "EmailDraft", "AiResult", "Transit"
fields: List[PortField]
class InputPortDef(BaseModel):
accepts: List[str] # list of accepted schema names
class OutputPortDef(BaseModel):
model_config = {"populate_by_name": True}
schema_: str = Field(alias="schema")
dynamic: bool = False
deriveFrom: Optional[str] = None
def model_dump(self, **kw):
d = super().model_dump(**kw)
d["schema"] = d.pop("schema_", d.get("schema"))
return d
# Declarative flag for the engine: when True, the executor attaches
# connection provenance ({id, authority, label}) onto the output. Replaces
# hard-coded schema lists in actionNodeExecutor._attachConnectionProvenance.
carriesConnectionProvenance: bool = False
# ---------------------------------------------------------------------------
@ -153,7 +131,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="text", type="str", required=False, description="Textinhalt"),
PortField(name="children", type="List[Any]", required=False, description="Unterblöcke"),
]),
"DocumentList": PortSchema(name="DocumentList", fields=[
"DocumentList": PortSchema(name="DocumentList", carriesConnectionProvenance=True, fields=[
PortField(name="documents", type="List[Document]",
description="Dokumente aus vorherigen Schritten", recommended=True),
PortField(name="connection", type="ConnectionRef", required=False,
@ -163,7 +141,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="count", type="int", required=False,
description="Anzahl Dokumente"),
]),
"FileList": PortSchema(name="FileList", fields=[
"FileList": PortSchema(name="FileList", carriesConnectionProvenance=True, fields=[
PortField(name="files", type="List[FileItem]",
description="Dateiliste"),
PortField(name="connection", type="ConnectionRef", required=False,
@ -173,7 +151,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="count", type="int", required=False,
description="Anzahl Dateien"),
]),
"EmailDraft": PortSchema(name="EmailDraft", fields=[
"EmailDraft": PortSchema(name="EmailDraft", carriesConnectionProvenance=True, fields=[
PortField(name="subject", type="str",
description="Betreff"),
PortField(name="body", type="str",
@ -187,7 +165,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="connection", type="ConnectionRef", required=False,
description="Outlook-/Graph-Verbindung"),
]),
"EmailList": PortSchema(name="EmailList", fields=[
"EmailList": PortSchema(name="EmailList", carriesConnectionProvenance=True, fields=[
PortField(name="emails", type="List[EmailItem]",
description="E-Mails"),
PortField(name="connection", type="ConnectionRef", required=False,
@ -195,7 +173,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="count", type="int", required=False,
description="Anzahl"),
]),
"TaskList": PortSchema(name="TaskList", fields=[
"TaskList": PortSchema(name="TaskList", carriesConnectionProvenance=True, fields=[
PortField(name="tasks", type="List[TaskItem]",
description="Aufgaben"),
PortField(name="connection", type="ConnectionRef", required=False,
@ -219,15 +197,39 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
]),
"AiResult": PortSchema(name="AiResult", fields=[
PortField(name="prompt", type="str",
description="Prompt"),
description="Prompt",
picker_label=t("Eingabe (Prompt des Schritts)"),
),
PortField(name="response", type="str",
description="Antworttext", recommended=True),
description=(
"Antworttext (Modell-Fließtext o. ä.; Bilder liegen in documents, nicht hier)."
),
recommended=True,
picker_label=t("Ausgabetext (Modell)"),
),
PortField(name="responseData", type="Dict", required=False,
description="Strukturierte Antwort (nur bei JSON-Ausgabe)"),
description="Strukturierte Antwort (nur bei JSON-Ausgabe)",
picker_label=t("Strukturierte Antwortdaten")),
PortField(name="context", type="str",
description="Kontext"),
description="Kontext",
picker_label=t("Eingabe-Kontext")),
PortField(name="documents", type="List[Document]",
description="Dokumente"),
description=(
"Erzeugte oder mitgegebene Dateien (z. B. Bilder); documentData = Nutzlast pro Eintrag."
),
picker_label=t("Alle Ausgabe-Dateien (Liste)"),
picker_item_label=t("je Datei"),
),
PortField(name="data", type="Dict", required=False,
description=(
"Internes Payload-Objekt (entspricht ``ActionResult.data``-Semantik). "
"Wird vom Executor gesetzt und enthält denselben Inhalt wie ``response`` "
"in strukturierter Form; primär für nachgelagerte Kontext-Nodes."
),
picker_label=t("Technische Detaildaten (data)")),
PortField(name="imageDocumentsOnly", type="List[Document]", required=False,
description="Nur Bild-bezogene Einträge aus documents.",
picker_label=t("Nur Bilder (Liste)")),
]),
"BoolResult": PortSchema(name="BoolResult", fields=[
PortField(name="result", type="bool",
@ -237,7 +239,8 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
]),
"TextResult": PortSchema(name="TextResult", fields=[
PortField(name="text", type="str",
description="Text"),
description="Text",
picker_label=t("Text (Schrittausgabe)")),
]),
"LoopItem": PortSchema(name="LoopItem", fields=[
PortField(name="currentItem", type="Any",
@ -263,13 +266,32 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="merged", type="Dict",
description="Zusammengeführte Daten"),
]),
"ContextBranch": PortSchema(name="ContextBranch", fields=[
PortField(name="items", type="List[Any]",
description="Schleifen-fertige Elemente aus dem (gefilterten) Kontext",
recommended=True,
picker_label=t("Gefilterte Elemente")),
PortField(name="data", type="Dict", required=False,
description="Gefilterter Presentation-Umschlag oder Eingabe-Spiegel",
picker_label=t("Kontext (data)")),
PortField(name="filterApplied", type="bool", required=False,
description="True wenn ein Kontext-Inhaltsfilter angewendet wurde"),
PortField(name="contentType", type="str", required=False,
description="Angewendeter Inhaltstyp-Filter (z. B. image)"),
PortField(name="match", type="int", required=False,
description="Aktiver Ausgangs-Index (Fall oder Sonst)"),
]),
"ActionDocument": PortSchema(name="ActionDocument", fields=[
PortField(name="documentName", type="str",
description="Dokumentname"),
description="Dokumentname",
picker_label=t("Dateiname")),
PortField(name="documentData", type="Any",
description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)"),
description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)",
picker_label=t("Dateiinhalt (JSON, Text oder Bild)"),
recommended=True),
PortField(name="mimeType", type="str",
description="MIME-Typ"),
description="MIME-Typ",
picker_label=t("Dateityp (MIME)")),
PortField(name="fileId", type="str", required=False,
description="Persistierte FileItem.id (vom Engine ergänzt)"),
PortField(name="fileName", type="str", required=False,
@ -285,12 +307,62 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
# Without it in the catalog the DataPicker cannot offer downstream
# bindings like `processDocuments → documents → *` for syncToAccounting.
PortField(name="documents", type="List[ActionDocument]", required=False,
description="Erzeugte Dokumente (immer befüllt für Trustee/AI/Email/...)"),
description=(
"Dokumentliste für Actions mit echten Artefakt-Dokumenten. "
"Beim Knoten „Inhalt extrahieren“ fehlt dieses Feld in der Knotenausgabe."
),
picker_label=t("Alle Ausgabe-Dokumente"),
picker_item_label=t("je Dokument"),
),
PortField(name="data", type="Dict", required=False,
description="Ergebnisdaten"),
description=(
"Strukturierter Inhalt. Bei **context.extractContent**: **Presentation**-Root "
"(`schemaVersion`, `kind`, `fileOrder`, `files`) plus **`_meta`** — ohne "
"zusätzliches `response`/`contentExtracted`-Duplikat."
),
picker_label=t("Technische Detaildaten (data)")),
# Mirror AiResult primary text fields so DataPicker / primaryTextRef behave the same
PortField(name="prompt", type="str", required=False,
description="Optional: auslösender Prompt / Schrittname",
picker_label=t("Auslöser / Prompt (falls vorhanden)")),
PortField(name="response", type="str", required=False,
description=(
"Fließtext wo die Action einen liefert. Bei **„Inhalt extrahieren“** absichtlich leer — "
"Inhalt liegt in ``data``.``files``."
),
recommended=True,
picker_label=t("Nur Fließtext (gesamt)")),
PortField(name="context", type="str", required=False,
description="Optional: Eingabe-Kontext",
picker_label=t("Mitgegebener Kontext")),
PortField(name="imageDocumentsOnly", type="List[ActionDocument]", required=False,
description=(
"Nur Bild-bezogene Einträge. Bei „Inhalt extrahieren“: synthetische "
"Einträge mit ``fileId`` aus persistierten Extrakt-Bildern (kein separates JSON-Dokument)."
),
picker_label=t("Nur Bilder (Liste)")),
PortField(name="responseData", type="Dict", required=False,
description="Optional: strukturierte Zusatzdaten",
picker_label=t("Strukturierte Zusatzdaten")),
PortField(name="presentation", type="Dict", required=False,
description=(
"Selten: Top-Level-Spiegel von Präsentationsdaten andere Actions. "
"Bei „Inhalt extrahieren“ liegt alles direkt unter ``data`` (kein zusätzlicher Spiegel)."
),
picker_label=t("Presentation (Top-Level-Spiegel)")),
PortField(name="presentationSummary", type="Dict", required=False,
description=(
"Kompakte Metadaten zu ``presentation`` (Debugging / traces)."
),
picker_label=t("Presentation-Zusammenfassung")),
PortField(name="presentationConfig", type="Dict", required=False,
description=(
"Optional: Debugging-Konfiguration; bei Extract liegt die Primärquelle in ``validationMetadata`` des JSON-Dokuments."
),
picker_label=t("Presentation-Konfiguration")),
]),
"Transit": PortSchema(name="Transit", fields=[]),
"UdmDocument": PortSchema(name="UdmDocument", fields=[
"UdmDocument": PortSchema(name="UdmDocument", carriesConnectionProvenance=True, fields=[
PortField(name="id", type="str", description="Dokument-ID"),
PortField(name="sourceType", type="str", description="Quellformat (pdf, docx, …)"),
PortField(name="sourcePath", type="str", description="Quellpfad"),
@ -402,6 +474,14 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Redmine-Instanz"),
]),
"RedmineRelationList": PortSchema(name="RedmineRelationList", fields=[
PortField(name="relations", type="List[Any]", description="Relationen"),
PortField(name="count", type="int", required=False, description="Anzahl in dieser Seite"),
PortField(name="totalMatched", type="int", required=False,
description="Gesamtanzahl nach Filter"),
PortField(name="offset", type="int", required=False, description="Pagination-Offset"),
PortField(name="hasMore", type="bool", required=False, description="Weitere Seiten verfügbar"),
]),
"RedmineStats": PortSchema(name="RedmineStats", fields=[
PortField(name="kpis", type="Dict[str,Any]",
description="Key Performance Indicators"),
@ -465,18 +545,13 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
]),
}
# ---------------------------------------------------------------------------
# Catalog validator
# ---------------------------------------------------------------------------
# Primitives accepted as PortField.type in addition to catalog schema names.
PRIMITIVE_TYPES: frozenset = frozenset({
"str", "int", "bool", "float", "Any", "Dict", "List",
})
def _stripContainer(typeStr: str) -> List[str]:
def stripContainer(typeStr: str) -> List[str]:
"""
Extract referenced type names from a PortField.type string.
@ -491,417 +566,7 @@ def _stripContainer(typeStr: str) -> List[str]:
if not s:
return []
if "[" in s and s.endswith("]"):
# outer container ignored, inner parts split by comma
inner = s[s.index("[") + 1 : -1]
parts = [p.strip() for p in inner.split(",") if p.strip()]
return parts or [s]
return [s]
def _isKnownType(typeName: str) -> bool:
return typeName in PRIMITIVE_TYPES or typeName in PORT_TYPE_CATALOG
def _validateCatalog() -> List[str]:
"""
Validate PORT_TYPE_CATALOG integrity.
Returns a list of error messages. Empty list means catalog is healthy.
Checks:
1. Every PortField.type references either a primitive or a known schema.
2. Discriminator fields exist, are typed "str", and at most one per schema.
3. No cyclic references via required schema-typed fields
(optional fields may form cycles intentionally, e.g. provenance).
4. Schema name in catalog key matches PortSchema.name.
"""
errors: List[str] = []
# Check 4: key consistency
for key, schema in PORT_TYPE_CATALOG.items():
if schema.name != key:
errors.append(f"Catalog key '{key}' does not match schema.name '{schema.name}'")
# Check 1 + 2: type refs and discriminators
for schemaName, schema in PORT_TYPE_CATALOG.items():
discriminatorCount = 0
for field in schema.fields:
for refName in _stripContainer(field.type):
if not _isKnownType(refName):
errors.append(
f"{schemaName}.{field.name}: unknown type '{refName}' "
f"(not a primitive and not in catalog)"
)
if field.discriminator:
discriminatorCount += 1
if field.type != "str":
errors.append(
f"{schemaName}.{field.name}: discriminator must be 'str', got '{field.type}'"
)
if discriminatorCount > 1:
errors.append(
f"{schemaName}: has {discriminatorCount} discriminator fields, max 1 allowed"
)
# Check 3: cycles via required schema-typed fields
def _requiredSchemaRefs(name: str) -> List[str]:
sch = PORT_TYPE_CATALOG.get(name)
if not sch:
return []
out: List[str] = []
for field in sch.fields:
if not field.required:
continue
for ref in _stripContainer(field.type):
if ref in PORT_TYPE_CATALOG:
out.append(ref)
return out
def _hasCycle(start: str) -> Optional[List[str]]:
stack: List[str] = [start]
path: List[str] = []
visiting: set = set()
def _dfs(name: str) -> Optional[List[str]]:
if name in visiting:
return path + [name]
visiting.add(name)
path.append(name)
for ref in _requiredSchemaRefs(name):
if ref == start and len(path) > 0:
return path + [ref]
cycle = _dfs(ref)
if cycle:
return cycle
path.pop()
visiting.discard(name)
return None
return _dfs(start)
for schemaName in PORT_TYPE_CATALOG.keys():
cycle = _hasCycle(schemaName)
if cycle and cycle[0] == schemaName:
errors.append(
f"{schemaName}: cyclic required-ref chain: {' -> '.join(cycle)}"
)
break # one cycle is enough — avoid spamming
return errors
# ---------------------------------------------------------------------------
# SYSTEM_VARIABLES
# ---------------------------------------------------------------------------
SYSTEM_VARIABLES: Dict[str, Dict[str, str]] = {
"system.timestamp": {"type": "int", "description": "Unix timestamp (ms)"},
"system.date": {"type": "str", "description": "ISO date (YYYY-MM-DD)"},
"system.datetime": {"type": "str", "description": "ISO datetime"},
"system.time": {"type": "str", "description": "HH:MM:SS"},
"system.userId": {"type": "str", "description": "Current user ID"},
"system.userName": {"type": "str", "description": "Current user name"},
"system.userEmail": {"type": "str", "description": "Current user email"},
"system.workflowId": {"type": "str", "description": "Workflow ID"},
"system.runId": {"type": "str", "description": "Run ID"},
"system.instanceId": {"type": "str", "description": "Feature instance ID"},
"system.mandateId": {"type": "str", "description": "Mandate ID"},
"system.loopIndex": {"type": "int", "description": "Current loop index (only in loop)"},
"system.loopCount": {"type": "int", "description": "Loop item count (only in loop)"},
"system.uuid": {"type": "str", "description": "Random UUID"},
}
def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
"""Resolve a system variable name to its runtime value."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
mapping = {
"system.timestamp": lambda: int(now.timestamp() * 1000),
"system.date": lambda: now.strftime("%Y-%m-%d"),
"system.datetime": lambda: now.isoformat(),
"system.time": lambda: now.strftime("%H:%M:%S"),
"system.userId": lambda: context.get("userId", ""),
"system.userName": lambda: context.get("userName", ""),
"system.userEmail": lambda: context.get("userEmail", ""),
"system.workflowId": lambda: context.get("workflowId", ""),
"system.runId": lambda: context.get("_runId", ""),
"system.instanceId": lambda: context.get("instanceId", ""),
"system.mandateId": lambda: context.get("mandateId", ""),
"system.loopIndex": lambda: (context.get("_loopState") or {}).get("currentIndex", -1),
"system.loopCount": lambda: len((context.get("_loopState") or {}).get("items", [])),
"system.uuid": lambda: str(uuid.uuid4()),
}
resolver = mapping.get(variable)
if resolver:
return resolver()
logger.warning("Unknown system variable: %s", variable)
return None
# ---------------------------------------------------------------------------
# Output normalizers
# ---------------------------------------------------------------------------
def _file_record_to_document(f: Any) -> Optional[Dict[str, Any]]:
"""Map API / task-upload file dicts onto PortSchema ``Document`` fields."""
if f is None:
return None
if isinstance(f, str) and f.strip():
return {"id": f.strip()}
if not isinstance(f, dict):
return None
inner = f.get("file") if isinstance(f.get("file"), dict) else None
src = inner or f
out: Dict[str, Any] = {}
fid = src.get("id") or f.get("id")
if fid is not None and str(fid).strip():
out["id"] = str(fid).strip()
name = (
src.get("name")
or src.get("fileName")
or f.get("fileName")
or f.get("name")
)
if name is not None and str(name).strip():
out["name"] = str(name).strip()
mime = src.get("mimeType") or src.get("mime") or f.get("mimeType")
if mime is not None and str(mime).strip():
out["mimeType"] = str(mime).strip()
for k in ("sizeBytes", "downloadUrl", "filePath"):
v = src.get(k) if k in src else f.get(k)
if v is not None and v != "":
out[k] = v
return out if out else None
def _coerce_document_list_upload_fields(result: Dict[str, Any]) -> None:
"""
Human task ``input.upload`` completes with ``file`` / ``files`` / ``fileIds``.
DocumentList expects ``documents``. Without this, resume adds ``documents: []`` and drops the real files.
"""
docs = result.get("documents")
if isinstance(docs, list) and len(docs) > 0:
return
collected: List[Dict[str, Any]] = []
files = result.get("files")
if isinstance(files, list):
for item in files:
d = _file_record_to_document(item)
if d:
collected.append(d)
if not collected:
single = result.get("file")
d = _file_record_to_document(single)
if d:
collected.append(d)
if not collected and isinstance(result.get("fileIds"), list):
for fid in result["fileIds"]:
if fid is not None and str(fid).strip():
collected.append({"id": str(fid).strip()})
if not collected:
return
result["documents"] = collected
if not result.get("count"):
result["count"] = len(collected)
def normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]:
"""
Normalize raw executor output to match the declared port schema.
Ensures _success/_error meta-fields are always present.
"""
if not isinstance(raw, dict):
raw = {"value": raw} if raw is not None else {}
result = dict(raw)
result.setdefault("_success", not bool(raw.get("error")))
result.setdefault("_error", raw.get("error"))
schema = PORT_TYPE_CATALOG.get(schemaName)
if not schema or schemaName == "Transit":
return result
if schemaName == "DocumentList":
_coerce_document_list_upload_fields(result)
# Only default **required** fields. Optional fields stay absent so DataRefs / context
# resolution never pick a synthetic `{}` or `[]` (e.g. AiResult.responseData when the
# model returned plain text only).
for field in schema.fields:
if field.name not in result and field.required:
result[field.name] = _defaultForType(field.type)
return result
def _defaultForType(typeStr: str) -> Any:
"""Return a sensible default for a type string."""
if typeStr.startswith("List"):
return []
if typeStr.startswith("Dict"):
return {}
if typeStr == "bool":
return False
if typeStr == "int":
return 0
if typeStr == "str":
return ""
if typeStr in PORT_TYPE_CATALOG:
return {}
return None
def _normalizeError(error: Exception, schemaName: str) -> Dict[str, Any]:
"""Build an error envelope matching the schema with _success=False."""
result = {"_success": False, "_error": str(error)}
schema = PORT_TYPE_CATALOG.get(schemaName)
if schema:
for field in schema.fields:
result.setdefault(field.name, _defaultForType(field.type))
return result
# ---------------------------------------------------------------------------
# Transit helpers
# ---------------------------------------------------------------------------
def wrapTransit(data: Any, meta: Dict[str, Any]) -> Dict[str, Any]:
"""Wrap data in a Transit envelope."""
return {"_transit": True, "_meta": meta, "data": data}
def unwrapTransit(output: Any) -> Any:
"""Unwrap a Transit envelope, returning the inner data."""
if isinstance(output, dict) and output.get("_transit"):
return output.get("data")
return output
def _resolveTransitChain(
nodeId: str,
nodeOutputs: Dict[str, Any],
connectionMap: Dict[str, list],
) -> Any:
"""
Follow _transit chain backwards until a real (non-transit) producer is found.
Returns the unwrapped output of the real producer.
"""
visited = set()
current = nodeId
while current and current not in visited:
visited.add(current)
out = nodeOutputs.get(current)
if not isinstance(out, dict) or not out.get("_transit"):
return out
sources = connectionMap.get(current, [])
if not sources:
return unwrapTransit(out)
srcId = sources[0][0] if sources else None
if not srcId:
return unwrapTransit(out)
current = srcId
return nodeOutputs.get(nodeId)
# ---------------------------------------------------------------------------
# Schema derivation for dynamic outputs
# ---------------------------------------------------------------------------
def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Optional[PortSchema]:
"""Derive output schema from a field-builder JSON list (``fields``, ``formFields``, …)."""
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
_FORM_TYPE_TO_PORT: Dict[str, str] = {f["id"]: f["portType"] for f in FORM_FIELD_TYPES}
fields_param = (node.get("parameters") or {}).get(param_key)
if not fields_param or not isinstance(fields_param, list):
return None
portFields: List[PortField] = []
def _append_field(fname: str, ftype: Any, lab: Any, required: bool) -> None:
_desc = resolveText(lab) if lab is not None else fname
if not str(_desc).strip():
_desc = fname
raw_type = str(ftype) if ftype is not None else "str"
port_type = _FORM_TYPE_TO_PORT.get(raw_type, raw_type)
portFields.append(PortField(
name=fname,
type=port_type,
description=_desc,
required=required,
))
for f in fields_param:
if not isinstance(f, dict) or not f.get("name"):
continue
fname = str(f["name"])
if str(f.get("type", "")).lower() == "group" and isinstance(f.get("fields"), list):
for sub in f["fields"]:
if isinstance(sub, dict) and sub.get("name"):
_append_field(
f"{fname}.{sub['name']}",
sub.get("type", "str"),
sub.get("label"),
bool(sub.get("required", False)),
)
continue
_append_field(fname, f.get("type", "str"), f.get("label"), bool(f.get("required", False)))
return PortSchema(name="FormPayload_dynamic", fields=portFields) if portFields else None
def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
"""Derive output schema from form field definitions (``parameters.fields``)."""
return deriveFormPayloadSchemaFromParam(node, "fields")
def parse_graph_defined_output_schema(
node: Dict[str, Any],
output_port: Dict[str, Any],
) -> Optional[PortSchema]:
"""
Resolve a node's output port to a concrete PortSchema.
Supports:
- Static catalog name: ``schema: "ActionResult"``
- Graph-defined: ``schema: {"kind": "fromGraph", "parameter": "fields"}``
- Legacy: ``dynamic`` + ``deriveFrom`` on the port dict.
"""
if not isinstance(output_port, dict):
return None
schema_spec = output_port.get("schema")
if isinstance(schema_spec, dict) and schema_spec.get("kind") == "fromGraph":
param_key = str(schema_spec.get("parameter") or "fields")
return deriveFormPayloadSchemaFromParam(node, param_key)
if output_port.get("dynamic") and output_port.get("deriveFrom"):
return deriveFormPayloadSchemaFromParam(node, str(output_port.get("deriveFrom")))
if isinstance(schema_spec, str) and schema_spec:
return PORT_TYPE_CATALOG.get(schema_spec)
return None
def resolve_output_schema_name(node: Dict[str, Any], output_port: Dict[str, Any]) -> str:
"""Return a schema name for port compatibility / path listing."""
derived = parse_graph_defined_output_schema(node, output_port)
if derived:
return derived.name
spec = output_port.get("schema") if isinstance(output_port, dict) else None
if isinstance(spec, str) and spec:
return spec
return "Any"
def _deriveTransformSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
"""Derive output schema from transform mappings."""
mappings = (node.get("parameters") or {}).get("mappings")
if not mappings or not isinstance(mappings, list):
return None
portFields = []
for m in mappings:
if isinstance(m, dict) and m.get("outputField"):
portFields.append(PortField(
name=m["outputField"],
type=m.get("type", "str"),
description=str(m.get("label", m["outputField"])),
))
return PortSchema(name="Transform_dynamic", fields=portFields) if portFields else None

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
RBAC models: AccessRule, AccessRuleContext, Role.
@ -10,7 +10,7 @@ Multi-Tenant Design:
"""
import uuid
from typing import Optional
from typing import Optional, Dict, List, Protocol, runtime_checkable
from enum import Enum
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
@ -174,6 +174,20 @@ class AccessRule(PowerOnModel):
)
@runtime_checkable
class RbacProtocol(Protocol):
"""Structural type for RBAC checkers — allows aicore (L3) to reference
the RBAC contract without importing from security (L4)."""
def checkResourceAccessBulk(
self,
user: "User",
resourcePaths: List[str],
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
) -> Dict[str, bool]: ...
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level
IMMUTABLE_FIELDS = {
"Role": ["mandateId", "featureInstanceId", "featureCode"],

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Security models: Token and AuthEvent.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Subscription models: SubscriptionPlan (catalog), MandateSubscription (instance per mandate),
StripePlanPrice (persisted Stripe IDs per plan).
@ -153,7 +153,7 @@ class SubscriptionPlan(BaseModel):
# ============================================================================
@i18nModel("Stripe-Planpreise")
class StripePlanPrice(BaseModel):
class StripePlanPrice(PowerOnModel):
"""Persistierte Zuordnung planKey zu Stripe Product/Price IDs."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Ticket datamodels used across Jira/ClickUp connectors."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Utility data models and classes for common tools and mappings.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
UAM models: User, Mandate, UserConnection.
@ -197,6 +197,26 @@ class Mandate(PowerOnModel):
# `customer.email`, `customer.tax_id_data` mappen kann
# (Stripe verlangt die Adresse strukturiert, nicht als Freitext).
# ``order`` 200-209 gruppiert die Felder visuell am Ende des Formulars.
mfaRequired: bool = Field(
default=False,
description="When true, all users with access to this mandate must have MFA enabled.",
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": False,
"frontend_required": False,
"label": "MFA-Pflicht",
"frontend_format_labels": ["Ja", "-", "Nein"],
"order": 190,
},
)
@field_validator("mfaRequired", mode="before")
@classmethod
def _coerceMfaRequired(cls, v):
if v is None:
return False
return v
invoiceCompanyName: Optional[str] = Field(
default=None,
description="Firmenname / Empfaenger der Rechnung (falls abweichend vom Voller Name).",
@ -475,7 +495,7 @@ class UserConnection(PowerOnModel):
description="OAuth scopes granted for this connection",
json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False, "label": "Gewährte Berechtigungen"},
)
knowledgeIngestionEnabled: bool = Field(
knowledgeIngestionEnabled: Optional[bool] = Field(
default=False,
description="Whether the user has consented to knowledge ingestion for this connection",
json_schema_extra={"frontend_type": "boolean", "frontend_readonly": False, "frontend_required": False, "label": "Wissensdatenbank aktiv"},
@ -623,6 +643,25 @@ class User(PowerOnModel):
return v
mfaEnabled: bool = Field(
default=False,
description="Whether the user has completed MFA setup and has TOTP active.",
json_schema_extra={
"frontend_type": "checkbox",
"frontend_readonly": True,
"frontend_required": False,
"label": "MFA aktiv",
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
@field_validator("mfaEnabled", mode="before")
@classmethod
def _coerceMfaEnabled(cls, v):
if v is None:
return False
return v
authenticationAuthority: AuthAuthority = Field(
default=AuthAuthority.LOCAL,
description="Primary authentication authority",
@ -655,6 +694,11 @@ class UserInDB(User):
description="Hash of the user password",
json_schema_extra={"label": "Passwort-Hash"},
)
mfaSecret: Optional[str] = Field(
None,
description="Encrypted TOTP secret for MFA. Stored via encryptValue/decryptValue.",
json_schema_extra={"label": "MFA-Secret", "frontend_visible": False},
)
resetToken: Optional[str] = Field(
None,
description="Password reset token (UUID)",
@ -747,4 +791,3 @@ class UserVoicePreferences(PowerOnModel):
def _validateTtsVoiceMap(cls, value: Any) -> Optional[Dict[str, str]]:
return normalizeTtsVoiceMap(value)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Unified Document Model (UDM) — hierarchical document tree and ContentPart bridge."""
from __future__ import annotations

View file

@ -1,4 +1,4 @@
# Copyright (c) 2025 Patrick Motsch
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""UI language sets: structured i18n entries (context, key, value)."""

Some files were not shown because too many files have changed in this diff Show more