diff --git a/README.md b/README.md index 94f7e93..cb4725f 100644 --- a/README.md +++ b/README.md @@ -97,16 +97,49 @@ When USE_LIVE_AI=false, the system falls back to a mock provider for safe local --- -🧪 Development +## 🧪 Development +```bash # Install dependencies pnpm install +# Setup database +pnpm db:setup + # Run web app in dev mode pnpm dev # Build all packages pnpm build +``` + +### Database Mode Toggle + +The application supports switching between in-memory and API/database data sources via environment variables: + +```bash +# Memory mode (default) - uses seed data +VITE_USE_DB=false + +# API/Database mode - loads data from database via API +VITE_USE_DB=true +``` + +**Testing Different Modes:** + +1. **Memory Mode (default)**: + - Set `VITE_USE_DB=false` in `.env.local` + - Sidebar tree loads from `apps/web/src/core/seed.ts` + - No database required + - Fast development iteration + +2. **API/Database Mode**: + - Set `VITE_USE_DB=true` in `.env.local` + - Requires database setup: `pnpm db:setup` + - Sidebar tree loads from `/api/org/*` endpoints + - Data persisted in SQLite database + +The toggle is handled in `useRepo.tsx` and maintains the same UI/UX regardless of data source. --- diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..ee47475 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,12 @@ +# API/Database Mode Toggle +# Set to true to load sidebar tree data from API/DB instead of in-memory seed data +# NOTE: import.meta.env is baked at build time - toggling VITE_USE_DB requires dev server restart +VITE_USE_DB=false + +# Mock Authentication (Development only) +# Set to true to enable fallback to mock auth when API is unavailable +# Only works when VITE_USE_DB=true. Disabled by default for security. +VITE_MOCK_AUTH=false + +# AI Provider Configuration +USE_LIVE_AI=false \ No newline at end of file diff --git a/apps/web/src/api/middleware/validation.ts b/apps/web/src/api/middleware/validation.ts index 1bbb312..29d1a68 100644 --- a/apps/web/src/api/middleware/validation.ts +++ b/apps/web/src/api/middleware/validation.ts @@ -94,7 +94,7 @@ export const ActionItemsSchema = z.object({ * Middleware to validate API responses with Zod schemas */ export function validateResponse(schema: z.ZodSchema) { - return (req: any, res: any, next: any) => { + return (_req: any, res: any, next: any) => { const originalJson = res.json; res.json = function(data: any) { @@ -111,7 +111,7 @@ export function validateResponse(schema: z.ZodSchema) { return originalJson.call(this, { error: 'RESPONSE_VALIDATION_ERROR', message: 'API response does not match expected schema', - details: error instanceof z.ZodError ? error.errors : [error.message], + details: error instanceof z.ZodError ? error.errors : [(error as Error).message], invalidData: data, }); } diff --git a/apps/web/src/api/plugin.ts b/apps/web/src/api/plugin.ts index 1b79366..1723d76 100644 --- a/apps/web/src/api/plugin.ts +++ b/apps/web/src/api/plugin.ts @@ -42,7 +42,7 @@ export function apiPlugin(): Plugin { app.use('/clients', clientRoutes); // Error handling - app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => { + app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error('API Error:', err); // Generate trace ID for debugging diff --git a/apps/web/src/api/services/simple-auth.cjs b/apps/web/src/api/services/simple-auth.cjs index b7c3fc8..bfb2212 100644 --- a/apps/web/src/api/services/simple-auth.cjs +++ b/apps/web/src/api/services/simple-auth.cjs @@ -4,7 +4,7 @@ const argon2 = require('argon2'); const jwt = require('jsonwebtoken'); const path = require('path'); -const dbPath = process.env.DATABASE_URL?.replace('file:', '') || path.join(__dirname, '../../packages/storage/dev.db'); +const dbPath = process.env.DATABASE_URL?.replace('file:', '') || path.join(__dirname, '../../../packages/storage/dev.db'); const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-in-production'; const JWT_ACCESS_EXPIRES = '15m'; const JWT_REFRESH_EXPIRES = '30d'; diff --git a/apps/web/src/api/tests/auth-refresh.test.ts b/apps/web/src/api/tests/auth-refresh.test.ts index bc94a63..ea7411d 100644 --- a/apps/web/src/api/tests/auth-refresh.test.ts +++ b/apps/web/src/api/tests/auth-refresh.test.ts @@ -300,8 +300,8 @@ describe('Authentication and Refresh Token Management', () => { describe('Rate Limiting', () => { it('should enforce rate limiting on login attempts', async () => { - const clientIp = '127.0.0.1'; - let rateLimitHit = false; + const _clientIp = '127.0.0.1'; + let _rateLimitHit = false; // Make multiple failed login attempts for (let i = 0; i < 10; i++) { diff --git a/apps/web/src/api/tests/cross-org-security.test.ts b/apps/web/src/api/tests/cross-org-security.test.ts index e4f293a..0b92702 100644 --- a/apps/web/src/api/tests/cross-org-security.test.ts +++ b/apps/web/src/api/tests/cross-org-security.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import request from 'supertest'; import express from 'express'; import { authRoutes } from '../routes/auth'; @@ -16,7 +16,7 @@ describe('Cross-Organization Security (IDOR Prevention)', () => { let demoUserToken: string; let viewerUserToken: string; let demoOrgClientId: string; - let viewerOrgId: string; + let _viewerOrgId: string; beforeAll(async () => { // Set up database and seed data diff --git a/apps/web/src/api/tests/zod-validation.test.ts b/apps/web/src/api/tests/zod-validation.test.ts index 444cd66..dcf73b4 100644 --- a/apps/web/src/api/tests/zod-validation.test.ts +++ b/apps/web/src/api/tests/zod-validation.test.ts @@ -5,8 +5,7 @@ import { ClientsOverviewSchema, ClientSummarySchema, ClientCallsSchema, - ActionItemsSchema, - validateResponse + ActionItemsSchema } from '../middleware/validation'; describe('Zod Response Validation', () => { diff --git a/apps/web/src/auth/AuthService.ts b/apps/web/src/auth/AuthService.ts index 5026649..055b939 100644 --- a/apps/web/src/auth/AuthService.ts +++ b/apps/web/src/auth/AuthService.ts @@ -2,10 +2,39 @@ import type { AuthSession, LoginCredentials, AuthError, - AuthResponse + AuthResponse, + User, + Organization, + Membership } from './types'; import { getAuthItem, setAuthItem, removeAuthItem, clearAuthData } from '../utils/storage'; +// Mock data for fallback auth +const MOCK_USER: User = { + id: 'user-1', + email: 'demo@mudul.com', + name: 'Demo User', + avatarUrl: undefined, + createdAt: '2024-01-01T00:00:00Z', + lastLoginAt: '2024-01-01T00:00:00Z' +}; + +const MOCK_ORGANIZATION: Organization = { + id: 'acme', + name: 'Acme Sales Org', + planTier: 'pro', + createdAt: '2024-01-01T00:00:00Z' +}; + +const MOCK_MEMBERSHIPS: Membership[] = [ + { + userId: 'user-1', + orgId: 'acme', + role: 'owner', + createdAt: '2024-01-01T00:00:00Z' + } +]; + // HTTP client for API calls class ApiClient { private baseUrl = ''; // Same origin @@ -96,6 +125,21 @@ class AuthService { * Authenticate user with email/password */ async login(credentials: LoginCredentials): Promise { + // Check if we should use API mode + const useDb = import.meta.env.VITE_USE_DB === "true"; + const useMockAuth = import.meta.env.VITE_MOCK_AUTH === "true"; + + if (useDb) { + return this.loginWithApi(credentials, useMockAuth); + } else { + return this.loginWithMock(credentials); + } + } + + /** + * API-based login + */ + private async loginWithApi(credentials: LoginCredentials, useMockAuth: boolean = false): Promise { await this.simulateDelay(); try { @@ -132,6 +176,12 @@ class AuthService { } catch (error: any) { console.error('Login failed:', error); + // Only fallback to mock auth if explicitly enabled + if (useMockAuth) { + console.warn('API login failed, falling back to mock auth (dev mode)'); + return this.loginWithMock(credentials); + } + if (error.message === 'INVALID_CREDENTIALS') { throw this.createError('invalid_credentials', 'Invalid email or password'); } @@ -144,8 +194,60 @@ class AuthService { throw this.createError('rate_limit', 'Too many login attempts. Please try again later.'); } - throw this.createError('server_error', 'Login failed due to server error'); + // For network/API errors in DB mode without mock auth, show clear error + throw this.createError('api_unavailable', 'Unable to connect to authentication service. Please check your connection or contact support.'); + } + } + + /** + * Mock-based login (fallback for memory mode) + */ + private async loginWithMock(credentials: LoginCredentials): Promise { + await this.simulateDelay(); + + // Validate demo credentials + if (credentials.email !== 'demo@mudul.com' || credentials.password !== 'password') { + throw this.createError('invalid_credentials', 'Invalid email or password'); } + + // Generate mock session + const user = MOCK_USER; + const tokens = this.generateTokens(user, credentials.rememberMe); + + const session: AuthSession = { + user, + organization: MOCK_ORGANIZATION, + membership: MOCK_MEMBERSHIPS[0], + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt + }; + + this.currentSession = session; + this.storeSession(session); + + return { + session, + isFirstLogin: false + }; + } + + /** + * Generate mock JWT tokens (in production, these would come from the server) + */ + private generateTokens(user: User, rememberMe: boolean = false): { accessToken: string; refreshToken?: string; expiresAt: string } { + const now = new Date(); + const expiresAt = new Date(now.getTime() + (15 * 60 * 1000)); // 15 minutes + + // Simple mock token (in production, use proper JWT) + const accessToken = `access_${user.id}_${now.getTime()}`; + const refreshToken = rememberMe ? `refresh_${user.id}_${now.getTime()}` : undefined; + + return { + accessToken, + refreshToken, + expiresAt: expiresAt.toISOString() + }; } /** diff --git a/apps/web/src/core/repo.api.test.ts b/apps/web/src/core/repo.api.test.ts new file mode 100644 index 0000000..65327a5 --- /dev/null +++ b/apps/web/src/core/repo.api.test.ts @@ -0,0 +1,53 @@ +// Simple test to verify repo.api.ts module loads correctly +import { describe, it, expect } from 'vitest'; + +describe('API Repo Module', () => { + it('should export required functions', async () => { + const apiRepo = await import('./repo.api'); + + // Check that all required functions are exported + expect(typeof apiRepo.getRoot).toBe('function'); + expect(typeof apiRepo.getNode).toBe('function'); + expect(typeof apiRepo.getChildren).toBe('function'); + expect(typeof apiRepo.getCallByNode).toBe('function'); + expect(typeof apiRepo.listCallsByClient).toBe('function'); + expect(typeof apiRepo.getDashboardId).toBe('function'); + expect(typeof apiRepo.getAllClients).toBe('function'); + expect(typeof apiRepo.getAllCalls).toBe('function'); + expect(typeof apiRepo.clearCache).toBe('function'); + }); + + it('should handle getDashboardId correctly', async () => { + const { getDashboardId } = await import('./repo.api'); + + // Test static dashboard ID logic + expect(getDashboardId('root')).toBe('org-dashboard'); + expect(getDashboardId('org:test')).toBe('org-dashboard'); + expect(getDashboardId('client:test')).toBe('client-dashboard'); + expect(getDashboardId('call:test')).toBe('sales-call-default'); + expect(getDashboardId('unknown')).toBe(null); + }); + + it('should warn about unimplemented mutation methods', async () => { + const { upsertCall, setDashboard, hasExistingAnalysis } = await import('./repo.api'); + + // Mock console.warn to check if warnings are issued + const originalWarn = console.warn; + const warnCalls: string[] = []; + console.warn = (message: string) => warnCalls.push(message); + + try { + const result = upsertCall('test', {}); + expect(result.updated).toBe(false); + expect(result.reason).toContain('API mutations not implemented'); + + setDashboard('test', {}); + expect(hasExistingAnalysis('test', 'hash')).toBe(false); + + expect(warnCalls.length).toBeGreaterThan(0); + expect(warnCalls.some(msg => msg.includes('not implemented in API repo'))).toBe(true); + } finally { + console.warn = originalWarn; + } + }); +}); \ No newline at end of file diff --git a/apps/web/src/core/repo.api.ts b/apps/web/src/core/repo.api.ts new file mode 100644 index 0000000..426e195 --- /dev/null +++ b/apps/web/src/core/repo.api.ts @@ -0,0 +1,356 @@ +// API-backed repo adapter for loading sidebar tree data from database +import { authService } from "../auth/AuthService"; +import type { NodeBase, SalesCallMinimal, NodeKind } from "./types"; + +// Stable node ID helpers to avoid future drift +export const nid = (kind: "org"|"client"|"call_session", id: string) => `${kind}:${id}`; +export const parseNid = (s: string) => { + const [kind, id] = s.split(":"); + return { kind, id }; +}; + +// HTTP client for API calls with authentication +class ApiClient { + private baseUrl = ''; // Same origin + + private async getAuthHeaders(): Promise> { + const session = authService.getCurrentSession(); + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (session?.accessToken) { + headers['Authorization'] = `Bearer ${session.accessToken}`; + } + + return headers; + } + + async get(endpoint: string): Promise { + const headers = await this.getAuthHeaders(); + + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'GET', + headers, + credentials: 'include', + }); + + if (!response.ok) { + // Special handling for 401 - trigger logout + if (response.status === 401) { + console.warn('API request unauthorized, triggering logout'); + authService.logout?.(); + throw new Error('Unauthorized'); + } + + // Error telemetry for debugging + const traceId = response.headers.get('x-trace-id') || 'unknown'; + console.warn(`API ${endpoint} failed:`, { + status: response.status, + path: endpoint, + traceId + }); + + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `API ${endpoint} failed: ${response.status}`); + } + + return response.json(); + } +} + +const api = new ApiClient(); + +// Cache for reducing API calls during tree expansion +// Note: Using TTL cache for Phase 1. This will cause stale data after updates. +// Future implementation should use React Query for proper invalidation. +// TODO: Include orgId in cache key for multi-org safety when implemented +const cache = new Map(); +const CACHE_TTL = 30000; // 30 seconds + +function getCached(key: string): T | null { + const cached = cache.get(key); + if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) { + return cached.data; + } + cache.delete(key); + return null; +} + +function setCache(key: string, data: any): void { + cache.set(key, { data, timestamp: Date.now() }); +} + +export async function getRoot(): Promise { + try { + const cached = getCached('root'); + if (cached) return cached; + + const { org } = await api.get('/api/org/summary'); + + const rootNode: NodeBase = { + id: nid("org", org.id), + orgId: org.id, + parentId: null, + kind: "group" as NodeKind, + name: org.name, + slug: org.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + dashboardId: "org-dashboard", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + setCache('root', rootNode); + return rootNode; + } catch (error) { + console.error('Failed to load org summary:', error); + return null; + } +} + +export async function getNode(id: string): Promise { + try { + const cached = getCached(`node:${id}`); + if (cached) return cached; + + if (id.startsWith("org:")) { + return getRoot(); + } + + if (id.startsWith("client:")) { + const { id: clientId } = parseNid(id); + const { clients } = await api.get('/api/org/clients-overview'); + const client = clients.find((c: any) => c.id === clientId); + + if (!client) return null; + + const node: NodeBase = { + id: nid("client", client.id), + orgId: client.orgId || "unknown", // Assuming org context from auth + parentId: "root", // All clients are under root for now + kind: "lead" as NodeKind, + name: client.name, + slug: client.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + dashboardId: "client-dashboard", + createdAt: client.createdAt, + updatedAt: new Date().toISOString() + }; + + setCache(`node:${id}`, node); + return node; + } + + if (id.startsWith("call:")) { + const { id: callId } = parseNid(id); + // Find which client this call belongs to by checking all clients + const { clients } = await api.get('/api/org/clients-overview'); + + for (const client of clients) { + const { calls } = await api.get(`/api/clients/${client.id}/calls`); + const call = calls.find((c: any) => c.id === callId); + + if (call) { + const node: NodeBase = { + id: nid("call_session", call.id), + orgId: client.orgId || "unknown", + parentId: nid("client", client.id), + kind: "call_session" as NodeKind, + name: call.title || "Call", + slug: (call.title || "call").toLowerCase().replace(/[^a-z0-9]+/g, '-'), + dashboardId: "sales-call-default", + dataRef: { type: "session", id: call.id }, + createdAt: call.createdAt, + updatedAt: new Date().toISOString() + }; + + setCache(`node:${id}`, node); + return node; + } + } + } + + return null; + } catch (error) { + console.error('Failed to load node:', error); + return null; + } +} + +export async function getChildren(parentId: string): Promise { + try { + const cached = getCached(`children:${parentId}`); + if (cached) return cached; + + if (parentId === "root" || parentId.startsWith("org:")) { + // Load clients for org + const { clients } = await api.get('/api/org/clients-overview'); + const children = clients.map((client: any) => ({ + id: nid("client", client.id), + orgId: client.orgId || "unknown", + parentId: parentId, + kind: "lead" as NodeKind, + name: client.name, + slug: client.name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + dashboardId: "client-dashboard", + createdAt: client.createdAt, + updatedAt: new Date().toISOString() + })); + + setCache(`children:${parentId}`, children); + return children; + } + + if (parentId.startsWith("client:")) { + // Load calls for client + const { id: clientId } = parseNid(parentId); + const { calls } = await api.get(`/api/clients/${clientId}/calls`); + const children = calls.map((call: any) => ({ + id: nid("call_session", call.id), + orgId: "unknown", // Will be set by parent client context + parentId: parentId, + kind: "call_session" as NodeKind, + name: call.title || "Call", + slug: (call.title || "call").toLowerCase().replace(/[^a-z0-9]+/g, '-'), + dashboardId: "sales-call-default", + dataRef: { type: "session", id: call.id }, + createdAt: call.createdAt, + updatedAt: new Date().toISOString() + })); + + setCache(`children:${parentId}`, children); + return children; + } + + return []; + } catch (error) { + console.error('Failed to load children:', error); + return []; + } +} + +export async function getCallByNode(nodeId: string): Promise { + try { + if (!nodeId.startsWith("call:")) return null; + + const cached = getCached(`call:${nodeId}`); + if (cached) return cached; + + const { id: callId } = parseNid(nodeId); + // Find which client this call belongs to + const { clients } = await api.get('/api/org/clients-overview'); + + for (const client of clients) { + const { calls } = await api.get(`/api/clients/${client.id}/calls`); + const call = calls.find((c: any) => c.id === callId); + + if (call) { + // Transform API call data to SalesCallMinimal format + const callData: SalesCallMinimal = { + id: call.id, + transcript: call.transcript || "", + summary: call.summary || "", + sentiment: call.sentiment || { overall: "neutral", score: 0 }, + bookingLikelihood: call.bookingLikelihood || 0, + objections: call.objections || [], + actionItems: call.actionItems || [], + keyMoments: call.keyMoments || [], + entities: call.entities || { prospect: [], people: [], products: [] }, + complianceFlags: call.complianceFlags || [], + meta: { + createdAt: call.createdAt, + updatedAt: call.updatedAt || call.createdAt + } + }; + + setCache(`call:${nodeId}`, callData); + return callData; + } + } + + return null; + } catch (error) { + console.error('Failed to load call data:', error); + return null; + } +} + +export async function listCallsByClient(clientId: string): Promise { + try { + const actualClientId = clientId.startsWith("client:") ? parseNid(clientId).id : clientId; + return getChildren(nid("client", actualClientId)); + } catch (error) { + console.error('Failed to list calls by client:', error); + return []; + } +} + +export function getDashboardId(nodeId: string): string | null { + // For now, return static dashboard IDs based on node type + if (nodeId === "root" || nodeId.startsWith("org:")) { + return "org-dashboard"; + } + if (nodeId.startsWith("client:")) { + return "client-dashboard"; + } + if (nodeId.startsWith("call:")) { + return "sales-call-default"; + } + return null; +} + +export function getAllClients(): Promise { + return getChildren("root"); +} + +export async function getAllCalls(): Promise { + try { + const clients = await getAllClients(); + const allCalls: NodeBase[] = []; + + for (const client of clients) { + const calls = await getChildren(client.id); + allCalls.push(...calls); + } + + return allCalls; + } catch (error) { + console.error('Failed to get all calls:', error); + return []; + } +} + +// Clear cache function for manual refresh +export function clearCache(): void { + cache.clear(); +} + +// The mutation methods from the original repo.ts are not implemented in the API version +// as they would require different API endpoints for CREATE/UPDATE operations. +// These will be implemented in future phases. + +export function upsertCall(_nodeId: string, _patch: any): any { + console.warn('upsertCall not implemented in API repo - requires API endpoints for mutation'); + return { updated: false, isDuplicate: false, reason: 'API mutations not implemented' }; +} + +export function setDashboard(_nodeId: string, _template: any): void { + console.warn('setDashboard not implemented in API repo - requires API endpoints for mutation'); +} + +export function hasExistingAnalysis(_nodeId: string, _contentHash: string, _schemaVersion?: string): boolean { + console.warn('hasExistingAnalysis not implemented in API repo'); + return false; +} + +export function createCallNode(_params: { clientId: string; title: string }): string { + console.warn('createCallNode not implemented in API repo - requires API endpoints for mutation'); + return ''; +} + +export function deleteNode(_nodeId: string): void { + console.warn('deleteNode not implemented in API repo - requires API endpoints for mutation'); +} + +export function markNodeActive(_nodeId: string): void { + console.warn('markNodeActive not implemented in API repo - requires API endpoints for mutation'); +} \ No newline at end of file diff --git a/apps/web/src/hooks/useNode.ts b/apps/web/src/hooks/useNode.ts index 77d7428..6f0a495 100644 --- a/apps/web/src/hooks/useNode.ts +++ b/apps/web/src/hooks/useNode.ts @@ -1,7 +1,60 @@ +import { useState, useEffect } from "react"; import { useRepo } from "./useRepo"; import type { NodeBase } from "../core/types"; -export function useNode(id: string): NodeBase | null { +export function useNode(id: string): { data: NodeBase | null; loading: boolean; error: string | null } { const repo = useRepo(); - return repo.getNode(id); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) { + setData(null); + setError(null); + setLoading(false); + return; + } + + let isMounted = true; + + const loadNode = async () => { + setLoading(true); + setError(null); + + try { + const nodeResult = repo.getNode(id); + + if (repo.isAsync) { + // API repo - handle async + const resolvedNode = await nodeResult; + if (isMounted) { + setData(resolvedNode); + } + } else { + // Memory repo - handle sync + if (isMounted) { + setData(nodeResult as NodeBase | null); + } + } + } catch (err) { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to load node'); + setData(null); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + loadNode(); + + return () => { + isMounted = false; + }; + }, [id, repo]); + + return { data, loading, error }; } \ No newline at end of file diff --git a/apps/web/src/hooks/useRepo.tsx b/apps/web/src/hooks/useRepo.tsx index d0c5c17..ead8bf9 100644 --- a/apps/web/src/hooks/useRepo.tsx +++ b/apps/web/src/hooks/useRepo.tsx @@ -1,62 +1,103 @@ -import React, { createContext, useContext, useMemo } from "react"; -import * as repo from "../core/repo"; +import React, { createContext, useContext, useMemo, useState } from "react"; +import * as memRepo from "../core/repo"; // existing in-memory repo +import * as apiRepo from "../core/repo.api"; // new API-backed repo import { useOrg } from "../auth/OrgContext"; +// Check environment flag to determine which repo to use +const useDb = import.meta.env.VITE_USE_DB === "true"; + +// Mixed interface that handles both sync and async repo implementations interface RepoContextValue { - getRoot: typeof repo.getRoot; - getNode: typeof repo.getNode; - getChildren: typeof repo.getChildren; - getCallByNode: typeof repo.getCallByNode; - listCallsByClient: typeof repo.listCallsByClient; - getDashboardId: typeof repo.getDashboardId; - getAllClients: typeof repo.getAllClients; - getAllCalls: typeof repo.getAllCalls; + getRoot: () => ReturnType | ReturnType; + getNode: (id: string) => ReturnType | ReturnType; + getChildren: (parentId: string) => ReturnType | ReturnType; + getCallByNode: (nodeId: string) => ReturnType | ReturnType; + listCallsByClient: (clientId: string) => ReturnType | ReturnType; + getDashboardId: (nodeId: string) => ReturnType | ReturnType; + getAllClients: () => ReturnType | ReturnType; + getAllCalls: () => ReturnType | ReturnType; + refreshTree: () => void; // New method for manual refresh + isAsync: boolean; // Flag to indicate if repo functions are async } const RepoContext = createContext(null); export function RepoProvider({ children }: { children: React.ReactNode }) { const { currentOrg } = useOrg(); + const [refreshCounter, setRefreshCounter] = useState(0); // Create org-scoped repo functions const value: RepoContextValue = useMemo(() => { const orgId = currentOrg?.id; - return { - getRoot: () => { - const root = repo.getRoot(); - return root && root.orgId === orgId ? root : null; - }, - getNode: (id: string) => { - const node = repo.getNode(id); - return node && node.orgId === orgId ? node : null; - }, - getChildren: (parentId: string) => { - return repo.getChildren(parentId).filter(node => node.orgId === orgId); - }, - getCallByNode: (nodeId: string) => { - const node = repo.getNode(nodeId); - if (!node || node.orgId !== orgId) return null; - return repo.getCallByNode(nodeId); - }, - listCallsByClient: (clientId: string) => { - const client = repo.getNode(clientId); - if (!client || client.orgId !== orgId) return []; - return repo.listCallsByClient(clientId).filter(node => node.orgId === orgId); - }, - getDashboardId: (nodeId: string) => { - const node = repo.getNode(nodeId); - if (!node || node.orgId !== orgId) return null; - return repo.getDashboardId(nodeId); - }, - getAllClients: () => { - return repo.getAllClients().filter(node => node.orgId === orgId); - }, - getAllCalls: () => { - return repo.getAllCalls().filter(node => node.orgId === orgId); - } - }; - }, [currentOrg?.id]); + // Select the appropriate repo implementation + const repoImpl = useDb ? apiRepo : memRepo; + + // For memory repo, we need org filtering (existing behavior) + // For API repo, org filtering is handled by the API endpoints themselves + if (useDb) { + // API repo - no org filtering needed as API handles it + return { + getRoot: () => repoImpl.getRoot(), + getNode: (id: string) => repoImpl.getNode(id), + getChildren: (parentId: string) => repoImpl.getChildren(parentId), + getCallByNode: (nodeId: string) => repoImpl.getCallByNode(nodeId), + listCallsByClient: (clientId: string) => repoImpl.listCallsByClient(clientId), + getDashboardId: (nodeId: string) => repoImpl.getDashboardId(nodeId), + getAllClients: () => repoImpl.getAllClients(), + getAllCalls: () => repoImpl.getAllCalls(), + isAsync: true, + refreshTree: () => { + // Clear API cache and trigger re-render + if ('clearCache' in repoImpl) { + (repoImpl as any).clearCache(); + } + setRefreshCounter(c => c + 1); + } + }; + } else { + // Memory repo - keep existing org filtering logic + return { + getRoot: () => { + const root = (repoImpl as typeof memRepo).getRoot(); + return root && root.orgId === orgId ? root : null; + }, + getNode: (id: string) => { + const node = (repoImpl as typeof memRepo).getNode(id); + return node && node.orgId === orgId ? node : null; + }, + getChildren: (parentId: string) => { + return (repoImpl as typeof memRepo).getChildren(parentId).filter(node => node.orgId === orgId); + }, + getCallByNode: (nodeId: string) => { + const node = (repoImpl as typeof memRepo).getNode(nodeId); + if (!node || node.orgId !== orgId) return null; + return (repoImpl as typeof memRepo).getCallByNode(nodeId); + }, + listCallsByClient: (clientId: string) => { + const client = (repoImpl as typeof memRepo).getNode(clientId); + if (!client || client.orgId !== orgId) return []; + return (repoImpl as typeof memRepo).listCallsByClient(clientId).filter(node => node.orgId === orgId); + }, + getDashboardId: (nodeId: string) => { + const node = (repoImpl as typeof memRepo).getNode(nodeId); + if (!node || node.orgId !== orgId) return null; + return (repoImpl as typeof memRepo).getDashboardId(nodeId); + }, + getAllClients: () => { + return (repoImpl as typeof memRepo).getAllClients().filter(node => node.orgId === orgId); + }, + getAllCalls: () => { + return (repoImpl as typeof memRepo).getAllCalls().filter(node => node.orgId === orgId); + }, + isAsync: false, + refreshTree: () => { + // For memory repo, just trigger a re-render + setRefreshCounter(c => c + 1); + } + }; + } + }, [currentOrg?.id, refreshCounter]); // Include refreshCounter to trigger updates return {children}; } diff --git a/apps/web/src/hooks/useSalesCall.ts b/apps/web/src/hooks/useSalesCall.ts index c6eec25..0bec529 100644 --- a/apps/web/src/hooks/useSalesCall.ts +++ b/apps/web/src/hooks/useSalesCall.ts @@ -1,17 +1,63 @@ +import { useState, useEffect } from "react"; import { useRepo } from "./useRepo"; import type { SalesCallMinimal } from "../core/types"; export function useSalesCall(nodeId?: string): { data: SalesCallMinimal | null; error: string | null; loading: boolean } { const repo = useRepo(); - - if (!nodeId) { - return { data: null, error: null, loading: false }; - } - - const data = repo.getCallByNode(nodeId); - return { - data, - error: data ? null : "No analysis yet for this session.", - loading: false - }; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!nodeId) { + setData(null); + setError(null); + setLoading(false); + return; + } + + let isMounted = true; + + const loadCall = async () => { + setLoading(true); + setError(null); + + try { + const callResult = repo.getCallByNode(nodeId); + + if (repo.isAsync) { + // API repo - handle async + const resolvedCall = await callResult; + if (isMounted) { + setData(resolvedCall); + setError(resolvedCall ? null : "No analysis yet for this session."); + } + } else { + // Memory repo - handle sync + const callData = callResult as SalesCallMinimal | null; + if (isMounted) { + setData(callData); + setError(callData ? null : "No analysis yet for this session."); + } + } + } catch (err) { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to load call data'); + setData(null); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + loadCall(); + + return () => { + isMounted = false; + }; + }, [nodeId, repo]); + + return { data, error, loading }; } \ No newline at end of file diff --git a/apps/web/src/pages/CallsPage.tsx b/apps/web/src/pages/CallsPage.tsx index 01067a5..eb8b3d8 100644 --- a/apps/web/src/pages/CallsPage.tsx +++ b/apps/web/src/pages/CallsPage.tsx @@ -1,15 +1,73 @@ -import { Box, Typography, Paper, IconButton } from '@mui/material'; +import { useState, useEffect } from 'react'; +import { Box, Typography, Paper, IconButton, CircularProgress, Alert } from '@mui/material'; import { DataGrid, GridToolbar, type GridColDef } from '@mui/x-data-grid'; import { Dashboard as DashboardIcon } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; import { useRepo } from '../hooks/useRepo'; +import type { NodeBase } from '../core/types'; export function CallsPage() { const navigate = useNavigate(); const repo = useRepo(); - - // Get all calls from the repo - const allCalls = repo.getAllCalls(); + const [allCalls, setAllCalls] = useState([]); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + + const loadCallsAndData = async () => { + setLoading(true); + setError(null); + + try { + const callsResult = repo.getAllCalls(); + + const resolvedCalls = repo.isAsync ? await callsResult : callsResult as NodeBase[]; + + if (!isMounted) return; + setAllCalls(resolvedCalls); + + // Transform call nodes into table rows + const tableRows = await Promise.all( + resolvedCalls.map(async (call) => { + const clientResult = repo.getNode(call.parentId || ""); + const callDataResult = repo.getCallByNode(call.id); + + const client = repo.isAsync ? await clientResult : clientResult; + const callData = repo.isAsync ? await callDataResult : callDataResult; + + return { + id: call.id, + title: call.name, + clientName: client?.name || "Unknown", + date: new Date(call.createdAt), + sentiment: callData?.sentiment?.overall || "unknown" + }; + }) + ); + + if (isMounted) { + setRows(tableRows); + } + } catch (err) { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to load calls'); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + loadCallsAndData(); + + return () => { + isMounted = false; + }; + }, [repo]); const columns: GridColDef[] = [ { field: 'id', headerName: 'ID', width: 150 }, @@ -70,19 +128,26 @@ export function CallsPage() { }, ]; - // Transform call nodes into table rows - const rows = allCalls.map((call) => { - const client = repo.getNode(call.parentId || ""); - const callData = repo.getCallByNode(call.id); - - return { - id: call.id, - title: call.name, - clientName: client?.name || "Unknown", - date: new Date(call.createdAt), - sentiment: callData?.sentiment?.overall || "unknown" - }; - }); + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + Sales Calls + + + {error} + + + ); + } return ( diff --git a/apps/web/src/pages/NewCallPage.tsx b/apps/web/src/pages/NewCallPage.tsx index 7b5a341..2e2e22e 100644 --- a/apps/web/src/pages/NewCallPage.tsx +++ b/apps/web/src/pages/NewCallPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, @@ -9,11 +9,13 @@ import { Alert, Paper, Stack, + CircularProgress, } from '@mui/material'; import { ArrowBack, PlayArrow, Cancel, Refresh } from '@mui/icons-material'; import { useRepo } from '../hooks/useRepo'; import { useAnalyzeCall } from '../hooks/useAnalyzeCall'; import { createCallNode, deleteNode, markNodeActive } from '../core/repo'; +import type { NodeBase } from '../core/types'; export function NewCallPage() { const navigate = useNavigate(); @@ -28,12 +30,49 @@ export function NewCallPage() { }); const [transcript, setTranscript] = useState(''); + // Client loading state + const [clients, setClients] = useState([]); + const [clientsLoading, setClientsLoading] = useState(true); + const [clientsError, setClientsError] = useState(null); + + // Load clients on mount + useEffect(() => { + let isMounted = true; + + const loadClients = async () => { + setClientsLoading(true); + setClientsError(null); + + try { + const clientsResult = repo.getAllClients(); + const resolvedClients = repo.isAsync ? await clientsResult : clientsResult as NodeBase[]; + + if (isMounted) { + setClients(resolvedClients); + } + } catch (err) { + if (isMounted) { + setClientsError(err instanceof Error ? err.message : 'Failed to load clients'); + } + } finally { + if (isMounted) { + setClientsLoading(false); + } + } + }; + + loadClients(); + + return () => { + isMounted = false; + }; + }, [repo]); + // UI state const [errors, setErrors] = useState>({}); const [successMessage, setSuccessMessage] = useState(''); const [currentDraftNodeId, setCurrentDraftNodeId] = useState(null); - const clients = repo.getAllClients(); const isAnalyzing = analyzeCall.loading; const hasAnalysisData = !!analyzeCall.lastResponse; @@ -135,6 +174,12 @@ export function NewCallPage() { {successMessage} )} + {clientsError && ( + + Failed to load clients: {clientsError} + + )} + - {clients.map((client) => ( - - {client.name} + {clientsLoading ? ( + + + Loading clients... - ))} + ) : clients.length === 0 ? ( + No clients available + ) : ( + clients.map((client) => ( + + {client.name} + + )) + )} ([]); + const [callsLoading, setCallsLoading] = useState(false); + const [callsError, setCallsError] = useState(null); + + React.useEffect(() => { + const abortController = new AbortController(); + + const loadCalls = async () => { + try { + setCallsLoading(true); + setCallsError(null); + + if (abortController.signal.aborted) return; + + const callsResult = repo.getChildren(client.id); + const resolvedCalls = repo.isAsync ? await callsResult : callsResult; + + if (abortController.signal.aborted) return; + setCalls(resolvedCalls || []); + } catch (error) { + if (!abortController.signal.aborted) { + console.error('Failed to load calls for client:', client.id, error); + setCallsError('Failed to load calls'); + setCalls([]); + } + } finally { + if (!abortController.signal.aborted) { + setCallsLoading(false); + } + } + }; + + loadCalls(); + + return () => { + abortController.abort(); + }; + }, [client.id, repo]); + + return ( + + + {client.name} + {callsLoading && ( + + (loading...) + + )} + {callsError && ( + + (error) + + )} + + } + > + {callsError ? ( + + + {callsError} + + + ) : calls.length === 0 && !callsLoading ? ( + + + No calls yet. + + + Start a call to begin tracking. + + + ) : ( + calls.map((call) => ( + + + + + {call.name} + + + + } + /> + )) + )} + + ); +} + export function AppShell() { const [mobileOpen, setMobileOpen] = useState(false); const [userMenuAnchor, setUserMenuAnchor] = useState(null); const [orgMenuAnchor, setOrgMenuAnchor] = useState(null); const [expandedItems, setExpandedItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [clients, setClients] = useState([]); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const navigate = useNavigate(); @@ -52,9 +158,56 @@ export function AppShell() { const { user, logout, membership } = useAuth(); const { currentOrg, availableOrgs, switchOrg } = useOrg(); - // Get the tree data from repo - const root = repo.getRoot(); - const clients = useMemo(() => root ? repo.getChildren(root.id) : [], [root?.id, repo]); + // Load tree data - handling both sync and async repos + React.useEffect(() => { + const abortController = new AbortController(); + + const loadTreeData = async () => { + try { + setLoading(true); + setError(null); + + // Check if component is still mounted + if (abortController.signal.aborted) return; + + const rootResult = repo.getRoot(); + const resolvedRoot = repo.isAsync ? await rootResult : rootResult; + + // Check again after async operation + if (abortController.signal.aborted) return; + + if (resolvedRoot) { + const clientsResult = repo.getChildren(resolvedRoot.id); + const resolvedClients = repo.isAsync ? await clientsResult : clientsResult; + + // Final check before setting state + if (abortController.signal.aborted) return; + setClients(resolvedClients || []); + } else { + if (!abortController.signal.aborted) { + setClients([]); + } + } + } catch (err) { + if (!abortController.signal.aborted) { + console.error('Failed to load tree data:', err); + setError('Failed to load data'); + setClients([]); + } + } finally { + if (!abortController.signal.aborted) { + setLoading(false); + } + } + }; + + loadTreeData(); + + // Cleanup function to abort on unmount + return () => { + abortController.abort(); + }; + }, [repo, currentOrg?.id]); // Re-load when repo or org changes // Initialize expanded items when clients change React.useEffect(() => { @@ -168,43 +321,40 @@ export function AppShell() { {/* Direct client list without org nesting */} - {clients.map((client) => { - const calls = repo.getChildren(client.id); - return ( - - - {client.name} - - } + {loading && ( + + + Loading projects... + + + )} + {error && ( + + + {error} + + + + )} + {!loading && !error && clients.length === 0 && ( + + + No clients yet. + + + Create a client to get started. + + + )} + {!loading && !error && clients.map((client) => ( + + ))} {/* New Call Button */} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 2e2d54e..95a4c1e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,7 +2,6 @@ import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import { mockAiPlugin } from './src/plugins/mockAi' import { liveAiPlugin } from './src/plugins/liveAi' -import { apiPlugin } from './src/api/plugin' // https://vite.dev/config/ export default defineConfig(({ mode }) => { @@ -11,13 +10,23 @@ export default defineConfig(({ mode }) => { .map(v => String(v).toLowerCase()) .includes('true'); + const plugins = [ + react(), + useLive ? liveAiPlugin() : mockAiPlugin() + ]; + + // TODO: Re-enable API plugin when SQLite import issue is resolved + // For now, we'll implement API-only mode without the embedded server + // const useDb = [env.VITE_USE_DB] + // .map(v => String(v).toLowerCase()) + // .includes('true'); + // if (useDb) { + // const { apiPlugin } = await import('./src/api/plugin'); + // plugins.push(apiPlugin()); + // } return { - plugins: [ - react(), - apiPlugin(), // Add API backend - useLive ? liveAiPlugin() : mockAiPlugin() - ], + plugins, ssr: { noExternal: [] }, build: { rollupOptions: {