diff --git a/apps/api/src/browserbase/browserbase-session.service.spec.ts b/apps/api/src/browserbase/browserbase-session.service.spec.ts index de2cc2059e..edf950016e 100644 --- a/apps/api/src/browserbase/browserbase-session.service.spec.ts +++ b/apps/api/src/browserbase/browserbase-session.service.spec.ts @@ -114,6 +114,22 @@ describe('BrowserbaseSessionService', () => { expect(createContext).toHaveBeenCalledTimes(3); }); + it('includes the underlying cause in the exhausted-retry message', async () => { + jest.useFakeTimers(); + const service = new BrowserbaseSessionService(); + const createContext = jest.fn().mockRejectedValue(prematureCloseError()); + jest + .spyOn(service, 'getBrowserbase') + .mockReturnValue(mockBrowserbaseClient({ createContext })); + + const promise = service.createBrowserbaseContext().catch((error) => error); + await jest.advanceTimersByTimeAsync(1_000); + const error = await promise; + + expect(error).toBeInstanceOf(ServiceUnavailableException); + expect(error.message).toContain('Premature close'); + }); + it('preserves non-retryable Browserbase failures', async () => { const service = new BrowserbaseSessionService(); const browserbaseError = Object.assign( @@ -178,6 +194,25 @@ describe('BrowserbaseSessionService', () => { expect(debugSession).toHaveBeenCalledTimes(2); }); + it('resolves the session connect URL via the identity-encoded client', async () => { + jest.useFakeTimers(); + const service = new BrowserbaseSessionService(); + const retrieveSession = jest + .fn() + .mockRejectedValueOnce(prematureCloseError()) + .mockResolvedValueOnce({ connectUrl: 'wss://connect.browserbase.test/s1' }); + jest + .spyOn(service, 'getBrowserbase') + .mockReturnValue(mockBrowserbaseClient({ retrieveSession })); + + const promise = service.getSessionConnectUrl('session_1'); + await jest.advanceTimersByTimeAsync(250); + + await expect(promise).resolves.toBe('wss://connect.browserbase.test/s1'); + expect(retrieveSession).toHaveBeenCalledTimes(2); + expect(retrieveSession).toHaveBeenCalledWith('session_1'); + }); + it('retries transient Stagehand init failures', async () => { jest.useFakeTimers(); const service = new BrowserbaseSessionService(); @@ -188,6 +223,9 @@ describe('BrowserbaseSessionService', () => { const close = jest.fn().mockResolvedValue(undefined); const StagehandCtor = mockStagehandClass({ init, close }); jest.spyOn(service, 'loadStagehand').mockResolvedValue(StagehandCtor); + jest + .spyOn(service, 'getSessionConnectUrl') + .mockResolvedValue('wss://connect.browserbase.test/s1'); const promise = service.createStagehand('session_1'); await jest.advanceTimersByTimeAsync(250); @@ -207,6 +245,9 @@ describe('BrowserbaseSessionService', () => { jest .spyOn(service, 'loadStagehand') .mockResolvedValue(mockStagehandClass({ init, close })); + jest + .spyOn(service, 'getSessionConnectUrl') + .mockResolvedValue('wss://connect.browserbase.test/s1'); const promise = service.createStagehand('session_1'); const expectation = expect(promise).rejects.toBeInstanceOf( @@ -229,6 +270,9 @@ describe('BrowserbaseSessionService', () => { jest .spyOn(service, 'loadStagehand') .mockResolvedValue(mockStagehandClass({ init, close })); + jest + .spyOn(service, 'getSessionConnectUrl') + .mockResolvedValue('wss://connect.browserbase.test/s1'); await expect(service.createStagehand('session_1')).rejects.toBe( sessionNotFound, @@ -238,22 +282,24 @@ describe('BrowserbaseSessionService', () => { expect(close).toHaveBeenCalledTimes(1); }); - it('runs Stagehand with the hosted API disabled', async () => { + it('attaches Stagehand to the resolved CDP URL instead of resuming via Browserbase', async () => { const service = new BrowserbaseSessionService(); const init = jest.fn().mockResolvedValue(undefined); const close = jest.fn().mockResolvedValue(undefined); const StagehandCtor = mockStagehandClass({ init, close }); jest.spyOn(service, 'loadStagehand').mockResolvedValue(StagehandCtor); + jest + .spyOn(service, 'getSessionConnectUrl') + .mockResolvedValue('wss://connect.browserbase.test/s1'); await service.createStagehand('session_1'); - // disableAPI:true skips Stagehand's hosted POST /sessions/start (the source - // of "Unknown error: 400"); the session still resumes over CDP. + // env:'LOCAL' + cdpUrl attaches over CDP and avoids Stagehand's own + // bb.sessions.retrieve (the "Premature close" source). expect(StagehandCtor).toHaveBeenCalledWith( expect.objectContaining({ - env: 'BROWSERBASE', - browserbaseSessionID: 'session_1', - disableAPI: true, + env: 'LOCAL', + localBrowserLaunchOptions: { cdpUrl: 'wss://connect.browserbase.test/s1' }, }), ); }); diff --git a/apps/api/src/browserbase/browserbase-session.service.ts b/apps/api/src/browserbase/browserbase-session.service.ts index 24485d719c..76e62ca6ee 100644 --- a/apps/api/src/browserbase/browserbase-session.service.ts +++ b/apps/api/src/browserbase/browserbase-session.service.ts @@ -114,6 +114,17 @@ export class BrowserbaseSessionService { return session.contextId; } + async getSessionConnectUrl(sessionId: string): Promise { + const session = await this.withBrowserbaseRetry({ + operationName: 'session connect URL lookup', + operation: () => this.getBrowserbase().sessions.retrieve(sessionId), + }); + if (!session.connectUrl) { + throw new Error('Browserbase session is missing a connect URL.'); + } + return session.connectUrl; + } + private async withBrowserbaseRetry({ operation, operationName, @@ -143,7 +154,9 @@ export class BrowserbaseSessionService { attempt, error: getBrowserbaseErrorText(error), }); - throw browserbaseUnavailableException(); + // Surface the underlying cause in the message so an exhausted retry + // is diagnosable from the UI/response, not just the server logs. + throw browserbaseUnavailableException(getBrowserbaseErrorText(error)); } this.logger.warn(`Browserbase ${operationName} failed; retrying`, { @@ -167,27 +180,25 @@ export class BrowserbaseSessionService { async createStagehand(sessionId: string): Promise { const Stagehand = await this.loadStagehand(); - // We create and own the Browserbase session ourselves, and this feature only - // needs CDP navigation plus local inference. Stagehand's default hosted-API - // mode adds a POST /sessions/start round-trip that is unnecessary here and is - // the source of opaque "Unknown error: " failures. disableAPI:true - // skips it: the session still resumes over CDP and extract/act/agent run - // locally against ANTHROPIC_API_KEY. - // - // init() still performs a Browserbase API round-trip to resume the session, - // whose transient failures — e.g. "Premature close" — bypass the retry that - // wraps our direct SDK calls, so retry init too, closing any half-initialized - // instance between attempts to avoid leaking it. Stagehand strips upstream - // error bodies from its throws, so forward its error logs into our logger. + // Resolve the CDP connect URL ourselves with our identity-encoded client. + // Stagehand's BROWSERBASE mode would instead call bb.sessions.retrieve on its + // OWN Browserbase client, which lacks our accept-encoding:identity header and + // so fails deterministically with "Premature close" (response decompression + // mishandling) in our runtime — the same failure the identity header already + // fixes for our own calls. Attaching via env:'LOCAL' + cdpUrl makes Stagehand + // connect straight to the session over CDP without that call; extract/act/ + // agent then run locally against ANTHROPIC_API_KEY. + const cdpUrl = await this.getSessionConnectUrl(sessionId); + + // A transient CDP attach can still fail; retry init, closing any + // half-initialized instance between attempts to avoid leaking it. Stagehand + // strips upstream error bodies from its throws, so forward its error logs. return this.withBrowserbaseRetry({ operationName: 'stagehand initialization', operation: async () => { const stagehand = new Stagehand({ - env: 'BROWSERBASE', - apiKey: process.env.BROWSERBASE_API_KEY, - projectId: this.getProjectId(), - browserbaseSessionID: sessionId, - disableAPI: true, + env: 'LOCAL', + localBrowserLaunchOptions: { cdpUrl }, model: { modelName: STAGEHAND_MODEL, apiKey: process.env.ANTHROPIC_API_KEY, @@ -206,8 +217,6 @@ export class BrowserbaseSessionService { await stagehand.init(); return stagehand; } catch (error) { - // keepAlive:true means close() will not end the Browserbase session, - // so the next attempt can resume the same sessionId. await this.safeCloseStagehand(stagehand); throw error; } diff --git a/apps/api/src/browserbase/browserbase-upstream-error.spec.ts b/apps/api/src/browserbase/browserbase-upstream-error.spec.ts index 0cb3eaa288..fd29978b82 100644 --- a/apps/api/src/browserbase/browserbase-upstream-error.spec.ts +++ b/apps/api/src/browserbase/browserbase-upstream-error.spec.ts @@ -26,4 +26,12 @@ describe('browserbase upstream errors', () => { 'Browserbase is temporarily unavailable. Please retry in a moment.', ); }); + + it('appends the underlying cause when provided', () => { + const error = browserbaseUnavailableException('Premature close'); + + expect(error.message).toBe( + 'Browserbase is temporarily unavailable. Please retry in a moment. (Premature close)', + ); + }); }); diff --git a/apps/api/src/browserbase/browserbase-upstream-error.ts b/apps/api/src/browserbase/browserbase-upstream-error.ts index ed054baa8c..124d3fe018 100644 --- a/apps/api/src/browserbase/browserbase-upstream-error.ts +++ b/apps/api/src/browserbase/browserbase-upstream-error.ts @@ -65,7 +65,9 @@ export const isRetryableBrowserbaseUpstreamError = ( return RETRYABLE_MESSAGE_PARTS.some((part) => message.includes(part)); }; -export const browserbaseUnavailableException = () => +export const browserbaseUnavailableException = (detail?: string) => new ServiceUnavailableException( - 'Browserbase is temporarily unavailable. Please retry in a moment.', + detail + ? `Browserbase is temporarily unavailable. Please retry in a moment. (${detail})` + : 'Browserbase is temporarily unavailable. Please retry in a moment.', ); diff --git a/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.spec.ts b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.spec.ts new file mode 100644 index 0000000000..ef18d679d7 --- /dev/null +++ b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.spec.ts @@ -0,0 +1,97 @@ +import { db } from '@db'; +import { requestValidCredentials } from './ensure-valid-credentials'; +import { refreshExpiringTokensSchedule } from './refresh-expiring-tokens-schedule'; + +jest.mock('@db', () => ({ + db: { + integrationConnection: { findMany: jest.fn() }, + }, +})); + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + schedules: { + task: (config: unknown) => config, + }, +})); + +jest.mock('./ensure-valid-credentials', () => ({ + requestValidCredentials: jest.fn(), +})); + +describe('refreshExpiringTokensSchedule', () => { + const nowMs = Date.parse('2026-04-24T00:00:00.000Z'); + const lookaheadMs = 24 * 60 * 60 * 1000; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(nowMs); + (requestValidCredentials as jest.Mock).mockResolvedValue({ success: true }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('refreshes only connections whose latest credential version expires soon', async () => { + const connectionWithOldVersionExpiringSoon = { + id: 'conn_old_soon', + providerSlug: 'example', + organizationId: 'org_1', + organization: { id: 'org_1', name: 'Org 1' }, + credentialVersions: [ + { expiresAt: new Date('2026-04-26T00:00:00.000Z') }, + { expiresAt: new Date('2026-04-24T12:00:00.000Z') }, + ], + }; + + const connectionWithLatestExpiringSoon = { + id: 'conn_latest_soon', + providerSlug: 'example', + organizationId: 'org_2', + organization: { id: 'org_2', name: 'Org 2' }, + credentialVersions: [{ expiresAt: new Date('2026-04-24T12:00:00.000Z') }], + }; + + (db.integrationConnection.findMany as jest.Mock).mockResolvedValue([ + connectionWithOldVersionExpiringSoon, + connectionWithLatestExpiringSoon, + ]); + + const result = await refreshExpiringTokensSchedule.run({ + timestamp: new Date(nowMs).toISOString(), + lastTimestamp: null, + } as any); + + expect(result.refreshed).toBe(1); + expect(requestValidCredentials).toHaveBeenCalledTimes(1); + expect(requestValidCredentials).toHaveBeenCalledWith({ + apiUrl: expect.any(String), + connectionId: 'conn_latest_soon', + organizationId: 'org_2', + forceRefresh: true, + }); + }); + + it('skips connections whose latest version is not expiring soon', async () => { + const connectionLatestValid = { + id: 'conn_latest_valid', + providerSlug: 'example', + organizationId: 'org_3', + organization: { id: 'org_3', name: 'Org 3' }, + credentialVersions: [{ expiresAt: new Date('2026-04-25T12:00:00.000Z') }], + }; + + (db.integrationConnection.findMany as jest.Mock).mockResolvedValue([ + connectionLatestValid, + ]); + + const result = await refreshExpiringTokensSchedule.run({ + timestamp: new Date(nowMs).toISOString(), + lastTimestamp: null, + } as any); + + expect(result.refreshed).toBe(0); + expect(requestValidCredentials).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts new file mode 100644 index 0000000000..1b5e41abd8 --- /dev/null +++ b/apps/api/src/trigger/integration-platform/refresh-expiring-tokens-schedule.ts @@ -0,0 +1,108 @@ +import { db } from '@db'; +import { logger, schedules } from '@trigger.dev/sdk'; +import { requestValidCredentials } from './ensure-valid-credentials'; + +// Refresh tokens expiring within the next 24 hours +const REFRESH_LOOKAHEAD_HOURS = 24; + +/** + * Daily scheduled task that proactively refreshes OAuth tokens before they + * expire. Prevents the "OAuth token expired. Please reconnect" error caused by + * tokens expiring between scheduled check runs. + * + * Runs 1 hour before the daily integration checks (05:00 UTC vs 06:00 UTC) so + * tokens are always fresh when checks execute. + */ +export const refreshExpiringTokensSchedule = schedules.task({ + id: 'refresh-expiring-tokens-schedule', + cron: '0 5 * * *', // Daily at 05:00 UTC — 1 hour before integration checks + maxDuration: 60 * 30, // 30 minutes + run: async (payload) => { + logger.info('Starting proactive OAuth token refresh', { + scheduledAt: payload.timestamp, + lastRun: payload.lastTimestamp, + }); + + const apiUrl = process.env.API_URL; + if (!apiUrl) { + logger.error('API_URL environment variable is not set — cannot refresh tokens'); + return { refreshed: 0, failed: 0, skipped: 0 }; + } + + const now = new Date(); + const lookaheadMs = REFRESH_LOOKAHEAD_HOURS * 60 * 60 * 1000; + const expiryThreshold = new Date(now.getTime() + lookaheadMs); + + // Find all active connections and check the expiry of the latest credential + // version. Older credential versions may exist, so a `some` predicate would + // incorrectly select connections where an older version is expiring while + // the latest version is still valid. + const activeConnections = await db.integrationConnection.findMany({ + where: { status: 'active' }, + include: { + organization: { select: { id: true, name: true } }, + credentialVersions: { + orderBy: { version: 'desc' }, + take: 1, + select: { expiresAt: true }, + }, + }, + }); + + const expiringConnections = activeConnections.filter((connection) => { + const expiresAt = connection.credentialVersions[0]?.expiresAt; + return ( + expiresAt !== undefined && + expiresAt !== null && + expiresAt <= expiryThreshold && + expiresAt > now + ); + }); + + logger.info(`Found ${expiringConnections.length} connections with tokens expiring within ${REFRESH_LOOKAHEAD_HOURS}h`); + + let refreshed = 0; + let failed = 0; + let skipped = 0; + + for (const connection of expiringConnections) { + const expiresAt = connection.credentialVersions[0]?.expiresAt; + const minutesUntilExpiry = expiresAt + ? Math.round((expiresAt.getTime() - Date.now()) / 60_000) + : null; + + logger.info(`Refreshing token for connection ${connection.id}`, { + organizationId: connection.organizationId, + organizationName: connection.organization?.name, + minutesUntilExpiry, + }); + + const result = await requestValidCredentials({ + apiUrl, + connectionId: connection.id, + organizationId: connection.organizationId, + forceRefresh: true, + }); + + if (result.success) { + refreshed++; + logger.info(`Successfully refreshed token for connection ${connection.id}`); + } else { + failed++; + logger.warn(`Failed to refresh token for connection ${connection.id}`, { + error: result.error, + status: result.status, + }); + } + } + + logger.info('Proactive OAuth token refresh complete', { + total: expiringConnections.length, + refreshed, + failed, + skipped, + }); + + return { refreshed, failed, skipped, total: expiringConnections.length }; + }, +}); diff --git a/apps/api/src/utils/file-type-validation.spec.ts b/apps/api/src/utils/file-type-validation.spec.ts index 6482cb2e0b..87395c93ad 100644 --- a/apps/api/src/utils/file-type-validation.spec.ts +++ b/apps/api/src/utils/file-type-validation.spec.ts @@ -18,6 +18,25 @@ describe('validateFileContent', () => { ).not.toThrow(); }); + it('should accept a PDF with a leading BOM/whitespace before %PDF', () => { + // Some exporters/vendors (e.g. GoodHire) prepend a UTF-8 BOM or whitespace; + // the %PDF header is still within the first 1024 bytes, so it must be accepted. + const pdfBuffer = Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), // UTF-8 BOM + Buffer.from('\n %PDF-1.7 rest of document'), + ]); + expect(() => + validateFileContent(pdfBuffer, 'application/pdf', 'report.pdf'), + ).not.toThrow(); + }); + + it('should reject a file declared as PDF with no %PDF header', () => { + const notPdf = Buffer.from('this is plainly not a pdf at all'); + expect(() => + validateFileContent(notPdf, 'application/pdf', 'fake.pdf'), + ).toThrow(); + }); + it('should accept a valid JPEG file', () => { const jpegBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); expect(() => diff --git a/apps/api/src/utils/file-type-validation.ts b/apps/api/src/utils/file-type-validation.ts index 82b63f2bb5..13a63d8e08 100644 --- a/apps/api/src/utils/file-type-validation.ts +++ b/apps/api/src/utils/file-type-validation.ts @@ -4,10 +4,16 @@ const MAGIC_BYTES: Record = { 'image/png': [Buffer.from([0x89, 0x50, 0x4e, 0x47])], 'image/jpeg': [Buffer.from([0xff, 0xd8, 0xff])], 'image/gif': [Buffer.from('GIF87a'), Buffer.from('GIF89a')], - 'application/pdf': [Buffer.from('%PDF')], 'application/zip': [Buffer.from([0x50, 0x4b, 0x03, 0x04])], }; +// PDFs are handled separately: the %PDF- header is allowed within the +// first 1024 bytes (ISO 32000 §7.5.2 / Adobe's reader behaviour), not necessarily +// at byte 0. Some exporters/vendors prepend a BOM or whitespace, so a strict +// offset-0 magic-byte check rejects otherwise-valid PDFs. +const PDF_HEADER = Buffer.from('%PDF'); +const PDF_HEADER_SEARCH_BYTES = 1024; + /** * RIFF-based formats need extra validation — RIFF is shared by WAV, AVI, WebP, etc. * WebP files are: RIFF (4 bytes) + file size (4 bytes) + WEBP (4 bytes at offset 8). @@ -55,6 +61,17 @@ export function validateFileContent( return; } + // PDFs: accept the %PDF header anywhere in the first 1024 bytes (not just at + // offset 0), so valid PDFs with a leading BOM/whitespace aren't rejected. + if (lowerMime === 'application/pdf') { + if (!fileBuffer.subarray(0, PDF_HEADER_SEARCH_BYTES).includes(PDF_HEADER)) { + throw new BadRequestException( + 'The uploaded file is invalid or corrupted. Please try again with a valid file.', + ); + } + return; + } + // Check magic bytes for other known binary types const expectedSignatures = MAGIC_BYTES[lowerMime]; if (expectedSignatures) { diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx index 1e47c68cf1..422c2a1def 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.test.tsx @@ -225,3 +225,30 @@ describe('TaskList automation status filter', () => { expect(screen.getAllByText('All types').length).toBeGreaterThan(0); }); }); + +describe('TaskList assignee filter eligibility', () => { + type TaskListMember = Parameters[0]['members'][number]; + + const makeMember = (id: string, role: string, name: string): TaskListMember => + ({ + id, + role, + user: { id: `usr_${id}`, name, email: `${name}@example.com`, image: null }, + }) as unknown as TaskListMember; + + beforeEach(() => { + vi.clearAllMocks(); + automationStatusValue = null; + }); + + // Regression: members reach this component already filtered to those with + // app access (custom roles included). The assignee filter must offer every + // such member — not just built-in admin/owner — or evidence assigned to a + // custom role (e.g. "SecDev") can't be filtered to. + it('lists a custom-role member (e.g. SecDev) as an assignee filter option', () => { + render( + , + ); + expect(screen.getByTestId('select-item-mem_secdev')).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx index 5d2f8084c9..758956cc2e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx @@ -108,23 +108,16 @@ export function TaskList({ document.cookie = `task-view-preference-${orgId}=${newTab}; expires=${expires.toUTCString()}; path=/`; }; + // `members` is already filtered to those with app access upstream (see + // tasks/page.tsx → filterAppAccessMembers), so each one is a valid assignee. + // Re-filtering by hardcoded role names here dropped auditors and custom roles + // (e.g. "SecDev") that legitimately hold evidence from the assignee filter. const eligibleAssignees = useMemo(() => { - return members - .filter((member) => { - const roleValue = member.role; - const roles = Array.isArray(roleValue) - ? roleValue.map((role) => role.trim().toLowerCase()) - : typeof roleValue === 'string' - ? roleValue.split(',').map((role) => role.trim().toLowerCase()) - : []; - - return roles.some((role) => role === 'admin' || role === 'owner'); - }) - .sort((a, b) => { - const nameA = a.user.name ?? ''; - const nameB = b.user.name ?? ''; - return nameA.localeCompare(nameB); - }); + return [...members].sort((a, b) => { + const nameA = a.user.name ?? ''; + const nameB = b.user.name ?? ''; + return nameA.localeCompare(nameB); + }); }, [members]); // Build a map of control IDs to their framework instances for efficient lookup diff --git a/apps/app/src/app/(app)/[orgId]/tasks/page.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/page.test.tsx new file mode 100644 index 0000000000..5ab8c42d5d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/page.test.tsx @@ -0,0 +1,97 @@ +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGet = vi.fn(); +vi.mock('@/lib/api-server', () => ({ + serverApi: { get: (url: string) => mockGet(url) }, +})); + +// app-access resolution (built-in + custom roles) is exercised by its own +// unit; here we stub it to keep everyone except pure employee/contractor so we +// can assert the page delegates filtering to it instead of a role allowlist. +const mockFilterAppAccessMembers = vi.fn(); +vi.mock('@/lib/compliance', () => ({ + filterAppAccessMembers: (members: unknown, organizationId: unknown) => + mockFilterAppAccessMembers(members, organizationId), +})); + +const captured = vi.hoisted(() => ({ + members: null as Array<{ id: string }> | null, +})); +vi.mock('./components/TasksPageClient', () => ({ + TasksPageClient: ({ members }: { members: Array<{ id: string }> }) => { + captured.members = members; + return null; + }, +})); + +import TasksPage from './page'; + +const adminMember = { + id: 'mem_admin', + role: 'admin', + user: { id: 'u1', name: 'Admin', email: 'admin@example.com', image: null }, +}; +const secDevMember = { + id: 'mem_secdev', + role: 'SecDev', + user: { id: 'u2', name: 'Sec Dev', email: 'secdev@example.com', image: null }, +}; +const employeeMember = { + id: 'mem_emp', + role: 'employee', + user: { id: 'u3', name: 'Employee', email: 'employee@example.com', image: null }, +}; +const allPeople = [adminMember, secDevMember, employeeMember]; + +describe('TasksPage member filtering', () => { + beforeEach(() => { + vi.clearAllMocks(); + captured.members = null; + + mockGet.mockImplementation((url: string) => { + if (url === '/v1/people') { + return Promise.resolve({ data: { data: allPeople, count: allPeople.length } }); + } + if (url === '/v1/tasks/options') { + return Promise.resolve({ + data: { + controls: [], + frameworkInstances: [], + organizationName: null, + hasEvidenceExportAccess: false, + evidenceApprovalEnabled: false, + }, + }); + } + if (url.startsWith('/v1/tasks')) { + return Promise.resolve({ data: { data: [], count: 0 } }); + } + return Promise.resolve({ data: undefined }); + }); + + mockFilterAppAccessMembers.mockImplementation( + async (members: Array<{ role: string }>) => + members.filter((m) => m.role !== 'employee' && m.role !== 'contractor'), + ); + }); + + // Regression: a custom role (e.g. "SecDev") granting app access was dropped by + // the old hardcoded ['owner','admin','auditor'] allowlist, so evidence + // assigned to such a member rendered "Unassigned". Filtering must run through + // the permission-aware helper instead. + it('resolves assignable members via app-access permissions, keeping custom roles', async () => { + const ui = await TasksPage({ + params: Promise.resolve({ orgId: 'org_1' }), + searchParams: Promise.resolve({}), + }); + render(ui); + + expect(mockFilterAppAccessMembers).toHaveBeenCalledWith(allPeople, 'org_1'); + + const ids = captured.members?.map((m) => m.id) ?? []; + expect(ids).toContain('mem_secdev'); + expect(ids).toContain('mem_admin'); + expect(ids).not.toContain('mem_emp'); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/page.tsx b/apps/app/src/app/(app)/[orgId]/tasks/page.tsx index 44ac95a24b..b1521f7033 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/page.tsx @@ -1,4 +1,5 @@ import { serverApi } from '@/lib/api-server'; +import { filterAppAccessMembers } from '@/lib/compliance'; import type { Member, Task, User } from '@db'; import { Metadata } from 'next'; import { cookies } from 'next/headers'; @@ -65,15 +66,11 @@ export default async function TasksPage({ evidenceApprovalEnabled: false, }; - // Filter members: exclude those with only employee/contractor roles (no app access) - // Auditors and anyone with owner/admin can still be assigned - const members = allMembers.filter((m) => { - const roles = m.role - ?.split(',') - .map((r) => r.trim()) - .filter(Boolean) ?? []; - return roles.some((r) => ['owner', 'admin', 'auditor'].includes(r)); - }); + // Only members with app access can be evidence assignees. Resolve this via + // RBAC permissions (built-in + custom roles), not a hardcoded role allowlist, + // so members with a custom role (e.g. "SecDev") that grants app access still + // show as assignees and appear as assignee filter options. + const members = await filterAppAccessMembers(allMembers, orgId); // Read tab preference from cookie const cookieStore = await cookies(); diff --git a/apps/app/src/utils/filter-members-by-role.test.ts b/apps/app/src/utils/filter-members-by-role.test.ts new file mode 100644 index 0000000000..fe6918c2de --- /dev/null +++ b/apps/app/src/utils/filter-members-by-role.test.ts @@ -0,0 +1,115 @@ +import type { Member, User } from '@db'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock @/lib/permissions to avoid resolving @trycompai/auth in the test runtime. +// The mock mirrors the real helpers: parseRolesString splits the comma list and +// isBuiltInRole checks against the built-in role names from @trycompai/auth. +const BUILT_IN_ROLES = new Set(['owner', 'admin', 'auditor', 'employee', 'contractor']); + +vi.mock('@/lib/permissions', () => ({ + parseRolesString: (rolesStr: string | null | undefined): string[] => { + if (!rolesStr) return []; + return rolesStr + .split(',') + .map((r) => r.trim()) + .filter((r) => r.length > 0); + }, + isBuiltInRole: (role: string): boolean => BUILT_IN_ROLES.has(role), +})); + +// Import after mock setup +const { filterMembersByOwnerOrAdmin } = await import('./filter-members-by-role'); + +// Minimal member factory for testing +function makeMember(overrides: { id: string; role: string | null }): Member & { user: User } { + const { id, role } = overrides; + return { + id, + role, + organizationId: 'org_1', + userId: `usr_${id}`, + createdAt: new Date(), + department: 'none' as never, + jobTitle: null, + isActive: true, + deactivated: false, + externalUserId: null, + externalUserSource: null, + fleetDmLabelId: null, + user: { + id: `usr_${id}`, + name: `User ${id}`, + email: `user-${id}@test.com`, + emailVerified: true, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + lastLogin: null, + role: 'user', + banned: false, + banReason: null, + banExpires: null, + twoFactorEnabled: false, + }, + } as Member & { user: User }; +} + +describe('filterMembersByOwnerOrAdmin', () => { + it('includes members with a custom role (e.g. SecDev) — regression', () => { + // SecDev is an org-defined custom role; the backend accepts these members as + // task assignees, so they must remain selectable in the assignee dropdowns. + const secDev = makeMember({ id: 'mem_secdev', role: 'SecDev' }); + + const result = filterMembersByOwnerOrAdmin({ members: [secDev] }); + + expect(result.map((m) => m.id)).toContain('mem_secdev'); + }); + + it('includes owner and admin members', () => { + const owner = makeMember({ id: 'mem_owner', role: 'owner' }); + const admin = makeMember({ id: 'mem_admin', role: 'admin' }); + + const result = filterMembersByOwnerOrAdmin({ members: [owner, admin] }); + + expect(result.map((m) => m.id)).toEqual(['mem_owner', 'mem_admin']); + }); + + it('excludes built-in restricted/auditor roles (employee, contractor, auditor)', () => { + const employee = makeMember({ id: 'mem_emp', role: 'employee' }); + const contractor = makeMember({ id: 'mem_con', role: 'contractor' }); + const auditor = makeMember({ id: 'mem_aud', role: 'auditor' }); + + const result = filterMembersByOwnerOrAdmin({ + members: [employee, contractor, auditor], + }); + + expect(result).toHaveLength(0); + }); + + it('includes a member whose roles combine a built-in restricted role with a custom role', () => { + const mixed = makeMember({ id: 'mem_mixed', role: 'employee,SecDev' }); + + const result = filterMembersByOwnerOrAdmin({ members: [mixed] }); + + expect(result.map((m) => m.id)).toContain('mem_mixed'); + }); + + it('excludes members with no role', () => { + const noRole = makeMember({ id: 'mem_none', role: null }); + + const result = filterMembersByOwnerOrAdmin({ members: [noRole] }); + + expect(result).toHaveLength(0); + }); + + it('always includes the current assignee even if their role is not assignable', () => { + const employee = makeMember({ id: 'mem_emp', role: 'employee' }); + + const result = filterMembersByOwnerOrAdmin({ + members: [employee], + currentAssigneeId: 'mem_emp', + }); + + expect(result.map((m) => m.id)).toContain('mem_emp'); + }); +}); diff --git a/apps/app/src/utils/filter-members-by-role.ts b/apps/app/src/utils/filter-members-by-role.ts index bc6ff8df1a..68bc21d063 100644 --- a/apps/app/src/utils/filter-members-by-role.ts +++ b/apps/app/src/utils/filter-members-by-role.ts @@ -1,17 +1,24 @@ +import { isBuiltInRole, parseRolesString } from '@/lib/permissions'; import { Member, User } from '@db'; interface FilterMembersByOwnerOrAdminParams { members: (Member & { user: User })[]; /** - * Optional current assignee ID to always include (even if not owner/admin), + * Optional current assignee ID to always include (even if not assignable), * so existing assignments/active filters remain visible. */ currentAssigneeId?: string | null; } /** - * Filters members to only include those with owner or admin roles - * Roles are stored as comma-separated strings (e.g., "owner,admin" or "employee") + * Filters members to those eligible to be task assignees. + * + * Roles are stored as comma-separated strings (e.g., "owner,admin" or "SecDev"). + * A member is eligible when any of their roles is `owner`/`admin` OR a custom + * (non-built-in) role such as "SecDev". Custom roles are org-defined and the + * backend accepts them as assignees, so they must be selectable here too. + * The built-in restricted roles (`employee`, `contractor`) and `auditor` remain + * excluded. */ export function filterMembersByOwnerOrAdmin( { members, currentAssigneeId }: FilterMembersByOwnerOrAdminParams, @@ -21,11 +28,16 @@ export function filterMembersByOwnerOrAdmin( if (currentAssigneeId && member.id === currentAssigneeId) { return true; } - + if (!member.role) return false; - - // Roles can be comma-separated, so we need to check if any role is owner or admin - const roles = member.role.split(',').map((r) => r.trim().toLowerCase()); - return roles.includes('owner') || roles.includes('admin'); + + // Roles can be comma-separated, so include the member if any single role qualifies. + const roles = parseRolesString(member.role); + return roles.some((role) => { + const normalized = role.toLowerCase(); + if (normalized === 'owner' || normalized === 'admin') return true; + // Custom roles (anything not built in) are valid assignees. + return !isBuiltInRole(role); + }); }); } diff --git a/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx b/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx index 7485aeecf2..32227e44d8 100644 --- a/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/FrameworksClientPage.tsx @@ -4,12 +4,13 @@ import PageLayout from '@/app/components/PageLayout'; import type { FrameworkEditorFramework, FrameworkEditorFrameworkFamilyStatus } from '@/db'; import { Button } from '@trycompai/ui/button'; import { Input } from '@trycompai/ui/input'; -import { FolderPlus, Plus, Upload } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { FolderPlus, MoveRight, Plus, Upload } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; import { CreateFrameworkDialog } from './components/CreateFrameworkDialog'; import { DeleteFrameworkFamilyDialog } from './components/DeleteFrameworkFamilyDialog'; import { FrameworkFamilyDialog } from './components/FrameworkFamilyDialog'; -import { FrameworkFamilySection } from './components/FrameworkFamilySection'; +import { buildFrameworkTreeRows } from './components/frameworks-tree'; +import { FrameworksTreeTable } from './components/FrameworksTreeTable'; import { ImportFrameworkDialog } from './components/ImportFrameworkDialog'; import { MoveFrameworkDialog } from './components/MoveFrameworkDialog'; @@ -41,12 +42,7 @@ export function FrameworksClientPage({ const [search, setSearch] = useState(''); const [isCreateFrameworkOpen, setIsCreateFrameworkOpen] = useState(false); const [isImportOpen, setIsImportOpen] = useState(false); - // Move is scoped to the section it's opened from ("frameworks in the current - // family", per FRAME-20) — these are the source frameworks for the dialog. - const [moveDialog, setMoveDialog] = useState<{ - open: boolean; - frameworks: FrameworkWithCounts[]; - }>({ open: false, frameworks: [] }); + const [isMoveOpen, setIsMoveOpen] = useState(false); const [familyDialog, setFamilyDialog] = useState<{ open: boolean; family: FrameworkFamilyWithCount | null; @@ -55,19 +51,28 @@ export function FrameworksClientPage({ open: boolean; family: FrameworkFamilyWithCount | null; }>({ open: false, family: null }); + // Families default to expanded so the whole tree is visible (Finder-style). + const [expanded, setExpanded] = useState>( + () => new Set(initialFamilies.map((f) => f.id)), + ); - const searching = search.trim().length > 0; + const toggle = useCallback((familyId: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(familyId)) next.delete(familyId); + else next.add(familyId); + return next; + }); + }, []); - const filtered = useMemo(() => { - const term = search.trim().toLowerCase(); - if (!term) return initialFrameworks; - return initialFrameworks.filter((fw) => fw.name.toLowerCase().includes(term)); - }, [initialFrameworks, search]); + const searching = search.trim().length > 0; const { byFamilyId, ungrouped } = useMemo(() => { + const term = search.trim().toLowerCase(); const map = new Map(); const root: FrameworkWithCounts[] = []; - for (const fw of filtered) { + for (const fw of initialFrameworks) { + if (term && !fw.name.toLowerCase().includes(term)) continue; if (fw.familyId) { const arr = map.get(fw.familyId); if (arr) arr.push(fw); @@ -77,25 +82,23 @@ export function FrameworksClientPage({ } } return { byFamilyId: map, ungrouped: root }; - }, [filtered]); - - const sortedFamilies = useMemo( - () => [...initialFamilies].sort((a, b) => a.name.localeCompare(b.name)), - [initialFamilies], - ); + }, [initialFrameworks, search]); - // True (unfiltered) count of ungrouped frameworks — drives the label and the - // move scope regardless of the search filter. - const ungroupedTotal = useMemo( - () => initialFrameworks.filter((fw) => !fw.familyId).length, - [initialFrameworks], + // Unified row model: families (folders) and ungrouped frameworks (files) are + // intermixed alphabetically at the root; a family's frameworks appear indented + // beneath it when expanded. + const rows = useMemo( + () => + buildFrameworkTreeRows({ + families: initialFamilies, + frameworksByFamilyId: byFamilyId, + ungrouped, + expanded, + searching, + }), + [initialFamilies, byFamilyId, ungrouped, expanded, searching], ); - // The full (unfiltered) framework list for a given family (null = ungrouped), - // used as the move dialog's scoped source. - const frameworksOf = (familyId: string | null) => - initialFrameworks.filter((fw) => (fw.familyId ?? null) === familyId); - return (
@@ -111,6 +114,10 @@ export function FrameworksClientPage({ Import +
-
- {sortedFamilies.map((family) => { - const frameworks = byFamilyId.get(family.id) ?? []; - // While searching, hide families with no matching frameworks. - if (searching && frameworks.length === 0) return null; - return ( - - setMoveDialog({ open: true, frameworks: frameworksOf(family.id) }) - } - onEdit={() => setFamilyDialog({ open: true, family })} - onDelete={() => setDeleteDialog({ open: true, family })} - /> - ); - })} - {(!searching || ungrouped.length > 0) && ( - setMoveDialog({ open: true, frameworks: frameworksOf(null) })} - /> - )} -
+ setFamilyDialog({ open: true, family })} + onDeleteFamily={(family) => setDeleteDialog({ open: true, family })} + /> setMoveDialog((s) => ({ ...s, open }))} - frameworks={moveDialog.frameworks} + isOpen={isMoveOpen} + onOpenChange={setIsMoveOpen} + frameworks={initialFrameworks} families={initialFamilies} /> void; - onEdit?: () => void; - onDelete?: () => void; - defaultOpen?: boolean; -} - -export function FrameworkFamilySection({ - title, - status, - count, - frameworks, - onMove, - onEdit, - onDelete, - defaultOpen = true, -}: FrameworkFamilySectionProps) { - const [open, setOpen] = useState(defaultOpen); - const isFamily = status !== undefined; - const countLabel = count === 0 ? 'Empty' : `${count} framework${count === 1 ? '' : 's'}`; - - return ( -
-
- -
- {onMove && count > 0 && ( - - )} - {isFamily && onEdit && ( - - )} - {isFamily && onDelete && ( - - )} -
-
- {open && ( -
- {frameworks.length === 0 ? ( -

- No frameworks in this {isFamily ? 'family' : 'group'}. -

- ) : ( - - - - - - - - - - - - {frameworks.map((fw) => ( - - - - - - - - ))} - -
NameVersionStatusRequirementsControls
- - {fw.name} - - {fw.latestVersion?.version ?? fw.version} - - {fw.requirementsCount}{fw.controlsCount}
- )} -
- )} -
- ); -} diff --git a/apps/framework-editor/app/(pages)/frameworks/components/FrameworksTreeTable.tsx b/apps/framework-editor/app/(pages)/frameworks/components/FrameworksTreeTable.tsx new file mode 100644 index 0000000000..8d71d9f9d2 --- /dev/null +++ b/apps/framework-editor/app/(pages)/frameworks/components/FrameworksTreeTable.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { Button } from '@trycompai/ui/button'; +import { ChevronDown, ChevronRight, FileText, Folder, Pencil, Trash2 } from 'lucide-react'; +import Link from 'next/link'; +import type { FrameworkFamilyWithCount, FrameworkWithCounts } from '../FrameworksClientPage'; +import { FamilyStatusBadge, FrameworkVisibilityBadge } from './family-status'; + +// A flat row in the unified Finder-style list: families (folders) and the +// frameworks (files) underneath them share one set of columns. +export type TreeRow = + | { kind: 'family'; family: FrameworkFamilyWithCount; expanded: boolean } + | { kind: 'framework'; framework: FrameworkWithCounts; indented: boolean }; + +interface FrameworksTreeTableProps { + rows: TreeRow[]; + onToggle: (familyId: string) => void; + onEditFamily: (family: FrameworkFamilyWithCount) => void; + onDeleteFamily: (family: FrameworkFamilyWithCount) => void; +} + +// Frameworks nested in a family indent ~6 characters past the root, like a file +// inside a folder in Finder / Windows Explorer. +const NESTED_INDENT_PX = 48; +const CENTER_CELL = 'px-3 py-2 text-center align-middle'; + +export function FrameworksTreeTable({ + rows, + onToggle, + onEditFamily, + onDeleteFamily, +}: FrameworksTreeTableProps) { + return ( +
+ + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((row) => + row.kind === 'family' ? ( + + ) : ( + + ), + )} + +
NameVersionStatusRequirementsControls +
+ No frameworks yet. +
+
+ ); +} + +function FamilyRow({ + family, + expanded, + onToggle, + onEdit, + onDelete, +}: { + family: FrameworkFamilyWithCount; + expanded: boolean; + onToggle: (familyId: string) => void; + onEdit: (family: FrameworkFamilyWithCount) => void; + onDelete: (family: FrameworkFamilyWithCount) => void; +}) { + const countLabel = + family.frameworksCount === 0 + ? 'Empty' + : `${family.frameworksCount} framework${family.frameworksCount === 1 ? '' : 's'}`; + const empty = family.frameworksCount === 0; + + return ( + + +
+ + + {family.name} +
+ + {countLabel} + + + + + + +
+ + +
+ + + ); +} + +function FrameworkRow({ + framework, + indented, +}: { + framework: FrameworkWithCounts; + indented: boolean; +}) { + return ( + + +
+ {/* Spacer matching the family chevron so names line up. */} + + + + {framework.name} + +
+ + + {framework.latestVersion?.version ?? framework.version} + + + + + {framework.requirementsCount} + {framework.controlsCount} + + + ); +} diff --git a/apps/framework-editor/app/(pages)/frameworks/components/frameworks-tree.test.ts b/apps/framework-editor/app/(pages)/frameworks/components/frameworks-tree.test.ts new file mode 100644 index 0000000000..d9b50f3aca --- /dev/null +++ b/apps/framework-editor/app/(pages)/frameworks/components/frameworks-tree.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import type { FrameworkFamilyWithCount, FrameworkWithCounts } from '../FrameworksClientPage'; +import { buildFrameworkTreeRows } from './frameworks-tree'; + +const fam = (id: string, name: string, count = 1): FrameworkFamilyWithCount => ({ + id, + name, + description: '', + status: 'visible', + frameworksCount: count, + createdAt: '', + updatedAt: '', +}); + +const fw = (id: string, name: string, familyId: string | null): FrameworkWithCounts => + ({ + id, + name, + familyId, + version: '1.0', + description: '', + visible: true, + requirementsCount: 0, + controlsCount: 0, + latestVersion: null, + createdAt: new Date(), + updatedAt: new Date(), + }) as unknown as FrameworkWithCounts; + +function group(frameworks: FrameworkWithCounts[]) { + const map = new Map(); + const ungrouped: FrameworkWithCounts[] = []; + for (const f of frameworks) { + if (f.familyId) { + const arr = map.get(f.familyId) ?? []; + arr.push(f); + map.set(f.familyId, arr); + } else { + ungrouped.push(f); + } + } + return { frameworksByFamilyId: map, ungrouped }; +} + +const label = (r: ReturnType[number]) => + r.kind === 'family' ? `📁 ${r.family.name}` : `${r.indented ? ' ' : ''}${r.framework.name}`; + +describe('buildFrameworkTreeRows', () => { + it('intermixes families and ungrouped frameworks alphabetically at the root', () => { + const families = [fam('z', 'Zebra'), fam('a', 'Alpha')]; + const { frameworksByFamilyId, ungrouped } = group([ + fw('fz', 'Z-child', 'z'), + fw('fa', 'A-child', 'a'), + fw('m', 'Mango', null), + ]); + const rows = buildFrameworkTreeRows({ + families, + frameworksByFamilyId, + ungrouped, + expanded: new Set(), + searching: false, + }); + // Collapsed families show no children; root order is alphabetical. + expect(rows.map(label)).toEqual(['📁 Alpha', 'Mango', '📁 Zebra']); + }); + + it('shows an expanded family’s frameworks indented beneath it, sorted', () => { + const families = [fam('g', 'Govern', 2)]; + const { frameworksByFamilyId, ungrouped } = group([ + fw('b', 'GV.B', 'g'), + fw('a', 'GV.A', 'g'), + ]); + const rows = buildFrameworkTreeRows({ + families, + frameworksByFamilyId, + ungrouped, + expanded: new Set(['g']), + searching: false, + }); + expect(rows.map(label)).toEqual(['📁 Govern', ' GV.A', ' GV.B']); + expect(rows[1]).toMatchObject({ kind: 'framework', indented: true }); + }); + + it('hides empty families and force-expands the rest while searching', () => { + const families = [fam('h', 'Has Match'), fam('e', 'Empty After Filter')]; + // Only the "Has Match" family has a (filtered) child. + const { frameworksByFamilyId, ungrouped } = group([fw('c', 'CC6.1', 'h')]); + const rows = buildFrameworkTreeRows({ + families, + frameworksByFamilyId, + ungrouped, + expanded: new Set(), // not expanded — but searching forces it open + searching: true, + }); + expect(rows.map(label)).toEqual(['📁 Has Match', ' CC6.1']); + }); +}); diff --git a/apps/framework-editor/app/(pages)/frameworks/components/frameworks-tree.ts b/apps/framework-editor/app/(pages)/frameworks/components/frameworks-tree.ts new file mode 100644 index 0000000000..d5aafcb238 --- /dev/null +++ b/apps/framework-editor/app/(pages)/frameworks/components/frameworks-tree.ts @@ -0,0 +1,62 @@ +import type { FrameworkFamilyWithCount, FrameworkWithCounts } from '../FrameworksClientPage'; +import type { TreeRow } from './FrameworksTreeTable'; + +const byName = (a: { name: string }, b: { name: string }) => + a.name.localeCompare(b.name, undefined, { numeric: true }); + +interface BuildTreeArgs { + families: FrameworkFamilyWithCount[]; + /** Search-filtered frameworks grouped by familyId. */ + frameworksByFamilyId: Map; + /** Search-filtered frameworks with no family. */ + ungrouped: FrameworkWithCounts[]; + /** Ids of expanded families. */ + expanded: Set; + /** Whether a search filter is active. */ + searching: boolean; +} + +/** + * Flattens families (folders) and frameworks (files) into a single Finder-style + * row list: roots (families + ungrouped frameworks) are intermixed + * alphabetically; an expanded family's frameworks follow it as indented rows. + * While searching, families with no matching frameworks are hidden and the rest + * are force-expanded so matches are visible. + */ +export function buildFrameworkTreeRows({ + families, + frameworksByFamilyId, + ungrouped, + expanded, + searching, +}: BuildTreeArgs): TreeRow[] { + const roots: { + name: string; + family?: FrameworkFamilyWithCount; + framework?: FrameworkWithCounts; + }[] = []; + + for (const family of families) { + if (searching && (frameworksByFamilyId.get(family.id)?.length ?? 0) === 0) continue; + roots.push({ name: family.name, family }); + } + for (const framework of ungrouped) roots.push({ name: framework.name, framework }); + roots.sort(byName); + + const rows: TreeRow[] = []; + for (const root of roots) { + if (root.family) { + const isExpanded = searching || expanded.has(root.family.id); + rows.push({ kind: 'family', family: root.family, expanded: isExpanded }); + if (isExpanded) { + const children = [...(frameworksByFamilyId.get(root.family.id) ?? [])].sort(byName); + for (const framework of children) { + rows.push({ kind: 'framework', framework, indented: true }); + } + } + } else if (root.framework) { + rows.push({ kind: 'framework', framework: root.framework, indented: false }); + } + } + return rows; +}