Merge branch 'int' of git.poweron.swiss:PowerOn/platform-core into int
Some checks failed
Deploy Plattform-Core INT / test (push) Failing after 2s
Deploy Plattform-Core INT / deploy (push) Has been skipped
Deploy Plattform-Core (Int) / test (push) Failing after 15s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped

This commit is contained in:
Ida 2026-05-27 09:18:23 +02:00
commit 7f7b21a384
43 changed files with 2186 additions and 2735 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

@ -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

@ -12,7 +12,7 @@ jobs:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
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
@ -22,9 +22,9 @@ jobs:
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-gateway-prod-forgejo.env
cp env-gateway-prod-forgejo.env .env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
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
@ -39,7 +39,7 @@ jobs:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
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
@ -49,9 +49,9 @@ jobs:
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-gateway-prod-forgejo.env
cp env-gateway-prod-forgejo.env .env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
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,74 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Patrick Motsch
"""Load CONFIG_KEY from Azure App Service for CI pytest (Kudu API + publish profile)."""
from __future__ import annotations
import base64
import json
import os
import sys
import urllib.request
import xml.etree.ElementTree as ET
def main() -> None:
profile_xml = os.environ.get("AZURE_PUBLISH_PROFILE")
setting_name = os.environ.get("SETTING_NAME", "CONFIG_KEY")
if not profile_xml:
print("::error::AZURE_PUBLISH_PROFILE is not set", file=sys.stderr)
sys.exit(1)
root = ET.fromstring(profile_xml)
pub = None
for element in root.findall(".//publishProfile"):
url = (element.get("publishUrl") or "").lower()
if "scm" in url:
pub = element
break
if pub is None:
pub = root.find(".//publishProfile")
if pub is None:
print("::error::No publishProfile in publish profile XML", file=sys.stderr)
sys.exit(1)
host = (pub.get("publishUrl") or "").split(":")[0]
user = pub.get("userName")
pwd = pub.get("userPWD")
if not (host and user and pwd):
print("::error::Could not parse SCM credentials from publish profile", file=sys.stderr)
sys.exit(1)
api = f"https://{host}/api/settings"
req = urllib.request.Request(api)
cred = base64.b64encode(f"{user}:{pwd}".encode()).decode()
req.add_header("Authorization", f"Basic {cred}")
try:
with urllib.request.urlopen(req, timeout=60) as resp:
settings = json.load(resp)
except Exception as exc:
print(f"::error::Kudu settings request failed: {exc}", file=sys.stderr)
sys.exit(1)
if not isinstance(settings, dict) or setting_name not in settings:
preview = sorted(settings.keys())[:25] if isinstance(settings, dict) else []
print(
f"::error::{setting_name} not in Azure App Service application settings "
f"(sample keys: {preview})",
file=sys.stderr,
)
sys.exit(1)
value = settings[setting_name]
if not value or not str(value).strip():
print(f"::error::{setting_name} is empty in Azure App Service", file=sys.stderr)
sys.exit(1)
github_env = os.environ.get("GITHUB_ENV")
if github_env:
with open(github_env, "a", encoding="utf-8") as handle:
handle.write(f"{setting_name}<<EOF\n{value}\nEOF\n")
print(f"Loaded {setting_name} from Azure App Service ({len(value)} characters)")
if __name__ == "__main__":
main()

View file

@ -1,194 +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:
test:
runs-on: ubuntu-latest
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_file=env-gateway-${ENV_TYPE}.env" >> $GITHUB_OUTPUT
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Set environment file
run: |
ENV_FILE="${{ steps.env.outputs.env_file }}"
test -f "$ENV_FILE"
cp "$ENV_FILE" .env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
- 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
- name: Run tests
run: python -m pytest tests/ --ignore=tests/demo
deploy:
runs-on: ubuntu-latest
needs: test
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,121 +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:
test:
runs-on: ubuntu-latest
environment: Production
steps:
- uses: actions/checkout@v5
- name: Set up Python version
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Set environment file
run: |
test -f env-gateway-int.env
cp env-gateway-int.env .env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
- 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
- name: Load CONFIG_KEY from Azure App Service
env:
AZURE_PUBLISH_PROFILE: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_INT }}
run: python .github/scripts/load_config_key_from_azure.py
- name: Run tests
run: python -m pytest tests/ --ignore=tests/demo
build:
runs-on: ubuntu-latest
needs: test
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
- 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,121 +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:
test:
runs-on: ubuntu-latest
environment: Production
steps:
- uses: actions/checkout@v5
- name: Set up Python version
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Set environment file
run: |
test -f env-gateway-prod.env
cp env-gateway-prod.env .env
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env
- 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
- name: Load CONFIG_KEY from Azure App Service
env:
AZURE_PUBLISH_PROFILE: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_GATEWAY_PROD }}
run: python .github/scripts/load_config_key_from_azure.py
- name: Run tests
run: python -m pytest tests/ --ignore=tests/demo
build:
runs-on: ubuntu-latest
needs: test
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
- 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,70 +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: Set environment file
run: |
if [ "${{ github.ref }}" == "refs/heads/int" ]; then
ENV_FILE="env-gateway-int.env"
else
ENV_FILE="env-gateway-prod.env"
fi
test -f "$ENV_FILE"
cp "$ENV_FILE" .env
- name: Install dependencies from generated lock
run: pip install -r requirements.lock --no-cache-dir
- name: Run tests
run: python -m pytest tests/ --ignore=tests/demo
- name: Clean up .env before commit
run: rm -f .env
- 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 uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} --workers 1 --timeout-graceful-shutdown 5

59
app.py
View file

@ -426,32 +426,36 @@ async def lifespan(app: FastAPI):
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}")
# 1. Stop scheduler first (removes all pending cron/interval jobs)
eventManager.stop()
# --- Close all PostgreSQL connection pools ---
# Must run LAST: feature `onStop` hooks may still issue DB calls during
# shutdown. Once we tear down the pools, no more borrows are possible.
try:
from modules.connectors.connectorDbPostgre import closeAllPools
closeAllPools()
except Exception as e:
logger.warning(f"Closing DB connection pools failed: {e}")
# 2. 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}")
logger.info("Application has been shut down")
# 3. Close all PostgreSQL connection pools (LAST -- features may still
# issue DB calls during their onStop hooks)
try:
from modules.connectors.connectorDbPostgre import closeAllPools
closeAllPools()
except Exception as e:
logger.warning(f"Closing DB connection pools 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
@ -719,4 +723,11 @@ 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__":
import uvicorn
port = int(os.environ.get("PORT", 8000))
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=5)

View file

@ -73,9 +73,6 @@ 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

View file

@ -1,92 +0,0 @@
# Integration Environment Configuration
# System Configuration
APP_ENV_TYPE = int
APP_ENV_LABEL = Integration Instance
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
APP_API_URL = https://api-int.poweron.swiss
APP_COOKIE_SECURE = true
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
# PostgreSQL DB Host
DB_HOST=10.20.0.175
DB_USER=poweron_dev
DB_PASSWORD_SECRET = your_new_password_here
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=https://porta-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:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
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:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
Service_GOOGLE_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/connect/callback
# ClickUp OAuth — same app as gateway-int; add https://api-int.poweron.swiss as second redirect in ClickUp (root URL, no path).
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
Service_CLICKUP_CLIENT_SECRET = CZECD706WLSX6UV13YI4ACNW50ADZHHXDAJALHE0YE030QFSI6Y9HP4Y61JT7CF0
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api-int.poweron.swiss
# 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:Z0FBQUFBQnFCdlFlYUZpRDFqLWhQajZxSElqMEMzdGZIRm5TeDBSSFlqenpZYVJEa1BtRXM1M21pd3hjTGZvSDJPcGJoY2gyQlNncWNwNkNIR0NFQnpjXzA5U2t6Zm1DWWNNVEZrTE5DVzRQVGdlZzRldGoyRWhaeTJfYjBHd0ludWpGcWdqd3hKTHJ5T0piVE15Tk1YZUZnSnE4OXdKOUhXd292dHpWMkxlR3dNclc1N2t0ckFoMmd5WTlBci11MXRGNV9UTlFCSmdOOE83bGJyODFUQ3E2NXJpRHJWZUM0cHFHekNJa0FlN3hjd2VFQ1Nqa1JFQ2NFdjlMWW1TbEV4TVZBeDFEZVVnUWlBVUV1Z0NUNHV0RE1fTEJaLTQxQksyVE1LSE1sSG0ycG9fTS1hNzh4dTQ9
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlRHFpNThJb3g3UU05cUw4SVJpOXBTblU5QzU1WFItZ2JkNXVILVN4VHp0Umh2RjJyZXJMNVp5OWFxLWhjRjhub3cxajkxMVRQMnZQdVBGT21obWN0Q0NlOU80MVhMMXRWb1l3cWNpR2Ytc1d0WnVlRUN1TTZ4NjFQcDd0Wll4cFN6dzk1OU5SZGNJck54WmNoeElITzEzejJrczVSQnp6ZTBINGtENHFiT3NnWjdUME9xXzJ5Y0N3dHk5QnpBRkpyVTgxOE0xTVllR2JMUC0yTkwyWWxHQT09
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: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,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:Z0FBQUFBQnFCdlFmcDVyOGNwbVkwWFJCWmFkZS12RkhLaFhLSF9kWWpEZ0d0NDBqV2FnWlpnYmpSckdLSGpjbmh6aHJXVUZxMElwY1MzcVg1MzBOdURUZXhnZ3pqNEZyQ1JWMVA0YmxhNWJlenNpa1A3TjZkYVZSclFONjU4MF9jMTJaS2d0ZDNnXzJKSmhSRVhyckJpTUlDa0RRWHN5cWVkOUJMTUp5aFRHcDV5Z1A1aWhSUnFNOHBJTDFPdzAzcVJ3bmhueTBmVkJDZTdJakhMOEFRdHBvWFduUzdRV2dNQVdpaXdFSVlHMDJ4NnZRUTBZZ3pOakxPLUdjNlNNQnJQMXpfSWR3NmFodDdDbkEtVmRjdVBhMjRWT1NOV1BYbU15VHRSWFR0UVBBMWtKRTRkS25KMFk9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmMGhla2xoZWowNjJzc1EzMWJYRXRTcGdWWWctU3hhcXNUbVVaOTJiRFJuSGM5S3ZGZ0M4RFotTGxOQ3loa3l4aVZ2T3FsRVVMck83RTlURFNOdWxHb0JfNVEtRGJ4X193dV9Bd0EtNlVGV0h4SWk2bldfWThxNVVnOGctSkNFR3FXa2pmY2ROcV9EVE1oMndFY1d4MjdLeWtUd0VEeW5CTlFwX2FOcW9DaWVXYWVfMy1ZUnFFUEZnanFOUGZILUpUZU8yUHNSODE3OXBSWVJFNlpBdTJtUT09
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmRm9saTZuR1VSZV9pQllKRGFURmN4cDNNanpsVFM3TVItdDNtNWdoWC1zVllrLUVPeGZDRXF1S3Rxd0tVUGV6bl9Ob0JMa3U5ZUNlRjRVQ1dRWXZDTXlsRU13b2o2R1paalU4RXB6SWxYVEJPa2NmaDRFdzExRXU1X2VnNDlhQzQ3cTE1RlJrSlB5elRMZ2w3NmxlV2l3PT0=
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmZGdyWkJibS03akJtSjF0U2doYXZVVDM1em1kY2ZpRGJISmVCUURfVkw3c2Z3OEFQd1h1SzE0cTExSUtVejRPY3VmWF9XT1ZyS3RxRmVRYktJeDR6OWhYaEM0bkNLVEI1cl9VZ1VFOG9IRTFWc2FUemh0UmNHTGprQ0FweThlSGpSSDAyZmw2YmR0OFREQWxpNERHWm1nPT0=
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnFCdlFmcEVpVmFuWkk4eTJTc3VtRFg4cE9QU3R5NVg0eVFIR29RSVhmXy1rR0pPTm4wbFhIVFFpckx5UmhvSGxqSWV4S0xoTzdESE55R2k5eHowZEprdGhrbEU3eG5JWGpaNWJIdDRqT05zZGNCQVpXd2xTek1teHRBS3NRU2FuUTlSQ2Q=
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

