diff --git a/apps/web/src/actions/analytics/__tests__/events.test.ts b/apps/web/src/actions/analytics/__tests__/events.test.ts new file mode 100644 index 0000000000..c9255c6b83 --- /dev/null +++ b/apps/web/src/actions/analytics/__tests__/events.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getTasks } from '../events'; + +// Mock the analytics module +vi.mock('@/lib/server', () => ({ + analytics: { + query: vi.fn(), + }, +})); + +// Mock the auth module +vi.mock('@/actions/auth', () => ({ + authorizeAnalytics: vi.fn().mockResolvedValue({ + effectiveUserId: 'test-user-id', + }), +})); + +// Mock the db module +vi.mock('@roo-code-cloud/db/server', () => ({ + getUsersById: vi.fn().mockResolvedValue({ + 'test-user-id': { + id: 'test-user-id', + name: 'Test User', + email: 'test@example.com', + }, + }), +})); + +describe('getTasks with filters', () => { + let mockQuery: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + const { analytics } = await import('@/lib/server'); + mockQuery = vi.mocked(analytics.query); + mockQuery.mockResolvedValue({ + json: () => + Promise.resolve([ + { + taskId: 'task-1', + userId: 'test-user-id', + provider: 'openai', + model: 'gpt-4', + mode: 'code', + completed: true, + tokens: 1000, + cost: 0.02, + timestamp: 1640995200, + title: 'Test Task', + repositoryUrl: 'https://github.com/test/repo', + repositoryName: 'test-repo', + defaultBranch: 'main', + }, + ]), + }); + }); + + it('should include userId filter in query parameters', async () => { + await getTasks({ + orgId: 'test-org', + filterType: 'userId', + filterValue: 'filter-user-id', + limit: 20, + }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query_params: expect.objectContaining({ + filterUserId: 'filter-user-id', + }), + }), + ); + + const queryCall = mockQuery.mock.calls[0]?.[0]; + expect(queryCall?.query).toContain('AND e.userId = {filterUserId: String}'); + }); + + it('should include model filter in query parameters', async () => { + await getTasks({ + orgId: 'test-org', + filterType: 'model', + filterValue: 'gpt-4', + limit: 20, + }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query_params: expect.objectContaining({ + filterModel: 'gpt-4', + }), + }), + ); + + const queryCall = mockQuery.mock.calls[0]?.[0]; + expect(queryCall?.query).toContain('AND e.modelId = {filterModel: String}'); + }); + + it('should include repository filter in query parameters', async () => { + await getTasks({ + orgId: 'test-org', + filterType: 'repositoryName', + filterValue: 'test-repo', + limit: 20, + }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query_params: expect.objectContaining({ + filterRepository: 'test-repo', + }), + }), + ); + + const queryCall = mockQuery.mock.calls[0]?.[0]; + expect(queryCall?.query).toContain( + 'AND e.repositoryName = {filterRepository: String}', + ); + }); + + it('should not include filter clauses when no filter is provided', async () => { + await getTasks({ + orgId: 'test-org', + limit: 20, + }); + + const queryCall = mockQuery.mock.calls[0]?.[0]; + expect(queryCall?.query).not.toContain('filterUserId'); + expect(queryCall?.query).not.toContain('filterModel'); + expect(queryCall?.query).not.toContain('filterRepository'); + }); + + it('should return properly formatted tasks with user data', async () => { + const result = await getTasks({ + orgId: 'test-org', + filterType: 'model', + filterValue: 'gpt-4', + limit: 20, + }); + + expect(result).toEqual({ + tasks: [ + expect.objectContaining({ + taskId: 'task-1', + userId: 'test-user-id', + model: 'gpt-4', + user: expect.objectContaining({ + id: 'test-user-id', + name: 'Test User', + email: 'test@example.com', + }), + }), + ], + hasMore: false, + nextCursor: undefined, + }); + }); +}); diff --git a/apps/web/src/actions/analytics/events.ts b/apps/web/src/actions/analytics/events.ts index 3c32079bf2..98e77cc9eb 100644 --- a/apps/web/src/actions/analytics/events.ts +++ b/apps/web/src/actions/analytics/events.ts @@ -409,6 +409,8 @@ export const getTasks = async ({ skipAuth = false, limit = 20, cursor, + filterType, + filterValue, }: { orgId?: string | null; userId?: string | null; @@ -417,6 +419,8 @@ export const getTasks = async ({ skipAuth?: boolean; limit?: number; cursor?: number; + filterType?: 'userId' | 'model' | 'repositoryName'; + filterValue?: string; }): Promise => { let effectiveUserId = userId; @@ -476,6 +480,28 @@ export const getTasks = async ({ queryParams.cursor = cursor; } + // Add filter parameters + if (filterType && filterValue) { + if (filterType === 'userId') { + queryParams.filterUserId = filterValue; + } else if (filterType === 'model') { + queryParams.filterModel = filterValue; + } else if (filterType === 'repositoryName') { + queryParams.filterRepository = filterValue; + } + } + + // Build filter conditions + const filterConditions = []; + if (filterType === 'userId' && filterValue) { + filterConditions.push('AND e.userId = {filterUserId: String}'); + } else if (filterType === 'model' && filterValue) { + filterConditions.push('AND e.modelId = {filterModel: String}'); + } else if (filterType === 'repositoryName' && filterValue) { + filterConditions.push('AND e.repositoryName = {filterRepository: String}'); + } + const filterClause = filterConditions.join(' '); + // TODO: Handle same-timestamp edge cases // Currently using only timestamp as cursor, but this can miss/duplicate tasks // if multiple tasks have the same timestamp at page boundaries. @@ -519,6 +545,7 @@ export const getTasks = async ({ AND e.modelId IS NOT NULL ${userFilter} ${taskFilter} + ${filterClause} GROUP BY 1, 2 ${havingFilter} ORDER BY timestamp DESC diff --git a/apps/web/src/app/(authenticated)/usage/Tasks.tsx b/apps/web/src/app/(authenticated)/usage/Tasks.tsx index 4a923f0003..50b31eab8b 100644 --- a/apps/web/src/app/(authenticated)/usage/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/usage/Tasks.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useAuth } from '@clerk/nextjs'; import { useQuery } from '@tanstack/react-query'; @@ -40,6 +40,8 @@ export const Tasks = ({ !orgId, pagination.currentCursor, pagination.pageSize, + filter?.type, + filter?.value, ], queryFn: () => getTasks({ @@ -47,6 +49,8 @@ export const Tasks = ({ userId: userRole === 'member' ? currentUserId : undefined, limit: pagination.pageSize, cursor: pagination.currentCursor, + filterType: filter?.type, + filterValue: filter?.value, }), enabled: true, // Run for both personal and organization context ...polling, @@ -59,26 +63,22 @@ export const Tasks = ({ } }, [data?.nextCursor, pagination]); - // Note: The pagination hook automatically handles total updates via the pagination controls + // Reset pagination when filter changes + const prevFilter = useRef(null); + useEffect(() => { + const currentFilter = filter; + const hasFilterChanged = + prevFilter.current?.type !== currentFilter?.type || + prevFilter.current?.value !== currentFilter?.value; - const tasks = useMemo(() => { - const allTasks = data?.tasks || []; - - if (!filter) { - return allTasks; + if (hasFilterChanged) { + pagination.reset(); + prevFilter.current = currentFilter; } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filter]); - return allTasks.filter((task) => { - if (filter.type === 'userId') { - return task.userId === filter.value; - } else if (filter.type === 'model') { - return task.model === filter.value; - } else if (filter.type === 'repositoryName') { - return task.repositoryName === filter.value; - } - return false; - }); - }, [filter, data?.tasks]); + const tasks = data?.tasks || []; if (isPending) { return (