feat(phase-b): Supabase navigation store, snapshot service, auth wiring

navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table,
  setAuthInfo(token, userId) pattern, auto-creates default room per context on first use
snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…),
  setAccessToken() instance method, static methods take accessToken not dbName
AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required
singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken
navigation types: NeoGraphNode updated for Supabase-backed tree structure
transcriptionStore/Service: getSession() removed, accessToken via AuthContext
LLMConfigModal: auth context wiring fixes
GraphNavigator/GraphSidebar: updated nav components

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 01:25:15 +01:00
co-authored by Claude Sonnet 4.6
parent 3a65cf436b
commit b0c7758135
11 changed files with 1641 additions and 1892 deletions
+273 -374
View File
@@ -1,32 +1,45 @@
import { create } from 'zustand';
import { UserNeoDBService } from '../services/graph/userNeoDBService';
import { logger } from '../debugConfig';
import { isValidNodeType } from '../utils/tldraw/cc-base/cc-graph/cc-graph-types';
import {
NavigationStore,
import {
NavigationStore,
NavigationNode,
NeoGraphNode,
MainContext,
BaseContext,
NavigationContextState,
isProfileContext,
isInstituteContext,
getContextDatabase,
addToHistory,
navigateHistory,
getCurrentHistoryNode,
ExtendedContext,
UnifiedContextSwitch,
NodeContext
} 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
}
history: { nodes: [], currentIndex: -1 }
};
function getDefaultBaseForMain(main: MainContext): BaseContext {
@@ -38,402 +51,288 @@ function validateContextTransition(
updates: Partial<NavigationContextState>
): NavigationContextState {
const newState = { ...current, ...updates };
// Validate main context
if (updates.main) {
newState.base = getDefaultBaseForMain(updates.main);
}
// Validate base context
if (updates.base) {
// Ensure base context matches main context
const isValid = newState.main === 'profile'
const isValid = newState.main === 'profile'
? isProfileContext(updates.base)
: isInstituteContext(updates.base);
if (!isValid) {
newState.base = getDefaultBaseForMain(newState.main);
}
}
return newState;
}
export interface NavigationActions {
// Context Navigation
setMainContext: (context: MainContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
setBaseContext: (context: BaseContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
setExtendedContext: (context: ExtendedContext, userDbName: string | null, workerDbName: string | null) => Promise<void>;
// Node Navigation
navigate: (nodeId: string, dbName: string) => Promise<void>;
navigateToNode: (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => Promise<void>;
// History Navigation
goBack: () => void;
goForward: () => void;
// Utility Methods
refreshNavigationState: (userDbName: string | null, workerDbName: string | null) => Promise<void>;
}
export const useNavigationStore = create<NavigationStoreWithAuth>((set, get) => {
const pgFetch = async <T = unknown>(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
table: string,
options: { body?: object; query?: string; prefer?: string; single?: boolean } = {}
): Promise<T | null> => {
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<string, string> = {
'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<T>;
};
export interface NavigationState {
context: {
main: NodeContext;
base: NodeContext;
extended?: string;
node: NavigationNode;
history: {
nodes: NavigationNode[];
currentIndex: number;
const getOrCreateDefaultRoom = async (contextType: string): Promise<NavigationNode> => {
const userId = get()._userId;
if (!userId) throw new Error('getOrCreateDefaultRoom: no user ID');
const rooms = await pgFetch<WhiteboardRoom[]>('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<WhiteboardRoom>('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',
};
};
// ... rest of the state interface ...
}
export const useNavigationStore = create<NavigationStore>((set, get) => ({
context: initialState,
isLoading: false,
error: null,
return {
_accessToken: null,
_userId: null,
setAuthInfo: (token: string | null, userId: string | null) => {
set({ _accessToken: token, _userId: userId });
},
switchContext: async (contextSwitch: UnifiedContextSwitch, userDbName: string | null, workerDbName: string | null) => {
try {
// Check if we have the necessary database connections
if (contextSwitch.main === 'profile' && !userDbName) {
logger.error('navigation-context', '❌ User database connection not initialized');
set({
error: 'User database connection not initialized',
isLoading: false
});
return;
}
if (contextSwitch.main === 'institute' && !workerDbName) {
logger.error('navigation-context', '❌ Worker database connection not initialized');
set({
error: 'Worker database connection not initialized',
isLoading: false
});
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 };
logger.debug('navigation-context', '🔄 Starting context switch', {
from: {
main: get().context.main,
base: get().context.base,
extended: contextSwitch.extended,
nodeId: get().context.node?.id
},
to: {
main: contextSwitch.main,
base: contextSwitch.base,
extended: contextSwitch.extended
},
skipBaseContextLoad: contextSwitch.skipBaseContextLoad
});
set({ isLoading: true, error: null });
const currentState = get().context;
// Clear node state immediately
const clearedState: NavigationContextState = {
...currentState,
node: null
};
set({
context: clearedState,
isLoading: true
});
let newState: NavigationContextState = {
...currentState,
node: null
};
// Update main context if provided
if (contextSwitch.main) {
newState = validateContextTransition(newState, { main: contextSwitch.main });
if (!contextSwitch.skipBaseContextLoad) {
newState.base = getDefaultBaseForMain(contextSwitch.main);
if (contextSwitch.main) {
newState = validateContextTransition(newState, { main: contextSwitch.main });
if (!contextSwitch.skipBaseContextLoad) {
newState.base = getDefaultBaseForMain(contextSwitch.main);
}
}
logger.debug('navigation-state', '✅ Main context updated', {
previous: currentState.main,
new: newState.main,
defaultBase: newState.base
});
}
// Update base context if provided
if (contextSwitch.base) {
newState = validateContextTransition(newState, { base: contextSwitch.base });
logger.debug('navigation-state', '✅ Base context updated', {
previous: currentState.base,
new: newState.base
});
}
logger.debug('navigation-state', '✅ Context validation complete', {
validatedState: newState,
originalState: currentState
});
// Determine which context to use for the node
const targetContext = contextSwitch.base ||
contextSwitch.extended ||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) :
newState.base);
// Get database name
const dbName = getContextDatabase(newState, userDbName, workerDbName);
logger.debug('context-switch', '🔍 Fetching default node for context', {
targetContext,
dbName,
currentState: newState
});
// Get default node for the final context
const defaultNode = await UserNeoDBService.getDefaultNode(targetContext, dbName);
if (!defaultNode) {
const errorMsg = `No default node found for context: ${targetContext}`;
logger.error('context-switch', '❌ Default node fetch failed', { targetContext });
set({
error: errorMsg,
isLoading: false
});
return;
}
logger.debug('context-switch', '✨ Default node fetched', {
nodeId: defaultNode.id,
node_storage_path: defaultNode.node_storage_path,
type: defaultNode.type
});
// Update history and state
const newHistory = addToHistory(currentState.history, defaultNode);
logger.debug('history-management', '📚 History updated', {
previousState: currentState.history,
newState: newHistory,
addedNode: defaultNode
});
set({
context: {
...newState,
node: defaultNode,
history: newHistory
},
isLoading: false,
error: null
});
logger.debug('navigation-context', '✅ Context switch completed', {
finalState: {
main: newState.main,
base: newState.base,
nodeId: defaultNode.id
if (contextSwitch.base) {
newState = validateContextTransition(newState, { base: contextSwitch.base });
}
});
} catch (error) {
logger.error('navigation-context', '❌ Failed to switch context:', 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);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
}
});
}
},
const targetContext = contextSwitch.base ||
contextSwitch.extended ||
(contextSwitch.main ? getDefaultBaseForMain(contextSwitch.main) : newState.base);
goForward: () => {
const currentState = get().context;
if (currentState.history.currentIndex < currentState.history.nodes.length - 1) {
const newHistory = navigateHistory(currentState.history, currentState.history.currentIndex + 1);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
}
});
}
},
const defaultNode = await getOrCreateDefaultRoom(targetContext);
const newHistory = addToHistory(currentState.history, defaultNode);
setMainContext: async (main: MainContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ main }, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to set main context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to set main context',
isLoading: false
});
}
},
setBaseContext: async (base: BaseContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ base }, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to set base context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to set base context',
isLoading: false
});
}
},
setExtendedContext: async (extended: ExtendedContext, userDbName: string | null, workerDbName: string | null) => {
try {
// Use switchContext instead of direct implementation
await get().switchContext({ extended }, userDbName, workerDbName);
} catch (error) {
logger.error('navigation', '❌ Failed to set extended context:', error);
set({
error: error instanceof Error ? error.message : 'Failed to set extended context',
isLoading: false
});
}
},
navigate: async (nodeId: string, dbName: string) => {
try {
set({ isLoading: true, error: null });
// Check if we already have this node in history
const currentState = get().context;
const existingNodeIndex = currentState.history.nodes.findIndex(n => n.id === nodeId);
// If node exists in history, just navigate to it
if (existingNodeIndex !== -1) {
logger.debug('navigation', '📍 Navigating to existing node in history', {
nodeId,
historyIndex: existingNodeIndex,
currentIndex: currentState.history.currentIndex
});
const newHistory = navigateHistory(currentState.history, existingNodeIndex);
const node = getCurrentHistoryNode(newHistory);
set({
context: {
...currentState,
node,
history: newHistory
},
context: { ...newState, node: defaultNode, history: newHistory },
isLoading: false,
error: null
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<WhiteboardRoom[]>('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<WhiteboardRoom[]>('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;
}
// Fetch new node data
const nodeData = await UserNeoDBService.fetchNodeData(nodeId, dbName);
if (!nodeData) {
throw new Error(`Node not found: ${nodeId}`);
}
const node: NavigationNode = {
id: nodeId,
node_storage_path: nodeData.node_data.node_storage_path || '',
label: nodeData.node_data.title || nodeData.node_data.user_name || nodeId,
type: nodeData.node_type
};
logger.debug('navigation', '📍 Adding new node to history', {
nodeId: node.id,
type: node.type,
node_storage_path: node.node_storage_path
});
// Add to history and update state
const newHistory = addToHistory(currentState.history, node);
set({
context: {
...currentState,
node,
history: newHistory
},
isLoading: false,
error: null
});
} catch (error) {
logger.error('navigation', '❌ Failed to navigate:', error);
set({
error: error instanceof Error ? error.message : 'Failed to navigate',
isLoading: false
});
}
},
navigateToNode: async (node: NavigationNode, userDbName: string | null, workerDbName: string | null) => {
try {
set({ isLoading: true, error: null });
if (!isValidNodeType(node.type)) {
throw new Error(`Invalid node type: ${node.type}`);
}
const dbName = getContextDatabase(get().context, userDbName, workerDbName);
await get().navigate(node.id, dbName);
} catch (error) {
logger.error('navigation', '❌ Failed to navigate to node:', error);
set({
error: error instanceof Error ? error.message : 'Failed to navigate to node',
isLoading: false
});
}
},
refreshNavigationState: async (userDbName: string | null, workerDbName: string | null) => {
try {
set({ isLoading: true, error: null });
const currentState = get().context;
if (currentState.node) {
const dbName = getContextDatabase(currentState, userDbName, workerDbName);
const nodeData = await UserNeoDBService.fetchNodeData(currentState.node.id, dbName);
if (nodeData) {
const node: NavigationNode = {
id: currentState.node.id,
node_storage_path: nodeData.node_data.node_storage_path || '',
label: nodeData.node_data.title || nodeData.node_data.user_name || currentState.node.id,
type: nodeData.node_type
};
set({
context: {
...currentState,
node
}
try {
set({ isLoading: true, error: null });
const existing = await pgFetch<WhiteboardRoom[]>('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<WhiteboardRoom>('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 });
}
set({ isLoading: false });
} catch (error) {
logger.error('navigation', '❌ Failed to refresh navigation state:', error);
set({
error: error instanceof Error ? error.message : 'Failed to refresh navigation state',
isLoading: false
});
}
}
}));
},
};
});
File diff suppressed because it is too large Load Diff