import { create } from 'zustand'; import { logger } from '../debugConfig'; import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types'; import { NavigationStore, NavigationNode, NeoGraphNode, MainContext, BaseContext, NavigationContextState, isProfileContext, isInstituteContext, addToHistory, navigateHistory, getCurrentHistoryNode, ExtendedContext, UnifiedContextSwitch, } from '../types/navigation'; interface WhiteboardRoom { id: string; user_id: string; name: string; context_type: string; is_default: boolean; storage_path: string | null; neo4j_node_id: string | null; neo4j_db_name: string | null; node_type: string | null; } interface NavigationStoreWithAuth extends NavigationStore { _accessToken: string | null; _userId: string | null; setAuthInfo: (token: string | null, userId: string | null) => void; } const initialState: NavigationContextState = { main: 'profile', base: 'profile', node: null, history: { nodes: [], currentIndex: -1 } }; function getDefaultBaseForMain(main: MainContext): BaseContext { return main === 'profile' ? 'profile' : 'school'; } function validateContextTransition( current: NavigationContextState, updates: Partial ): NavigationContextState { const newState = { ...current, ...updates }; if (updates.main) { newState.base = getDefaultBaseForMain(updates.main); } if (updates.base) { const isValid = newState.main === 'profile' ? isProfileContext(updates.base) : isInstituteContext(updates.base); if (!isValid) { newState.base = getDefaultBaseForMain(newState.main); } } return newState; } export const useNavigationStore = create((set, get) => { const pgFetch = async ( method: 'GET' | 'POST' | 'PATCH' | 'DELETE', table: string, options: { body?: object; query?: string; prefer?: string; single?: boolean } = {} ): Promise => { const token = get()._accessToken; if (!token) throw new Error('pgFetch: no access token'); const url = `${import.meta.env.VITE_SUPABASE_URL}/rest/v1/${table}${options.query ? `?${options.query}` : ''}`; const headers: Record = { 'Authorization': `Bearer ${token}`, 'apikey': import.meta.env.VITE_SUPABASE_ANON_KEY, 'Content-Type': 'application/json', }; if (options.prefer) headers['Prefer'] = options.prefer; if (options.single) headers['Accept'] = 'application/vnd.pgrst.object+json'; const res = await fetch(url, { method, headers, ...(options.body ? { body: JSON.stringify(options.body) } : {}), }); if (!res.ok) { const err = await res.text(); throw new Error(`PostgREST ${res.status}: ${err}`); } if (res.status === 204) return null; return res.json() as Promise; }; const getOrCreateDefaultRoom = async (contextType: string): Promise => { const userId = get()._userId; if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID'); const rooms = await pgFetch('GET', 'whiteboard_rooms', { query: `user_id=eq.${userId}&context_type=eq.${contextType}&is_default=eq.true`, }); if (rooms && rooms.length > 0) { const room = rooms[0]; return { id: room.id, node_storage_path: room.storage_path || `${userId}/workspaces/${contextType}_default.json`, label: room.name, type: 'workspace', }; } const storagePath = `${userId}/workspaces/${contextType}_default.json`; const room = await pgFetch('POST', 'whiteboard_rooms', { body: { user_id: userId, name: `${contextType.charAt(0).toUpperCase() + contextType.slice(1)} Workspace`, context_type: contextType, is_default: true, storage_path: storagePath, }, prefer: 'return=representation', single: true, }); if (!room) throw new Error('Failed to create default whiteboard room'); logger.debug('navigation-context', '✅ Created default whiteboard room', { contextType, roomId: room.id }); return { id: room.id, node_storage_path: room.storage_path || storagePath, label: room.name, type: 'workspace', }; }; return { _accessToken: null, _userId: null, setAuthInfo: (token: string | null, userId: string | null) => { set({ _accessToken: token, _userId: userId }); }, context: initialState, isLoading: false, error: null, switchContext: async (contextSwitch: UnifiedContextSwitch, _userDbName: string | null = null, _workerDbName: string | null = null) => { if (!get()._accessToken || !get()._userId) { logger.warn('navigation-context', '⚠️ switchContext called without auth — skipping'); return; } try { set({ isLoading: true, error: null }); const currentState = get().context; let newState: NavigationContextState = { ...currentState, node: null }; if (contextSwitch.main) { newState = validateContextTransition(newState, { main: contextSwitch.main }); if (!contextSwitch.skipBaseContextLoad) { newState.base = getDefaultBaseForMain(contextSwitch.main); } } if (contextSwitch.base) { newState = validateContextTransition(newState, { base: contextSwitch.base }); } const targetContext = contextSwitch.base || contextSwitch.extended || (contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base); const defaultNode = await getOrCreateDefaultRoom(targetContext); const newHistory = addToHistory(currentState.history, defaultNode); set({ context: { ...newState, node: defaultNode, history: newHistory }, isLoading: false, error: null, }); logger.debug('navigation-context', '✅ Context switch complete', { main: newState.main, base: newState.base, nodeId: defaultNode.id, }); } catch (error) { logger.error('navigation-context', '❌ switchContext failed', error); set({ error: error instanceof Error ? error.message : 'Failed to switch context', isLoading: false }); } }, goBack: () => { const currentState = get().context; if (currentState.history.currentIndex > 0) { const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex - 1); set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } }); } }, goForward: () => { const currentState = get().context; if (currentState.history.currentIndex < currentState.history.nodes.length - 1) { const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1); set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory } }); } }, setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => { await get().switchContext({ main }, userDbName, workerDbName); }, setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => { await get().switchContext({ base }, userDbName, workerDbName); }, setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => { await get().switchContext({ extended }, userDbName, workerDbName); }, navigate: async (nodeId: string, _dbName: string) => { try { set({ isLoading: true, error: null }); if (!get()._accessToken) { set({ isLoading: false }); return; } const currentState = get().context; const existingIndex = currentState.history.nodes.findIndex(n => n.id === nodeId); if (existingIndex !== -1) { const newHistory = navigateHistory(currentState.history, existingIndex); set({ context: { ...currentState, node: getCurrentHistoryNode(newHistory), history: newHistory }, isLoading: false }); return; } const rooms = await pgFetch('GET', 'whiteboard_rooms', { query: `id=eq.${nodeId}&user_id=eq.${get()._userId}`, }); if (!rooms || rooms.length === 0) throw new Error(`Whiteboard room not found: ${nodeId}`); const room = rooms[0]; const node: NavigationNode = { id: room.id, node_storage_path: room.storage_path || `${get()._userId}/workspaces/${room.context_type}_default.json`, label: room.name, type: 'workspace', }; const newHistory = addToHistory(currentState.history, node); set({ context: { ...currentState, node, history: newHistory }, isLoading: false }); } catch (error) { logger.error('navigation', '❌ navigate failed', error); set({ error: error instanceof Error ? error.message : 'Failed to navigate', isLoading: false }); } }, navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => { if (!isValidNodeType(node.type)) { logger.warn('navigation', `⚠️ navigateToNode called with non-graph type: ${node.type} — navigating anyway`); } await get().navigate(node.id, userDbName || ''); }, refreshNavigationState: async (_userDbName: string | null, _workerDbName: string | null) => { try { set({ isLoading: true, error: null }); const currentNode = get().context.node; if (currentNode && get()._accessToken) { const rooms = await pgFetch('GET', 'whiteboard_rooms', { query: `id=eq.${currentNode.id}`, }); if (rooms && rooms.length > 0) { const room = rooms[0]; set({ context: { ...get().context, node: { id: room.id, node_storage_path: room.storage_path || currentNode.node_storage_path, label: room.name, type: 'workspace', }, }, }); } } set({ isLoading: false }); } catch (error) { logger.error('navigation', '❌ refreshNavigationState failed', error); set({ error: error instanceof Error ? error.message : 'Failed to refresh', isLoading: false }); } }, navigateToNeoNode: async (neoNode: NeoGraphNode) => { const userId = get()._userId; if (!userId || !get()._accessToken) { logger.warn('navigation', '⚠️ navigateToNeoNode called without auth'); return; } try { set({ isLoading: true, error: null }); const existing = await pgFetch('GET', 'whiteboard_rooms', { query: `user_id=eq.${userId}&neo4j_node_id=eq.${neoNode.neo4j_node_id}`, }); let room: WhiteboardRoom; if (existing && existing.length > 0) { room = existing[0]; } else { const storagePath = `${userId}/nodes/${neoNode.neo4j_node_id}.json`; const created = await pgFetch('POST', 'whiteboard_rooms', { body: { user_id: userId, name: neoNode.label, context_type: neoNode.node_type.toLowerCase(), is_default: false, storage_path: storagePath, neo4j_node_id: neoNode.neo4j_node_id, neo4j_db_name: neoNode.neo4j_db_name, node_type: neoNode.node_type, }, prefer: 'return=representation', single: true, }); if (!created) throw new Error('Failed to create whiteboard room for node'); room = created; } const node: NavigationNode = { id: room.id, node_storage_path: room.storage_path || `${userId}/nodes/${neoNode.neo4j_node_id}.json`, label: room.name, type: neoNode.node_type, }; const currentState = get().context; const newHistory = addToHistory(currentState.history, node); set({ context: { ...currentState, node, history: newHistory }, isLoading: false, error: null }); logger.debug('navigation', '✅ Navigated to Neo4j node', { neoNode }); } catch (error) { logger.error('navigation', '❌ navigateToNeoNode failed', error); set({ error: error instanceof Error ? error.message : 'Navigation failed', isLoading: false }); } }, }; });