@ -3,17 +3,17 @@
# System Configuration
APP_ENV_TYPE = int
APP_ENV_LABEL = Integration Instance
APP_API_URL = https://gateway-int.poweron.swiss
APP_API_URL = https://api-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_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
DB_HOST=gateway-int-server.postgres.database.azure.com
DB_USER=heeshkdlby
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
# 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
@ -21,11 +21,11 @@ APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZ
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
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,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_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
@ -36,23 +36,22 @@ 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_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://gateway-int.poweron.swiss/api/msft/auth/connect/callback
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://gateway-int.poweron.swiss/api/google/auth/login/callback
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://gateway-int.poweron.swiss/api/google/auth/connect/callback
Service_GOOGLE_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/connect/callback
# ClickUp OAuth — redirect URL must match ClickUp app exactly (often API root only).
# OAuth lands on /?code=&state=; gateway forwards to /api/clickup/auth/connect/callback (routeAdmin root).
# 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
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.
@ -76,11 +75,8 @@ 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
# 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

View file

@ -8,8 +8,8 @@ APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://api.poweron.swiss
# PostgreSQL DB Host
DB_HOST=10.20.0.21
# 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
@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUl
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=https://porta.poweron.swiss
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,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
@ -74,11 +74,8 @@ 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
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
# Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE

View file

@ -29,25 +29,6 @@ _msg = apiRouteContext("oauthConnectTicket")
_CONNECT_TICKET_TTL_SEC = 600
# OAuth providers sometimes redirect to the API root if the app redirect URL omits the path.
OAUTH_FLOW_CALLBACK_PATHS: Dict[str, str] = {
"clickup_connect": "/api/clickup/auth/connect/callback",
"msft_connect": "/api/msft/auth/connect/callback",
"google_connect": "/api/google/auth/connect/callback",
}
def oauth_callback_redirect_path(state: str) -> str | None:
"""Map connect-ticket JWT (ClickUp ``state`` param) to the correct callback route."""
try:
data = jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
return None
flow = data.get("flow")
if not isinstance(flow, str):
return None
return OAUTH_FLOW_CALLBACK_PATHS.get(flow)
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."""

View file

@ -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

@ -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

@ -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

@ -319,7 +319,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

@ -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

@ -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

@ -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

@ -475,7 +475,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"},
@ -747,4 +747,3 @@ class UserVoicePreferences(PowerOnModel):
def _validateTtsVoiceMap(cls, value: Any) -> Optional[Dict[str, str]]:
return normalizeTtsVoiceMap(value)

View file

@ -74,9 +74,18 @@ class CoachingScoreTrend(str, Enum):
class TrainingModule(PowerOnModel):
"""A training module representing a topic the user is working on."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str = Field(description="Owner user ID (strict ownership)")
mandateId: str = Field(description="Mandate ID")
instanceId: str = Field(description="Feature instance ID")
userId: str = Field(
description="Owner user ID (strict ownership)",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
instanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
title: str = Field(description="Module title, e.g. 'Conflict with team lead'")
description: Optional[str] = Field(default=None, description="Short description")
moduleType: TrainingModuleType = Field(default=TrainingModuleType.COACHING)
@ -84,7 +93,10 @@ class TrainingModule(PowerOnModel):
goals: Optional[str] = Field(default=None, description="Free-text goal description")
insights: Optional[str] = Field(default=None, description="JSON array of AI insights [{id, text, sessionId, createdAt}]")
metadata: Optional[str] = Field(default=None, description="JSON object with flexible metadata")
personaId: Optional[str] = Field(default=None, description="Default persona for sessions")
personaId: Optional[str] = Field(
default=None, description="Default persona for sessions",
json_schema_extra={"label": "Persona", "fk_target": {"db": "poweron_commcoach", "table": "CoachingPersona", "labelField": "label"}},
)
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
sessionCount: int = Field(default=0)
taskCount: int = Field(default=0)
@ -96,12 +108,27 @@ class TrainingModule(PowerOnModel):
class CoachingSession(PowerOnModel):
"""A single coaching conversation session within a module."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
moduleId: str = Field(description="FK to TrainingModule")
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
instanceId: str = Field(description="Feature instance ID")
moduleId: str = Field(
description="FK to TrainingModule",
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
)
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
instanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
status: CoachingSessionStatus = Field(default=CoachingSessionStatus.ACTIVE)
personaId: Optional[str] = Field(default=None, description="FK to CoachingPersona (Iteration 2)")
personaId: Optional[str] = Field(
default=None, description="FK to CoachingPersona",
json_schema_extra={"label": "Persona", "fk_target": {"db": "poweron_commcoach", "table": "CoachingPersona", "labelField": "label"}},
)
summary: Optional[str] = Field(default=None, description="AI-generated session summary")
coachNotes: Optional[str] = Field(default=None, description="JSON: AI internal notes for continuity")
compressedHistorySummary: Optional[str] = Field(default=None, description="AI summary of older messages for long sessions")
@ -118,9 +145,18 @@ class CoachingSession(PowerOnModel):
class CoachingMessage(PowerOnModel):
"""A single message in a coaching session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
sessionId: str = Field(description="FK to CoachingSession")
moduleId: str = Field(description="FK to TrainingModule")
userId: str = Field(description="Owner user ID")
sessionId: str = Field(
description="FK to CoachingSession",
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_commcoach", "table": "CoachingSession", "labelField": None}},
)
moduleId: str = Field(
description="FK to TrainingModule",
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
)
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
role: CoachingMessageRole = Field(description="Message author role")
content: str = Field(description="Message content (Markdown)")
contentType: CoachingMessageContentType = Field(default=CoachingMessageContentType.TEXT)
@ -131,10 +167,22 @@ class CoachingMessage(PowerOnModel):
class CoachingTask(PowerOnModel):
"""A task/checklist item assigned within a training module."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
moduleId: str = Field(description="FK to TrainingModule")
sessionId: Optional[str] = Field(default=None, description="FK to originating session")
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
moduleId: str = Field(
description="FK to TrainingModule",
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
)
sessionId: Optional[str] = Field(
default=None, description="FK to originating session",
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_commcoach", "table": "CoachingSession", "labelField": None}},
)
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
title: str = Field(description="Task title")
description: Optional[str] = Field(default=None)
status: CoachingTaskStatus = Field(default=CoachingTaskStatus.OPEN)
@ -146,10 +194,22 @@ class CoachingTask(PowerOnModel):
class CoachingScore(PowerOnModel):
"""A competence score for a dimension, recorded after a session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
moduleId: str = Field(description="FK to TrainingModule")
sessionId: str = Field(description="FK to CoachingSession")
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
moduleId: str = Field(
description="FK to TrainingModule",
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
)
sessionId: str = Field(
description="FK to CoachingSession",
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_commcoach", "table": "CoachingSession", "labelField": None}},
)
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
dimension: str = Field(description="e.g. empathy, clarity, assertiveness, listening")
score: float = Field(ge=0.0, le=100.0)
trend: CoachingScoreTrend = Field(default=CoachingScoreTrend.STABLE)
@ -159,9 +219,18 @@ class CoachingScore(PowerOnModel):
class CoachingUserProfile(PowerOnModel):
"""Per-user coaching profile and preferences."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
instanceId: str = Field(description="Feature instance ID")
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
instanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
dailyReminderTime: Optional[str] = Field(default=None, description="HH:MM format")
dailyReminderEnabled: bool = Field(default=False)
emailSummaryEnabled: bool = Field(default=True)
@ -179,9 +248,18 @@ class CoachingUserProfile(PowerOnModel):
class CoachingPersona(PowerOnModel):
"""A roleplay persona for coaching sessions."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str = Field(description="Owner user ID ('system' for builtins)")
mandateId: Optional[str] = Field(default=None)
instanceId: Optional[str] = Field(default=None)
userId: str = Field(
description="Owner user ID ('system' for builtins)",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True}},
)
mandateId: Optional[str] = Field(
default=None,
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
instanceId: Optional[str] = Field(
default=None,
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
key: str = Field(description="Unique key, e.g. 'critical_cfo_f'")
label: str = Field(description="Display label, e.g. 'Kritische CFO'")
description: str = Field(description="Detailed role description for the AI")
@ -198,9 +276,18 @@ class CoachingPersona(PowerOnModel):
class ModulePersonaMapping(PowerOnModel):
"""Maps which personas are available for a specific training module."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
moduleId: str = Field(description="FK to TrainingModule")
personaId: str = Field(description="FK to CoachingPersona")
instanceId: str = Field(description="Feature instance ID")
moduleId: str = Field(
description="FK to TrainingModule",
json_schema_extra={"label": "Trainingsmodul", "fk_target": {"db": "poweron_commcoach", "table": "TrainingModule", "labelField": "title"}},
)
personaId: str = Field(
description="FK to CoachingPersona",
json_schema_extra={"label": "Persona", "fk_target": {"db": "poweron_commcoach", "table": "CoachingPersona", "labelField": "label"}},
)
instanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
class SetModulePersonasRequest(BaseModel):
@ -214,9 +301,18 @@ class SetModulePersonasRequest(BaseModel):
class CoachingBadge(PowerOnModel):
"""An achievement badge awarded to a user."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
instanceId: str = Field(description="Feature instance ID")
userId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
instanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'")
awardedAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})

View file

