// Copyright (c) 2026 Patrick Motsch // All rights reserved. /** * UdbSourcesProvider — TreeNodeProvider for the UDB Sources tab. * * Single responsibility: translate the generic UDB backend tree contract * (POST /api/udb/tree/children -> nodesByParent map) into the generic * TreeNode shape that FormGeneratorTree consumes, and forward flag * mutations to the single generic UDB endpoint * (POST /api/udb/node/{key}/flag/{flag}). * * No effective-value computation, no inheritance logic, no mixed-state math: * the backend is the single source of truth. The provider only: * 1. caches the most recently loaded backend node payload per id so the * drag/settings handlers have direct access to coordinates, * 2. emits stable display ordering via `displayOrder`, * 3. hides flag affordances on synthetic container nodes (synthRoot, * mandateGroup) by leaving the corresponding TreeNode field undefined. * * The UDB API endpoints are intentionally feature-agnostic; they do not * carry an `instanceId` in the path. The legacy `instanceId` argument is * still accepted by `createUdbSourcesProvider` because: * - the drag payloads for "create personal DataSource" / "create FDS" * hit feature-instance-scoped helper endpoints under /api/workspace/ * to keep ingestion/RAG bound to the caller's workspace, * - the rootKey is namespaced per instance so two UDBs on the same * screen never collide. */ import React from 'react'; import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaFile, FaEnvelope, FaCloudUploadAlt, FaCalendarAlt, FaComments, FaUser, FaTable, FaDatabase, FaBuilding, } from 'react-icons/fa'; import { SiJira } from 'react-icons/si'; import api from '../../api'; import type { TreeNode, TreeNodeProvider, ScopeValue } from '../FormGenerator/FormGeneratorTree'; // --------------------------------------------------------------------------- // Backend contract types // --------------------------------------------------------------------------- export type UdbBackendKind = | 'synthRoot' | 'connection' | 'service' | 'folder' | 'file' | 'mandateGroup' | 'featureNode' | 'fdsTable' | 'fdsRecord' | 'fdsField'; export interface UdbBackendNode { key: string; kind: UdbBackendKind; parentKey: string | null; label: string; icon?: string; hasChildren: boolean; dataSourceId: string | null; modelType: 'DataSource' | 'FeatureDataSource' | null; effectiveNeutralize: boolean | 'mixed'; effectiveScope: string; effectiveRagIndexEnabled: boolean | 'mixed'; supportsRag: boolean; canBeAdded: boolean; displayOrder?: number; defaultExpanded?: boolean; authority?: string; connectionId?: string; service?: string; sourceType?: string; path?: string; featureInstanceId?: string; featureCode?: string; mandateId?: string; tableName?: string; fieldName?: string; objectKey?: string; displayPath?: string; /** fdsTable-only: persisted list of column names to neutralize (PII mask). */ neutralizeFields?: string[]; } /** Kinds that represent the *root* of a data source (one DataSource record per * node). Settings are only meaningful here; folders/files/services/tables * inherit settings from the root and don't get their own gear icon. */ const _DATA_SOURCE_ROOT_KINDS = new Set([ 'connection', 'featureNode', ]); // --------------------------------------------------------------------------- // Icon resolution (kept inline; UDB-domain mapping, not Tree-generic) // --------------------------------------------------------------------------- const _AUTHORITY_ICONS: Record = { msft: , google: , clickup: , infomaniak: , 'local:ftp': , 'local:jira': , }; const _SERVICE_ICONS: Record = { sharepoint: , onedrive: , outlook: , teams: , drive: , gmail: , files: , kdrive: , calendar: , contact: , }; const _KIND_FALLBACK_ICONS: Record = { synthRoot: , connection: , service: , folder: , file: , mandateGroup: , featureNode: , fdsTable: , fdsRecord: , fdsField: {'\u22EE'}, }; function _renderIcon(node: UdbBackendNode): React.ReactNode { if (node.kind === 'connection') { return _AUTHORITY_ICONS[node.icon || ''] ?? _KIND_FALLBACK_ICONS.connection; } if (node.kind === 'service') { return _SERVICE_ICONS[node.icon || ''] ?? _KIND_FALLBACK_ICONS.service; } return _KIND_FALLBACK_ICONS[node.kind] ?? null; } // --------------------------------------------------------------------------- // Domain rule: which kinds expose flag toggles // --------------------------------------------------------------------------- /** Synthetic / structural containers carry no DB record and have no flags. * The provider hides scope/neutralize/ragIndexEnabled for them so the tree * doesn't render dead buttons. */ function _isSyntheticContainer(kind: UdbBackendKind): boolean { return kind === 'synthRoot' || kind === 'mandateGroup'; } /** FDS-family kinds (no `scope` attribute; visibility is feature RBAC). */ function _isFdsKind(kind: UdbBackendKind): boolean { return kind === 'featureNode' || kind === 'fdsTable' || kind === 'fdsRecord' || kind === 'fdsField'; } // --------------------------------------------------------------------------- // Mapping: backend payload -> generic TreeNode // --------------------------------------------------------------------------- function _mapBackendNode( n: UdbBackendNode, onSettingsClick: (n: UdbBackendNode) => Promise | void, ): TreeNode { const isSynthetic = _isSyntheticContainer(n.kind); const isFolderLike = n.hasChildren; const node: TreeNode = { id: n.key, name: n.label, type: isFolderLike ? 'folder' : 'file', parentId: n.parentKey, ownership: 'own', icon: _renderIcon(n), displayOrder: n.displayOrder, defaultExpanded: n.defaultExpanded, data: n, }; if (!isSynthetic) { if (n.kind === 'fdsField') { // Fields expose ONLY neutralize (mapped to parent table's // neutralizeFields list). Scope and RAG are not field-level concepts. node.neutralize = n.effectiveNeutralize; } else if (_isFdsKind(n.kind)) { // FDS records have neutralize + ragIndexEnabled, but no scope. node.neutralize = n.effectiveNeutralize; if (n.supportsRag) { node.ragIndexEnabled = n.effectiveRagIndexEnabled; } } else { // DataSource family carries the full three-flag set. node.scope = n.effectiveScope as ScopeValue | 'mixed'; node.neutralize = n.effectiveNeutralize; if (n.supportsRag) { node.ragIndexEnabled = n.effectiveRagIndexEnabled; } } } if (_DATA_SOURCE_ROOT_KINDS.has(n.kind)) { node.extraActions = [{ key: 'settings', icon: '\u2699\uFE0F', tooltip: 'Einstellungen', onClick: () => onSettingsClick(n), }]; } return node; } // --------------------------------------------------------------------------- // Provider factory // --------------------------------------------------------------------------- export interface UdbSourcesProviderHandle extends TreeNodeProvider { /** Resolve the DataSource UUID for a node, creating a record if needed. */ ensureDataSourceId(node: UdbBackendNode): Promise; /** Test/diagnostic hook only -- exposes the latest cached backend payloads * so consumers can inspect data flow without round-tripping through the * network. Not part of the contract used at runtime. */ _diagnosticGetCacheSize(): number; } export function createUdbSourcesProvider( instanceId: string, onOpenSettings: (dataSourceId: string, label: string) => void, ): UdbSourcesProviderHandle { // Per-id cache of the most recent backend payload. Used by the // settings/drag handlers (NOT by the generic flag PATCH path; that // identifies the node purely by its tree key). const nodeCache = new Map(); async function _ensureRecordForSettings(node: UdbBackendNode): Promise { if (node.dataSourceId) return node.dataSourceId; try { if (node.kind === 'connection' || node.kind === 'service' || node.kind === 'folder' || node.kind === 'file') { const sourceType = node.sourceType || (node.kind === 'connection' ? node.authority : '') || ''; const res = await api.post(`/api/workspace/${instanceId}/datasources`, { connectionId: node.connectionId || '', sourceType, path: node.path || '/', label: node.label, displayPath: node.displayPath || node.label, }); const newId: string | null = res.data?.id ?? null; if (newId) { nodeCache.set(node.key, { ...node, dataSourceId: newId, modelType: 'DataSource' }); } return newId; } if (node.kind === 'featureNode' || node.kind === 'fdsTable' || node.kind === 'fdsRecord') { const tableName = node.tableName || (node.kind === 'featureNode' ? '*' : ''); const objectKey = node.objectKey || (node.kind === 'featureNode' ? `data.feature.${node.featureCode}.*` : ''); const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, { featureInstanceId: node.featureInstanceId || '', featureCode: node.featureCode || '', tableName, objectKey, label: node.label, }); const newId: string | null = res.data?.id ?? null; if (newId) { nodeCache.set(node.key, { ...node, dataSourceId: newId, modelType: 'FeatureDataSource' }); } return newId; } } catch (err) { console.error('[UdbSourcesProvider] ensureRecordForSettings failed', err); } return null; } async function _onSettingsClick(node: UdbBackendNode): Promise { const dsId = await _ensureRecordForSettings(node); if (!dsId) { console.warn('[UdbSourcesProvider] settings click: cannot ensure record', node.key); return; } onOpenSettings(dsId, node.label); } /** Forward a flag mutation to the generic UDB endpoint. The backend * resolves the node from `nodeKey`, runs the polymorphic `canEdit` * permission check, and applies the cascade-reset. */ async function _patchFlag( ids: string[], flag: 'neutralize' | 'scope' | 'ragIndexEnabled', value: unknown, ): Promise { for (const nodeKey of ids) { try { await api.post(`/api/udb/node/${encodeURIComponent(nodeKey)}/flag/${flag}`, { value }); } catch (err) { console.error('[UdbSourcesProvider] patch failed', { nodeKey, flag, err }); throw err; } } } return { rootKey: `udb-sources-${instanceId}`, async loadChildren(parentId, _ownership) { const res = await api.post(`/api/udb/tree/children`, { parents: [parentId], }); const nodesByParent = res.data?.nodesByParent || {}; const lookupKey = parentId ?? '__root__'; const list: UdbBackendNode[] = nodesByParent[lookupKey] || []; for (const n of list) nodeCache.set(n.key, n); return list.map((n) => _mapBackendNode(n, _onSettingsClick)); }, canPatchScope(node) { const data = node.data; // Scope only exists on DataSource family; FDS / synthetic containers / fields hide it. return !!data && !_isSyntheticContainer(data.kind) && !_isFdsKind(data.kind); }, canPatchNeutralize(node) { const data = node.data; return !!data && !_isSyntheticContainer(data.kind); }, canPatchRagIndex(node) { const data = node.data; // RAG exists at the data-source level (DS root / FDS table+rows), never on fields. return !!data && data.supportsRag === true && data.kind !== 'fdsField'; }, async patchScope(ids, scope, _cascadeChildren) { // Backend cascades NULL on descendants automatically based on the // existence of explicit child records; the cascadeChildren flag is the // FilesTab convention and is irrelevant here. await _patchFlag(ids, 'scope', scope); }, async patchNeutralize(ids, neutralize) { await _patchFlag(ids, 'neutralize', neutralize); }, async patchRagIndex(ids, ragIndexEnabled) { await _patchFlag(ids, 'ragIndexEnabled', ragIndexEnabled); }, customizeDragData(node, dataTransfer) { const data = node.data as UdbBackendNode | undefined; if (!data || _isSyntheticContainer(data.kind)) return; if (data.kind === 'connection' || data.kind === 'service' || data.kind === 'folder' || data.kind === 'file') { const sourceType = data.sourceType || (data.kind === 'connection' ? data.authority : '') || ''; const payload = { connectionId: data.connectionId || '', sourceType, path: data.path || '/', label: data.label, displayPath: data.displayPath || data.label, }; dataTransfer.setData('application/datasource', JSON.stringify(payload)); } else if (data.kind === 'featureNode' || data.kind === 'fdsTable' || data.kind === 'fdsRecord') { const tableName = data.tableName || (data.kind === 'featureNode' ? '*' : ''); const objectKey = data.objectKey || (data.kind === 'featureNode' ? `data.feature.${data.featureCode}.*` : ''); const payload = { featureInstanceId: data.featureInstanceId || '', featureCode: data.featureCode || '', tableName, objectKey, label: data.label, }; dataTransfer.setData('application/feature-source', JSON.stringify(payload)); } }, async ensureDataSourceId(node: UdbBackendNode): Promise { return _ensureRecordForSettings(node); }, _diagnosticGetCacheSize() { return nodeCache.size; }, }; }