diff --git a/.cursor/plans/swift_ios_app_nachbau_3dc75f35.plan.md b/.cursor/plans/swift_ios_app_nachbau_3dc75f35.plan.md deleted file mode 100644 index 8fa5384c..00000000 --- a/.cursor/plans/swift_ios_app_nachbau_3dc75f35.plan.md +++ /dev/null @@ -1,1036 +0,0 @@ ---- -name: Swift iOS App Nachbau -overview: Vollstaendiger Implementierungsplan fuer den Nachbau des React-Web-Frontends (frontend_nyla) als native Swift/SwiftUI iOS 18+ App fuer iPhone und iPad. Baut auf dem bestehenden Plan auf, korrigiert Fehler und integriert alle geklaerten Entscheidungen (kein Backend-Aenderungen, kein Voice, kein Push, ASWebAuthenticationSession fuer OAuth, read-only Billing, nativer Code-Editor). -todos: - - id: phase-0 - content: "Phase 0: Xcode-Projekt erstellen, Ordnerstruktur, SPM-Dependencies (Runestone, MarkdownUI), Build-Configs (Dev/Int/Prod), TestFlight Setup" - status: pending - - id: phase-1 - content: "Phase 1: Core Networking Layer -- APIClient (URLSession + Cookies + Headers), SSEClient (POST-basiert, async bytes), CSRFManager" - status: pending - - id: phase-2 - content: "Phase 2: Authentication -- LocalAuth, ASWebAuthenticationSession fuer MS/Google, Keychain, Biometrie, 401-Handler" - status: pending - - id: phase-3 - content: "Phase 3: Domain Models (Mandate, Feature, Instance, Permissions, Pagination) + FeatureStore (@Observable)" - status: pending - - id: phase-4 - content: "Phase 4: App Shell -- NavigationSplitView (iPad/iPhone adaptiv), Backend-driven Sidebar, Dashboard, SF Symbol Mapping" - status: pending - - id: phase-5 - content: "Phase 5: i18n String Catalogs (de/en/fr) + Theme System (System/Light/Dark) + DesignTokens" - status: pending - - id: phase-6 - content: "Phase 6: Shared UI -- FormGeneratorForm + FormGeneratorTable + Report, ContentPreview, ChatMessage, Toast, SearchBar" - status: pending - - id: phase-7 - content: "Phase 7: Core Pages -- Store, GDPR, Basedata (Prompts/Files/Connections), Billing (read-only), Settings" - status: pending - - id: phase-8 - content: "Phase 8: Admin Module -- 16 Admin-Seiten (Mandates, Users, RBAC, Invitations, Wizards, Billing Admin, Logs)" - status: pending - - id: phase-9 - content: "Phase 9: Feature Trustee -- Dashboard, Documents, Positions, Roles, Expense-Import, VisionKit Scan, Accounting" - status: pending - - id: phase-10 - content: "Phase 10: Feature Workspace -- SSE Chat-Streaming, Workflows, Files, Datasources, Pending Edits, KeepAlive" - status: pending - - id: phase-11 - content: "Phase 11: Feature Chatbot -- SSE-Streaming Chat, Threads, Conversations" - status: pending - - id: phase-12 - content: "Phase 12: Feature Teamsbot -- Sessions, WebSocket Bot, Config, MFA, Screenshots" - status: pending - - id: phase-13 - content: "Phase 13: Feature CommCoach -- Coaching Sessions, Text-Streaming, Tasks, Badges, Scores, Export" - status: pending - - id: phase-14 - content: "Phase 14: Feature Automation -- Definitions CRUD, Templates, Execute, Workflow-Management" - status: pending - - id: phase-15 - content: "Phase 15: Feature Automation2 -- Workflows CRUD, Tasks, Execute (ohne visuellen Node-Editor)" - status: pending - - id: phase-16 - content: "Phase 16: Feature CodeEditor -- Runestone Editor, SSE-Stream, Pending Edits, Syntax-Highlighting" - status: pending - - id: phase-17 - content: "Phase 17: Feature RealEstate/PEK -- MapKit Integration, Parcels, Address-Search, BZO, Koordinaten-Transformation" - status: pending - - id: phase-18 - content: "Phase 18: Feature Neutralization -- Config, Neutralize Text/File, Stats" - status: pending -isProject: false ---- - -# Nyla iOS/iPadOS App -- Aktualisierter Implementierungsplan - -## Aenderungen gegenueber dem bestehenden Plan - -Basierend auf der vollstaendigen Code-Analyse und den Klaerungen: - -- **Korrektur Auth**: Backend-Popup-Flow (window.open) wird durch `ASWebAuthenticationSession` ersetzt -- KEIN MSAL iOS SDK noetig, da das Backend die OAuth-Flows selbst handled -- **Entfernt**: Voice-Features (Phase ueberall vereinfacht), Push Notifications (Phase 8 entfaellt), Automation2 visueller Node-Editor, Stripe Checkout -- **Hinzugefuegt**: Detailliertes FormGenerator-Mapping, Workspace-Layout nach Apple HIG, nativer Code-Editor mit Runestone -- **Korrektur Billing**: Nur read-only (Balance, Transaktionen, Statistiken) -- kein Checkout -- **iOS 18+**: Erlaubt Nutzung aller modernen SwiftUI APIs -- **Team**: 2-3 Entwickler, parallelisierbare Feature-Module - ---- - -## Architektur-Ueberblick - -```mermaid -graph TD - subgraph presentation [SwiftUI Views] - Views[Screens und Components] - FormGen[FormGenerator SwiftUI] - ChatUI[Chat und Streaming UI] - end - - subgraph viewmodels [ViewModels] - VM["@Observable ViewModels"] - end - - subgraph repositories [Repositories] - Repo[Repository Protokolle] - end - - subgraph networking [Networking Layer] - APIClient[APIClient URLSession] - SSEClient[SSEClient async bytes] - CSRFMgr[CSRFManager] - CookieStore[HTTPCookieStorage] - end - - subgraph backend [Gateway FastAPI] - API["/api/* Endpoints"] - end - - Views --> VM - FormGen --> VM - ChatUI --> VM - VM --> Repo - Repo --> APIClient - Repo --> SSEClient - APIClient --> CSRFMgr - APIClient --> CookieStore - APIClient --> API - SSEClient --> API -``` - - - -### Technische Entscheidungen (aktualisiert) - -- **Plattform**: iOS 18+ / iPadOS 18+ (iPhone + iPad mit adaptivem Layout) -- **UI-Framework**: SwiftUI (rein, kein UIKit ausser wo zwingend noetig) -- **Architektur**: MVVM + Repository Pattern -- **Networking**: URLSession + async/await + Codable -- **SSE**: Custom Client auf `URLSession.bytes(for:)` Basis (POST-basiert, nicht EventSource) -- **Auth**: ASWebAuthenticationSession fuer MS/Google OAuth, Local Login via API, Keychain fuer Token-Storage -- **State**: `@Observable` (Observation Framework) -- **Navigation**: `NavigationSplitView` (iPad 2-3 Spalten) + `NavigationStack` (iPhone auto-collapse) -- **DI**: SwiftUI `@Environment` -- **Packages**: SPM -- Runestone (Code-Editor), ggf. MarkdownUI -- **Karten**: MapKit (SwiftUI) -- **Charts**: Swift Charts Framework -- **i18n**: String Catalogs (`.xcstrings`) fuer de/en/fr -- **Persistenz**: Keychain (Secrets), UserDefaults (Preferences) -- **Distribution**: TestFlight - -### Projektstruktur - -``` -NylaApp/ - NylaApp.swift - Config/ - AppConfig.swift # API URLs per Build Config - Environment.swift # Dev/Int/Prod - Core/ - Networking/ - APIClient.swift # Zentraler HTTP-Client (= api.ts) - APIError.swift - APIEndpoints.swift - SSEClient.swift # Server-Sent Events (= sseClient.ts) - CSRFManager.swift # CSRF Token (= csrfUtils.ts) - RequestContext.swift # X-Mandate-Id, X-Instance-Id - Auth/ - AuthManager.swift # Zentrale Auth-Logik - LocalAuthService.swift # Username/Password - OAuthService.swift # ASWebAuthenticationSession (MS + Google) - KeychainService.swift - Navigation/ - NavigationStore.swift # Backend-driven Nav State - AppRouter.swift # Root Navigation Coordinator - Localization/ - Localizable.xcstrings - LanguageManager.swift - Theme/ - ThemeManager.swift - DesignTokens.swift - Domain/ - Models/ # Codable Structs - Repositories/ # Protokolle - Data/ - API/ # 21 API-Module (= src/api/*.ts) - Repositories/ # Implementierungen - Features/ # Feature-Module (je Ordner) - Dashboard/ - Store/ - Settings/ - GDPR/ - Basedata/ - Billing/ - Admin/ - Trustee/ - Workspace/ - Chatbot/ - Teamsbot/ - CommCoach/ - Automation/ - Automation2/ - RealEstate/ - Neutralization/ - Shared/ - Components/ - FormGenerator/ # Dynamische Formulare - ContentPreview/ - ChatMessage/ - AccessRules/ - SearchBar/ - LoadingView/ - ErrorView/ - EmptyStateView/ - Extensions/ - Utilities/ - Resources/ - Assets.xcassets -``` - ---- - -## Phase 0: Projekt-Setup (1-2 Tage) - -- Xcode-Projekt erstellen (iOS 18+, SwiftUI App Lifecycle, iPhone + iPad) -- Ordnerstruktur gemaess obigem Schema -- SPM Dependencies: - - **Runestone** (Code-Editor mit TreeSitter) -- fuer Phase 17 - - **MarkdownUI** (oder Apple's native AttributedString) -- fuer Chat-Rendering - - Alle anderen Features nutzen System-Frameworks (MapKit, Charts, PDFKit) -- Build-Konfigurationen (`.xcconfig`): **Dev** / **Int** / **Prod** - - `API_BASE_URL`: `http://localhost:8000` / INT-URL / PROD-URL - - Analog zu [frontend_nyla/config/config.ts](frontend_nyla/config/config.ts) (`VITE_API_BASE_URL`) -- TestFlight: App ID, Provisioning Profile, Code Signing -- SwiftLint Konfiguration -- Imlementiere unter zuhilfenahme der rules unter /.cursor - ---- - -## Phase 1: Core Networking Layer (3-5 Tage) - -### APIClient.swift -- Equivalent zu [frontend_nyla/src/api.ts](frontend_nyla/src/api.ts) - -Zentrale Anforderungen aus der Web-Analyse: - -- **Cookie-basierte Auth**: `URLSessionConfiguration.default` mit `httpCookieStorage = .shared` -- httpOnly Cookies werden automatisch gesendet (`withCredentials: true` Equivalent) -- **Bearer Token**: Aus Keychain lesen, als `Authorization: Bearer {token}` Header setzen (nur wenn vorhanden, analog Zeile ~100-110 in `api.ts`) -- **Kontext-Headers**: `X-Mandate-Id` und `X-Instance-Id` aus dem aktuellen NavigationStore (analog `getContextFromUrl()` in `api.ts` das die URL parst -- in Swift kommt der Context aus dem Navigation State, nicht aus einer URL) -- **CSRF**: Fuer POST/PUT/PATCH/DELETE automatisch `X-CSRF-Token` Header setzen -- **401 Handler**: Auth-State clearen und zur Login-Ansicht navigieren (analog `api.ts` Response Interceptor) -- **429 Handler**: Rate-Limit Warning anzeigen -- **Error Parsing**: FastAPI `detail` kann String oder Array sein -- beide Faelle behandeln - -```swift -// Konzept APIClient -@Observable class APIClient { - func get(_ path: String, query: [String: String]?) async throws -> T - func post(_ path: String, body: Encodable?) async throws -> T - func put(_ path: String, body: Encodable?) async throws -> T - func delete(_ path: String) async throws - func upload(_ path: String, fileData: Data, fileName: String, fields: [String: String]?) async throws -> T -} -``` - -### SSEClient.swift -- Equivalent zu [frontend_nyla/src/utils/sseClient.ts](frontend_nyla/src/utils/sseClient.ts) - -**Kritisch**: Das Web nutzt `fetch(POST)` mit `ReadableStream` -- NICHT standard EventSource (GET). In Swift: - -```swift -func startStream(url: URL, body: Encodable, onEvent: @escaping (SSEEvent) -> Void) async throws { - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = try JSONEncoder().encode(body) - // + Auth headers, CSRF, Content-Type - - let (bytes, response) = try await URLSession.shared.bytes(for: request) - for try await line in bytes.lines { - if line.hasPrefix("data: ") { - let json = String(line.dropFirst(6)) - let event = try JSONDecoder().decode(SSEEvent.self, from: json.data(using: .utf8)!) - onEvent(event) - } - } -} -``` - -Wird benoetigt fuer: Workspace Chat, Chatbot, CommCoach, Trustee Streaming. - -### CSRFManager.swift -- Equivalent zu [frontend_nyla/src/utils/csrfUtils.ts](frontend_nyla/src/utils/csrfUtils.ts) - -- Token: 16 Hex-Zeichen via `SecRandomCopyBytes` (analog `crypto.getRandomValues`) -- Gespeichert im Keychain (nicht UserDefaults -- Sicherheit) -- Automatisch bei jedem mutierenden Request angehaengt - ---- - -## Phase 2: Authentication (3-5 Tage) - -### Kritische Erkenntnis: Zwei Auth-Patterns koexistieren - -Die Web-App nutzt **zwei verschiedene** Auth-Mechanismen gleichzeitig: - -1. **Local Login**: `POST /api/local/login` (form-encoded) setzt httpOnly Cookies -- danach Cookie-basierte Auth -2. **Microsoft/Google**: Backend-Popup-Flow setzt sowohl Cookies ALS AUCH `localStorage.authToken` + Bearer Header - -**Fuer die native App (ohne Backend-Aenderungen):** - -- **Local Login**: Funktioniert direkt -- `POST /api/local/login` setzt Cookies, `URLSession` speichert sie automatisch -- **Microsoft OAuth**: `ASWebAuthenticationSession` oeffnet `/api/msft/auth/login` im Safari-Sheet. Das Backend macht den OAuth-Dance und redirected am Ende zurueck. Der Callback-URL-Handler muss die Cookies aus dem Safari-Sheet in die URLSession uebernehmen. **Herausforderung**: ASWebAuthenticationSession teilt Cookies mit Safari, NICHT mit URLSession. Loesung: Nach dem OAuth-Flow `GET /api/msft/me` aufrufen -- wenn Cookies korrekt gesetzt sind, kommt der User zurueck. Falls nicht: `prefersEphemeralWebBrowserSession = false` verwenden, damit Cookies persistent sind. -- **Google OAuth**: Gleicher Flow wie Microsoft via `ASWebAuthenticationSession` mit `/api/google/auth/login` - -### AuthManager.swift - -```swift -@Observable class AuthManager { - var isAuthenticated = false - var currentUser: User? - var authAuthority: String? // "local", "msft", "google" - - func loginLocal(username: String, password: String) async throws - func loginMicrosoft(presentingWindow: ASPresentationAnchor) async throws - func loginGoogle(presentingWindow: ASPresentationAnchor) async throws - func fetchCurrentUser() async throws -> User - func logout() async throws -} -``` - -Analog zu: - -- [frontend_nyla/src/hooks/useAuthentication.ts](frontend_nyla/src/hooks/useAuthentication.ts) -- `useAuth`, `useMsalAuth`, `useGoogleAuth` -- [frontend_nyla/src/api/authApi.ts](frontend_nyla/src/api/authApi.ts) -- `loginApi`, `fetchCurrentUserApi`, `logoutApi` - -### Login Screen - -- Username/Password Felder -- "Mit Microsoft anmelden" Button -> ASWebAuthenticationSession -- "Mit Google anmelden" Button -> ASWebAuthenticationSession -- Face ID / Touch ID (wenn zuvor erfolgreich eingeloggt) -- Links: Registrierung, Passwort vergessen -- Analog zu [frontend_nyla/src/pages/Login.tsx](frontend_nyla/src/pages/Login.tsx) - -### Biometrische Auth (Bonus, nicht im Web) - -- Nach erstem Login: Frage ob Face ID/Touch ID aktiviert werden soll -- Credentials verschluesselt im Keychain speichern -- Bei App-Start: Biometrie -> Auto-Login - ---- - -## Phase 3: Domain Models + Feature Store (2-3 Tage) - -### Zentrale Models - -Mapping der TypeScript-Types aus [frontend_nyla/src/types/mandate.ts](frontend_nyla/src/types/mandate.ts): - -```swift -struct I18nLabel: Codable { - var de: String; var en: String; var fr: String? - func localized(_ lang: String) -> String { - switch lang { - case "en": return en - case "fr": return fr ?? de - default: return de - } - } -} - -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] -} -``` - -### FeatureStore -- Analog zu [frontend_nyla/src/stores/featureStore.tsx](frontend_nyla/src/stores/featureStore.tsx) - -```swift -@Observable class FeatureStore { - var mandates: [Mandate] = [] - var isLoading = false - var isInitialized = false - - func loadFeatures() async throws // GET /api/features/my - func getMandateById(_ id: String) -> Mandate? - func getInstanceById(_ id: String) -> FeatureInstance? -} -``` - -### Pagination Model -- Analog zum Backend `PaginatedResponse` - -```swift -struct PaginatedResponse: Codable { - var items: [T] - var total: Int - var page: Int - var pageSize: Int - var totalPages: Int -} - -struct PaginationParams: Encodable { - var page: Int = 1 - var pageSize: Int = 25 - var search: String? - var sort: [SortConfig]? - var filters: [String: String]? -} -``` - ---- - -## Phase 4: App Shell + Navigation (4-6 Tage) - -### Adaptive Layout nach Apple HIG - -**iPad** (Regular Width): - -```mermaid -graph LR - subgraph splitView [NavigationSplitView] - Sidebar[Sidebar: Navigation] - Content[Content Area] - end - Sidebar --> Content -``` - - - -- `NavigationSplitView` mit Sidebar + Detail -- Sidebar: Backend-driven Navigation (Mandate > Feature > Instance > Views) -- Detail: NavigationStack fuer den Content-Bereich - -**iPhone** (Compact Width): - -- Automatisches Collapse durch `NavigationSplitView` in `NavigationStack` -- Tab-basierte Hauptnavigation fuer schnellen Zugriff auf Dashboard, Settings -- Sidebar oeffnet sich als Sheet oder wird zum NavigationStack - -### Backend-driven Navigation - -`GET /api/navigation?language={lang}` liefert den kompletten Navigationsbaum. - -Analog zu [frontend_nyla/src/hooks/useNavigation.ts](frontend_nyla/src/hooks/useNavigation.ts) und [frontend_nyla/src/components/Navigation/MandateNavigation.tsx](frontend_nyla/src/components/Navigation/MandateNavigation.tsx): - -- **Static Blocks**: "Meine Sicht" (Dashboard, Store, Settings, etc.), "Administration" (Admin-Seiten mit Subgroups) -- **Dynamic Block**: Mandate > Features > Instances > Views (hierarchischer Baum) -- Icon-Mapping: Web `react-icons` -> SF Symbols (Mapping-Tabelle erstellen) - -### Screen-Routing - -```swift -enum AppDestination: Hashable { - case dashboard - case store - case settings - case gdpr - case basedata(BasedataSection) - case billing - case admin(AdminSection) - case feature(mandateId: String, featureCode: String, instanceId: String, view: String) -} -``` - -Analog zum Route-Setup in [frontend_nyla/src/App.tsx](frontend_nyla/src/App.tsx) -- der `VIEW_COMPONENTS` Map in [frontend_nyla/src/pages/FeatureView.tsx](frontend_nyla/src/pages/FeatureView.tsx). - -### Feature-View-Dispatcher - -```swift -@ViewBuilder -func featureView(code: String, view: String, instance: FeatureInstance) -> some View { - switch (code, view) { - case ("trustee", "dashboard"): TrusteeDashboardView(instance: instance) - case ("trustee", "documents"): TrusteeDocumentsView(instance: instance) - case ("workspace", "dashboard"): WorkspaceView(instance: instance) - case ("chatbot", "conversations"): ChatbotConversationsView(instance: instance) - // ... alle Mappings - default: NotFoundView() - } -} -``` - -### MainLayout-Equivalent - -Analog zu [frontend_nyla/src/layouts/MainLayout.tsx](frontend_nyla/src/layouts/MainLayout.tsx): - -- Logo oben in der Sidebar -- MandateNavigation als Hauptinhalt der Sidebar -- UserSection am unteren Rand der Sidebar (Profilbild, Name, Logout) -- FeatureLayout: Breadcrumb Header (Mandate > Feature > Instance) + Role Badge, analog [frontend_nyla/src/layouts/FeatureLayout.tsx](frontend_nyla/src/layouts/FeatureLayout.tsx) - -### Dashboard - -Analog zu [frontend_nyla/src/pages/Dashboard.tsx](frontend_nyla/src/pages/Dashboard.tsx): - -- Titel "Uebersicht" mit Anzahl Instanzen/Mandate -- Sektionen pro Mandate mit Instance-Karten -- Jede Karte linkt zur ersten View der Instanz (`instance.views[0].uiPath`) -- Bei 0 Instanzen: Navigation zum Store - ---- - -## Phase 5: i18n + Theme (2-3 Tage) - -### Internationalisierung - -- **String Catalogs** (`.xcstrings`) fuer de/en/fr -- Alle statischen Strings aus [frontend_nyla/src/locales/de.ts](frontend_nyla/src/locales/de.ts), `en.ts`, `fr.ts` uebernehmen -- Dynamische Labels: `I18nLabel.localized(lang)` Helper -- `LanguageManager` speichert Praeferenz in UserDefaults, default `de` -- Sprachauswahl in Settings analog Web - -### Theme - -- `@AppStorage("theme") var theme: String = "system"` -- `.preferredColorScheme()` fuer System-Integration (auto/light/dark) -- `DesignTokens`: Farben als `Color` Extensions, Spacing als CGFloat Constants -- CSS-Variable-Equivalent: SwiftUI `@Environment(\.colorScheme)` + Custom EnvironmentValues -- Mapping der CSS-Variablen aus dem Web (`--primary-color`, `--border-color`, etc.) zu Swift `Color` Assets - ---- - -## Phase 6: Shared UI Components (5-8 Tage) - -### FormGenerator (KRITISCH -- wird von fast allen Features genutzt) - -Analog zu [frontend_nyla/src/components/FormGenerator/](frontend_nyla/src/components/FormGenerator/): - -**Konzept**: Dynamische Formulare und Tabellen basierend auf `AttributeDefinition` Arrays vom Backend (`GET /api/attributes/{entityType}`). - -#### Attribut-Typ-Mapping (Web -> Swift) - -Basierend auf [frontend_nyla/src/utils/attributeTypeMapper.ts](frontend_nyla/src/utils/attributeTypeMapper.ts): - -- `text` / `string` -> `TextField` -- `textarea` -> `TextEditor` (mit `minRows`/`maxRows` -> `.frame(minHeight:maxHeight:)`) -- `email` -> `TextField` mit `.keyboardType(.emailAddress)` -- `url` -> `TextField` mit `.keyboardType(.URL)` -- `password` -> `SecureField` -- `number` / `integer` / `float` -> `TextField` mit `.keyboardType(.decimalPad)` + NumberFormatter -- `select` / `enum` -> `Picker` (Wheel/Menu/Inline je nach Kontext) -- `multiselect` -> Multi-Selection List oder Chip-basierte Auswahl -- `checkbox` / `boolean` -> `Toggle` -- `date` -> `DatePicker(.date)` -- `time` -> `DatePicker(.hourAndMinute)` -- `timestamp` -> `DatePicker` (date + time) -- `multilingual` -> Custom View mit 3 TextFields (de/en/fr) und Sprach-Tabs -- `file` -> File-Picker Button + Vorschau -- `readonly` -> `Text` (nicht editierbar) - -#### FormGeneratorForm (Formular-Ansicht) - -```swift -struct FormGeneratorForm: View { - let entityType: String - let mode: FormMode // .create, .edit, .view - @Binding var data: [String: AnyCodable] - var attributes: [AttributeDefinition]? // optional, sonst von API laden - var onSubmit: (([String: AnyCodable]) async throws -> Void)? - var filterFields: [String]? - var customValidator: (([String: AnyCodable], [AttributeDefinition]) -> [String: String])? -} -``` - -- Laedt Attribute von `GET /api/attributes/{entityType}` wenn nicht uebergeben -- Sortiert nach `order` (default 999) -- Filtert nach `visible`, `editableOnCreate`/`editableOnUpdate` -- Options-Loading: Wenn `options` ein String ist (API-Pfad), parallele GETs mit `{instanceId}` Replacement - -#### FormGeneratorTable (Tabellen-Ansicht) - -Analog zu [frontend_nyla/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx](frontend_nyla/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx): - -- **Server-seitige** Pagination, Sortierung, Filterung (kein Client-Side!) -- Debounced Suche (300ms) -> `paginationParams.search` -- Multi-Column Sort (asc -> desc -> entfernt) -- Filter-Dropdowns pro Spalte (Werte von `{apiEndpoint}/filter-values?column=...`) -- Pagination Controls: First/Prev/Next/Last + Seitenzahlen -- Row Actions: Edit, Delete, Download, Custom Actions -- Inline Editing fuer unterstuetzte Typen -- Group-By Support mit Custom Group Renderer - -**Auf iOS/iPad**: `List` oder `LazyVStack` statt HTML-Table. Auf iPad breites Layout, auf iPhone Card-basiert oder horizontales Scrollen. - -#### FormGeneratorReport (Statistik-Ansicht) - -- Sektionen: KPI Grid, Bar, Line, Area, Pie Charts, Tabellen -- Toolbar: Periodenfilter, Datumsbereich -- Swift Charts fuer alle Chart-Typen (Recharts-Equivalent) - -### ContentPreview - -- PDF: `PDFKitView` (UIViewRepresentable mit PDFKit) -- Bilder: AsyncImage -- JSON: Syntax-Highlighting -- HTML: WKWebView (Mini) - -### ChatMessage Components - -Analog zu [frontend_nyla/src/components/UiComponents/Messages/](frontend_nyla/src/components/UiComponents/Messages/): - -- User vs. Assistant Bubbles -- Markdown-Rendering (MarkdownUI oder AttributedString) -- Code-Blocks mit Syntax-Highlighting -- File-Attachments (Karten mit Icon + Name) -- Streaming-Indicator (Typing Animation) -- Tool-Activity Anzeige -- Auto-Scroll zum neuesten Eintrag - -### Weitere Shared Components - -- **SearchBar**: Debounced Suchfeld -- **LoadingView**: Spinner/Skeleton -- **ErrorView**: Fehlermeldung mit Retry-Button -- **EmptyStateView**: Illustration + Text + Action -- **Toast**: Analog [frontend_nyla/src/contexts/ToastContext.tsx](frontend_nyla/src/contexts/ToastContext.tsx) -- Types: success (5s), error (8s), warning (6s), info (5s) - ---- - -## Phase 7: Core Pages (5-7 Tage) - -### Store (Feature Marketplace) - -- `GET /api/store/features` -> Feature-Liste als Karten -- `POST /api/store/activate` / `deactivate` -- Analog [frontend_nyla/src/pages/Store.tsx](frontend_nyla/src/pages/Store.tsx) - -### GDPR - -- `GET /api/user/me/data-export`, `/data-portability` -- `DELETE /api/user/me/` (mit Bestaetigung) -- Analog [frontend_nyla/src/pages/GDPR.tsx](frontend_nyla/src/pages/GDPR.tsx) - -### Basedata -- Prompts - -- FormGeneratorTable + FormGeneratorForm fuer CRUD auf `/api/prompts` -- Analog [frontend_nyla/src/pages/PromptsPage.tsx](frontend_nyla/src/pages/PromptsPage.tsx) - -### Basedata -- Files - -- `GET /api/files/list`, Upload, Download, Preview -- Ordnerstruktur: `GET /api/files/folders` -- Upload: `UIDocumentPickerViewController` (via UIViewControllerRepresentable) -- Vorschau: QuickLook Framework -- Batch-Delete, Move, Folder-Management -- Analog [frontend_nyla/src/pages/FilesPage.tsx](frontend_nyla/src/pages/FilesPage.tsx) - -### Basedata -- Connections - -- FormGeneratorTable + FormGeneratorForm fuer CRUD auf `/api/connections/` -- Connect/Disconnect Aktionen -- Analog [frontend_nyla/src/pages/ConnectionsPage.tsx](frontend_nyla/src/pages/ConnectionsPage.tsx) - -### Billing (Read-Only) - -- `GET /api/billing/balance` -> Saldo-Anzeige -- `GET /api/billing/transactions` -> Transaktionsliste -- `GET /api/billing/statistics/{period}` -> Charts mit Swift Charts -- KEIN Checkout/Stripe in der App -- Analog [frontend_nyla/src/pages/billing/BillingDataView.tsx](frontend_nyla/src/pages/billing/BillingDataView.tsx) - -### Settings - -- Theme-Toggle (System/Light/Dark) -- Sprachauswahl (de/en/fr) -- Profil-Bearbeitung (via `/api/users/{userId}`) -- Passwort aendern (`POST /api/users/change-password`) -- Analog [frontend_nyla/src/pages/Settings.tsx](frontend_nyla/src/pages/Settings.tsx) - ---- - -## Phase 8: Admin Module (5-7 Tage) - -Alle Admin-Seiten nutzen den FormGenerator intensiv. Analog zu [frontend_nyla/src/pages/admin/](frontend_nyla/src/pages/admin/): - -- **Mandates**: CRUD `/api/mandates/` + User-Zuweisung -- **Users**: CRUD `/api/users/` + Password-Link senden -- **User-Mandates**: `/api/mandates/{id}/users` -- Zuweisungen verwalten -- **Access Hub**: `/api/rbac/permissions`, `/api/rbac/rules` -- Regel-Editor -- **Feature Instances**: `/api/features/instances` -- CRUD + Sync-Roles -- **Feature Roles**: `/api/features/templates/roles` -- Template-Rollen -- **Feature Users**: `/api/features/instances/{id}/users` -- Benutzer pro Instanz -- **Invitations**: CRUD `/api/invitations/` + Token-Validierung -- **Mandate Roles**: `/api/rbac/roles` -- Rollen-Verwaltung -- **Role Permissions**: `/api/rbac/rules/by-role/{roleId}` -- Matrix-Ansicht -- **User Access Overview**: `/api/admin/user-access-overview/` -- Read-Only Uebersicht -- **Billing Admin**: `/api/billing/admin/` -- Read-Only (Accounts, Transactions, Settings) -- **Subscriptions Admin**: `/api/subscription/admin/all` -- Uebersicht -- **Automation Events**: `/api/admin/automation-events` -- Liste + Sync -- **Logs**: `/api/admin/logs` -- Systemlogs mit Download -- **Mandate Wizard**: Kombination von Mandate erstellen + Features zuweisen + Benutzer einladen -- **Invitation Wizard**: Gesteuerter Einladungs-Flow - ---- - -## Phase 9: Feature Trustee (5-7 Tage) - -API-Basis: `/api/trustee/{instanceId}/` - -Views (aus [frontend_nyla/src/pages/views/trustee/](frontend_nyla/src/pages/views/trustee/)): - -- **Dashboard**: Uebersicht ueber Organisationen, Vertraege, Positionen -- **Documents**: CRUD + Upload (`POST .../documents/upload`) + Download -- **Positions**: CRUD mit verschachtelten Position-Documents -- **Instance-Roles**: Rollenverwaltung pro Instanz -- **Expense-Import**: CSV/Excel Import von Belegen (Automation-basiert) -- **Scan-Upload**: Dokument-Scan -> auf iOS: `VNDocumentCameraViewController` (VisionKit) fuer native Scan-Funktion (besser als Web!) -- **Accounting Settings**: Connector-Konfiguration, Sync, Chart of Accounts - -Besonderheiten: - -- Viele Options-Endpoints fuer Dropdowns (`.../organisations/options`, `.../roles/options`, etc.) -- Hierarchische Daten: Organisation > Contract > Document > Position -- Scan-Upload ist auf iOS BESSER als im Web dank VisionKit - ---- - -## Phase 10: Feature Workspace (5-7 Tage) - -API-Basis: `/api/workspace/{instanceId}/` - -**Haupt-View**: AI Chat mit SSE-Streaming - -### Workspace Layout (Apple HIG Best Practice) - -**iPad** (Regular Width) -- `NavigationSplitView` mit 3 Spalten: - -- **Sidebar**: Conversations/Workflows Liste (analog linke Spalte im Web) -- **Content**: Chat-Bereich mit Nachrichten + Input-Feld -- **Detail (Inspector)**: Activity Panel, File Preview, Pending Edits (als `.inspector` Modifier oder dritte Spalte) - -**iPhone** (Compact Width) -- Automatischer Collapse: - -- Chat ist Primary View -- Conversations-Liste als Back-Navigation -- Activity/Files als Toolbar-Button -> Sheet/Overlay - -### SSE Chat-Streaming - -Analog zu [frontend_nyla/src/pages/views/workspace/useWorkspace.ts](frontend_nyla/src/pages/views/workspace/useWorkspace.ts): - -- `POST /api/workspace/{instanceId}/start/stream` mit JSON Body: - - `prompt`, `fileIds`, `dataSourceIds`, `featureDataSourceIds`, `userLanguage`, `workflowId`, `allowedProviders` -- Event-Types aus dem Stream: `message`, `chunk`, `status`, `tool_activity`, `agent_progress`, `agent_summary`, `fileCreated`, `dataSourceAccess`, `workflowUpdated`, `complete`, `stopped`, `error` -- Stop: `POST .../stop/{workflowId}` - -### Weitere Workspace-Features (ohne Voice) - -- Workflows CRUD: List, Create, Patch, Delete -- Messages: `GET .../workflows/{id}/messages` -- Files + Folders: Browse, Upload, Preview -- Datasources: CRUD + Feature-Datasources -- Pending Edits: Accept/Reject (einzeln und alle) -- Settings: General + RAG-Statistiken - -### WorkspaceKeepAlive-Equivalent - -Im Web bleibt die Workspace-Komponente gemounted (analog [frontend_nyla/src/pages/views/workspace/WorkspaceKeepAlive.tsx](frontend_nyla/src/pages/views/workspace/WorkspaceKeepAlive.tsx)). - -In Swift: `@Observable WorkspaceViewModel` wird im Environment gehalten und ueberlebt Navigation-Wechsel. Der SSE-Stream bleibt aktiv solange die Instanz ausgewaehlt ist. - ---- - -## Phase 11: Feature Chatbot (3-5 Tage) - -API-Basis: `/api/chatbot/{instanceId}/` - -Views: - -- **Conversations**: Thread-Liste + Chat-Stream -- **Settings**: Placeholder im Web, aber Endpoints existieren - -Technisch identisch zum Workspace-Chat-Pattern: - -- `POST .../start/stream` -> SSE -- `POST .../stop/{workflowId}` -- Threads: List, Delete -- Verwendet dieselben ChatMessage-Components aus Phase 6 - ---- - -## Phase 12: Feature Teamsbot (3-5 Tage, OHNE Voice) - -API-Basis: `/api/teamsbot/{instanceId}/` - -Views: - -- **Dashboard**: Uebersicht Sessions + Config -- **Sessions**: Session-Liste, Session-Details mit Stream -- **Settings**: Config, System Bots, User Account - -Technisch: - -- SSE fuer Session-Stream (`GET .../sessions/{sessionId}/stream`) -- Config/Settings CRUD -- Screenshots anzeigen (`GET .../sessions/{sessionId}/screenshots`) -- **WebSocket** (`/bot/ws/{sessionId}`) fuer Live-Bot-Interaktion -- `URLSessionWebSocketTask` -- MFA-Support fuer Session-Authentifizierung - ---- - -## Phase 13: Feature CommCoach (4-6 Tage, OHNE Audio-Streaming) - -API-Basis: `/api/commcoach/{instanceId}/` - -Views: - -- **Dashboard**: Kontext-Uebersicht, aktive Sessions, Fortschritt -- **Coaching**: Session starten, Nachrichten-Stream (SSE), Tasks -- **Dossier**: Export, Score-History, Badges -- **Settings**: Personas, Documents - -Technisch: - -- Contexts CRUD (Create, Archive, Activate) -- Sessions: Start, Complete, Cancel -- Message-Stream: SSE fuer Coaching-Nachrichten -- Tasks: CRUD + Status-Updates -- Badges + Scores: Visualisierung mit Swift Charts -- Export als PDF/Download - -**Hinweis**: Audio-Streaming (Mikrofon -> Backend) wird in dieser Version uebersprungen. Text-basiertes Coaching ist voll funktional. - ---- - -## Phase 14: Feature Automation (3-5 Tage) - -API-Basis: `/api/automations/` - -Views: - -- **Definitions**: Automation-Definitionen CRUD + Execute + Duplicate -- **Templates**: Template-Verwaltung - -Technisch: - -- FormGeneratorTable fuer Definitionen/Templates -- Execute mit Status-Tracking -- Workflow-Management: List, Get, Status, Logs, Messages -- Actions-Endpoint fuer verfuegbare Aktionen - ---- - -## Phase 15: Feature Automation2 (2-3 Tage, OHNE visuellen Editor) - -API-Basis: `/api/automation2/{instanceId}/` - -Views: - -- **Workflows**: Liste aller Workflows + CRUD -- **Workflow-Tasks**: Offene Tasks mit Complete-Aktion - -**Kein visueller Node-Editor** -- nur Listen-basierte Verwaltung: - -- Workflows: List, Create, Update, Delete -- Workflow Runs: `GET .../workflows/{id}/runs` -- Execute: `POST .../execute` -- Tasks: List + Complete -- Node-Types und Connections als Reference-Info - ---- - -## Phase 16: Feature CodeEditor (3-5 Tage) - -API-Basis: Nutzt Workspace/Automation2 API-Patterns - -Views: - -- **Editor**: Code-Anzeige mit Syntax-Highlighting (Runestone) -- **Workflows**: Workflow-Liste - -Technisch: - -- **Runestone** (SPM Package) fuer nativen Code-Editor mit: - - TreeSitter-basiertes Syntax-Highlighting - - Zeilennummern - - Theme-Integration (Light/Dark) -- SSE-Stream fuer Code-Generierung -- Pending Edits: Accept/Reject wie im Workspace -- File-Content anzeigen: `GET .../files/{fileId}/content` - ---- - -## Phase 17: Feature RealEstate/PEK (5-7 Tage) - -API-Basis: `/api/realestate/{instanceId}/` - -Views: - -- **Dashboard (Map)**: Karten-Visualisierung mit Parzellen -- **Instance-Roles**: Rollen-Verwaltung - -Technisch: - -- **MapKit** (SwiftUI `Map` View): - - Parzellen als Polygone auf der Karte - - Parcel-Selection durch Tap - - Adjacent Parcels Highlight - - Cluster-Ansicht fuer viele Parzellen -- Address-Autocomplete: `GET /api/realestate/address/autocomplete` + optionales MKLocalSearchCompleter -- Projects + Parcels CRUD -- BZO Information: Bauvorschriften anzeigen -- WFS (Web Feature Service): Parcel-Geometrie laden -- Selection Summary - -**Hinweis**: Leaflet (Web) -> MapKit (iOS) erfordert Koordinaten-Transformation. `proj4` im Web wird durch MapKit's native Projektionen ersetzt. Falls Swiss-spezifische Koordinatensysteme (LV95/LV03) benoetigt werden, braucht es einen Converter. - ---- - -## Phase 18: Feature Neutralization (2-3 Tage) - -API-Basis: `/api/neutralization/` - -Views: - -- **Dashboard/Playground** (gleiche View): Text eingeben -> neutralisieren/aufloesen - -Technisch: - -- Config: `GET/POST /api/neutralization/config` -- Neutralize Text: `POST .../neutralize-text` -- Resolve Text: `POST .../resolve-text` -- Neutralize File: `POST .../neutralize-file` (File-Upload) -- Stats: `GET .../stats` -- Attributes: `GET .../attributes` - ---- - -## API-Header-Konvention (fuer alle Requests) - -Jeder Request muss folgende Header senden (analog [frontend_nyla/src/api.ts](frontend_nyla/src/api.ts)): - -- `Authorization: Bearer {token}` -- aus Keychain, wenn JWT vorhanden -- `X-Mandate-Id: {mandateId}` -- aus NavigationStore, bei Feature-Seiten -- `X-Instance-Id: {instanceId}` -- aus NavigationStore, bei Feature-Seiten -- `X-CSRF-Token: {token}` -- aus CSRFManager, bei POST/PUT/PATCH/DELETE -- `Content-Type: application/json` -- Standard fuer JSON Bodies -- Cookies (httpOnly) -- automatisch via URLSession HTTPCookieStorage - ---- - -## Parallelisierungsstrategie (2-3 Entwickler) - -```mermaid -gantt - title Entwicklungsplan 2-3 Entwickler - dateFormat YYYY-MM-DD - axisFormat %d.%m - - section Gemeinsam - Phase0_Setup :p0, 2026-04-01, 2d - Phase1_Networking :p1, after p0, 5d - Phase2_Auth :p2, after p1, 5d - Phase3_Models :p3, after p2, 3d - - section Dev1_Core - Phase4_AppShell :p4, after p3, 6d - Phase5_i18n_Theme :p5, after p4, 3d - Phase7_CorePages :p7, after p5, 7d - Phase8_Admin :p8, after p7, 7d - - section Dev2_Components - Phase6_SharedUI :p6, after p3, 8d - Phase10_Workspace :p10, after p6, 7d - Phase11_Chatbot :p11, after p10, 5d - Phase13_CommCoach :p13, after p11, 6d - - section Dev3_Features - Phase9_Trustee :p9, after p6, 7d - Phase12_Teamsbot :p12, after p9, 5d - Phase14_Automation :p14, after p12, 5d - Phase15_Automation2 :p15, after p14, 3d - Phase16_CodeEditor :p16, after p15, 5d - Phase17_RealEstate :p17, after p16, 7d - Phase18_Neutralization :p18, after p17, 3d -``` - - - -- **Phase 0-3**: Gemeinsam (Basis fuer alle) -- **Ab Phase 4**: Parallel -- Core/Admin, Shared UI/Chat-Features, Trustee/Weitere Features -- Features sind nach Phase 6 (Shared Components, besonders FormGenerator) unabhaengig voneinander - ---- - -## Gesamtaufwand-Schaetzung (aktualisiert) - -- Phase 0: Setup -- 1-2 Tage -- Phase 1: Networking -- 3-5 Tage -- Phase 2: Authentication -- 3-5 Tage -- Phase 3: Domain Models -- 2-3 Tage -- Phase 4: App Shell + Navigation -- 4-6 Tage -- Phase 5: i18n + Theme -- 2-3 Tage -- Phase 6: Shared UI Components -- 5-8 Tage -- Phase 7: Core Pages -- 5-7 Tage -- Phase 8: Admin -- 5-7 Tage -- Phase 9: Trustee -- 5-7 Tage -- Phase 10: Workspace -- 5-7 Tage -- Phase 11: Chatbot -- 3-5 Tage -- Phase 12: Teamsbot -- 3-5 Tage -- Phase 13: CommCoach -- 4-6 Tage -- Phase 14: Automation -- 3-5 Tage -- Phase 15: Automation2 -- 2-3 Tage -- Phase 16: CodeEditor -- 3-5 Tage -- Phase 17: RealEstate -- 5-7 Tage -- Phase 18: Neutralization -- 2-3 Tage -- **Gesamt sequentiell**: ~65-100 Tage -- **Gesamt mit 3 Devs parallel**: ~35-50 Tage (nach Phase 3) - ---- - -## Risiken und offene Punkte - -1. **Auth ohne Backend-Aenderungen**: `ASWebAuthenticationSession` teilt Cookies mit Safari, nicht direkt mit `URLSession`. Falls das Backend nach dem OAuth-Redirect nur httpOnly Cookies setzt (ohne Token im Redirect-URL), muss getestet werden ob die Cookie-Uebernahme funktioniert. Worst case: Backend muss doch einen Token-Parameter im Callback-URL zurueckgeben. -2. **CSRF Validierung**: Das Backend validiert CSRF-Tokens mit einem Hex-Format-Check. Die client-seitige Generierung muss identisch sein (16 Hex-Zeichen). Testen ob das Backend Session-gebundene CSRF-Validierung macht oder nur Format-Check. -3. **SSE ueber POST**: Standard `EventSource` ist GET-only. Das Web nutzt `fetch(POST)` + `ReadableStream`. In Swift: `URLSession.bytes(for:)` mit POST Request -- funktioniert, aber muss getestet werden mit dem spezifischen Backend-Setup. -4. **FormGenerator Komplexitaet**: Der FormGenerator ist das komplexeste Shared Component. Die Tabellen-Ansicht auf iPhone (schmaler Screen) braucht ein alternatives Layout (Cards statt Tabelle). Dies erfordert sorgfaeltiges Responsive Design. -5. **MapKit vs Leaflet**: Falls Swiss-spezifische Koordinatensysteme (LV95/LV03) benoetigt werden, muss ein nativer Koordinaten-Transformer implementiert werden (im Web: `proj4`). -6. **Keine Offline-Faehigkeit**: Bei schlechter Netzwerkverbindung (z.B. auf dem Bau) koennen Ladezustaende frustrierend sein. Empfehlung: Zumindest ein Request-Cache fuer die letzte erfolgreiche Response pro Endpoint. -7. **TestFlight Limitierung**: Max 10'000 Tester, 90-Tage Build-Ablauf. Fuer laengerfristigen Einsatz Enterprise Distribution oder App Store evaluieren. - diff --git a/.cursor/plans/swift_ios_app_nachbau_80bb1212.plan.md b/.cursor/plans/swift_ios_app_nachbau_80bb1212.plan.md deleted file mode 100644 index c8ae939c..00000000 --- a/.cursor/plans/swift_ios_app_nachbau_80bb1212.plan.md +++ /dev/null @@ -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 - 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()`, `post()`, `put()`, `delete()`, `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. - diff --git a/.forgejo/workflows/int_porta-int-platform-core.yml b/.forgejo/workflows/int_porta-int-platform-core.yml new file mode 100644 index 00000000..f55bc472 --- /dev/null +++ b/.forgejo/workflows/int_porta-int-platform-core.yml @@ -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 + " diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/main_porta-main-platform-core.yml similarity index 76% rename from .forgejo/workflows/deploy.yml rename to .forgejo/workflows/main_porta-main-platform-core.yml index 3d5d5f3e..9dfa1958 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/main_porta-main-platform-core.yml @@ -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 diff --git a/.github/scripts/load_config_key_from_azure.py b/.github/scripts/load_config_key_from_azure.py deleted file mode 100644 index 08da7be4..00000000 --- a/.github/scripts/load_config_key_from_azure.py +++ /dev/null @@ -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}<> $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 }}" diff --git a/.github/workflows/int_gateway-int.yml b/.github/workflows/int_gateway-int.yml deleted file mode 100644 index d9fa4d6a..00000000 --- a/.github/workflows/int_gateway-int.yml +++ /dev/null @@ -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 }} \ No newline at end of file diff --git a/.github/workflows/main_gateway-prod.yml b/.github/workflows/main_gateway-prod.yml deleted file mode 100644 index 60de29ff..00000000 --- a/.github/workflows/main_gateway-prod.yml +++ /dev/null @@ -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 }} \ No newline at end of file diff --git a/.github/workflows/update-requirements-lock.yml b/.github/workflows/update-requirements-lock.yml deleted file mode 100644 index 3d21839f..00000000 --- a/.github/workflows/update-requirements-lock.yml +++ /dev/null @@ -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 diff --git a/Dockerfile b/Dockerfile index f476a47c..e8300a5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app.py b/app.py index 93cc8b79..b53caebc 100644 --- a/app.py +++ b/app.py @@ -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}") \ No newline at end of file +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) \ No newline at end of file diff --git a/env-gateway-dev.env b/env-dev.env similarity index 95% rename from env-gateway-dev.env rename to env-dev.env index fd0ee428..13b0bf4f 100644 --- a/env-gateway-dev.env +++ b/env-dev.env @@ -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 diff --git a/env-gateway-int-forgejo.env b/env-gateway-int-forgejo.env deleted file mode 100644 index 49eb8675..00000000 --- a/env-gateway-int-forgejo.env +++ /dev/null @@ -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 diff --git a/env-gateway-prod.env b/env-gateway-prod.env deleted file mode 100644 index c6979c1c..00000000 --- a/env-gateway-prod.env +++ /dev/null @@ -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 diff --git a/env-gateway-int.env b/env-int.env similarity index 86% rename from env-gateway-int.env rename to env-int.env index 16619558..24ae762b 100644 --- a/env-gateway-int.env +++ b/env-int.env @@ -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 diff --git a/env-gateway-prod-forgejo.env b/env-prod.env similarity index 95% rename from env-gateway-prod-forgejo.env rename to env-prod.env index b22a5c87..9d07d88c 100644 --- a/env-gateway-prod-forgejo.env +++ b/env-prod.env @@ -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 diff --git a/modules/auth/oauthConnectTicket.py b/modules/auth/oauthConnectTicket.py index 82547cf1..f54187cb 100644 --- a/modules/auth/oauthConnectTicket.py +++ b/modules/auth/oauthConnectTicket.py @@ -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.""" diff --git a/modules/datamodels/datamodelAiAudit.py b/modules/datamodels/datamodelAiAudit.py index 833a175a..f78ecd23 100644 --- a/modules/datamodels/datamodelAiAudit.py +++ b/modules/datamodels/datamodelAiAudit.py @@ -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, diff --git a/modules/datamodels/datamodelAudit.py b/modules/datamodels/datamodelAudit.py index 4d030fd3..85cdfbf2 100644 --- a/modules/datamodels/datamodelAudit.py +++ b/modules/datamodels/datamodelAudit.py @@ -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}, }, ) diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py index d3967f12..78024ce1 100644 --- a/modules/datamodels/datamodelBilling.py +++ b/modules/datamodels/datamodelBilling.py @@ -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()), diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py index f846b52c..6546f6d9 100644 --- a/modules/datamodels/datamodelChat.py +++ b/modules/datamodels/datamodelChat.py @@ -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"}) diff --git a/modules/datamodels/datamodelKnowledge.py b/modules/datamodels/datamodelKnowledge.py index d0af2216..725c0158 100644 --- a/modules/datamodels/datamodelKnowledge.py +++ b/modules/datamodels/datamodelKnowledge.py @@ -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, diff --git a/modules/datamodels/datamodelMessaging.py b/modules/datamodels/datamodelMessaging.py index 9fcb9944..904ee526 100644 --- a/modules/datamodels/datamodelMessaging.py +++ b/modules/datamodels/datamodelMessaging.py @@ -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()), diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py index 4196a959..c8263e37 100644 --- a/modules/datamodels/datamodelSubscription.py +++ b/modules/datamodels/datamodelSubscription.py @@ -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()), diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index f6cbd8fa..da074f57 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -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) - diff --git a/modules/features/commcoach/datamodelCommcoach.py b/modules/features/commcoach/datamodelCommcoach.py index 250d4799..06928998 100644 --- a/modules/features/commcoach/datamodelCommcoach.py +++ b/modules/features/commcoach/datamodelCommcoach.py @@ -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"}) diff --git a/modules/features/graphicalEditor/nodeDefinitions/context.py b/modules/features/graphicalEditor/nodeDefinitions/context.py index 3171f58a..e38e5366 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/context.py +++ b/modules/features/graphicalEditor/nodeDefinitions/context.py @@ -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", diff --git a/modules/features/neutralization/datamodelFeatureNeutralizer.py b/modules/features/neutralization/datamodelFeatureNeutralizer.py index d83820fa..9465667c 100644 --- a/modules/features/neutralization/datamodelFeatureNeutralizer.py +++ b/modules/features/neutralization/datamodelFeatureNeutralizer.py @@ -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()), diff --git a/modules/features/realEstate/datamodelFeatureRealEstate.py b/modules/features/realEstate/datamodelFeatureRealEstate.py index 5ae732fe..8de665de 100644 --- a/modules/features/realEstate/datamodelFeatureRealEstate.py +++ b/modules/features/realEstate/datamodelFeatureRealEstate.py @@ -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()), diff --git a/modules/features/teamsbot/datamodelTeamsbot.py b/modules/features/teamsbot/datamodelTeamsbot.py index 18904525..70ba5fd5 100644 --- a/modules/features/teamsbot/datamodelTeamsbot.py +++ b/modules/features/teamsbot/datamodelTeamsbot.py @@ -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") diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index fcce44bd..7e6eef59 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -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") diff --git a/modules/routes/routeAdminDatabaseHealth.py b/modules/routes/routeAdminDatabaseHealth.py index 44b9f0c1..4e74646d 100644 --- a/modules/routes/routeAdminDatabaseHealth.py +++ b/modules/routes/routeAdminDatabaseHealth.py @@ -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} diff --git a/modules/routes/routeDataSources.py b/modules/routes/routeDataSources.py index b2f919b7..9ffd42ed 100644 --- a/modules/routes/routeDataSources.py +++ b/modules/routes/routeDataSources.py @@ -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 diff --git a/modules/serviceCenter/services/serviceKnowledge/_costEstimate.py b/modules/serviceCenter/services/serviceKnowledge/_costEstimate.py index 565c219d..c50da1fa 100644 --- a/modules/serviceCenter/services/serviceKnowledge/_costEstimate.py +++ b/modules/serviceCenter/services/serviceKnowledge/_costEstimate.py @@ -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), diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py index 9e99ba12..73fbfb02 100644 --- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py +++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py @@ -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: diff --git a/modules/shared/eventManagement.py b/modules/shared/eventManagement.py index ebbf2131..1edd53be 100644 --- a/modules/shared/eventManagement.py +++ b/modules/shared/eventManagement.py @@ -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: diff --git a/modules/system/databaseHealth.py b/modules/system/databaseHealth.py index 91f26225..80a19ac9 100644 --- a/modules/system/databaseHealth.py +++ b/modules/system/databaseHealth.py @@ -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() diff --git a/modules/system/databaseMigration.py b/modules/system/databaseMigration.py new file mode 100644 index 00000000..645fcab7 --- /dev/null +++ b/modules/system/databaseMigration.py @@ -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": "", "adminUser": "", "eventUser": ""}``. + """ + 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} diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py index f6674124..8a23e400 100644 --- a/modules/workflows/methods/methodContext/actions/extractContent.py +++ b/modules/workflows/methods/methodContext/actions/extractContent.py @@ -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( diff --git a/requirements.txt b/requirements.txt index 2d2f5ee5..3d8ee88a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/exportDbSchemaFromModels.py b/scripts/exportDbSchemaFromModels.py new file mode 100644 index 00000000..be715c80 --- /dev/null +++ b/scripts/exportDbSchemaFromModels.py @@ -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() diff --git a/tests/unit/connectors/test_connectorDbPostgre_pool.py b/tests/unit/connectors/test_connectorDbPostgre_pool.py index 1d8d5d1d..bb4170e9 100644 --- a/tests/unit/connectors/test_connectorDbPostgre_pool.py +++ b/tests/unit/connectors/test_connectorDbPostgre_pool.py @@ -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}"') diff --git a/tests/unit/services/test_costEstimate.py b/tests/unit/services/test_costEstimate.py index e49aca6a..00fbb6b6 100644 --- a/tests/unit/services/test_costEstimate.py +++ b/tests/unit/services/test_costEstimate.py @@ -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")