@ -40,9 +40,15 @@ CONTEXT_NODES = [
),
"injectRunContext": True,
"parameters": [
<<<<<<< HEAD
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
=======
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": "",
"graphInherit": {"port": 0, "kind": "documentListWire"}},
>>>>>>> 513ded84d529502d07a04d199df3f873f263cff0
{
"name": "contentFilter",
"type": "str",

View file

@ -93,7 +93,7 @@ class DataNeutraliserConfig(PowerOnModel):
@i18nModel("Neutralisiertes Datenattribut")
class DataNeutralizerAttributes(BaseModel):
class DataNeutralizerAttributes(PowerOnModel):
"""Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@ -152,7 +152,7 @@ class DataNeutralizerAttributes(BaseModel):
@i18nModel("Neutralisierungs-Snapshot")
class DataNeutralizationSnapshot(BaseModel):
class DataNeutralizationSnapshot(PowerOnModel):
"""Speichert den vollstaendigen neutralisierten Text (mit Platzhaltern) pro Quelle."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),

View file

@ -110,7 +110,7 @@ class GeoPolylinie(BaseModel):
@i18nModel("Dokument")
class Dokument(BaseModel):
class Dokument(PowerOnModel):
"""Supporting data object for file and URL management with versioning."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@ -204,7 +204,7 @@ class Kontext(PowerOnModel):
)
class Land(BaseModel):
class Land(PowerOnModel):
"""National level administrative entity."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
@ -265,15 +265,19 @@ class Kanton(PowerOnModel):
)
mandateId: str = Field(
description="ID of the mandate",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
json_schema_extra={
"frontend_type": "text", "frontend_readonly": True, "frontend_required": False,
"label": "Mandant",
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
},
)
featureInstanceId: str = Field(
description="ID of the feature instance",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
json_schema_extra={
"frontend_type": "text", "frontend_readonly": True, "frontend_required": False,
"label": "Feature-Instanz",
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
},
)
label: str = Field(
description="Canton name (e.g. 'Zürich')",
@ -314,7 +318,7 @@ class Kanton(PowerOnModel):
)
class Gemeinde(BaseModel):
class Gemeinde(PowerOnModel):
"""Municipal level administrative entity."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),

View file

@ -102,12 +102,24 @@ class TeamsbotModuleStatus(str, Enum):
class TeamsbotMeetingModule(PowerOnModel):
"""A meeting module groups related sessions (e.g. 'Weekly Standup')."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Module ID")
instanceId: str = Field(description="Feature instance ID (FK)")
mandateId: str = Field(description="Mandate ID (FK)")
ownerUserId: str = Field(description="Owner user ID")
instanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
ownerUserId: str = Field(
description="Owner user ID",
json_schema_extra={"label": "Besitzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
title: str = Field(description="Module title, e.g. 'Weekly Standup'")
seriesType: TeamsbotSeriesType = Field(default=TeamsbotSeriesType.ADHOC)
defaultBotId: Optional[str] = Field(default=None, description="FK to TeamsbotSystemBot")
defaultBotId: Optional[str] = Field(
default=None, description="FK to TeamsbotSystemBot",
json_schema_extra={"label": "Standard-Bot", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSystemBot", "labelField": "name"}},
)
defaultDirectorPrompts: Optional[str] = Field(default=None, description="JSON list of default director prompts")
goals: Optional[str] = Field(default=None, description="Free-text goals")
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
@ -120,8 +132,8 @@ class TeamsbotMeetingModule(PowerOnModel):
description="Default display name for the bot when starting a session from this module",
)
defaultAvatarFileId: Optional[str] = Field(
default=None,
description="FileItem ID for the default avatar image/video shown in the meeting",
default=None, description="FileItem ID for the default avatar image/video shown in the meeting",
json_schema_extra={"label": "Avatar-Datei", "fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"}},
)
status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE)
@ -129,15 +141,27 @@ class TeamsbotMeetingModule(PowerOnModel):
class TeamsbotSession(PowerOnModel):
"""A Teams Bot meeting session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
instanceId: str = Field(description="Feature instance ID (FK)")
mandateId: str = Field(description="Mandate ID (FK)")
moduleId: Optional[str] = Field(default=None, description="FK to TeamsbotMeetingModule (nullable during transition)")
instanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
moduleId: Optional[str] = Field(
default=None, description="FK to TeamsbotMeetingModule",
json_schema_extra={"label": "Meeting-Modul", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotMeetingModule", "labelField": "title"}},
)
meetingLink: str = Field(description="Teams meeting join link")
botName: str = Field(default="AI Assistant", description="Display name of the bot in the meeting")
status: TeamsbotSessionStatus = Field(default=TeamsbotSessionStatus.PENDING, description="Current session status")
startedAt: Optional[float] = Field(default=None, description="UTC unix timestamp when session started", json_schema_extra={"frontend_type": "timestamp"})
endedAt: Optional[float] = Field(default=None, description="UTC unix timestamp when session ended", json_schema_extra={"frontend_type": "timestamp"})
startedByUserId: str = Field(description="User ID who started the session")
startedByUserId: str = Field(
description="User ID who started the session",
json_schema_extra={"label": "Gestartet von", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
bridgeSessionId: Optional[str] = Field(default=None, description="Session ID on the .NET Media Bridge")
meetingChatId: Optional[str] = Field(default=None, description="Teams meeting chat ID for Graph API messages")
sessionContext: Optional[str] = Field(default=None, description="Custom context/knowledge provided by the user for this session")
@ -150,7 +174,10 @@ class TeamsbotSession(PowerOnModel):
class TeamsbotTranscript(PowerOnModel):
"""A single transcript segment from the meeting."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Transcript segment ID")
sessionId: str = Field(description="Session ID (FK)")
sessionId: str = Field(
description="FK to TeamsbotSession",
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSession", "labelField": "botName"}},
)
speaker: Optional[str] = Field(default=None, description="Speaker name or identifier")
text: str = Field(description="Transcribed text")
timestamp: float = Field(description="UTC unix timestamp of the speech segment", json_schema_extra={"frontend_type": "timestamp"})
@ -163,12 +190,18 @@ class TeamsbotTranscript(PowerOnModel):
class TeamsbotBotResponse(PowerOnModel):
"""A bot response generated during a meeting session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Response ID")
sessionId: str = Field(description="Session ID (FK)")
sessionId: str = Field(
description="FK to TeamsbotSession",
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSession", "labelField": "botName"}},
)
responseText: str = Field(description="The bot's response text")
responseType: TeamsbotResponseType = Field(default=TeamsbotResponseType.BOTH, description="How the response was delivered")
detectedIntent: TeamsbotDetectedIntent = Field(default=TeamsbotDetectedIntent.NONE, description="What triggered the response")
reasoning: Optional[str] = Field(default=None, description="AI reasoning for why it responded")
triggeredByTranscriptId: Optional[str] = Field(default=None, description="Transcript segment that triggered this response")
triggeredByTranscriptId: Optional[str] = Field(
default=None, description="Transcript segment that triggered this response",
json_schema_extra={"label": "Ausgelöst durch", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotTranscript", "labelField": None}},
)
modelName: Optional[str] = Field(default=None, description="AI model used for this response")
processingTime: float = Field(default=0.0, description="Processing time in seconds")
priceCHF: float = Field(default=0.0, description="Cost of this AI call in CHF")
@ -184,7 +217,10 @@ class TeamsbotSystemBot(PowerOnModel):
Credentials are stored encrypted in the database, NOT in the UI-visible config.
Only mandate admins can manage system bots."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="System bot ID")
mandateId: str = Field(description="Mandate ID (FK) - bots are scoped to mandates")
mandateId: str = Field(
description="Mandate ID - bots are scoped to mandates",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
name: str = Field(description="Display name (e.g. 'Nyla Larsson')")
email: str = Field(description="Microsoft account email")
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
@ -200,8 +236,14 @@ class TeamsbotUserAccount(PowerOnModel):
Each user can store their own MS credentials per mandate.
Password is encrypted; on login only MFA confirmation is needed."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Record ID")
userId: str = Field(description="Poweron user ID (FK)")
mandateId: str = Field(description="Mandate ID (FK)")
userId: str = Field(
description="Poweron user ID",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
email: str = Field(description="Microsoft account email")
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
displayName: Optional[str] = Field(default=None, description="Display name derived from MS account")
@ -216,8 +258,14 @@ class TeamsbotUserSettings(PowerOnModel):
Each user has their own settings per feature instance.
These override the instance-level defaults (TeamsbotConfig)."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Settings ID")
userId: str = Field(description="User ID (FK)")
instanceId: str = Field(description="Feature instance ID (FK)")
userId: str = Field(
description="User ID",
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
instanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
botName: Optional[str] = Field(default=None, description="Bot display name override")
aiSystemPrompt: Optional[str] = Field(default=None, description="AI system prompt override")
responseMode: Optional[str] = Field(default=None, description="Response mode override: auto, manual, transcribeOnly")
@ -229,7 +277,10 @@ class TeamsbotUserSettings(PowerOnModel):
triggerCooldownSeconds: Optional[int] = Field(default=None, description="Trigger cooldown override")
contextWindowSegments: Optional[int] = Field(default=None, description="Context window override")
debugMode: Optional[bool] = Field(default=None, description="Debug mode override")
avatarFileId: Optional[str] = Field(default=None, description="FileItem ID for bot avatar image/video override")
avatarFileId: Optional[str] = Field(
default=None, description="FileItem ID for bot avatar image/video override",
json_schema_extra={"label": "Avatar-Datei", "fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"}},
)
# ============================================================================
@ -382,9 +433,18 @@ class TeamsbotDirectorPrompt(PowerOnModel):
meeting participants.
"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Director prompt ID")
sessionId: str = Field(description="Teams Bot session ID (FK)")
instanceId: str = Field(description="Feature instance ID (FK)")
operatorUserId: str = Field(description="User ID of the operator who issued the prompt")
sessionId: str = Field(
description="FK to TeamsbotSession",
json_schema_extra={"label": "Session", "fk_target": {"db": "poweron_teamsbot", "table": "TeamsbotSession", "labelField": "botName"}},
)
instanceId: str = Field(
description="Feature instance ID",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
operatorUserId: str = Field(
description="User ID of the operator who issued the prompt",
json_schema_extra={"label": "Operator", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
)
text: str = Field(description="The director instruction text", max_length=DIRECTOR_PROMPT_TEXT_LIMIT)
mode: TeamsbotDirectorPromptMode = Field(default=TeamsbotDirectorPromptMode.ONE_SHOT, description="oneShot or persistent")
fileIds: List[str] = Field(default_factory=list, description="UDB-selected file/object IDs to attach as RAG context")

View file

@ -796,7 +796,7 @@ class TeamsbotService:
import base64
from modules.interfaces import interfaceDbManagement
try:
mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId)
mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId, featureInstanceId=self.instanceId)
fileRecord = mgmt.getFile(fileId)
if not fileRecord:
logger.warning(f"Avatar file {fileId} not found")

View file

@ -1,13 +1,16 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
SysAdmin API for database table statistics and FK orphan detection/cleanup.
SysAdmin API for database table statistics, FK orphan detection/cleanup,
and database migration (backup / restore).
"""
import json
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from modules.auth import limiter
@ -17,11 +20,23 @@ from modules.system.databaseHealth import (
OrphanCleanupRefused,
_cleanAllOrphans,
_cleanOrphans,
_discoverLegacyTables,
_dropLegacyTable,
_getTableStats,
_isUserIdFk,
_listOrphans,
_scanOrphans,
)
from modules.system.databaseMigration import (
_exportDatabases,
_exportSingleDb,
_getAvailableDatabases,
_getInstanceLabel,
_importDatabases,
_importSingleDb,
_prepareImport,
_validateImportPayload,
)
logger = logging.getLogger(__name__)
@ -194,3 +209,531 @@ def postDatabaseOrphansCleanAll(
excludeUserFks,
)
return {"results": results, "skipped": skipped, "errored": errored, "deleted": deletedTotal}
# ---------------------------------------------------------------------------
# Legacy Tables (tables without Pydantic model)
# ---------------------------------------------------------------------------
class LegacyTableDropRequest(BaseModel):
"""Body for dropping a legacy table."""
db: str = Field(..., description="Database name")
table: str = Field(..., description="Table name to drop")
@router.get("/legacy-tables")
@limiter.limit("10/minute")
def getLegacyTables(
request: Request,
db: Optional[str] = None,
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""List tables that exist in the database but have no Pydantic model.
Optional ``db`` filter to scope to a single database.
"""
tables = _discoverLegacyTables(dbFilter=db)
totalRows = sum(t["rowCount"] for t in tables)
totalSize = sum(t["sizeBytes"] for t in tables)
return {
"legacyTables": tables,
"totalCount": len(tables),
"totalRows": totalRows,
"totalSizeBytes": totalSize,
}
@router.post("/legacy-tables/drop")
@limiter.limit("10/minute")
def postLegacyTableDrop(
request: Request,
body: LegacyTableDropRequest,
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Drop a legacy table (CASCADE). Refuses if the table is model-backed."""
try:
result = _dropLegacyTable(body.db, body.table)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Drop failed: {e}",
) from e
logger.info(
"SysAdmin legacy-table drop: user=%s db=%s table=%s rows=%s",
currentUser.username, body.db, body.table, result.get("rowCount"),
)
return result
# ---------------------------------------------------------------------------
# Migration (Backup / Restore)
# ---------------------------------------------------------------------------
class MigrationImportRequest(BaseModel):
"""Body for the import endpoint."""
payload: dict = Field(..., description="The full export JSON payload")
mode: str = Field(
...,
description="'replace' (clear + insert) or 'merge' (insert missing only)",
)
@router.get("/migration/databases")
@limiter.limit("30/minute")
def getMigrationDatabases(
request: Request,
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""List registered databases with table/record counts for the migration UI."""
databases = _getAvailableDatabases()
return {"databases": databases, "instanceLabel": _getInstanceLabel()}
@router.get("/migration/export")
@limiter.limit("2/minute")
def getMigrationExport(
request: Request,
databases: str = "all",
currentUser: User = Depends(requireSysAdmin),
) -> StreamingResponse:
"""Export selected databases as a downloadable JSON file.
``databases`` is a comma-separated list of database names, or ``"all"``.
"""
if databases == "all":
available = _getAvailableDatabases()
dbList = [db["name"] for db in available]
else:
dbList = [d.strip() for d in databases.split(",") if d.strip()]
if not dbList:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No databases selected for export.",
)
logger.info(
"SysAdmin migration export: user=%s databases=%s",
currentUser.username,
dbList,
)
try:
exportData = _exportDatabases(dbList)
except Exception as e:
logger.error("Migration export failed: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Export failed: {e}",
) from e
from datetime import datetime, timezone
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M")
filename = f"migration_backup_{ts}.json"
content = json.dumps(exportData, ensure_ascii=False, default=str)
return StreamingResponse(
iter([content]),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/migration/validate")
@limiter.limit("5/minute")
async def postMigrationValidate(
request: Request,
file: UploadFile = File(...),
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Validate an uploaded migration JSON file without writing anything."""
try:
rawBytes = await file.read()
payload = json.loads(rawBytes.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid JSON file: {e}",
) from e
result = _validateImportPayload(payload)
logger.info(
"SysAdmin migration validate: user=%s valid=%s",
currentUser.username,
result.get("valid"),
)
return result
@router.post("/migration/import")
@limiter.limit("2/minute")
async def postMigrationImport(
request: Request,
file: UploadFile = File(...),
mode: str = "merge",
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Import a migration JSON file.
``mode`` is passed as a form field:
- ``replace``: clear all tables (except system objects) and insert.
- ``merge``: insert only records whose ID does not yet exist.
"""
if mode not in ("replace", "merge"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid mode: '{mode}'. Must be 'replace' or 'merge'.",
)
try:
rawBytes = await file.read()
payload = json.loads(rawBytes.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid JSON file: {e}",
) from e
validation = _validateImportPayload(payload)
if not validation.get("valid"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"message": "Payload validation failed", "warnings": validation.get("warnings", [])},
)
logger.info(
"SysAdmin migration import: user=%s mode=%s",
currentUser.username,
mode,
)
try:
result = _importDatabases(payload, mode)
except Exception as e:
logger.error("Migration import failed: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Import failed: {e}",
) from e
logger.info(
"SysAdmin migration import complete: user=%s mode=%s totalRecords=%s warnings=%s",
currentUser.username,
mode,
result.get("totalRecords"),
len(result.get("warnings", [])),
)
return result
# ---------------------------------------------------------------------------
# Per-DB endpoints (progress-friendly)
# ---------------------------------------------------------------------------
_pendingExports: Dict[str, dict] = {}
@router.post("/migration/export-start")
@limiter.limit("10/minute")
def postMigrationExportStart(
request: Request,
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Start an export session. Returns a token for subsequent per-DB calls."""
import uuid
token = str(uuid.uuid4())
_pendingExports[token] = {"databases": {}}
logger.info("SysAdmin migration export-start: user=%s token=%s", currentUser.username, token)
return {"token": token}
@router.get("/migration/export-single")
@limiter.limit("60/minute")
def getMigrationExportSingle(
request: Request,
token: str,
database: str,
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Export a single database and store it server-side. Returns only metadata."""
from modules.shared.dbRegistry import getRegisteredDatabases
pending = _pendingExports.get(token)
if not pending:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid export token.")
if database not in getRegisteredDatabases():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Database '{database}' is not registered.",
)
logger.info("SysAdmin migration export-single: user=%s db=%s", currentUser.username, database)
try:
dbPayload = _exportSingleDb(database)
except Exception as e:
logger.error("Export-single failed for %s: %s", database, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Export failed for '{database}': {e}",
) from e
pending["databases"][database] = dbPayload
logger.info("SysAdmin migration export-single done: user=%s db=%s tables=%s records=%s",
currentUser.username, database, dbPayload.get("tableCount", 0), dbPayload.get("totalRecords", 0))
return {
"database": database,
"tableCount": dbPayload.get("tableCount", 0),
"totalRecords": dbPayload.get("totalRecords", 0),
}
@router.get("/migration/export-download")
@limiter.limit("5/minute")
def getMigrationExportDownload(
request: Request,
token: str,
filename: str = "backup.json",
currentUser: User = Depends(requireSysAdmin),
) -> StreamingResponse:
"""Assemble and stream the final export file from server-side data."""
from datetime import datetime, timezone
pending = _pendingExports.pop(token, None)
if not pending or not pending.get("databases"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired export token.")
databases = pending["databases"]
totalTables = sum(d.get("tableCount", 0) for d in databases.values())
totalRecords = sum(d.get("totalRecords", 0) for d in databases.values())
exportData = {
"meta": {
"exportedAt": datetime.now(timezone.utc).isoformat(),
"version": "1.0",
"databaseCount": len(databases),
"totalTables": totalTables,
"totalRecords": totalRecords,
},
"databases": databases,
}
logger.info("SysAdmin migration export-download: user=%s dbs=%s tables=%s records=%s",
currentUser.username, len(databases), totalTables, totalRecords)
content = json.dumps(exportData, ensure_ascii=False, default=str)
return StreamingResponse(
iter([content]),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _processUploadedFile(filePath: str, tmpDir: str, token: str) -> dict:
"""Parse JSON, validate, remap, split into per-DB files.
Runs in a thread pool to avoid blocking the asyncio event loop
during the CPU-heavy json.load() of large (500+ MB) files.
"""
import gc
import os
with open(filePath, "r", encoding="utf-8") as f:
payload = json.load(f)
try:
os.remove(filePath)
except OSError:
pass
result = _prepareImport(payload)
if not result.get("valid"):
del payload
gc.collect()
return {"result": result, "dbFiles": {}}
protectedIds = result.get("protectedIds", [])
dbFiles = {}
databases = payload.get("databases", {})
for dbName, dbData in databases.items():
dbPath = os.path.join(tmpDir, f"poweron_import_{token}_{dbName}.json")
with open(dbPath, "w", encoding="utf-8") as dbF:
json.dump(dbData, dbF, ensure_ascii=False, default=str)
dbFiles[dbName] = dbPath
del payload
del databases
gc.collect()
return {"result": result, "dbFiles": dbFiles, "protectedIds": protectedIds}
@router.post("/migration/upload-import")
@limiter.limit("5/minute")
async def postMigrationUploadImport(
request: Request,
file: UploadFile = File(...),
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Upload a backup file to disk (chunked), validate, remap IDs,
split into per-DB temp files so the full payload doesn't stay in RAM.
"""
import asyncio
import os
import tempfile
import uuid
token = str(uuid.uuid4())
tmpDir = tempfile.gettempdir()
filePath = os.path.join(tmpDir, f"poweron_import_{token}.json")
logger.info("SysAdmin migration upload-import: user=%s streaming to %s", currentUser.username, filePath)
totalBytes = 0
chunkSize = 1024 * 1024
try:
with open(filePath, "wb") as f:
while True:
chunk = await file.read(chunkSize)
if not chunk:
break
f.write(chunk)
totalBytes += len(chunk)
except Exception as e:
logger.error("Upload-import write failed: %s", e)
if os.path.exists(filePath):
os.remove(filePath)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Upload failed: {e}") from e
logger.info("SysAdmin migration upload-import: %s bytes on disk (%.1f MB)",
totalBytes, totalBytes / 1024 / 1024)
try:
processed = await asyncio.to_thread(_processUploadedFile, filePath, tmpDir, token)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
if os.path.exists(filePath):
os.remove(filePath)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid JSON file: {e}") from e
except Exception as e:
if os.path.exists(filePath):
os.remove(filePath)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Processing failed: {e}") from e
result = processed["result"]
dbFiles = processed.get("dbFiles", {})
if not result.get("valid"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"message": "Payload validation failed", "warnings": result.get("warnings", [])},
)
logger.info("SysAdmin migration upload-import: split into %d per-DB files, payload freed",
len(dbFiles))
_pendingImports[token] = {
"dbFiles": dbFiles,
"protectedIds": processed.get("protectedIds", []),
}
return {
"token": token,
"valid": result.get("valid", False),
"databases": result.get("databases", []),
"warnings": result.get("warnings", []),
"systemObjectsFound": result.get("systemObjectsFound", []),
}
_pendingImports: Dict[str, dict] = {}
@router.post("/migration/import-single")
@limiter.limit("60/minute")
def postMigrationImportSingle(
request: Request,
body: dict,
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Import a single database from a previously uploaded + prepared payload.
Body: ``{token, database, mode}``
"""
import os
token = body.get("token", "")
database = body.get("database", "")
mode = body.get("mode", "merge")
if mode not in ("replace", "merge"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid mode: '{mode}'.")
pending = _pendingImports.get(token)
if not pending:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired import token.")
dbFiles = pending.get("dbFiles", {})
dbFilePath = dbFiles.get(database)
if not dbFilePath or not os.path.exists(dbFilePath):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"No data for database '{database}'.",
)
logger.info("SysAdmin migration import-single: user=%s db=%s mode=%s", currentUser.username, database, mode)
try:
with open(dbFilePath, "r", encoding="utf-8") as f:
dbData = json.load(f)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to read import data for '{database}': {e}",
) from e
payload = {"databases": {database: dbData}}
try:
result = _importSingleDb(payload, database, mode, pending["protectedIds"])
except Exception as e:
logger.error("Import-single failed for %s: %s", database, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Import failed for '{database}': {e}",
) from e
return result
@router.post("/migration/import-done")
@limiter.limit("10/minute")
def postMigrationImportDone(
request: Request,
body: dict,
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Clean up the per-DB temp files."""
import os
token = body.get("token", "")
pending = _pendingImports.pop(token, None)
if pending:
for dbPath in pending.get("dbFiles", {}).values():
try:
os.remove(dbPath)
except OSError:
pass
return {"ok": True}

View file

@ -496,7 +496,7 @@ def _getDataSourceCostEstimate(
Uses the current effective ragLimits (DataSource.settings.ragLimits with
fallback to centralized defaults) as the basis. Returns the same
`{estimatedTokens, estimatedUsd, basis}` shape regardless of source kind.
`{estimatedTokens, estimatedChf, basis}` shape regardless of source kind.
"""
try:
from modules.interfaces.interfaceDbApp import getRootInterface

View file

@ -3,15 +3,17 @@
"""Indicative cost estimation for a RAG bootstrap run.
This is **not** a billing-grade forecast: it gives the user a back-of-the-envelope
USD figure for the worst-case full sync, so they can sanity-check before raising
CHF figure for the worst-case full sync, so they can sanity-check before raising
`maxBytes`/`maxItems`. The output always carries the underlying assumptions
(`basis`) so the user can judge plausibility.
Heuristic:
estimatedTokens = ceil(maxBytes / CHARS_PER_TOKEN_BYTES_FACTOR)
estimatedUsd = estimatedTokens / 1_000_000 * EMBEDDING_USD_PER_MTOKEN
estimatedChf = estimatedTokens / 1_000_000 * EMBEDDING_CHF_PER_MTOKEN
Defaults match OpenAI `text-embedding-3-small` pricing (2026-Q2).
Defaults match OpenAI `text-embedding-3-small` published pricing (2026-Q2);
the project convention treats provider list prices as CHF directly (see
`calculatepriceCHF` in `aicorePluginOpenai.py`), so no FX conversion applies.
"""
from __future__ import annotations
@ -21,7 +23,7 @@ from typing import Any, Dict
CHARS_PER_TOKEN = 4
EMBEDDING_USD_PER_MTOKEN = 0.02
EMBEDDING_CHF_PER_MTOKEN = 0.02
DEFAULT_TOKENS_PER_ITEM = 1500
BYTES_PER_TOKEN_TEXT_FACTOR = 4
EXTRACTABLE_FRACTION = 0.4
@ -34,12 +36,12 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
{
"estimatedTokens": int,
"estimatedUsd": float, # rounded to 4 decimals
"estimatedChf": float, # rounded to 4 decimals
"basis": {
"kind": "files"|"clickup",
"limits": {...},
"assumptions": {
"embeddingUsdPerMToken": 0.02,
"embeddingChfPerMToken": 0.02,
"charsPerToken": 4,
"extractableFraction": 0.4,
"tokensPerItem": 1500 # only for clickup-like item counts
@ -49,7 +51,7 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
}
"""
assumptions: Dict[str, Any] = {
"embeddingUsdPerMToken": EMBEDDING_USD_PER_MTOKEN,
"embeddingChfPerMToken": EMBEDDING_CHF_PER_MTOKEN,
"charsPerToken": CHARS_PER_TOKEN,
}
@ -69,11 +71,11 @@ def estimateBootstrapCost(limits: Dict[str, int], kind: str = "files") -> Dict[s
estimatedTokens = 0
assumptions["formula"] = "unknown kind, returning zero"
estimatedUsd = round(estimatedTokens / 1_000_000 * EMBEDDING_USD_PER_MTOKEN, 4)
estimatedChf = round(estimatedTokens / 1_000_000 * EMBEDDING_CHF_PER_MTOKEN, 4)
return {
"estimatedTokens": estimatedTokens,
"estimatedUsd": estimatedUsd,
"estimatedChf": estimatedChf,
"basis": {
"kind": kind,
"limits": dict(limits),

View file

@ -216,9 +216,9 @@ def _archiveOtherRecurringPrices(
stripe.Price.modify(p.id, active=False)
logger.info("Archived stale Stripe Price %s on product %s", p.id, productId)
except Exception as ex:
logger.warning("Could not archive price %s: %s", p.id, ex)
logger.debug("Could not archive price %s: %s", p.id, ex)
except Exception as e:
logger.warning("Stale price archive pass failed for product %s: %s", productId, e)
logger.debug("Stale price archive pass skipped for product %s: %s", productId, e)
def _validateStripeIdsExist(stripe, mapping: StripePlanPrice) -> bool:

View file

@ -55,6 +55,7 @@ class EventManagement:
def stop(self) -> None:
if self._scheduler and self._scheduler.running:
try:
self._scheduler.remove_all_jobs()
self._scheduler.shutdown(wait=False)
logger.info("EventManagement scheduler stopped")
except Exception as exc:

View file

@ -790,3 +790,98 @@ def _jsonSafe(v):
except Exception:
return repr(v)
return str(v)
# ---------------------------------------------------------------------------
# Legacy table discovery + drop
# ---------------------------------------------------------------------------
def _discoverLegacyTables(dbFilter: Optional[str] = None) -> List[dict]:
"""Find tables that exist in the DB but have no entry in MODEL_REGISTRY.
A table is legacy if its name does NOT match any PowerOnModel class.
Tables that exist in multiple DBs (shared-table pattern) are NOT flagged
as legacy -- the connector creates them wherever code writes that model.
Returns a list of dicts: {db, table, rowCount, sizeBytes}.
"""
from modules.datamodels.datamodelBase import MODEL_REGISTRY
from modules.shared.fkRegistry import _ensureModelsLoaded
_ensureModelsLoaded()
registeredDbs = getRegisteredDatabases()
results: List[dict] = []
for dbName in sorted(registeredDbs.keys()):
if dbFilter and dbName != dbFilter:
continue
try:
conn = _getConnection(dbName)
except Exception as e:
logger.warning("Legacy scan: cannot connect to %s: %s", dbName, e)
continue
try:
with conn.cursor() as cur:
cur.execute("""
SELECT c.relname AS table_name,
c.reltuples::bigint AS row_estimate,
pg_total_relation_size(c.oid) AS size_bytes
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind = 'r'
AND c.relname NOT LIKE '\\_%'
ORDER BY c.relname
""")
for row in cur.fetchall():
tblName = row["table_name"]
if tblName not in MODEL_REGISTRY:
results.append({
"db": dbName,
"table": tblName,
"rowCount": max(0, int(row["row_estimate"])),
"sizeBytes": int(row["size_bytes"]),
})
finally:
conn.close()
return results
def _dropLegacyTable(dbName: str, tableName: str) -> dict:
"""Drop a single legacy table after verifying it is NOT in MODEL_REGISTRY.
Returns {db, table, dropped, rowCount}.
Raises ValueError if the table is model-backed (safety guard).
"""
from modules.datamodels.datamodelBase import MODEL_REGISTRY
from modules.shared.fkRegistry import _ensureModelsLoaded
_ensureModelsLoaded()
if tableName in MODEL_REGISTRY:
raise ValueError(
f"Table '{dbName}.{tableName}' is backed by a Pydantic model and cannot be dropped via legacy cleanup."
)
conn = _getConnection(dbName)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT reltuples::bigint AS row_estimate
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relname = %s
""", (tableName,))
row = cur.fetchone()
rowCount = max(0, int(row["row_estimate"])) if row else 0
cur.execute(f'DROP TABLE IF EXISTS "{tableName}" CASCADE')
conn.commit()
logger.info("Dropped legacy table %s.%s (%d rows)", dbName, tableName, rowCount)
return {"db": dbName, "table": tableName, "dropped": True, "rowCount": rowCount}
except Exception as e:
conn.rollback()
logger.error("Failed to drop legacy table %s.%s: %s", dbName, tableName, e)
raise
finally:
conn.close()

View file

@ -0,0 +1,816 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Database migration utilities backup (export) and restore (import) for all
registered PowerOn databases.
System objects (root mandate, admin user, event user) are protected: they are
never deleted or overwritten during import. Their IDs in the backup payload
are remapped to the IDs of the corresponding live objects so that all FK
references stay consistent.
All functions are intended for SysAdmin use only (access control in the route layer).
"""
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set, Tuple
import psycopg2
import psycopg2.extras
from modules.shared.configuration import APP_CONFIG
from modules.shared.dbRegistry import getRegisteredDatabases
from modules.shared.fkRegistry import getFkRelationships
from modules.datamodels.datamodelBase import MODEL_REGISTRY
from modules.system.databaseHealth import _getConnection, _jsonSafe
logger = logging.getLogger(__name__)
_EXPORT_FORMAT_VERSION = "1.0"
_SYSTEM_TABLE = "_system"
_EXCLUDED_TABLES: Dict[str, Set[str]] = {
"poweron_app": {"Token", "AuthEvent"},
}
# ---------------------------------------------------------------------------
# Instance label
# ---------------------------------------------------------------------------
def _getInstanceLabel() -> str:
"""Return the instance type from APP_ENV_TYPE (e.g. 'dev', 'int', 'prod')."""
return APP_CONFIG.get("APP_ENV_TYPE", "unknown")
# ---------------------------------------------------------------------------
# Database list
# ---------------------------------------------------------------------------
def _getAvailableDatabases() -> List[dict]:
"""Return registered databases with table/row counts for the UI."""
registeredDbs = getRegisteredDatabases()
results: List[dict] = []
for dbName in sorted(registeredDbs):
if dbName == "poweron_test":
continue
entry: dict = {"name": dbName, "tableCount": 0, "recordCount": 0}
try:
conn = _getConnection(dbName)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT relname, n_live_tup
FROM pg_stat_user_tables
WHERE schemaname = 'public'
AND relname NOT LIKE '\\_%%'
""")
for row in cur.fetchall():
entry["tableCount"] += 1
entry["recordCount"] += int(row["n_live_tup"])
finally:
conn.close()
except Exception as e:
logger.warning("Could not stat database %s: %s", dbName, e)
results.append(entry)
return results
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
def _exportDatabases(databases: List[str]) -> dict:
"""Export selected databases as a JSON-serialisable dict.
Returns ``{meta: {...}, databases: {dbName: {tables: {tbl: [rows]}, summary: {...}}}}``
"""
registeredDbs = getRegisteredDatabases()
if not databases:
raise ValueError("No databases selected for export.")
exportData: dict = {
"meta": {
"exportedAt": datetime.now(timezone.utc).isoformat(),
"version": _EXPORT_FORMAT_VERSION,
"databaseCount": 0,
"totalTables": 0,
"totalRecords": 0,
},
"databases": {},
}
for dbName in databases:
if dbName not in registeredDbs:
logger.warning("Export: skipping unregistered database %s", dbName)
continue
try:
dbPayload = _exportSingleDb(dbName)
exportData["databases"][dbName] = dbPayload
exportData["meta"]["databaseCount"] += 1
exportData["meta"]["totalTables"] += dbPayload["tableCount"]
exportData["meta"]["totalRecords"] += dbPayload["totalRecords"]
except Exception as e:
logger.error("Export failed for database %s: %s", dbName, e)
return exportData
def _getModelTablesForDb(dbName: str, physicalTables: List[str]) -> List[str]:
"""Return only those physical tables that have a matching Pydantic model
registered in MODEL_REGISTRY.
Tables without a Pydantic class (legacy / orphan tables) are excluded
from export so the backup contains only model-backed data.
Note: the same model can exist in multiple databases (shared-table
pattern), so we only check membership in MODEL_REGISTRY, not the
DB mapping.
"""
return sorted(
t for t in physicalTables
if t in MODEL_REGISTRY
)
def _exportSingleDb(dbName: str) -> dict:
conn = _getConnection(dbName)
excluded = _EXCLUDED_TABLES.get(dbName, set())
try:
allTables = _listTables(conn)
modelTables = _getModelTablesForDb(dbName, allTables)
skippedLegacy = set(allTables) - set(modelTables) - excluded - {_SYSTEM_TABLE}
if skippedLegacy:
logger.info("Export %s: skipping %d legacy tables without model: %s",
dbName, len(skippedLegacy), sorted(skippedLegacy))
dbPayload: dict = {"tables": {}, "summary": {}, "tableCount": 0, "totalRecords": 0}
for tbl in modelTables:
if tbl in excluded:
logger.info("Export: skipping excluded table %s.%s", dbName, tbl)
continue
rows = _readTableRows(conn, tbl)
dbPayload["tables"][tbl] = rows
dbPayload["summary"][tbl] = {"recordCount": len(rows)}
dbPayload["tableCount"] += 1
dbPayload["totalRecords"] += len(rows)
return dbPayload
finally:
conn.close()
def _listTables(conn) -> List[str]:
with conn.cursor() as cur:
cur.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name != %s
ORDER BY table_name
""", (_SYSTEM_TABLE,))
return [row["table_name"] for row in cur.fetchall()]
def _readTableRows(conn, tableName: str) -> List[dict]:
with conn.cursor() as cur:
cur.execute(f'SELECT * FROM "{tableName}"')
return [{k: _jsonSafe(v) for k, v in dict(row).items()} for row in cur.fetchall()]
# ---------------------------------------------------------------------------
# Validate
# ---------------------------------------------------------------------------
def _validateImportPayload(payload: dict) -> dict:
"""Validate an import payload without writing anything.
Returns ``{valid, summary, warnings, systemObjectsFound}``.
"""
warnings: List[str] = []
summary: List[dict] = []
meta = payload.get("meta")
if not meta or not isinstance(meta, dict):
return {"valid": False, "summary": [], "warnings": ["Fehlende oder ungueltige 'meta'-Sektion"], "systemObjectsFound": []}
version = meta.get("version", "")
if version != _EXPORT_FORMAT_VERSION:
warnings.append(f"Unbekannte Format-Version: {version} (erwartet: {_EXPORT_FORMAT_VERSION})")
databases = payload.get("databases")
if not databases or not isinstance(databases, dict):
return {"valid": False, "summary": [], "warnings": ["Fehlende oder ungueltige 'databases'-Sektion"], "systemObjectsFound": []}
registeredDbs = getRegisteredDatabases()
for dbName, dbData in databases.items():
tables = dbData.get("tables", {})
tableCount = len(tables)
recordCount = sum(len(rows) for rows in tables.values() if isinstance(rows, list))
registered = dbName in registeredDbs
if not registered:
warnings.append(f"Datenbank '{dbName}' ist nicht registriert und wird uebersprungen")
summary.append({
"database": dbName,
"tableCount": tableCount,
"recordCount": recordCount,
"registered": registered,
})
systemObjectsFound = _detectSystemObjectsInPayload(payload)
valid = any(s["registered"] for s in summary)
return {
"valid": valid,
"summary": summary,
"warnings": warnings,
"systemObjectsFound": systemObjectsFound,
}
def _detectSystemObjectsInPayload(payload: dict) -> List[dict]:
"""Find system objects (root mandate, admin user, event user) in a payload."""
found: List[dict] = []
appData = payload.get("databases", {}).get("poweron_app", {}).get("tables", {})
for row in appData.get("Mandate", []):
if row.get("name") == "root" and row.get("isSystem") is True:
found.append({"type": "mandate", "label": "Root Mandate", "payloadId": row.get("id")})
for row in appData.get("UserInDB", []):
if row.get("username") == "admin":
found.append({"type": "user", "label": "Admin User", "payloadId": row.get("id")})
elif row.get("username") == "event":
found.append({"type": "user", "label": "Event User", "payloadId": row.get("id")})
return found
# ---------------------------------------------------------------------------
# System-object ID remapping
# ---------------------------------------------------------------------------
def _loadLiveSystemObjectIds() -> Dict[str, str]:
"""Load the IDs of the 3 protected system objects from the live DB.
Returns a dict like ``{"rootMandate": "<uuid>", "adminUser": "<uuid>", "eventUser": "<uuid>"}``.
"""
registeredDbs = getRegisteredDatabases()
if "poweron_app" not in registeredDbs:
return {}
result: Dict[str, str] = {}
conn = _getConnection("poweron_app")
try:
with conn.cursor() as cur:
cur.execute("""SELECT id FROM "Mandate" WHERE "name" = 'root' AND "isSystem" = true LIMIT 1""")
row = cur.fetchone()
if row:
result["rootMandate"] = str(row["id"])
cur.execute("""SELECT id FROM "UserInDB" WHERE "username" = 'admin' LIMIT 1""")
row = cur.fetchone()
if row:
result["adminUser"] = str(row["id"])
cur.execute("""SELECT id FROM "UserInDB" WHERE "username" = 'event' LIMIT 1""")
row = cur.fetchone()
if row:
result["eventUser"] = str(row["id"])
finally:
conn.close()
return result
def _buildIdRemapFromPayload(payload: dict, liveIds: Dict[str, str]) -> Dict[str, str]:
"""Build an ``{oldId: newId}`` mapping for system objects.
Compares IDs found in the payload with the live system-object IDs.
Only entries where the IDs actually differ are included.
"""
remap: Dict[str, str] = {}
appTables = payload.get("databases", {}).get("poweron_app", {}).get("tables", {})
for row in appTables.get("Mandate", []):
if row.get("name") == "root" and row.get("isSystem") is True:
oldId = str(row.get("id", ""))
newId = liveIds.get("rootMandate", "")
if oldId and newId and oldId != newId:
remap[oldId] = newId
for row in appTables.get("UserInDB", []):
username = row.get("username")
oldId = str(row.get("id", ""))
if username == "admin":
newId = liveIds.get("adminUser", "")
elif username == "event":
newId = liveIds.get("eventUser", "")
else:
continue
if oldId and newId and oldId != newId:
remap[oldId] = newId
return remap
def _remapSystemObjectIds(payload: dict, remap: Dict[str, str]) -> dict:
"""Walk the entire payload and replace every value that matches an old system-object ID."""
if not remap:
return payload
remapSet = set(remap.keys())
databases = payload.get("databases", {})
for dbName, dbData in databases.items():
tables = dbData.get("tables", {})
for tableName, rows in tables.items():
if not isinstance(rows, list):
continue
for row in rows:
_remapRowValues(row, remap, remapSet)
return payload
def _remapDbTables(tables: dict, remap: Dict[str, str]) -> None:
"""In-place remap system-object IDs in a single DB's tables dict."""
if not remap:
return
remapSet = set(remap.keys())
for tableName, rows in tables.items():
if not isinstance(rows, list):
continue
for row in rows:
_remapRowValues(row, remap, remapSet)
def _remapRowValues(row: dict, remap: Dict[str, str], remapSet: Set[str]) -> None:
"""In-place replace string values in a row dict that match a remap key."""
for key, val in row.items():
if isinstance(val, str) and val in remapSet:
row[key] = remap[val]
elif isinstance(val, dict):
_remapRowValues(val, remap, remapSet)
elif isinstance(val, list):
for i, item in enumerate(val):
if isinstance(item, str) and item in remapSet:
val[i] = remap[item]
elif isinstance(item, dict):
_remapRowValues(item, remap, remapSet)
# ---------------------------------------------------------------------------
# Import
# ---------------------------------------------------------------------------
_PROTECTED_ROWS: Dict[str, List[dict]] = {
"Mandate": [{"name": "root", "isSystem": True}],
"UserInDB": [{"username": "admin"}, {"username": "event"}],
}
def _isProtectedRow(tableName: str, row: dict) -> bool:
"""Return True if a row represents a protected system object."""
patterns = _PROTECTED_ROWS.get(tableName, [])
for pattern in patterns:
if all(row.get(k) == v for k, v in pattern.items()):
return True
return False
def _importDatabases(payload: dict, mode: str) -> dict:
"""Import databases from a validated payload.
``mode`` is ``"replace"`` (clear + insert) or ``"merge"`` (insert missing only).
"""
if mode not in ("replace", "merge"):
raise ValueError(f"Invalid import mode: {mode}")
registeredDbs = getRegisteredDatabases()
liveIds = _loadLiveSystemObjectIds()
remap = _buildIdRemapFromPayload(payload, liveIds)
if remap:
logger.info("System-object ID remap: %s", remap)
_remapSystemObjectIds(payload, remap)
protectedIdSet = set(liveIds.values())
imported: Dict[str, dict] = {}
warnings: List[str] = []
databases = payload.get("databases", {})
for dbName, dbData in databases.items():
if dbName not in registeredDbs:
warnings.append(f"Datenbank '{dbName}' uebersprungen (nicht registriert)")
continue
tables = dbData.get("tables", {})
dbResult: Dict[str, int] = {}
conn = _getConnection(dbName)
try:
conn.autocommit = False
existingTables = set(_listTables(conn))
for tableName, rows in tables.items():
if not isinstance(rows, list):
continue
if tableName not in existingTables:
warnings.append(f"Tabelle '{dbName}.{tableName}' existiert nicht, uebersprungen")
continue
physicalCols = _getPhysicalColumns(conn, tableName)
if not physicalCols:
continue
filteredRows = []
for row in rows:
if _isProtectedRow(tableName, row):
continue
if row.get("id") and str(row["id"]) in protectedIdSet:
continue
filteredRows.append(row)
if mode == "replace":
_deleteNonProtected(conn, tableName, protectedIdSet)
insertedCount = _insertRows(conn, tableName, filteredRows, physicalCols, mode)
dbResult[tableName] = insertedCount
conn.commit()
except Exception as e:
conn.rollback()
logger.error("Import failed for database %s: %s", dbName, e)
warnings.append(f"Import fuer '{dbName}' fehlgeschlagen: {e}")
continue
finally:
conn.close()
imported[dbName] = dbResult
totalRecords = sum(sum(v.values()) for v in imported.values())
return {
"success": True,
"imported": imported,
"totalRecords": totalRecords,
"warnings": warnings,
}
def _getPhysicalColumns(conn, tableName: str) -> List[str]:
with conn.cursor() as cur:
cur.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
ORDER BY ordinal_position
""", (tableName,))
return [row["column_name"] for row in cur.fetchall()]
def _deleteNonProtected(conn, tableName: str, protectedIds: Set[str]) -> int:
"""Delete all rows except protected system objects."""
if not protectedIds:
with conn.cursor() as cur:
cur.execute(f'DELETE FROM "{tableName}"')
return cur.rowcount
idList = list(protectedIds)
with conn.cursor() as cur:
cur.execute(
f'DELETE FROM "{tableName}" WHERE "id"::text != ALL(%(ids)s)',
{"ids": idList},
)
return cur.rowcount
def _insertRows(
conn,
tableName: str,
rows: List[dict],
physicalCols: List[str],
mode: str,
) -> int:
"""Insert rows into a table. In merge mode, skip rows whose id already exists."""
if not rows:
return 0
physicalColSet = set(physicalCols)
inserted = 0
for row in rows:
cols = [c for c in row.keys() if c in physicalColSet]
if not cols:
continue
values = [_pgSafe(row[c]) for c in cols]
colNames = ", ".join(f'"{c}"' for c in cols)
placeholders = ", ".join(["%s"] * len(cols))
if mode == "merge":
sql = f'INSERT INTO "{tableName}" ({colNames}) VALUES ({placeholders}) ON CONFLICT ("id") DO NOTHING'
else:
sql = f'INSERT INTO "{tableName}" ({colNames}) VALUES ({placeholders})'
try:
with conn.cursor() as cur:
cur.execute("SAVEPOINT row_sp")
cur.execute(sql, values)
inserted += cur.rowcount
cur.execute("RELEASE SAVEPOINT row_sp")
except Exception as e:
logger.warning("Insert failed for %s row: %s", tableName, e)
with conn.cursor() as cur:
cur.execute("ROLLBACK TO SAVEPOINT row_sp")
return inserted
def _pgSafe(v: Any) -> Any:
"""Convert Python values to psycopg2-compatible types."""
import json as _json
if v is None or isinstance(v, (str, int, float, bool)):
return v
if isinstance(v, (dict, list)):
return _json.dumps(v)
return str(v)
# ---------------------------------------------------------------------------
# Prepare import (validate + remap, return context for per-DB import)
# ---------------------------------------------------------------------------
def _prepareImport(payload: dict) -> dict:
"""Validate, remap system-object IDs, and return the prepared payload
together with metadata the frontend needs to drive per-DB import.
Returns ``{valid, warnings, systemObjectsFound, databases, protectedIds, remappedPayload}``.
"""
validation = _validateImportPayload(payload)
if not validation.get("valid"):
return {
"valid": False,
"warnings": validation.get("warnings", []),
"systemObjectsFound": validation.get("systemObjectsFound", []),
"databases": [],
"protectedIds": [],
}
liveIds = _loadLiveSystemObjectIds()
remap = _buildIdRemapFromPayload(payload, liveIds)
if remap:
logger.info("System-object ID remap: %s", remap)
_remapSystemObjectIds(payload, remap)
protectedIdSet = set(liveIds.values())
registeredDbs = getRegisteredDatabases()
dbList = []
for dbName, dbData in payload.get("databases", {}).items():
if dbName not in registeredDbs:
continue
tables = dbData.get("tables", {})
recordCount = sum(len(rows) for rows in tables.values() if isinstance(rows, list))
dbList.append({
"database": dbName,
"tableCount": len(tables),
"recordCount": recordCount,
})
return {
"valid": True,
"warnings": validation.get("warnings", []),
"systemObjectsFound": validation.get("systemObjectsFound", []),
"databases": dbList,
"protectedIds": list(protectedIdSet),
}
def _ensureDatabaseExists(dbName: str) -> bool:
"""Create the PostgreSQL database if it does not yet exist.
Connects to the ``postgres`` admin database using the same credentials
as the target DB. Returns True if the database was created, False if
it already existed.
"""
registeredDbs = getRegisteredDatabases()
configPrefix = registeredDbs.get(dbName)
if configPrefix is None:
return False
hostKey = f"{configPrefix}_HOST" if configPrefix != "DB" else "DB_HOST"
portKey = f"{configPrefix}_PORT" if configPrefix != "DB" else "DB_PORT"
userKey = f"{configPrefix}_USER" if configPrefix != "DB" else "DB_USER"
passwordKey = f"{configPrefix}_PASSWORD_SECRET" if configPrefix != "DB" else "DB_PASSWORD_SECRET"
adminConn = psycopg2.connect(
host=APP_CONFIG.get(hostKey, "localhost"),
port=int(APP_CONFIG.get(portKey, 5432)),
database="postgres",
user=APP_CONFIG.get(userKey),
password=APP_CONFIG.get(passwordKey),
client_encoding="utf8",
)
try:
adminConn.autocommit = True
with adminConn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (dbName,))
if cur.fetchone():
return False
cur.execute(f'CREATE DATABASE "{dbName}"')
logger.info("Created missing database: %s", dbName)
return True
finally:
adminConn.close()
def _createTableFromExport(conn, tableName: str, rows: List[dict]) -> None:
"""Create a table based on the column structure found in the export data.
Uses TEXT for all columns since we don't have the original DDL.
The ``id`` column gets a PRIMARY KEY constraint.
"""
allKeys: List[str] = []
seen: set = set()
for row in rows:
for k in row.keys():
if k not in seen:
allKeys.append(k)
seen.add(k)
if not allKeys:
return
colDefs = []
for col in allKeys:
if col == "id":
colDefs.append(f'"{col}" TEXT PRIMARY KEY')
else:
colDefs.append(f'"{col}" TEXT')
ddl = f'CREATE TABLE IF NOT EXISTS "{tableName}" ({", ".join(colDefs)})'
with conn.cursor() as cur:
cur.execute(ddl)
logger.info("Created table %s with %d columns", tableName, len(allKeys))
def _getTableImportOrder(conn, tableNames: List[str], dbName: str = "") -> List[str]:
"""Sort tables by FK dependencies (parents first) using topological sort.
Uses Pydantic ``fk_target`` metadata from ``fkRegistry`` as the single
source of truth (works for ALL databases, not just those with SQL FKs).
Only *intra-DB* dependencies are considered; cross-DB FKs (e.g. to
``poweron_app.Mandate``) are handled by importing databases in order.
"""
tableSet = set(tableNames)
allRels = getFkRelationships()
deps: Dict[str, Set[str]] = {t: set() for t in tableNames}
for rel in allRels:
if rel.sourceDb != dbName or rel.targetDb != dbName:
continue
child = rel.sourceTable
parent = rel.targetTable
if child in tableSet and parent in tableSet and child != parent:
deps[child].add(parent)
inDegree = {t: len(deps[t]) for t in tableNames}
queue = sorted(t for t in tableNames if inDegree[t] == 0)
ordered: List[str] = []
while queue:
node = queue.pop(0)
ordered.append(node)
for t in tableNames:
if node in deps[t]:
deps[t].discard(node)
inDegree[t] -= 1
if inDegree[t] == 0:
queue.append(t)
queue.sort()
remaining = [t for t in tableNames if t not in set(ordered)]
if remaining:
logger.warning("FK cycle detected, appending without order guarantee: %s", remaining)
ordered.extend(sorted(remaining))
return ordered
def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[str]) -> dict:
"""Import a single database from the (already remapped) payload.
Tables are sorted by FK dependencies: parent tables are inserted first,
child tables are deleted first (reverse order) in replace mode.
Returns ``{database, tables: {tableName: insertedCount}, recordCount, warnings}``.
"""
if mode not in ("replace", "merge"):
raise ValueError(f"Invalid import mode: {mode}")
registeredDbs = getRegisteredDatabases()
if dbName not in registeredDbs:
return {"database": dbName, "tables": {}, "recordCount": 0,
"warnings": [f"Datenbank '{dbName}' nicht registriert"]}
dbData = payload.get("databases", {}).get(dbName)
if not dbData:
return {"database": dbName, "tables": {}, "recordCount": 0,
"warnings": [f"Keine Daten fuer '{dbName}' im Payload"]}
try:
dbCreated = _ensureDatabaseExists(dbName)
except Exception as e:
logger.error("Failed to ensure database %s exists: %s", dbName, e)
return {"database": dbName, "tables": {}, "recordCount": 0,
"warnings": [f"Datenbank '{dbName}' konnte nicht erstellt werden: {e}"]}
protectedIdSet = set(protectedIds)
tables = dbData.get("tables", {})
warnings: List[str] = []
dbResult: Dict[str, int] = {}
excluded = _EXCLUDED_TABLES.get(dbName, set())
if dbCreated:
warnings.append(f"Datenbank '{dbName}' wurde neu erstellt")
conn = _getConnection(dbName)
try:
existingTables = set(_listTables(conn))
conn.rollback()
# Ensure all import tables exist (create missing ones from export schema)
conn.autocommit = True
for tableName, rows in tables.items():
if tableName in excluded or not isinstance(rows, list) or not rows:
continue
if tableName not in existingTables:
_createTableFromExport(conn, tableName, rows)
existingTables.add(tableName)
logger.info("Pre-created missing table %s.%s", dbName, tableName)
# Build importable table list and sort by FK dependencies
importable = [t for t in tables
if t not in excluded
and isinstance(tables.get(t), list)
and t in existingTables]
importOrder = _getTableImportOrder(conn, importable, dbName)
logger.info("Import order for %s: %s", dbName, importOrder)
for tableName in tables:
if tableName in excluded and isinstance(tables.get(tableName), list):
warnings.append(f"Table '{dbName}.{tableName}' excluded (security/transient)")
# Phase 1 (replace only): DELETE children first (reverse topological order)
if mode == "replace":
conn.autocommit = False
for tableName in reversed(importOrder):
try:
_deleteNonProtected(conn, tableName, protectedIdSet)
conn.commit()
except Exception as e:
conn.rollback()
warnings.append(f"DELETE from {dbName}.{tableName} failed: {e}")
logger.warning("DELETE from %s.%s failed: %s", dbName, tableName, e)
# Phase 2: INSERT parents first (topological order)
conn.autocommit = False
for tableName in importOrder:
try:
rows = tables[tableName]
physicalCols = _getPhysicalColumns(conn, tableName)
if not physicalCols:
conn.rollback()
continue
filteredRows = []
for row in rows:
if _isProtectedRow(tableName, row):
continue
if row.get("id") and str(row["id"]) in protectedIdSet:
continue
filteredRows.append(row)
insertedCount = _insertRows(conn, tableName, filteredRows, physicalCols, mode)
conn.commit()
dbResult[tableName] = insertedCount
except Exception as e:
conn.rollback()
warnings.append(f"INSERT into {dbName}.{tableName} failed: {e}")
logger.warning("INSERT into %s.%s failed: %s", dbName, tableName, e)
except Exception as e:
logger.error("Import failed for database %s: %s", dbName, e)
return {"database": dbName, "tables": {}, "recordCount": 0,
"warnings": [f"Import fuer '{dbName}' fehlgeschlagen: {e}"]}
finally:
conn.close()
recordCount = sum(dbResult.values())
return {"database": dbName, "tables": dbResult, "recordCount": recordCount, "warnings": warnings}

View file

@ -1751,6 +1751,7 @@ def presentation_envelopes_to_document_json(
}
<<<<<<< HEAD
def _document_list_from_context(raw: Any, *, _depth: int = 0) -> DocumentReferenceList:
"""Best-effort extraction of document/file references from ``context`` payloads.
@ -1807,6 +1808,8 @@ def _document_list_from_context(raw: Any, *, _depth: int = 0) -> DocumentReferen
return DocumentReferenceList(references=deduped)
=======
>>>>>>> 513ded84d529502d07a04d199df3f873f263cff0
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
operation_id = None
try:
@ -1814,6 +1817,7 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
operation_id = f"context_extract_{wf}_{int(time.time())}"
document_list_param = parameters.get("documentList")
<<<<<<< HEAD
if document_list_param:
dl = coerceDocumentReferenceList(document_list_param)
source = "documentList"
@ -1832,6 +1836,20 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
),
)
logger.info("extractContent resolved %d document reference(s) from %s", len(dl.references), source)
=======
if not document_list_param:
return ActionResult.isFailure(error="documentList is required")
dl = coerceDocumentReferenceList(document_list_param)
if not dl.references:
return ActionResult.isFailure(
error=(
f"documentList could not be parsed (type={type(document_list_param).__name__}); "
"expected DocumentReferenceList, list of strings/dicts, or "
"a wrapper dict like {'documents': [...]}"
),
)
>>>>>>> 513ded84d529502d07a04d199df3f873f263cff0
parent_operation_id = parameters.get("parentOperationId")
self.services.chat.progressLogStart(

View file

@ -31,11 +31,13 @@ openpyxl>=3.1.2 # Für Excel-Dateien
python-pptx>=0.6.21 # Für PowerPoint-Dateien
## Data Processing & Analysis
numpy==1.26.3 # Version die mit pandas und matplotlib kompatibel ist
pandas==2.2.3 # Aktuelle Version beibehalten
numpy==1.26.3; python_version < "3.13"
numpy>=2.1.0; python_version >= "3.13"
pandas==2.2.3
## Data Visualization
matplotlib==3.8.0 # Aktuelle Version beibehalten
matplotlib==3.8.0; python_version < "3.13"
matplotlib>=3.9.0; python_version >= "3.13"
seaborn==0.13.0
markdown

View file

@ -0,0 +1,308 @@
"""Export the database schema from Pydantic MODEL_REGISTRY + fk_target metadata.
Usage (run from gateway/):
python scripts/exportDbSchemaFromModels.py
python scripts/exportDbSchemaFromModels.py --validate
python scripts/exportDbSchemaFromModels.py --output ../wiki/b-reference/database-schema.md
The Pydantic classes are the single source of truth. The optional --validate
flag cross-checks against the live database and reports mismatches.
"""
import argparse
import importlib
import os
import sys
from collections import defaultdict
from datetime import datetime
from typing import Dict, List, Optional, Tuple
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _getArgs():
p = argparse.ArgumentParser(description="Export DB schema from Pydantic models")
p.add_argument("--output", default="../wiki/b-reference/database-schema.md")
p.add_argument("--validate", action="store_true",
help="Cross-check against live DB and report mismatches")
return p.parse_args()
def _loadAllModels():
"""Import all datamodel and interface modules to populate MODEL_REGISTRY + dbRegistry."""
for root, _dirs, files in os.walk("modules"):
for f in files:
if not f.endswith(".py") or f.startswith("__"):
continue
isDatamodel = f.startswith("datamodel")
isInterface = f.startswith("interface") and ("Db" in f or "Feature" in f)
if not isDatamodel and not isInterface:
continue
modPath = os.path.join(root, f).replace(os.sep, ".").replace(".py", "")
try:
importlib.import_module(modPath)
except Exception:
pass
def _buildCompleteTableToDbMap() -> Dict[str, str]:
"""Build tableName -> dbName by querying every registered DB's catalog.
More reliable than fkRegistry._buildTableToDbMap() for the schema script
because it catches ALL tables, not just FK targets.
"""
from modules.shared.dbRegistry import getRegisteredDatabases
from modules.system.databaseHealth import _getConnection
mapping: Dict[str, str] = {}
for dbName in getRegisteredDatabases():
try:
conn = _getConnection(dbName)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
AND table_name NOT LIKE '\\_%'
""")
for row in cur.fetchall():
tbl = row["table_name"] if isinstance(row, dict) else row[0]
if tbl not in mapping:
mapping[tbl] = dbName
finally:
conn.close()
except Exception as e:
print(f" Warning: could not query {dbName}: {e}")
return mapping
def _buildSchema() -> Tuple[Dict[str, List[dict]], Dict[str, str]]:
"""Build {dbName: [tableInfo, ...]} from MODEL_REGISTRY + fk_target.
Returns (schema, tableToDb).
"""
from modules.datamodels.datamodelBase import MODEL_REGISTRY
tableToDb = _buildCompleteTableToDbMap()
schema: Dict[str, List[dict]] = defaultdict(list)
for tableName, modelCls in sorted(MODEL_REGISTRY.items()):
dbName = tableToDb.get(tableName)
if not dbName:
continue
fields = []
fkRefs = []
pkField = None
for fieldName, fieldInfo in modelCls.model_fields.items():
annotation = modelCls.__annotations__.get(fieldName)
typeName = _resolveTypeName(annotation)
isOptional = typeName.startswith("Optional[")
extra = fieldInfo.json_schema_extra or {}
fkTarget = extra.get("fk_target")
if fieldName == "id":
pkField = {"name": fieldName, "type": typeName}
continue
if fkTarget:
fkRefs.append({
"column": fieldName,
"targetDb": fkTarget.get("db", ""),
"targetTable": fkTarget.get("table", ""),
"targetColumn": fkTarget.get("column", "id"),
"labelField": fkTarget.get("labelField"),
"softFk": fkTarget.get("softFk", False),
})
fields.append({
"name": fieldName,
"type": typeName,
"optional": isOptional,
"description": fieldInfo.description or "",
})
schema[dbName].append({
"tableName": tableName,
"pk": pkField,
"fields": fields,
"fks": fkRefs,
"modelClass": f"{modelCls.__module__}.{modelCls.__name__}",
})
return dict(schema), tableToDb
def _resolveTypeName(annotation) -> str:
"""Best-effort stringification of a type annotation."""
if annotation is None:
return "Any"
origin = getattr(annotation, "__origin__", None)
if origin is not None:
args = getattr(annotation, "__args__", ())
if str(origin) == "typing.Union" or getattr(origin, "__name__", "") == "Union":
nonNone = [a for a in args if a is not type(None)]
if len(nonNone) == 1:
return f"Optional[{_resolveTypeName(nonNone[0])}]"
return f"Union[{', '.join(_resolveTypeName(a) for a in args)}]"
argStr = ", ".join(_resolveTypeName(a) for a in args)
name = getattr(origin, "__name__", str(origin))
return f"{name}[{argStr}]" if argStr else name
return getattr(annotation, "__name__", str(annotation))
def _renderMarkdown(schema: Dict[str, List[dict]]) -> str:
"""Render the schema as markdown."""
from modules.shared.dbRegistry import getRegisteredDatabases
registeredDbs = getRegisteredDatabases()
now = datetime.now().strftime("%Y-%m-%d %H:%M")
totalTables = sum(len(tables) for tables in schema.values())
totalFks = sum(len(t["fks"]) for tables in schema.values() for t in tables)
lines = [
"# PowerOn Database Schema\n",
f"> **Generated from**: Pydantic MODEL_REGISTRY + fk_target",
f"> **Date**: {now}",
f"> **Registered databases**: {len(registeredDbs)}",
f"> **Tables**: {totalTables}",
f"> **FK relationships**: {totalFks}\n",
"---\n",
]
for dbName in sorted(schema.keys()):
tables = schema[dbName]
lines.append(f"## {dbName}\n")
for tbl in sorted(tables, key=lambda t: t["tableName"]):
lines.append(f"### {tbl['tableName']}\n")
if tbl["pk"]:
lines.append(f"- **PK**: `{tbl['pk']['name']}` ({tbl['pk']['type']})")
for fk in tbl["fks"]:
crossDb = ""
if fk["targetDb"] != dbName:
crossDb = f" [cross-db: {fk['targetDb']}]"
soft = " **(soft)**" if fk["softFk"] else ""
lines.append(
f"- **FK**: `{fk['column']}` -> `{fk['targetTable']}.{fk['targetColumn']}`{crossDb}{soft}"
)
nonFkFields = []
fkCols = {fk["column"] for fk in tbl["fks"]}
for f in tbl["fields"]:
if f["name"] in fkCols or f["name"].startswith("sys"):
continue
opt = " (optional)" if f["optional"] else ""
nonFkFields.append(f"`{f['name']}` {f['type']}{opt}")
if nonFkFields:
lines.append(f"- **Fields**: {', '.join(nonFkFields)}")
lines.append("")
return "\n".join(lines)
def _validateAgainstLiveDb(schema: Dict[str, List[dict]], tableToDb: Dict[str, str]) -> List[str]:
"""Compare Pydantic schema against live PostgreSQL and return mismatch warnings."""
from modules.shared.configuration import APP_CONFIG
import psycopg2
import psycopg2.extras
host = APP_CONFIG.get("DB_HOST", "localhost")
port = int(APP_CONFIG.get("DB_PORT", 5432))
user = APP_CONFIG.get("DB_USER", "poweron_dev")
password = APP_CONFIG.get("DB_PASSWORD_SECRET")
if not password:
return ["ERROR: DB_PASSWORD_SECRET not available for validation"]
warnings = []
for dbName, tables in sorted(schema.items()):
try:
conn = psycopg2.connect(
host=host, port=port, user=user, password=password,
database=dbName, client_encoding="utf8",
cursor_factory=psycopg2.extras.RealDictCursor,
)
except Exception as e:
warnings.append(f" {dbName}: connection failed ({e})")
continue
try:
with conn.cursor() as cur:
cur.execute("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
""")
liveTables = {row["table_name"] for row in cur.fetchall()}
for tbl in tables:
name = tbl["tableName"]
if name not in liveTables:
warnings.append(f" {dbName}.{name}: model exists but NO table in DB")
continue
with conn.cursor() as cur:
cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
""", (name,))
liveCols = {row["column_name"] for row in cur.fetchall()}
modelCols = {"id"} | {f["name"] for f in tbl["fields"]}
missingInDb = modelCols - liveCols
legacyAuditCols = {
"_createdAt", "_createdBy", "_modifiedAt", "_modifiedBy",
"sysCreatedAt", "sysCreatedBy", "sysModifiedAt", "sysModifiedBy",
"createdAt", "updatedAt", "creationDate", "lastModified",
}
extraInDb = liveCols - modelCols - legacyAuditCols
if missingInDb:
warnings.append(f" {dbName}.{name}: columns in model but not in DB: {sorted(missingInDb)}")
if extraInDb:
warnings.append(f" {dbName}.{name}: columns in DB but not in model: {sorted(extraInDb)}")
modelTableNames = {t["tableName"] for t in tables}
for lt in sorted(liveTables):
if lt not in modelTableNames and not lt.startswith("_"):
warnings.append(f" {dbName}.{lt}: table in DB but no Pydantic model (legacy?)")
finally:
conn.close()
return warnings
def main():
args = _getArgs()
_loadAllModels()
print("Building schema from MODEL_REGISTRY...")
schema, tableToDb = _buildSchema()
totalTables = sum(len(t) for t in schema.values())
totalFks = sum(len(t["fks"]) for tables in schema.values() for t in tables)
print(f" {len(schema)} databases, {totalTables} tables, {totalFks} FK relationships")
md = _renderMarkdown(schema)
with open(args.output, "w", encoding="utf-8") as f:
f.write(md)
print(f"\nSchema written to {args.output}")
if args.validate:
print("\nValidating against live database...")
warnings = _validateAgainstLiveDb(schema, tableToDb)
if warnings:
print(f"\n{len(warnings)} mismatches found:")
for w in warnings:
print(w)
else:
print(" No mismatches - live DB matches Pydantic models perfectly.")
if __name__ == "__main__":
main()

View file

@ -171,7 +171,8 @@ def liveConnector():
try:
with adminConn.cursor() as cur:
cur.execute(
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s',
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity '
'WHERE datname = %s AND pid != pg_backend_pid() AND usename = current_user',
(dbName,),
)
cur.execute(f'DROP DATABASE IF EXISTS "{dbName}"')

View file

@ -17,7 +17,7 @@ class TestCostEstimate(unittest.TestCase):
{"maxBytes": 200 * 1024 * 1024}, kind="files",
)
self.assertIn("estimatedTokens", result)
self.assertIn("estimatedUsd", result)
self.assertIn("estimatedChf", result)
self.assertIn("basis", result)
self.assertIn("assumptions", result["basis"])
self.assertIn("formula", result["basis"]["assumptions"])
@ -39,12 +39,12 @@ class TestCostEstimate(unittest.TestCase):
def test_unknown_kind_returns_zero(self):
result = _costEstimate.estimateBootstrapCost({}, kind="totally-unknown")
self.assertEqual(result["estimatedTokens"], 0)
self.assertEqual(result["estimatedUsd"], 0.0)
self.assertEqual(result["estimatedChf"], 0.0)
def test_usd_is_rounded_4_decimals(self):
def test_chf_is_rounded_4_decimals(self):
result = _costEstimate.estimateBootstrapCost({"maxBytes": 1024 * 1024}, kind="files")
rounded = round(result["estimatedUsd"], 4)
self.assertEqual(result["estimatedUsd"], rounded)
rounded = round(result["estimatedChf"], 4)
self.assertEqual(result["estimatedChf"], rounded)
def test_basis_includes_input_limits(self):
result = _costEstimate.estimateBootstrapCost({"maxBytes": 42}, kind="files")