From 3a169b84ced56b75cb3d17f8935d84f735d0458d Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 7 Apr 2026 22:26:17 -0400 Subject: [PATCH 01/29] feat(db): add scope column to Finding table --- .../20260402120000_add_finding_scope/migration.sql | 5 +++++ packages/db/prisma/schema/finding.prisma | 8 ++++++++ 2 files changed, 13 insertions(+) create mode 100644 packages/db/prisma/migrations/20260402120000_add_finding_scope/migration.sql diff --git a/packages/db/prisma/migrations/20260402120000_add_finding_scope/migration.sql b/packages/db/prisma/migrations/20260402120000_add_finding_scope/migration.sql new file mode 100644 index 0000000000..5b5fad77f2 --- /dev/null +++ b/packages/db/prisma/migrations/20260402120000_add_finding_scope/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "FindingScope" AS ENUM ('people', 'people_tasks', 'people_devices', 'people_chart'); + +-- AlterTable +ALTER TABLE "Finding" ADD COLUMN "scope" "FindingScope"; diff --git a/packages/db/prisma/schema/finding.prisma b/packages/db/prisma/schema/finding.prisma index f092559ae8..f026dd3cae 100644 --- a/packages/db/prisma/schema/finding.prisma +++ b/packages/db/prisma/schema/finding.prisma @@ -10,6 +10,13 @@ enum FindingStatus { closed } +enum FindingScope { + people + people_tasks + people_devices + people_chart +} + model FindingTemplate { id String @id @default(dbgenerated("generate_prefixed_cuid('fnd_t'::text)")) category String // e.g., "evidence_issue", "further_evidence", "task_specific", "na_incorrect" @@ -28,6 +35,7 @@ model Finding { status FindingStatus @default(open) content String // Custom message or copied from template revisionNote String? // Auditor's note when requesting revision + scope FindingScope? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 97652c51ad0287c0dc7d39e45d1f69677b964fa2 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 7 Apr 2026 23:46:53 -0400 Subject: [PATCH 02/29] feat(app): create finding with new scope --- .../src/findings/dto/create-finding.dto.ts | 12 ++++++- .../api/src/findings/finding-audit.service.ts | 2 ++ apps/api/src/findings/findings.controller.ts | 27 +++++++++++++--- apps/api/src/findings/findings.service.ts | 31 ++++++++++++++++--- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/apps/api/src/findings/dto/create-finding.dto.ts b/apps/api/src/findings/dto/create-finding.dto.ts index 33d6766ec1..acb5887aa1 100644 --- a/apps/api/src/findings/dto/create-finding.dto.ts +++ b/apps/api/src/findings/dto/create-finding.dto.ts @@ -7,7 +7,7 @@ import { IsOptional, MaxLength, } from 'class-validator'; -import { FindingType } from '@db'; +import { FindingScope, FindingType } from '@db'; import { evidenceFormTypeSchema, type EvidenceFormType, @@ -43,6 +43,16 @@ export class CreateFindingDto { @IsOptional() evidenceFormType?: EvidenceFormType; + @ApiProperty({ + description: + 'People area scope (e.g. people directory) when not tied to a task or evidence', + enum: FindingScope, + required: false, + }) + @IsEnum(FindingScope) + @IsOptional() + scope?: FindingScope; + @ApiProperty({ description: 'Type of finding (SOC 2 or ISO 27001)', enum: FindingType, diff --git a/apps/api/src/findings/finding-audit.service.ts b/apps/api/src/findings/finding-audit.service.ts index 5f3405abaa..f13e845a3e 100644 --- a/apps/api/src/findings/finding-audit.service.ts +++ b/apps/api/src/findings/finding-audit.service.ts @@ -21,6 +21,7 @@ export class FindingAuditService { taskTitle?: string; evidenceSubmissionId?: string; evidenceSubmissionFormType?: string; + findingScope?: string; content: string; type: FindingType; }, @@ -41,6 +42,7 @@ export class FindingAuditService { taskTitle: params.taskTitle, evidenceSubmissionId: params.evidenceSubmissionId, evidenceSubmissionFormType: params.evidenceSubmissionFormType, + findingScope: params.findingScope, content: params.content, type: params.type, status: FindingStatus.open, diff --git a/apps/api/src/findings/findings.controller.ts b/apps/api/src/findings/findings.controller.ts index 2f0d3dc9ed..f05affb158 100644 --- a/apps/api/src/findings/findings.controller.ts +++ b/apps/api/src/findings/findings.controller.ts @@ -22,7 +22,7 @@ import { ApiTags, ApiSecurity, } from '@nestjs/swagger'; -import { FindingStatus } from '@db'; +import { FindingScope, FindingStatus } from '@db'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; import { RequirePermission } from '../auth/require-permission.decorator'; @@ -68,6 +68,12 @@ export class FindingsController { example: 'access-request', enum: evidenceFormTypeSchema.options, }) + @ApiQuery({ + name: 'scope', + required: false, + description: 'People-area scope (e.g. people directory)', + enum: FindingScope, + }) @ApiResponse({ status: 200, description: 'List of findings', @@ -84,19 +90,20 @@ export class FindingsController { @Query('taskId') taskId: string, @Query('evidenceSubmissionId') evidenceSubmissionId: string, @Query('evidenceFormType') evidenceFormType: string, + @Query('scope') scope: string, @AuthContext() authContext: AuthContextType, ) { - const targets = [taskId, evidenceSubmissionId, evidenceFormType].filter( + const targets = [taskId, evidenceSubmissionId, evidenceFormType, scope].filter( Boolean, ); if (targets.length === 0) { throw new BadRequestException( - 'One of taskId, evidenceSubmissionId, or evidenceFormType query parameter is required', + 'One of taskId, evidenceSubmissionId, evidenceFormType, or scope query parameter is required', ); } if (targets.length > 1) { throw new BadRequestException( - 'Provide only one target: taskId, evidenceSubmissionId, or evidenceFormType', + 'Provide only one target: taskId, evidenceSubmissionId, evidenceFormType, or scope', ); } @@ -109,6 +116,18 @@ export class FindingsController { ); } + if (scope) { + if (!Object.values(FindingScope).includes(scope as FindingScope)) { + throw new BadRequestException( + `Invalid scope value. Must be one of: ${Object.values(FindingScope).join(', ')}`, + ); + } + return await this.findingsService.findByScope( + authContext.organizationId, + scope as FindingScope, + ); + } + if (taskId) { return await this.findingsService.findByTaskId( authContext.organizationId, diff --git a/apps/api/src/findings/findings.service.ts b/apps/api/src/findings/findings.service.ts index a8864b39dc..9bbedeae50 100644 --- a/apps/api/src/findings/findings.service.ts +++ b/apps/api/src/findings/findings.service.ts @@ -8,6 +8,7 @@ import { import { db, EvidenceFormType as DbEvidenceFormType, + FindingScope, FindingStatus, FindingType, } from '@db'; @@ -175,6 +176,22 @@ export class FindingsService { return findings.map((finding) => this.normalizeFindingFormTypes(finding)); } + /** + * Get all findings for a People-area scope (directory, devices tab, etc.) + */ + async findByScope(organizationId: string, scope: FindingScope) { + const findings = await db.finding.findMany({ + where: { organizationId, scope }, + include: this.findingInclude, + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + }); + + this.logger.log( + `Retrieved ${findings.length} findings for scope ${scope} in org ${organizationId}`, + ); + return findings.map((finding) => this.normalizeFindingFormTypes(finding)); + } + /** * Get all findings for an organization */ @@ -225,19 +242,21 @@ export class FindingsService { const hasTaskTarget = Boolean(createDto.taskId); const hasSubmissionTarget = Boolean(createDto.evidenceSubmissionId); const hasFormTypeTarget = Boolean(createDto.evidenceFormType); + const hasScopeTarget = Boolean(createDto.scope); const targetCount = (hasTaskTarget ? 1 : 0) + (hasSubmissionTarget ? 1 : 0) + - (hasFormTypeTarget ? 1 : 0); + (hasFormTypeTarget ? 1 : 0) + + (hasScopeTarget ? 1 : 0); if (targetCount === 0) { throw new BadRequestException( - 'One of taskId, evidenceSubmissionId, or evidenceFormType is required', + 'One of taskId, evidenceSubmissionId, evidenceFormType, or scope is required', ); } if (targetCount > 1) { throw new BadRequestException( - 'Provide only one target: taskId, evidenceSubmissionId, or evidenceFormType', + 'Provide only one target: taskId, evidenceSubmissionId, evidenceFormType, or scope', ); } @@ -308,6 +327,7 @@ export class FindingsService { evidenceFormType: createDto.evidenceFormType ? toDbEvidenceFormType(createDto.evidenceFormType) : null, + scope: createDto.scope ?? null, type: createDto.type, content: createDto.content, templateId: createDto.templateId, @@ -328,6 +348,7 @@ export class FindingsService { taskTitle: task?.title, evidenceSubmissionId: evidenceSubmission?.id, evidenceSubmissionFormType: resolvedFormType, + findingScope: createDto.scope, content: createDto.content, type: createDto.type ?? FindingType.soc2, }); @@ -356,7 +377,9 @@ export class FindingsService { ? `task ${task.id}` : createDto.evidenceFormType ? `evidence form type ${createDto.evidenceFormType}` - : `evidence submission ${evidenceSubmission?.id}`; + : createDto.scope + ? `scope ${createDto.scope}` + : `evidence submission ${evidenceSubmission?.id}`; this.logger.log(`Created finding ${finding.id} for ${target}`); return this.normalizeFindingFormTypes(finding); } From 79fbde4477be33dca15c0f2235110472571b2e6a Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 7 Apr 2026 23:56:11 -0400 Subject: [PATCH 03/29] feat(app): add Findings section on People tab --- .../people/all/components/PeopleFindings.tsx | 48 ++++ .../components/PeopleFindingsList.test.tsx | 114 ++++++++ .../all/components/PeopleFindingsList.tsx | 243 ++++++++++++++++++ .../people/all/components/TeamMembers.tsx | 11 +- .../all/components/TeamMembersClient.tsx | 10 + .../app/src/app/(app)/[orgId]/people/page.tsx | 2 + .../findings/CreateFindingButton.tsx | 4 + .../findings/CreateFindingSheet.tsx | 6 +- apps/app/src/hooks/use-findings-api.ts | 16 +- 9 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx new file mode 100644 index 0000000000..ed87e59bef --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx @@ -0,0 +1,48 @@ +'use client'; + +import type { ComponentProps } from 'react'; +import { useState } from 'react'; + +import { FindingScope } from '@db'; + +import { FindingHistoryPanel } from '../../../tasks/[taskId]/components/findings/FindingHistoryPanel'; +import { PeopleFindingsList } from './PeopleFindingsList'; + +type PeopleFindingsScope = ComponentProps['scope']; + +export interface PeopleFindingsProps { + isAuditor: boolean; + isPlatformAdmin: boolean; + isAdminOrOwner: boolean; + /** Defaults to the main People directory scope */ + scope?: PeopleFindingsScope; +} + +export function PeopleFindings({ + isAuditor, + isPlatformAdmin, + isAdminOrOwner, + scope = FindingScope.people, +}: PeopleFindingsProps) { + const [selectedFindingIdForHistory, setSelectedFindingIdForHistory] = useState( + null, + ); + + return ( + <> + + {selectedFindingIdForHistory && ( + setSelectedFindingIdForHistory(null)} + /> + )} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx new file mode 100644 index 0000000000..792149a489 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { UserPermissions } from '@/lib/permissions'; + +let mockPermissionsState: UserPermissions = {}; + +const mockHasPermission = vi.fn((resource: string, action: string): boolean => { + return mockPermissionsState[resource]?.includes(action) ?? false; +}); + +function setMockPermissions(permissions: UserPermissions): void { + mockPermissionsState = permissions; + mockHasPermission.mockImplementation((resource: string, action: string) => { + return mockPermissionsState[resource]?.includes(action) ?? false; + }); +} + +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ + permissions: {}, + hasPermission: mockHasPermission, + }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const mockFindings = [ + { + id: 'finding_1', + status: 'open', + type: 'soc2', + content: 'People finding', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }, +]; + +const mockMutate = vi.fn(); + +vi.mock('@/hooks/use-findings-api', () => ({ + useScopeFindings: () => ({ + data: { data: mockFindings }, + isLoading: false, + error: null, + mutate: mockMutate, + }), + useFindingActions: () => ({ + updateFinding: vi.fn(), + deleteFinding: vi.fn(), + }), +})); + +vi.mock('@db', () => ({ + FindingStatus: { + open: 'open', + needs_revision: 'needs_revision', + ready_for_review: 'ready_for_review', + closed: 'closed', + }, +})); + + +vi.mock('../../../tasks/[taskId]/components/findings/CreateFindingButton', () => ({ + CreateFindingButton: () => , +})); + +vi.mock('../../../tasks/[taskId]/components/findings/FindingItem', () => ({ + FindingItem: ({ finding }: { finding: { id: string; content: string } }) => ( +
{finding.content}
+ ), +})); + +import { PeopleFindingsList } from './PeopleFindingsList'; + +const defaultProps = { + scope: 'people' as const, + isAuditor: false, + isPlatformAdmin: false, + isAdminOrOwner: false, + onViewHistory: vi.fn(), +}; + +describe('PeopleFindingsList permission gating', () => { + beforeEach(() => { + setMockPermissions({}); + vi.clearAllMocks(); + }); + + it('shows Create Finding for platform admin with finding:create', () => { + setMockPermissions({ finding: ['create', 'read'] }); + + render(); + + expect(screen.getByTestId('create-finding-btn')).toBeInTheDocument(); + }); + + it('shows Create Finding for auditor with finding:create', () => { + setMockPermissions({ finding: ['create', 'read'] }); + + render(); + + expect(screen.getByTestId('create-finding-btn')).toBeInTheDocument(); + }); + + it('hides Create Finding when user lacks finding:create even if auditor', () => { + setMockPermissions({ finding: ['read', 'update'] }); + + render(); + + expect(screen.queryByTestId('create-finding-btn')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx new file mode 100644 index 0000000000..224b57e633 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { + useFindingActions, + useScopeFindings, + type Finding, +} from '@/hooks/use-findings-api'; +import { usePermissions } from '@/hooks/use-permissions'; +import { Button } from '@trycompai/design-system'; +import type { FindingScope } from '@db'; +import { FindingStatus } from '@db'; +import { + ChevronDown, + ChevronUp, + WarningAlt, + WarningAltFilled, +} from '@trycompai/design-system/icons'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { CreateFindingButton } from '../../../tasks/[taskId]/components/findings/CreateFindingButton'; +import { FindingItem } from '../../../tasks/[taskId]/components/findings/FindingItem'; + +const INITIAL_DISPLAY_COUNT = 5; + +interface PeopleFindingsListProps { + scope: FindingScope; + isAuditor: boolean; + isPlatformAdmin: boolean; + isAdminOrOwner: boolean; + onViewHistory?: (findingId: string) => void; +} + +const STATUS_ORDER: Record = { + [FindingStatus.open]: 0, + [FindingStatus.needs_revision]: 1, + [FindingStatus.ready_for_review]: 2, + [FindingStatus.closed]: 3, +}; + +export function PeopleFindingsList({ + scope, + isAuditor, + isPlatformAdmin, + isAdminOrOwner, + onViewHistory, +}: PeopleFindingsListProps) { + const { data, isLoading, error, mutate } = useScopeFindings(scope); + const { updateFinding, deleteFinding } = useFindingActions(); + const { hasPermission } = usePermissions(); + const [expandedId, setExpandedId] = useState(null); + const [showAll, setShowAll] = useState(false); + const [targetFindingId, setTargetFindingId] = useState(null); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const hash = window.location.hash; + if (hash.startsWith('#finding-')) { + const findingId = hash.replace('#finding-', ''); + setTargetFindingId(findingId); + setShowAll(true); + + const timer = setTimeout(() => { + setTargetFindingId(null); + window.history.replaceState(null, '', window.location.pathname); + }, 2500); + + return () => clearTimeout(timer); + } + }, []); + + const rawFindings = data?.data || []; + + const sortedFindings = useMemo(() => { + return [...rawFindings].sort((a: Finding, b: Finding) => { + const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; + if (statusDiff !== 0) return statusDiff; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + }, [rawFindings]); + + const visibleFindings = showAll ? sortedFindings : sortedFindings.slice(0, INITIAL_DISPLAY_COUNT); + const hiddenCount = sortedFindings.length - visibleFindings.length; + + const canCreateFinding = hasPermission('finding', 'create') && (isAuditor || isPlatformAdmin); + const canUpdateFinding = hasPermission('finding', 'update'); + const canDeleteFinding = hasPermission('finding', 'delete'); + const canChangeStatus = canUpdateFinding || isAuditor || isPlatformAdmin || isAdminOrOwner; + const canSetRestrictedStatus = isAuditor || isPlatformAdmin; + + const handleStatusChange = useCallback( + async (findingId: string, status: FindingStatus, revisionNote?: string) => { + try { + const updateData: { status: FindingStatus; revisionNote?: string | null } = { status }; + + if (status === FindingStatus.needs_revision && revisionNote) { + updateData.revisionNote = revisionNote; + } + + await updateFinding(findingId, updateData); + toast.success( + revisionNote ? 'Finding marked for revision with note' : 'Finding status updated', + ); + mutate(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update status'); + } + }, + [updateFinding, mutate], + ); + + const handleDelete = useCallback( + async (findingId: string) => { + if (!confirm('Are you sure you want to delete this finding?')) { + return; + } + + try { + await deleteFinding(findingId); + toast.success('Finding deleted'); + mutate(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete finding'); + } + }, + [deleteFinding, mutate], + ); + + const openFindingsCount = sortedFindings.filter( + (f: Finding) => f.status === FindingStatus.open || f.status === FindingStatus.needs_revision, + ).length; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ + Failed to load findings +
+ ); + } + + return ( +
+
+
+
+
+ +
+
+

Findings

+

+ Audit findings and issues requiring attention +

+
+
+ +
+ {openFindingsCount > 0 && ( +
+
+ + {openFindingsCount} requires action + +
+ )} + + {canCreateFinding && ( + mutate()} /> + )} +
+
+
+ +
+ {sortedFindings.length === 0 ? ( +
+ +

No findings for this area

+ {canCreateFinding && ( +

Create a finding to flag an issue

+ )} +
+ ) : ( +
+ {visibleFindings.map((finding: Finding) => ( + setExpandedId(expandedId === finding.id ? null : finding.id)} + onStatusChange={(status, revisionNote) => + handleStatusChange(finding.id, status, revisionNote) + } + onDelete={() => handleDelete(finding.id)} + onViewHistory={onViewHistory ? () => onViewHistory(finding.id) : undefined} + /> + ))} + + {sortedFindings.length > INITIAL_DISPLAY_COUNT && ( +
+ +
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index 8fa34be45d..e1290d0376 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -28,12 +28,20 @@ export interface TeamMembersProps { canManageMembers: boolean; canInviteUsers: boolean; isAuditor: boolean; + isPlatformAdmin: boolean; isCurrentUserOwner: boolean; organizationId: string; } export async function TeamMembers(props: TeamMembersProps) { - const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner, organizationId } = props; + const { + canManageMembers, + canInviteUsers, + isAuditor, + isPlatformAdmin, + isCurrentUserOwner, + organizationId, + } = props; if (!organizationId) { return null; @@ -153,6 +161,7 @@ export async function TeamMembers(props: TeamMembersProps) { canManageMembers={canManageMembers} canInviteUsers={canInviteUsers} isAuditor={isAuditor} + isPlatformAdmin={isPlatformAdmin} isCurrentUserOwner={isCurrentUserOwner} employeeSyncData={employeeSyncData} taskCompletionMap={taskCompletionMap} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index f0e6268373..00329efa73 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -3,6 +3,7 @@ import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; + import { toast } from 'sonner'; import { useApi } from '@/hooks/use-api'; @@ -43,6 +44,7 @@ import type { MemberWithUser, TaskCompletion, TeamMembersData } from './TeamMemb import type { EmployeeSyncConnectionsData } from '../data/queries'; import { useEmployeeSync } from '../hooks/useEmployeeSync'; +import { PeopleFindings } from './PeopleFindings'; interface TeamMembersClientProps { data: TeamMembersData; @@ -50,6 +52,7 @@ interface TeamMembersClientProps { canManageMembers: boolean; canInviteUsers: boolean; isAuditor: boolean; + isPlatformAdmin: boolean; isCurrentUserOwner: boolean; employeeSyncData: EmployeeSyncConnectionsData; taskCompletionMap: Record; @@ -62,6 +65,7 @@ export function TeamMembersClient({ canManageMembers, canInviteUsers, isAuditor, + isPlatformAdmin, isCurrentUserOwner, employeeSyncData, taskCompletionMap, @@ -486,6 +490,12 @@ export function TeamMembersClient({ )} + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index 6a2fcb9cf5..d7a16e3fd4 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -41,6 +41,7 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: const isAuditor = currentUserRoles.includes('auditor'); const canInviteUsers = canManageMembers || isAuditor; const isCurrentUserOwner = currentUserRoles.includes('owner'); + const isPlatformAdmin = session.user.role === 'admin'; // Fetch members with user info (used for both employee check and org chart) const membersWithUsers = await db.member.findMany({ @@ -162,6 +163,7 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: canManageMembers={canManageMembers} canInviteUsers={canInviteUsers} isAuditor={isAuditor} + isPlatformAdmin={isPlatformAdmin} isCurrentUserOwner={isCurrentUserOwner} organizationId={orgId} /> diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx index eea28690a3..725dece7cf 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx @@ -1,6 +1,7 @@ 'use client'; import type { EvidenceFormType } from '@trycompai/company'; +import type { FindingScope } from '@db'; import { Button } from '@trycompai/design-system'; import { Plus } from 'lucide-react'; import { useState } from 'react'; @@ -10,6 +11,7 @@ interface CreateFindingButtonProps { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; + scope?: FindingScope; onSuccess?: () => void; } @@ -17,6 +19,7 @@ export function CreateFindingButton({ taskId, evidenceSubmissionId, evidenceFormType, + scope, onSuccess, }: CreateFindingButtonProps) { const [open, setOpen] = useState(false); @@ -30,6 +33,7 @@ export function CreateFindingButton({ taskId={taskId} evidenceSubmissionId={evidenceSubmissionId} evidenceFormType={evidenceFormType} + scope={scope} open={open} onOpenChange={setOpen} onSuccess={onSuccess} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx index ed582387d9..5a83509179 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx @@ -9,6 +9,7 @@ import { type FindingTemplate, } from '@/hooks/use-findings-api'; import type { EvidenceFormType } from '@trycompai/company'; +import type { FindingScope } from '@db'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@trycompai/ui/form'; import { useMediaQuery } from '@trycompai/ui/hooks'; import { FindingType } from '@db'; @@ -51,6 +52,7 @@ interface CreateFindingSheetProps { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; + scope?: FindingScope; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; @@ -60,6 +62,7 @@ export function CreateFindingSheet({ taskId, evidenceSubmissionId, evidenceFormType, + scope, open, onOpenChange, onSuccess, @@ -113,6 +116,7 @@ export function CreateFindingSheet({ taskId, evidenceSubmissionId, evidenceFormType, + scope, type: data.type, templateId: templateId || undefined, content: data.content, @@ -127,7 +131,7 @@ export function CreateFindingSheet({ setIsSubmitting(false); } }, - [createFinding, taskId, evidenceSubmissionId, evidenceFormType, onOpenChange, form, onSuccess], + [createFinding, taskId, evidenceSubmissionId, evidenceFormType, scope, onOpenChange, form, onSuccess], ); const handleTemplateChange = useCallback( diff --git a/apps/app/src/hooks/use-findings-api.ts b/apps/app/src/hooks/use-findings-api.ts index 1eaa0d8ff9..771c1baa95 100644 --- a/apps/app/src/hooks/use-findings-api.ts +++ b/apps/app/src/hooks/use-findings-api.ts @@ -3,7 +3,7 @@ import { useApi } from '@/hooks/use-api'; import { useApiSWR, UseApiSWROptions } from '@/hooks/use-api-swr'; import type { EvidenceFormType } from '@trycompai/company'; -import type { FindingStatus, FindingType } from '@db'; +import type { FindingStatus, FindingType, FindingScope } from '@db'; import { useCallback } from 'react'; // Types for findings @@ -18,6 +18,7 @@ export interface Finding { taskId: string | null; evidenceSubmissionId: string | null; evidenceFormType: EvidenceFormType | null; + scope?: FindingScope | null; templateId: string | null; createdById: string; organizationId: string; @@ -61,6 +62,7 @@ interface CreateFindingData { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; + scope?: FindingScope; type?: FindingType; templateId?: string; content: string; @@ -151,6 +153,18 @@ export function useFormTypeFindings( }); } +/** + * Hook to fetch findings for a People-area scope (directory, devices, etc.) + */ +export function useScopeFindings(scope: FindingScope | null, options: UseFindingsOptions = {}) { + const endpoint = scope ? `/v1/findings?scope=${encodeURIComponent(scope)}` : null; + + return useApiSWR(endpoint, { + ...options, + refreshInterval: options.refreshInterval ?? DEFAULT_FINDINGS_POLLING_INTERVAL, + }); +} + /** * Hook to fetch all findings for an organization */ From 02f37c8f02774833fbadfa57992f5133e25e046d Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 7 Apr 2026 23:59:17 -0400 Subject: [PATCH 04/29] feat(app): add Findings on People/Tasks tab --- .../components/EmployeesOverview.tsx | 20 ++++++++++++++++++- .../app/src/app/(app)/[orgId]/people/page.tsx | 10 +++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx index 61f128c55f..e75f7f35bf 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx @@ -5,8 +5,16 @@ import { auth } from '@/utils/auth'; import type { EmployeeTrainingVideoCompletion, Member, Organization, Policy, User } from '@db'; import { db } from '@db/server'; import { headers } from 'next/headers'; +import { FindingScope } from '@db'; +import { PeopleFindings } from '../../all/components/PeopleFindings'; import { EmployeeCompletionChart } from './EmployeeCompletionChart'; +interface EmployeesOverviewProps { + isAuditor: boolean; + isPlatformAdmin: boolean; + isAdminOrOwner: boolean; +} + // Define EmployeeWithUser type similar to EmployeesList interface EmployeeWithUser extends Member { user: User; @@ -27,7 +35,11 @@ interface ProcessedTrainingVideo { }; } -export async function EmployeesOverview() { +export async function EmployeesOverview({ + isAuditor, + isPlatformAdmin, + isAdminOrOwner, +}: EmployeesOverviewProps) { const session = await auth.api.getSession({ headers: await headers(), }); @@ -123,6 +135,12 @@ export async function EmployeesOverview() { hasHipaaFramework={hasHipaaFramework} hipaaCompletions={hipaaCompletions} /> +
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index d7a16e3fd4..aa7cc18bbe 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -168,7 +168,15 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: organizationId={orgId} /> } - employeeTasksContent={showEmployeeTasks ? : null} + employeeTasksContent={ + showEmployeeTasks ? ( + + ) : null + } devicesContent={
{/* Device Agent devices (new system) */} From 6e5ff45bf3752c194b035e8e3617f1ec571ac576 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 7 Apr 2026 23:59:44 -0400 Subject: [PATCH 05/29] fix(docs): update doc --- packages/docs/openapi.json | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index c985d60c58..b433ddd537 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -13824,6 +13824,21 @@ ], "type": "string" } + }, + { + "name": "scope", + "required": false, + "in": "query", + "description": "People-area scope (e.g. people directory)", + "schema": { + "enum": [ + "people", + "people_tasks", + "people_devices", + "people_chart" + ], + "type": "string" + } } ], "responses": { @@ -24586,6 +24601,16 @@ "tabletop-exercise" ] }, + "scope": { + "type": "string", + "description": "People area scope (e.g. people directory) when not tied to a task or evidence", + "enum": [ + "people", + "people_tasks", + "people_devices", + "people_chart" + ] + }, "type": { "type": "string", "description": "Type of finding (SOC 2 or ISO 27001)", From 382d436f3c10ab3949a39b8a7b0ad957d49b3c25 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 00:20:23 -0400 Subject: [PATCH 06/29] feat(app): add Findings on People/Devices tab --- apps/app/src/app/(app)/[orgId]/people/page.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index aa7cc18bbe..d1c75311b5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -3,10 +3,12 @@ import { auth } from '@/utils/auth'; import { s3Client, BUCKET_NAME } from '@/app/s3'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { FindingScope } from '@db'; import { db } from '@db/server'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; +import { PeopleFindings } from './all/components/PeopleFindings'; import { TeamMembers } from './all/components/TeamMembers'; import { getEmployeeSyncConnections } from './all/data/queries'; import { PeoplePageTabs } from './components/PeoplePageTabs'; @@ -187,6 +189,12 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: {/* Fleet devices (legacy) — shown exactly as main branch */} +
} orgChartContent={ From b03e4b22ee9697cf6a1799331d30847720cd3e1b Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 00:22:13 -0400 Subject: [PATCH 07/29] feat(app): add Findings on People/Chart tab --- .../org-chart/components/OrgChartContent.tsx | 70 ++++++++++++++----- .../app/src/app/(app)/[orgId]/people/page.tsx | 3 + 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx index 09c4bc6ccf..f17e2fff6e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx @@ -1,6 +1,9 @@ 'use client'; import type { Edge, Node } from '@xyflow/react'; +import { FindingScope } from '@db'; + +import { PeopleFindings } from '../../all/components/PeopleFindings'; import { OrgChartEditor } from './OrgChartEditor'; import { OrgChartEmptyState } from './OrgChartEmptyState'; import { OrgChartImageView } from './OrgChartImageView'; @@ -19,50 +22,79 @@ interface OrgChartData { interface OrgChartContentProps { chartData: OrgChartData | null; members: OrgChartMember[]; + isAuditor: boolean; + isPlatformAdmin: boolean; + isAdminOrOwner: boolean; } export function OrgChartContent({ chartData, members, + isAuditor, + isPlatformAdmin, + isAdminOrOwner, }: OrgChartContentProps) { + const findingsSection = ( + + ); + // No chart exists yet - show empty state if (!chartData) { - return ; + return ( +
+ + {findingsSection} +
+ ); } // Uploaded image mode if (chartData.type === 'uploaded' && chartData.signedImageUrl) { return ( - +
+ + {findingsSection} +
); } // Uploaded chart but image could not be loaded (e.g. S3 unavailable) if (chartData.type === 'uploaded') { return ( -
-
-

- The uploaded org chart image could not be loaded. -

-

- Please try again later or re-upload the image. -

+
+
+
+

+ The uploaded org chart image could not be loaded. +

+

+ Please try again later or re-upload the image. +

+
+ {findingsSection}
); } // Interactive mode return ( - +
+ + {findingsSection} +
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index d1c75311b5..b68d9177cc 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -201,6 +201,9 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: } showRoleMapping={false} From 50cfe1186ce3f91cdbf004758f35ed0bc45561b9 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 08:49:16 -0400 Subject: [PATCH 08/29] fix(app): update finding title on Findings overview --- .../overview/components/FindingsOverview.tsx | 61 +++++++++++++------ 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx index efa51e0430..c89beadc8d 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx @@ -4,7 +4,7 @@ import { Button } from '@trycompai/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@trycompai/ui/card'; import { ScrollArea } from '@trycompai/ui/scroll-area'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@trycompai/ui/tabs'; -import { Finding, FindingStatus } from '@db'; +import { Finding, FindingScope, FindingStatus } from '@db'; import { ArrowRight, CheckCircle2, FileWarning } from 'lucide-react'; import Link from 'next/link'; import { useMemo } from 'react'; @@ -20,6 +20,46 @@ interface FindingWithTask extends Finding { } | null; } +function findingListTitle(finding: FindingWithTask): string { + if (finding.task?.title) { + return finding.task.title; + } + if (finding.evidenceFormType) { + return `Document: ${finding.evidenceFormType.replace(/-/g, ' ')}`; + } + if (finding.evidenceSubmission) { + return `Document: ${finding.evidenceSubmission.formType.replace(/-/g, ' ')}`; + } + switch (finding.scope) { + case FindingScope.people: + return 'People'; + case FindingScope.people_tasks: + return 'People: Tasks'; + case FindingScope.people_devices: + return 'People: Devices'; + case FindingScope.people_chart: + return 'People: Chart'; + default: + return 'Finding'; + } +} + +function findingHref(finding: FindingWithTask, organizationId: string): string { + if (finding.task) { + return `/${organizationId}/tasks/${finding.task.id}?tab=findings`; + } + if (finding.evidenceFormType) { + return `/${organizationId}/documents/${finding.evidenceFormType}?tab=findings`; + } + if (finding.evidenceSubmission) { + return `/${organizationId}/documents/${finding.evidenceSubmission.formType}?tab=findings`; + } + if (finding.scope) { + return `/${organizationId}/people#people-findings`; + } + return `/${organizationId}/overview`; +} + function FindingsList({ findings, organizationId, @@ -40,12 +80,7 @@ function FindingsList({
- {finding.task?.title ?? - (finding.evidenceFormType - ? `Document: ${finding.evidenceFormType.replace(/-/g, ' ')}` - : finding.evidenceSubmission - ? `Document: ${finding.evidenceSubmission.formType.replace(/-/g, ' ')}` - : 'Finding')} + {findingListTitle(finding)}

{finding.content} @@ -53,17 +88,7 @@ function FindingsList({

From 90b1f42bd3dab2711d0b05eda8de8d234defb4ba Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 09:06:24 -0400 Subject: [PATCH 09/29] feat(app): sync people page tabs with URL hashes --- .../overview/components/FindingsOverview.tsx | 12 ++- .../people/components/PeoplePageTabs.tsx | 80 ++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx index c89beadc8d..615034c230 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx @@ -55,7 +55,17 @@ function findingHref(finding: FindingWithTask, organizationId: string): string { return `/${organizationId}/documents/${finding.evidenceSubmission.formType}?tab=findings`; } if (finding.scope) { - return `/${organizationId}/people#people-findings`; + const peopleHash = + finding.scope === FindingScope.people + ? 'people' + : finding.scope === FindingScope.people_tasks + ? 'tasks' + : finding.scope === FindingScope.people_devices + ? 'devices' + : finding.scope === FindingScope.people_chart + ? 'chart' + : 'people'; + return `/${organizationId}/people#${peopleHash}`; } return `/${organizationId}/overview`; } diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 1b1249871c..1d36fe0228 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -11,7 +11,7 @@ import { } from '@trycompai/design-system'; import { Add } from '@trycompai/design-system/icons'; import type { ReactNode } from 'react'; -import { useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { InviteMembersModal } from '../all/components/InviteMembersModal'; interface PeoplePageTabsProps { @@ -27,6 +27,54 @@ interface PeoplePageTabsProps { organizationId: string; } +/** Tab value (Radix) → URL hash fragment (without #) */ +function hashForTab(tab: string): string { + switch (tab) { + case 'people': + return 'people'; + case 'employee-tasks': + return 'tasks'; + case 'devices': + return 'devices'; + case 'org-chart': + return 'chart'; + case 'role-mapping': + return 'role-mapping'; + default: + return 'people'; + } +} + +/** URL hash → tab value; falls back to `people` when missing or unavailable */ +function tabFromHash( + hash: string, + showEmployeeTasks: boolean, + showRoleMapping: boolean, +): string { + const raw = hash.replace(/^#/, '').toLowerCase(); + if (!raw || raw === 'people') { + return 'people'; + } + if (raw === 'tasks') { + return showEmployeeTasks ? 'employee-tasks' : 'people'; + } + if (raw === 'devices') { + return 'devices'; + } + if (raw === 'chart') { + return 'org-chart'; + } + if (raw === 'role-mapping') { + return showRoleMapping ? 'role-mapping' : 'people'; + } + return 'people'; +} + +function replaceUrlHash(nextHash: string) { + const url = `${window.location.pathname}${window.location.search}#${nextHash}`; + window.history.replaceState(null, '', url); +} + export function PeoplePageTabs({ peopleContent, employeeTasksContent, @@ -40,9 +88,37 @@ export function PeoplePageTabs({ organizationId, }: PeoplePageTabsProps) { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + const [activeTab, setActiveTab] = useState('people'); + + const syncFromLocation = useCallback(() => { + const tab = tabFromHash(window.location.hash, showEmployeeTasks, showRoleMapping); + setActiveTab(tab); + const expectedHash = hashForTab(tab); + const currentRaw = window.location.hash.replace(/^#/, ''); + if (currentRaw !== '' && currentRaw !== expectedHash) { + replaceUrlHash(expectedHash); + } + }, [showEmployeeTasks, showRoleMapping]); + + useLayoutEffect(() => { + syncFromLocation(); + }, [syncFromLocation]); + + useEffect(() => { + const onHashChange = () => { + setActiveTab(tabFromHash(window.location.hash, showEmployeeTasks, showRoleMapping)); + }; + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, [showEmployeeTasks, showRoleMapping]); + + const handleTabChange = (value: string) => { + setActiveTab(value); + replaceUrlHash(hashForTab(value)); + }; return ( - + Date: Wed, 8 Apr 2026 10:07:46 -0400 Subject: [PATCH 10/29] fix(api): correct finding notifications for People scope findings --- .../findings/finding-notifier.service.spec.ts | 34 +++- .../src/findings/finding-notifier.service.ts | 158 +++++++++++++++--- apps/api/src/findings/findings.service.ts | 2 + 3 files changed, 170 insertions(+), 24 deletions(-) diff --git a/apps/api/src/findings/finding-notifier.service.spec.ts b/apps/api/src/findings/finding-notifier.service.spec.ts index 9ba8a4e873..404d76580a 100644 --- a/apps/api/src/findings/finding-notifier.service.spec.ts +++ b/apps/api/src/findings/finding-notifier.service.spec.ts @@ -10,6 +10,12 @@ jest.mock( soc2: 'soc2', iso27001: 'iso27001', }, + FindingScope: { + people: 'people', + people_tasks: 'people_tasks', + people_devices: 'people_devices', + people_chart: 'people_chart', + }, FindingStatus: { open: 'open', ready_for_review: 'ready_for_review', @@ -75,8 +81,14 @@ const mockDbModule: { soc2: 'soc2'; iso27001: 'iso27001'; }; + FindingScope: { + people: 'people'; + people_tasks: 'people_tasks'; + people_devices: 'people_devices'; + people_chart: 'people_chart'; + }; } = jest.requireMock('@db'); -const { db, FindingType } = mockDbModule; +const { db, FindingType, FindingScope } = mockDbModule; describe('FindingNotifierService', () => { const mockedDb = db; @@ -192,5 +204,25 @@ describe('FindingNotifierService', () => { }), ); }); + + it('builds People page URLs with hash for scope-based findings', async () => { + await service.notifyFindingCreated({ + organizationId: 'org_123', + findingId: 'fdg_scope', + findingScope: FindingScope.people_devices, + findingContent: 'Device area finding', + findingType: FindingType.soc2, + actorUserId: 'usr_actor', + actorName: 'Actor', + }); + + expect(novuTriggerMock).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + findingUrl: 'https://app.trycomp.ai/org_123/people#devices', + }), + }), + ); + }); }); }); diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts index a7fff542bc..7f27ad9bab 100644 --- a/apps/api/src/findings/finding-notifier.service.ts +++ b/apps/api/src/findings/finding-notifier.service.ts @@ -1,4 +1,4 @@ -import { db, FindingStatus, FindingType } from '@db'; +import { db, FindingScope, FindingStatus, FindingType } from '@db'; import { Injectable, Logger } from '@nestjs/common'; import { isUserUnsubscribed } from '@trycompai/email'; import { triggerEmail } from '../email/trigger-email'; @@ -37,6 +37,7 @@ interface NotificationParams { evidenceSubmissionId?: string; evidenceSubmissionFormType?: string; evidenceSubmissionSubmittedById?: string | null; + findingScope?: FindingScope | null; findingContent: string; findingType: FindingType; actorUserId: string; @@ -100,6 +101,92 @@ function getDocumentContextTitle( return 'Document submission'; } +/** Matches People page tab URL hashes (see PeoplePageTabs). */ +function scopeHashFragment(scope: FindingScope): string { + switch (scope) { + case FindingScope.people: + return 'people'; + case FindingScope.people_tasks: + return 'tasks'; + case FindingScope.people_devices: + return 'devices'; + case FindingScope.people_chart: + return 'chart'; + default: + return 'people'; + } +} + +function scopeContextTitle(scope: FindingScope): string { + switch (scope) { + case FindingScope.people: + return 'People'; + case FindingScope.people_tasks: + return 'People: Tasks'; + case FindingScope.people_devices: + return 'People: Devices'; + case FindingScope.people_chart: + return 'People: Chart'; + default: + return 'People'; + } +} + +function resolveFindingContextTitle(params: { + taskTitle?: string; + evidenceSubmissionFormType?: string; + evidenceSubmissionId?: string; + findingScope?: FindingScope | null; +}): string { + const { + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + } = params; + if (taskTitle) { + return taskTitle; + } + if (findingScope) { + return scopeContextTitle(findingScope); + } + return getDocumentContextTitle( + evidenceSubmissionFormType, + evidenceSubmissionId, + ); +} + +function buildFindingDeepLink(params: { + organizationId: string; + taskId?: string; + evidenceSubmissionId?: string; + evidenceSubmissionFormType?: string; + findingScope?: FindingScope | null; +}): string { + const base = getAppUrl(); + const { + organizationId, + taskId, + evidenceSubmissionId, + evidenceSubmissionFormType, + findingScope, + } = params; + + if (evidenceSubmissionId && evidenceSubmissionFormType) { + return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}`; + } + if (evidenceSubmissionFormType) { + return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}`; + } + if (taskId) { + return `${base}/${organizationId}/tasks/${taskId}`; + } + if (findingScope) { + return `${base}/${organizationId}/people#${scopeHashFragment(findingScope)}`; + } + return `${base}/${organizationId}/overview`; +} + // ============================================================================ // Service // ============================================================================ @@ -129,6 +216,7 @@ export class FindingNotifierService { findingType, actorUserId, actorName, + findingScope, } = params; const recipients = taskId @@ -145,10 +233,17 @@ export class FindingNotifierService { return; } - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); - const contextLabel = taskId ? 'task' : 'document submission'; + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); + const contextLabel = taskId + ? 'task' + : findingScope + ? 'People area' + : 'document submission'; await this.sendNotifications({ ...params, @@ -175,6 +270,7 @@ export class FindingNotifierService { actorUserId, actorName, findingCreatorMemberId, + findingScope, } = params; this.logger.log( @@ -197,9 +293,12 @@ export class FindingNotifierService { `[notifyReadyForReview] Finding ${findingId}: Sending to ${recipients.length} recipient(s): ${recipients.map((r) => r.email).join(', ')}`, ); - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); await this.sendNotifications({ ...params, @@ -226,6 +325,7 @@ export class FindingNotifierService { evidenceSubmissionSubmittedById, actorUserId, actorName, + findingScope, } = params; const recipients = taskId @@ -242,9 +342,12 @@ export class FindingNotifierService { return; } - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); await this.sendNotifications({ ...params, @@ -271,6 +374,7 @@ export class FindingNotifierService { evidenceSubmissionSubmittedById, actorUserId, actorName, + findingScope, } = params; const recipients = taskId @@ -287,9 +391,12 @@ export class FindingNotifierService { return; } - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); await this.sendNotifications({ ...params, @@ -328,6 +435,7 @@ export class FindingNotifierService { heading, message, newStatus, + findingScope, } = params; // Fetch organization name @@ -337,15 +445,19 @@ export class FindingNotifierService { }); const organizationName = organization?.name ?? 'your organization'; - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); - const findingUrl = - evidenceSubmissionId && evidenceSubmissionFormType - ? `${getAppUrl()}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}` - : evidenceSubmissionFormType - ? `${getAppUrl()}/${organizationId}/documents/${evidenceSubmissionFormType}` - : `${getAppUrl()}/${organizationId}/tasks/${taskId}`; + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); + const findingUrl = buildFindingDeepLink({ + organizationId, + taskId, + evidenceSubmissionId, + evidenceSubmissionFormType, + findingScope, + }); const typeLabel = TYPE_LABELS[findingType]; const statusLabel = newStatus ? STATUS_LABELS[newStatus] : undefined; diff --git a/apps/api/src/findings/findings.service.ts b/apps/api/src/findings/findings.service.ts index 9bbedeae50..072a853ac8 100644 --- a/apps/api/src/findings/findings.service.ts +++ b/apps/api/src/findings/findings.service.ts @@ -367,6 +367,7 @@ export class FindingsService { evidenceSubmissionId: evidenceSubmission?.id, evidenceSubmissionFormType: resolvedFormType, evidenceSubmissionSubmittedById: evidenceSubmission?.submittedById, + findingScope: createDto.scope ?? null, findingContent: createDto.content, findingType: createDto.type ?? FindingType.soc2, actorUserId: userId, @@ -539,6 +540,7 @@ export class FindingsService { finding.evidenceFormType ?? finding.evidenceSubmission?.formType, evidenceSubmissionSubmittedById: finding.evidenceSubmission?.submittedById, + findingScope: finding.scope ?? null, findingContent: updatedFinding.content, findingType: updatedFinding.type, actorUserId: userId, From 977389e36d7927a71a3e7ca136087ea5b8ebefe4 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 11:11:07 -0400 Subject: [PATCH 11/29] fix(api): resolve scope finding notification recipients to owners/admins --- .../findings/finding-notifier.service.spec.ts | 3 + .../src/findings/finding-notifier.service.ts | 146 ++++++++++++++---- 2 files changed, 122 insertions(+), 27 deletions(-) diff --git a/apps/api/src/findings/finding-notifier.service.spec.ts b/apps/api/src/findings/finding-notifier.service.spec.ts index 404d76580a..ac3210930a 100644 --- a/apps/api/src/findings/finding-notifier.service.spec.ts +++ b/apps/api/src/findings/finding-notifier.service.spec.ts @@ -216,6 +216,9 @@ describe('FindingNotifierService', () => { actorName: 'Actor', }); + expect(mockedDb.task.findUnique).not.toHaveBeenCalled(); + expect(mockedDb.evidenceSubmission.findUnique).not.toHaveBeenCalled(); + expect(novuTriggerMock).toHaveBeenCalledWith( expect.objectContaining({ payload: expect.objectContaining({ diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts index 7f27ad9bab..e466b541eb 100644 --- a/apps/api/src/findings/finding-notifier.service.ts +++ b/apps/api/src/findings/finding-notifier.service.ts @@ -203,7 +203,8 @@ export class FindingNotifierService { /** * Notify when a new finding is created. - * Recipients: All org members (filtered by notification matrix) + * Recipients: task-linked → assignee pool; People scope → owners/admins; + * document-linked → submitter + admins (see getSubmissionSubmitterAndAdmins). */ async notifyFindingCreated(params: NotificationParams): Promise { const { @@ -219,14 +220,14 @@ export class FindingNotifierService { findingScope, } = params; - const recipients = taskId - ? await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId) - : await this.getSubmissionSubmitterAndAdmins( - organizationId, - evidenceSubmissionId, - evidenceSubmissionSubmittedById, - actorUserId, - ); + const recipients = await this.resolveFindingNotificationRecipients({ + organizationId, + taskId, + findingScope, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + }); if (recipients.length === 0) { this.logger.log('No recipients for finding created notification'); @@ -313,7 +314,8 @@ export class FindingNotifierService { /** * Notify when status changes to Needs Revision. - * Recipients: All org members (filtered by notification matrix) + * Recipients: task-linked → assignee pool; People scope → owners/admins; + * document-linked → submitter + admins. */ async notifyNeedsRevision(params: NotificationParams): Promise { const { @@ -328,14 +330,14 @@ export class FindingNotifierService { findingScope, } = params; - const recipients = taskId - ? await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId) - : await this.getSubmissionSubmitterAndAdmins( - organizationId, - evidenceSubmissionId, - evidenceSubmissionSubmittedById, - actorUserId, - ); + const recipients = await this.resolveFindingNotificationRecipients({ + organizationId, + taskId, + findingScope, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + }); if (recipients.length === 0) { this.logger.log('No recipients for needs revision notification'); @@ -362,7 +364,8 @@ export class FindingNotifierService { /** * Notify when finding is closed. - * Recipients: All org members (filtered by notification matrix) + * Recipients: task-linked → assignee pool; People scope → owners/admins; + * document-linked → submitter + admins. */ async notifyFindingClosed(params: NotificationParams): Promise { const { @@ -377,14 +380,14 @@ export class FindingNotifierService { findingScope, } = params; - const recipients = taskId - ? await this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId) - : await this.getSubmissionSubmitterAndAdmins( - organizationId, - evidenceSubmissionId, - evidenceSubmissionSubmittedById, - actorUserId, - ); + const recipients = await this.resolveFindingNotificationRecipients({ + organizationId, + taskId, + findingScope, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + }); if (recipients.length === 0) { this.logger.log('No recipients for finding closed notification'); @@ -701,6 +704,95 @@ export class FindingNotifierService { // Private Methods - Recipient Resolution // ========================================================================== + /** + * Task findings → task notification pool; People scope → owners/admins only; + * otherwise document flow (submitter + admins). + */ + private async resolveFindingNotificationRecipients(args: { + organizationId: string; + taskId?: string; + findingScope?: FindingScope | null; + evidenceSubmissionId?: string; + evidenceSubmissionSubmittedById?: string | null; + actorUserId: string; + }): Promise { + const { + organizationId, + taskId, + findingScope, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + } = args; + + if (taskId) { + return this.getTaskAssigneeAndAdmins(organizationId, taskId, actorUserId); + } + if (findingScope) { + return this.getOrganizationOwnersAndAdmins(organizationId, actorUserId); + } + return this.getSubmissionSubmitterAndAdmins( + organizationId, + evidenceSubmissionId, + evidenceSubmissionSubmittedById, + actorUserId, + ); + } + + /** + * Recipients for People-area (scope) findings: organization owners and admins. + * Excludes the actor. Notification matrix (unsubscribe) still applies per recipient. + */ + private async getOrganizationOwnersAndAdmins( + organizationId: string, + excludeUserId: string, + ): Promise { + try { + const allMembers = await db.member.findMany({ + where: { + organizationId, + deactivated: false, + }, + select: { + role: true, + user: { select: { id: true, email: true, name: true } }, + }, + }); + + const members = allMembers.filter( + (member) => + member.role.includes('admin') || member.role.includes('owner'), + ); + + const recipients: Recipient[] = []; + const addedUserIds = new Set(); + + for (const member of members) { + const user = member.user; + if ( + user.id !== excludeUserId && + user.email && + !addedUserIds.has(user.id) + ) { + recipients.push({ + userId: user.id, + email: user.email, + name: user.name || user.email, + }); + addedUserIds.add(user.id); + } + } + + return recipients; + } catch (error) { + this.logger.error( + 'Failed to get organization owners and admins for scope finding:', + error instanceof Error ? error.message : 'Unknown error', + ); + return []; + } + } + /** * Get all organization members as potential recipients. * Excludes the actor (person who triggered the action). From da038feb1c0aa49271b82ba513960c5a1b197c92 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 11:24:09 -0400 Subject: [PATCH 12/29] fix(api): align finding notification deep links with context title precedence --- .../findings/finding-notifier.service.spec.ts | 21 +++++++++++++++++++ .../src/findings/finding-notifier.service.ts | 12 +++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/api/src/findings/finding-notifier.service.spec.ts b/apps/api/src/findings/finding-notifier.service.spec.ts index ac3210930a..5f37aac7bf 100644 --- a/apps/api/src/findings/finding-notifier.service.spec.ts +++ b/apps/api/src/findings/finding-notifier.service.spec.ts @@ -227,5 +227,26 @@ describe('FindingNotifierService', () => { }), ); }); + + it('aligns title and URL by preferring People scope over document fields when both are set', async () => { + await service.notifyFindingCreated({ + organizationId: 'org_123', + findingId: 'fdg_mixed', + findingScope: FindingScope.people, + evidenceSubmissionFormType: 'meeting', + findingContent: 'Ambiguous finding', + findingType: FindingType.soc2, + actorUserId: 'usr_actor', + actorName: 'Actor', + }); + + expect(novuTriggerMock).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + findingUrl: 'https://app.trycomp.ai/org_123/people#people', + }), + }), + ); + }); }); }); diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts index e466b541eb..9ad6b3cbb5 100644 --- a/apps/api/src/findings/finding-notifier.service.ts +++ b/apps/api/src/findings/finding-notifier.service.ts @@ -172,18 +172,18 @@ function buildFindingDeepLink(params: { findingScope, } = params; - if (evidenceSubmissionId && evidenceSubmissionFormType) { - return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}`; - } - if (evidenceSubmissionFormType) { - return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}`; - } if (taskId) { return `${base}/${organizationId}/tasks/${taskId}`; } if (findingScope) { return `${base}/${organizationId}/people#${scopeHashFragment(findingScope)}`; } + if (evidenceSubmissionId && evidenceSubmissionFormType) { + return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}`; + } + if (evidenceSubmissionFormType) { + return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}`; + } return `${base}/${organizationId}/overview`; } From c48b845545412c05e728c5a8da5ca73d8c8ad3ba Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 12:54:23 -0400 Subject: [PATCH 13/29] fix(app): preserve #finding- hash on People page tab sync --- .../[orgId]/people/components/PeoplePageTabs.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 1d36fe0228..f93ad63e38 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -45,6 +45,14 @@ function hashForTab(tab: string): string { } } +/** + * Deep link to expand/highlight a finding (PeopleFindingsList, FindingsList). + * Must not be rewritten by tab hash normalization. + */ +function isFindingDeepLinkHash(hash: string): boolean { + return hash.replace(/^#/, '').toLowerCase().startsWith('finding-'); +} + /** URL hash → tab value; falls back to `people` when missing or unavailable */ function tabFromHash( hash: string, @@ -52,6 +60,9 @@ function tabFromHash( showRoleMapping: boolean, ): string { const raw = hash.replace(/^#/, '').toLowerCase(); + if (raw.startsWith('finding-')) { + return 'people'; + } if (!raw || raw === 'people') { return 'people'; } @@ -93,6 +104,9 @@ export function PeoplePageTabs({ const syncFromLocation = useCallback(() => { const tab = tabFromHash(window.location.hash, showEmployeeTasks, showRoleMapping); setActiveTab(tab); + if (isFindingDeepLinkHash(window.location.hash)) { + return; + } const expectedHash = hashForTab(tab); const currentRaw = window.location.hash.replace(/^#/, ''); if (currentRaw !== '' && currentRaw !== expectedHash) { From a82e84c8f9c9bda79780df1649e571aeafb91208 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 13:20:44 -0400 Subject: [PATCH 14/29] fix(app): fix finding redirect issue on FindingsOverview --- .../people/all/components/PeopleFindingsList.tsx | 10 ++-------- .../[orgId]/people/components/PeoplePageTabs.tsx | 14 -------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx index 224b57e633..e66fecb09a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx @@ -49,19 +49,14 @@ export function PeopleFindingsList({ const { hasPermission } = usePermissions(); const [expandedId, setExpandedId] = useState(null); const [showAll, setShowAll] = useState(false); - const [targetFindingId, setTargetFindingId] = useState(null); useEffect(() => { if (typeof window === 'undefined') return; const hash = window.location.hash; - if (hash.startsWith('#finding-')) { - const findingId = hash.replace('#finding-', ''); - setTargetFindingId(findingId); - setShowAll(true); - + if (hash) { const timer = setTimeout(() => { - setTargetFindingId(null); + // Clean up the URL hash window.history.replaceState(null, '', window.location.pathname); }, 2500); @@ -199,7 +194,6 @@ export function PeopleFindingsList({ key={finding.id} finding={finding} isExpanded={expandedId === finding.id} - isTarget={targetFindingId === finding.id} canChangeStatus={canChangeStatus} canSetRestrictedStatus={canSetRestrictedStatus} canSetReadyForReview={isPlatformAdmin || !isAuditor} diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index f93ad63e38..1d36fe0228 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -45,14 +45,6 @@ function hashForTab(tab: string): string { } } -/** - * Deep link to expand/highlight a finding (PeopleFindingsList, FindingsList). - * Must not be rewritten by tab hash normalization. - */ -function isFindingDeepLinkHash(hash: string): boolean { - return hash.replace(/^#/, '').toLowerCase().startsWith('finding-'); -} - /** URL hash → tab value; falls back to `people` when missing or unavailable */ function tabFromHash( hash: string, @@ -60,9 +52,6 @@ function tabFromHash( showRoleMapping: boolean, ): string { const raw = hash.replace(/^#/, '').toLowerCase(); - if (raw.startsWith('finding-')) { - return 'people'; - } if (!raw || raw === 'people') { return 'people'; } @@ -104,9 +93,6 @@ export function PeoplePageTabs({ const syncFromLocation = useCallback(() => { const tab = tabFromHash(window.location.hash, showEmployeeTasks, showRoleMapping); setActiveTab(tab); - if (isFindingDeepLinkHash(window.location.hash)) { - return; - } const expectedHash = hashForTab(tab); const currentRaw = window.location.hash.replace(/^#/, ''); if (currentRaw !== '' && currentRaw !== expectedHash) { From cf5604294711b782bee83905617cd30c9ea89648 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 13:34:54 -0400 Subject: [PATCH 15/29] fix(app): fix hash cleanup issue on PeopleFindingsList --- .../all/components/PeopleFindingsList.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx index e66fecb09a..ee2c19a038 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx @@ -49,19 +49,27 @@ export function PeopleFindingsList({ const { hasPermission } = usePermissions(); const [expandedId, setExpandedId] = useState(null); const [showAll, setShowAll] = useState(false); + const [targetFindingId, setTargetFindingId] = useState(null); + // Deep link: #finding-{id}. Do not clear other hashes (#tasks, #devices, …) — those are tab state. useEffect(() => { if (typeof window === 'undefined') return; const hash = window.location.hash; - if (hash) { - const timer = setTimeout(() => { - // Clean up the URL hash - window.history.replaceState(null, '', window.location.pathname); - }, 2500); - - return () => clearTimeout(timer); - } + const match = hash.match(/^#finding-(.+)$/i); + if (!match) return; + + const findingId = match[1]; + setTargetFindingId(findingId); + setShowAll(true); + + const timer = setTimeout(() => { + setTargetFindingId(null); + const { pathname, search } = window.location; + window.history.replaceState(null, '', `${pathname}${search}`); + }, 2500); + + return () => clearTimeout(timer); }, []); const rawFindings = data?.data || []; @@ -194,6 +202,7 @@ export function PeopleFindingsList({ key={finding.id} finding={finding} isExpanded={expandedId === finding.id} + isTarget={targetFindingId === finding.id} canChangeStatus={canChangeStatus} canSetRestrictedStatus={canSetRestrictedStatus} canSetReadyForReview={isPlatformAdmin || !isAuditor} From f8203924386505c37089d3e529caf944222e242e Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 15:52:54 -0400 Subject: [PATCH 16/29] fix(api): fix people page url with tab --- apps/api/src/findings/finding-notifier.service.spec.ts | 6 +++--- apps/api/src/findings/finding-notifier.service.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/api/src/findings/finding-notifier.service.spec.ts b/apps/api/src/findings/finding-notifier.service.spec.ts index 5f37aac7bf..97192d0e92 100644 --- a/apps/api/src/findings/finding-notifier.service.spec.ts +++ b/apps/api/src/findings/finding-notifier.service.spec.ts @@ -205,7 +205,7 @@ describe('FindingNotifierService', () => { ); }); - it('builds People page URLs with hash for scope-based findings', async () => { + it('builds People page URLs with tab query for scope-based findings', async () => { await service.notifyFindingCreated({ organizationId: 'org_123', findingId: 'fdg_scope', @@ -222,7 +222,7 @@ describe('FindingNotifierService', () => { expect(novuTriggerMock).toHaveBeenCalledWith( expect.objectContaining({ payload: expect.objectContaining({ - findingUrl: 'https://app.trycomp.ai/org_123/people#devices', + findingUrl: 'https://app.trycomp.ai/org_123/people?tab=devices', }), }), ); @@ -243,7 +243,7 @@ describe('FindingNotifierService', () => { expect(novuTriggerMock).toHaveBeenCalledWith( expect.objectContaining({ payload: expect.objectContaining({ - findingUrl: 'https://app.trycomp.ai/org_123/people#people', + findingUrl: 'https://app.trycomp.ai/org_123/people?tab=people', }), }), ); diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts index 9ad6b3cbb5..c2c312a301 100644 --- a/apps/api/src/findings/finding-notifier.service.ts +++ b/apps/api/src/findings/finding-notifier.service.ts @@ -101,8 +101,8 @@ function getDocumentContextTitle( return 'Document submission'; } -/** Matches People page tab URL hashes (see PeoplePageTabs). */ -function scopeHashFragment(scope: FindingScope): string { +/** Matches People page `?tab=` query (see PeoplePageTabs). */ +function scopePeopleTabParam(scope: FindingScope): string { switch (scope) { case FindingScope.people: return 'people'; @@ -176,7 +176,7 @@ function buildFindingDeepLink(params: { return `${base}/${organizationId}/tasks/${taskId}`; } if (findingScope) { - return `${base}/${organizationId}/people#${scopeHashFragment(findingScope)}`; + return `${base}/${organizationId}/people?tab=${scopePeopleTabParam(findingScope)}`; } if (evidenceSubmissionId && evidenceSubmissionFormType) { return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}`; From 576337b3cdd216a3efb9b77efc54478afa87d2ef Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 15:53:39 -0400 Subject: [PATCH 17/29] fix(app): use tab param on people page instead of hash --- .../overview/components/FindingsOverview.tsx | 4 +- .../people/components/PeoplePageTabs.tsx | 78 ++++--------------- 2 files changed, 19 insertions(+), 63 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx index 615034c230..c070aaefad 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx @@ -55,7 +55,7 @@ function findingHref(finding: FindingWithTask, organizationId: string): string { return `/${organizationId}/documents/${finding.evidenceSubmission.formType}?tab=findings`; } if (finding.scope) { - const peopleHash = + const peopleTab = finding.scope === FindingScope.people ? 'people' : finding.scope === FindingScope.people_tasks @@ -65,7 +65,7 @@ function findingHref(finding: FindingWithTask, organizationId: string): string { : finding.scope === FindingScope.people_chart ? 'chart' : 'people'; - return `/${organizationId}/people#${peopleHash}`; + return `/${organizationId}/people?tab=${peopleTab}`; } return `/${organizationId}/overview`; } diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 1d36fe0228..8dde2ad604 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -10,8 +10,9 @@ import { TabsTrigger, } from '@trycompai/design-system'; import { Add } from '@trycompai/design-system/icons'; +import { useSearchParams } from 'next/navigation'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { useState } from 'react'; import { InviteMembersModal } from '../all/components/InviteMembersModal'; interface PeoplePageTabsProps { @@ -27,54 +28,30 @@ interface PeoplePageTabsProps { organizationId: string; } -/** Tab value (Radix) → URL hash fragment (without #) */ -function hashForTab(tab: string): string { - switch (tab) { - case 'people': - return 'people'; - case 'employee-tasks': - return 'tasks'; - case 'devices': - return 'devices'; - case 'org-chart': - return 'chart'; - case 'role-mapping': - return 'role-mapping'; - default: - return 'people'; - } -} - -/** URL hash → tab value; falls back to `people` when missing or unavailable */ -function tabFromHash( - hash: string, +/** ?tab= value → Radix tab value */ +function tabParamToInternal( + tabParam: string | null, showEmployeeTasks: boolean, showRoleMapping: boolean, ): string { - const raw = hash.replace(/^#/, '').toLowerCase(); - if (!raw || raw === 'people') { + if (!tabParam || tabParam === 'people') { return 'people'; } - if (raw === 'tasks') { + if (tabParam === 'tasks') { return showEmployeeTasks ? 'employee-tasks' : 'people'; } - if (raw === 'devices') { + if (tabParam === 'devices') { return 'devices'; } - if (raw === 'chart') { + if (tabParam === 'chart') { return 'org-chart'; } - if (raw === 'role-mapping') { + if (tabParam === 'role-mapping') { return showRoleMapping ? 'role-mapping' : 'people'; } return 'people'; } -function replaceUrlHash(nextHash: string) { - const url = `${window.location.pathname}${window.location.search}#${nextHash}`; - window.history.replaceState(null, '', url); -} - export function PeoplePageTabs({ peopleContent, employeeTasksContent, @@ -87,38 +64,17 @@ export function PeoplePageTabs({ canManageMembers, organizationId, }: PeoplePageTabsProps) { + const searchParams = useSearchParams(); const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); - const [activeTab, setActiveTab] = useState('people'); - const syncFromLocation = useCallback(() => { - const tab = tabFromHash(window.location.hash, showEmployeeTasks, showRoleMapping); - setActiveTab(tab); - const expectedHash = hashForTab(tab); - const currentRaw = window.location.hash.replace(/^#/, ''); - if (currentRaw !== '' && currentRaw !== expectedHash) { - replaceUrlHash(expectedHash); - } - }, [showEmployeeTasks, showRoleMapping]); - - useLayoutEffect(() => { - syncFromLocation(); - }, [syncFromLocation]); - - useEffect(() => { - const onHashChange = () => { - setActiveTab(tabFromHash(window.location.hash, showEmployeeTasks, showRoleMapping)); - }; - window.addEventListener('hashchange', onHashChange); - return () => window.removeEventListener('hashchange', onHashChange); - }, [showEmployeeTasks, showRoleMapping]); - - const handleTabChange = (value: string) => { - setActiveTab(value); - replaceUrlHash(hashForTab(value)); - }; + const defaultTab = tabParamToInternal( + searchParams.get('tab'), + showEmployeeTasks, + showRoleMapping, + ); return ( - + Date: Wed, 8 Apr 2026 15:55:10 -0400 Subject: [PATCH 18/29] fix(app): remove hash-related code from PeopleFindingsList --- .../all/components/PeopleFindingsList.tsx | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx index ee2c19a038..bc0316930a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx @@ -15,7 +15,7 @@ import { WarningAlt, WarningAltFilled, } from '@trycompai/design-system/icons'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { CreateFindingButton } from '../../../tasks/[taskId]/components/findings/CreateFindingButton'; import { FindingItem } from '../../../tasks/[taskId]/components/findings/FindingItem'; @@ -49,28 +49,6 @@ export function PeopleFindingsList({ const { hasPermission } = usePermissions(); const [expandedId, setExpandedId] = useState(null); const [showAll, setShowAll] = useState(false); - const [targetFindingId, setTargetFindingId] = useState(null); - - // Deep link: #finding-{id}. Do not clear other hashes (#tasks, #devices, …) — those are tab state. - useEffect(() => { - if (typeof window === 'undefined') return; - - const hash = window.location.hash; - const match = hash.match(/^#finding-(.+)$/i); - if (!match) return; - - const findingId = match[1]; - setTargetFindingId(findingId); - setShowAll(true); - - const timer = setTimeout(() => { - setTargetFindingId(null); - const { pathname, search } = window.location; - window.history.replaceState(null, '', `${pathname}${search}`); - }, 2500); - - return () => clearTimeout(timer); - }, []); const rawFindings = data?.data || []; @@ -202,7 +180,6 @@ export function PeopleFindingsList({ key={finding.id} finding={finding} isExpanded={expandedId === finding.id} - isTarget={targetFindingId === finding.id} canChangeStatus={canChangeStatus} canSetRestrictedStatus={canSetRestrictedStatus} canSetReadyForReview={isPlatformAdmin || !isAuditor} From 98882babb53a228ebd46d2fca07888e66241cb6d Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 8 Apr 2026 16:11:15 -0400 Subject: [PATCH 19/29] fix(app): minor change - remove empty line --- .../(app)/[orgId]/people/all/components/TeamMembersClient.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 00329efa73..82c95f2130 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -3,7 +3,6 @@ import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; - import { toast } from 'sonner'; import { useApi } from '@/hooks/use-api'; From 7851151c038725e8115f037fd6c6ff9930abcc76 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 10 Apr 2026 13:13:19 -0400 Subject: [PATCH 20/29] fix(app): remove Findings on People tabs and add new Findings tab on People --- .../people/all/components/TeamMembers.tsx | 11 +-- .../all/components/TeamMembersClient.tsx | 9 --- .../people/components/PeoplePageTabs.tsx | 7 ++ .../components/EmployeesOverview.tsx | 20 +----- .../org-chart/components/OrgChartContent.tsx | 69 +++++-------------- .../app/src/app/(app)/[orgId]/people/page.tsx | 22 ++---- 6 files changed, 34 insertions(+), 104 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index e1290d0376..8fa34be45d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -28,20 +28,12 @@ export interface TeamMembersProps { canManageMembers: boolean; canInviteUsers: boolean; isAuditor: boolean; - isPlatformAdmin: boolean; isCurrentUserOwner: boolean; organizationId: string; } export async function TeamMembers(props: TeamMembersProps) { - const { - canManageMembers, - canInviteUsers, - isAuditor, - isPlatformAdmin, - isCurrentUserOwner, - organizationId, - } = props; + const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner, organizationId } = props; if (!organizationId) { return null; @@ -161,7 +153,6 @@ export async function TeamMembers(props: TeamMembersProps) { canManageMembers={canManageMembers} canInviteUsers={canInviteUsers} isAuditor={isAuditor} - isPlatformAdmin={isPlatformAdmin} isCurrentUserOwner={isCurrentUserOwner} employeeSyncData={employeeSyncData} taskCompletionMap={taskCompletionMap} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 82c95f2130..f0e6268373 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -43,7 +43,6 @@ import type { MemberWithUser, TaskCompletion, TeamMembersData } from './TeamMemb import type { EmployeeSyncConnectionsData } from '../data/queries'; import { useEmployeeSync } from '../hooks/useEmployeeSync'; -import { PeopleFindings } from './PeopleFindings'; interface TeamMembersClientProps { data: TeamMembersData; @@ -51,7 +50,6 @@ interface TeamMembersClientProps { canManageMembers: boolean; canInviteUsers: boolean; isAuditor: boolean; - isPlatformAdmin: boolean; isCurrentUserOwner: boolean; employeeSyncData: EmployeeSyncConnectionsData; taskCompletionMap: Record; @@ -64,7 +62,6 @@ export function TeamMembersClient({ canManageMembers, canInviteUsers, isAuditor, - isPlatformAdmin, isCurrentUserOwner, employeeSyncData, taskCompletionMap, @@ -489,12 +486,6 @@ export function TeamMembersClient({ )} - - ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 8dde2ad604..4302827f20 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -20,6 +20,7 @@ interface PeoplePageTabsProps { employeeTasksContent: ReactNode | null; devicesContent: ReactNode; orgChartContent: ReactNode; + findingsContent: ReactNode; roleMappingContent: ReactNode | null; showRoleMapping: boolean; showEmployeeTasks: boolean; @@ -46,6 +47,9 @@ function tabParamToInternal( if (tabParam === 'chart') { return 'org-chart'; } + if (tabParam === 'findings') { + return 'findings'; + } if (tabParam === 'role-mapping') { return showRoleMapping ? 'role-mapping' : 'people'; } @@ -58,6 +62,7 @@ export function PeoplePageTabs({ devicesContent, orgChartContent, roleMappingContent, + findingsContent, showRoleMapping, showEmployeeTasks, canInviteUsers, @@ -85,6 +90,7 @@ export function PeoplePageTabs({ {showEmployeeTasks && Tasks} Devices Chart + Findings {showRoleMapping && Role Mapping} } @@ -109,6 +115,7 @@ export function PeoplePageTabs({ {showRoleMapping && ( {roleMappingContent} )} + {findingsContent} -
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx index f17e2fff6e..3d7d690124 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx @@ -1,9 +1,6 @@ 'use client'; import type { Edge, Node } from '@xyflow/react'; -import { FindingScope } from '@db'; - -import { PeopleFindings } from '../../all/components/PeopleFindings'; import { OrgChartEditor } from './OrgChartEditor'; import { OrgChartEmptyState } from './OrgChartEmptyState'; import { OrgChartImageView } from './OrgChartImageView'; @@ -22,79 +19,51 @@ interface OrgChartData { interface OrgChartContentProps { chartData: OrgChartData | null; members: OrgChartMember[]; - isAuditor: boolean; - isPlatformAdmin: boolean; - isAdminOrOwner: boolean; } export function OrgChartContent({ chartData, members, - isAuditor, - isPlatformAdmin, - isAdminOrOwner, }: OrgChartContentProps) { - const findingsSection = ( - - ); // No chart exists yet - show empty state if (!chartData) { - return ( -
- - {findingsSection} -
- ); + return ; } // Uploaded image mode if (chartData.type === 'uploaded' && chartData.signedImageUrl) { return ( -
- - {findingsSection} -
+ ); } // Uploaded chart but image could not be loaded (e.g. S3 unavailable) if (chartData.type === 'uploaded') { return ( -
-
-
-

- The uploaded org chart image could not be loaded. -

-

- Please try again later or re-upload the image. -

-
+
+
+

+ The uploaded org chart image could not be loaded. +

+

+ Please try again later or re-upload the image. +

- {findingsSection}
); } // Interactive mode return ( -
- - {findingsSection} -
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index b68d9177cc..02fc9d7cb5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -165,20 +165,11 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: canManageMembers={canManageMembers} canInviteUsers={canInviteUsers} isAuditor={isAuditor} - isPlatformAdmin={isPlatformAdmin} isCurrentUserOwner={isCurrentUserOwner} organizationId={orgId} /> } - employeeTasksContent={ - showEmployeeTasks ? ( - - ) : null - } + employeeTasksContent={showEmployeeTasks ? : null} devicesContent={
{/* Device Agent devices (new system) */} @@ -189,18 +180,17 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: {/* Fleet devices (legacy) — shown exactly as main branch */} -
} orgChartContent={ + } + findingsContent={ + Date: Fri, 10 Apr 2026 13:15:05 -0400 Subject: [PATCH 21/29] fix(app): add Scope select on 'Create Finding' sheet --- .../all/components/PeopleFindingsList.tsx | 2 +- .../findings/CreateFindingButton.tsx | 7 ++- .../findings/CreateFindingSheet.tsx | 49 ++++++++++++++++--- apps/app/src/hooks/use-findings-api.ts | 10 +++- 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx index bc0316930a..fc97e193af 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx @@ -158,7 +158,7 @@ export function PeopleFindingsList({ )} {canCreateFinding && ( - mutate()} /> + mutate()} /> )}
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx index 725dece7cf..4d3aa8a4f5 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingButton.tsx @@ -1,7 +1,6 @@ 'use client'; import type { EvidenceFormType } from '@trycompai/company'; -import type { FindingScope } from '@db'; import { Button } from '@trycompai/design-system'; import { Plus } from 'lucide-react'; import { useState } from 'react'; @@ -11,7 +10,7 @@ interface CreateFindingButtonProps { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; - scope?: FindingScope; + showScope?: boolean; onSuccess?: () => void; } @@ -19,7 +18,7 @@ export function CreateFindingButton({ taskId, evidenceSubmissionId, evidenceFormType, - scope, + showScope = false, onSuccess, }: CreateFindingButtonProps) { const [open, setOpen] = useState(false); @@ -33,7 +32,7 @@ export function CreateFindingButton({ taskId={taskId} evidenceSubmissionId={evidenceSubmissionId} evidenceFormType={evidenceFormType} - scope={scope} + showScope={showScope} open={open} onOpenChange={setOpen} onSuccess={onSuccess} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx index 5a83509179..c7b39aa959 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/CreateFindingSheet.tsx @@ -3,16 +3,16 @@ import { DEFAULT_FINDING_TEMPLATES, FINDING_CATEGORY_LABELS, + FINDING_SCOPE_LABELS, FINDING_TYPE_LABELS, useFindingActions, useFindingTemplates, type FindingTemplate, } from '@/hooks/use-findings-api'; import type { EvidenceFormType } from '@trycompai/company'; -import type { FindingScope } from '@db'; +import { FindingScope, FindingType } from '@db'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@trycompai/ui/form'; import { useMediaQuery } from '@trycompai/ui/hooks'; -import { FindingType } from '@db'; import { zodResolver } from '@hookform/resolvers/zod'; import { Button, @@ -42,6 +42,7 @@ import { usePermissions } from '@/hooks/use-permissions'; const createFindingSchema = z.object({ type: z.nativeEnum(FindingType), + scope: z.nativeEnum(FindingScope).optional(), templateId: z.string().nullable().optional(), content: z.string().min(1, { message: 'Finding content is required', @@ -52,7 +53,7 @@ interface CreateFindingSheetProps { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; - scope?: FindingScope; + showScope?: boolean; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; @@ -62,7 +63,7 @@ export function CreateFindingSheet({ taskId, evidenceSubmissionId, evidenceFormType, - scope, + showScope = false, open, onOpenChange, onSuccess, @@ -79,6 +80,7 @@ export function CreateFindingSheet({ resolver: zodResolver(createFindingSchema), defaultValues: { type: FindingType.soc2, + scope: FindingScope.people, templateId: null, content: '', }, @@ -116,14 +118,19 @@ export function CreateFindingSheet({ taskId, evidenceSubmissionId, evidenceFormType, - scope, + scope: showScope ? data.scope : undefined, type: data.type, templateId: templateId || undefined, content: data.content, }); toast.success('Finding created successfully'); onOpenChange(false); - form.reset(); + form.reset({ + type: FindingType.soc2, + scope: FindingScope.people, + templateId: null, + content: '', + }); onSuccess?.(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to create finding'); @@ -131,7 +138,7 @@ export function CreateFindingSheet({ setIsSubmitting(false); } }, - [createFinding, taskId, evidenceSubmissionId, evidenceFormType, scope, onOpenChange, form, onSuccess], + [createFinding, taskId, evidenceSubmissionId, evidenceFormType, showScope, onOpenChange, form, onSuccess], ); const handleTemplateChange = useCallback( @@ -181,6 +188,34 @@ export function CreateFindingSheet({ )} /> + {showScope && ( + ( + + Scope + + + + )} + /> + )} + = { soc2: 'SOC 2', iso27001: 'ISO 27001', }; + +/** Labels for People-area finding scopes (see FindingsOverview). */ +export const FINDING_SCOPE_LABELS: Record = { + [FindingScope.people]: 'People', + [FindingScope.people_tasks]: 'People: Tasks', + [FindingScope.people_devices]: 'People: Devices', + [FindingScope.people_chart]: 'People: Chart', +}; From b7dd6994c48c09e66f94bf4b09f6757521374c5d Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 10 Apr 2026 13:29:25 -0400 Subject: [PATCH 22/29] fix(api): add hasScope param to findings api endpoint --- .../src/findings/findings.controller.spec.ts | 20 ++++++++++++-- apps/api/src/findings/findings.controller.ts | 27 +++++++++++++++---- apps/api/src/findings/findings.service.ts | 19 +++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/apps/api/src/findings/findings.controller.spec.ts b/apps/api/src/findings/findings.controller.spec.ts index 2010d9b328..09bb64b0a4 100644 --- a/apps/api/src/findings/findings.controller.spec.ts +++ b/apps/api/src/findings/findings.controller.spec.ts @@ -62,11 +62,15 @@ describe('FindingsController', () => { const findingsServiceMock: Pick< FindingsService, - 'findByTaskId' | 'findByEvidenceFormType' | 'findByEvidenceSubmissionId' + | 'findByTaskId' + | 'findByEvidenceFormType' + | 'findByEvidenceSubmissionId' + | 'findWithScopeDefined' > = { findByTaskId: jest.fn(), findByEvidenceFormType: jest.fn(), findByEvidenceSubmissionId: jest.fn(), + findWithScopeDefined: jest.fn(), }; const controller = new FindingsController( @@ -84,6 +88,8 @@ describe('FindingsController', () => { '', '', 'not-a-valid-form-type', + '', + undefined, authContext, ), ).rejects.toThrow(BadRequestException); @@ -93,18 +99,28 @@ describe('FindingsController', () => { '', '', 'not-a-valid-form-type', + '', + undefined, authContext, ), ).rejects.toThrow('Invalid evidenceFormType value. Must be one of:'); }); it('routes valid evidenceFormType through findByEvidenceFormType', async () => { - await controller.getFindingsByTask('', '', 'meeting', authContext); + await controller.getFindingsByTask('', '', 'meeting', '', undefined, authContext); expect(findingsServiceMock.findByEvidenceFormType).toHaveBeenCalledWith( authContext.organizationId, 'meeting', ); }); + + it('routes hasScope=true through findWithScopeDefined', async () => { + await controller.getFindingsByTask('', '', '', '', 'true', authContext); + + expect(findingsServiceMock.findWithScopeDefined).toHaveBeenCalledWith( + authContext.organizationId, + ); + }); }); }); diff --git a/apps/api/src/findings/findings.controller.ts b/apps/api/src/findings/findings.controller.ts index f05affb158..e0cdc950a4 100644 --- a/apps/api/src/findings/findings.controller.ts +++ b/apps/api/src/findings/findings.controller.ts @@ -74,6 +74,13 @@ export class FindingsController { description: 'People-area scope (e.g. people directory)', enum: FindingScope, }) + @ApiQuery({ + name: 'hasScope', + required: false, + description: + 'When true, return all findings in the organization that have a scope value set', + type: Boolean, + }) @ApiResponse({ status: 200, description: 'List of findings', @@ -91,19 +98,25 @@ export class FindingsController { @Query('evidenceSubmissionId') evidenceSubmissionId: string, @Query('evidenceFormType') evidenceFormType: string, @Query('scope') scope: string, + @Query('hasScope') hasScope: string | undefined, @AuthContext() authContext: AuthContextType, ) { - const targets = [taskId, evidenceSubmissionId, evidenceFormType, scope].filter( - Boolean, - ); + const hasScopeParam = hasScope === 'true' || hasScope === '1'; + const targets = [ + taskId, + evidenceSubmissionId, + evidenceFormType, + scope, + hasScopeParam ? 'hasScope' : null, + ].filter(Boolean); if (targets.length === 0) { throw new BadRequestException( - 'One of taskId, evidenceSubmissionId, evidenceFormType, or scope query parameter is required', + 'One of taskId, evidenceSubmissionId, evidenceFormType, scope, or hasScope=true query parameter is required', ); } if (targets.length > 1) { throw new BadRequestException( - 'Provide only one target: taskId, evidenceSubmissionId, evidenceFormType, or scope', + 'Provide only one target: taskId, evidenceSubmissionId, evidenceFormType, scope, or hasScope=true', ); } @@ -116,6 +129,10 @@ export class FindingsController { ); } + if (hasScopeParam) { + return await this.findingsService.findWithScopeDefined(authContext.organizationId); + } + if (scope) { if (!Object.values(FindingScope).includes(scope as FindingScope)) { throw new BadRequestException( diff --git a/apps/api/src/findings/findings.service.ts b/apps/api/src/findings/findings.service.ts index 072a853ac8..e11d1f0c26 100644 --- a/apps/api/src/findings/findings.service.ts +++ b/apps/api/src/findings/findings.service.ts @@ -192,6 +192,25 @@ export class FindingsService { return findings.map((finding) => this.normalizeFindingFormTypes(finding)); } + /** + * Get all findings that have a People-area scope set (any non-null scope). + */ + async findWithScopeDefined(organizationId: string) { + const findings = await db.finding.findMany({ + where: { + organizationId, + scope: { not: null }, + }, + include: this.findingInclude, + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], + }); + + this.logger.log( + `Retrieved ${findings.length} findings with scope set in org ${organizationId}`, + ); + return findings.map((finding) => this.normalizeFindingFormTypes(finding)); + } + /** * Get all findings for an organization */ From 50b474d4b55ed4236f8f8461216bbddab74fa048 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 10 Apr 2026 13:42:09 -0400 Subject: [PATCH 23/29] fix(app): show all people-scope findings on People --- .../people/all/components/PeopleFindings.tsx | 9 --- .../components/PeopleFindingsList.test.tsx | 1 - .../all/components/PeopleFindingsList.tsx | 5 +- .../app/src/app/(app)/[orgId]/people/page.tsx | 2 - .../components/findings/FindingItem.tsx | 2 + .../components/findings/FindingScopeBadge.tsx | 21 ++++++ .../[taskId]/components/findings/index.ts | 1 + apps/app/src/hooks/use-findings-api.ts | 14 ++-- packages/docs/openapi.json | 65 +++++++++++++++++++ 9 files changed, 96 insertions(+), 24 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/FindingScopeBadge.tsx diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx index ed87e59bef..5922f54f97 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx @@ -1,28 +1,20 @@ 'use client'; -import type { ComponentProps } from 'react'; import { useState } from 'react'; -import { FindingScope } from '@db'; - import { FindingHistoryPanel } from '../../../tasks/[taskId]/components/findings/FindingHistoryPanel'; import { PeopleFindingsList } from './PeopleFindingsList'; -type PeopleFindingsScope = ComponentProps['scope']; - export interface PeopleFindingsProps { isAuditor: boolean; isPlatformAdmin: boolean; isAdminOrOwner: boolean; - /** Defaults to the main People directory scope */ - scope?: PeopleFindingsScope; } export function PeopleFindings({ isAuditor, isPlatformAdmin, isAdminOrOwner, - scope = FindingScope.people, }: PeopleFindingsProps) { const [selectedFindingIdForHistory, setSelectedFindingIdForHistory] = useState( null, @@ -31,7 +23,6 @@ export function PeopleFindings({ return ( <> ({ import { PeopleFindingsList } from './PeopleFindingsList'; const defaultProps = { - scope: 'people' as const, isAuditor: false, isPlatformAdmin: false, isAdminOrOwner: false, diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx index fc97e193af..272ad9b12e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx @@ -7,7 +7,6 @@ import { } from '@/hooks/use-findings-api'; import { usePermissions } from '@/hooks/use-permissions'; import { Button } from '@trycompai/design-system'; -import type { FindingScope } from '@db'; import { FindingStatus } from '@db'; import { ChevronDown, @@ -23,7 +22,6 @@ import { FindingItem } from '../../../tasks/[taskId]/components/findings/Finding const INITIAL_DISPLAY_COUNT = 5; interface PeopleFindingsListProps { - scope: FindingScope; isAuditor: boolean; isPlatformAdmin: boolean; isAdminOrOwner: boolean; @@ -38,13 +36,12 @@ const STATUS_ORDER: Record = { }; export function PeopleFindingsList({ - scope, isAuditor, isPlatformAdmin, isAdminOrOwner, onViewHistory, }: PeopleFindingsListProps) { - const { data, isLoading, error, mutate } = useScopeFindings(scope); + const { data, isLoading, error, mutate } = useScopeFindings(); const { updateFinding, deleteFinding } = useFindingActions(); const { hasPermission } = usePermissions(); const [expandedId, setExpandedId] = useState(null); diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index 02fc9d7cb5..7078083102 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -3,7 +3,6 @@ import { auth } from '@/utils/auth'; import { s3Client, BUCKET_NAME } from '@/app/s3'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { FindingScope } from '@db'; import { db } from '@db/server'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; @@ -190,7 +189,6 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: } findingsContent={ + {finding.scope != null && }

{formatDistanceToNow(new Date(finding.createdAt), { addSuffix: true })} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/FindingScopeBadge.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/FindingScopeBadge.tsx new file mode 100644 index 0000000000..ffd00fb577 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/FindingScopeBadge.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { FINDING_SCOPE_LABELS } from '@/hooks/use-findings-api'; +import { Badge } from '@trycompai/ui/badge'; +import { FindingScope } from '@db'; +import { cn } from '@/lib/utils'; + +const SCOPE_BADGE_CLASS = 'text-xs'; + +interface FindingScopeBadgeProps { + scope: FindingScope; + className?: string; +} + +export function FindingScopeBadge({ scope, className }: FindingScopeBadgeProps) { + return ( + + {FINDING_SCOPE_LABELS[scope]} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/index.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/index.ts index 376fc8ea63..4775bdd035 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/index.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/findings/index.ts @@ -4,4 +4,5 @@ export { FindingHistoryPanel } from './FindingHistoryPanel'; export { FindingHistorySheet } from './FindingHistorySheet'; export { FindingItem } from './FindingItem'; export { FindingsList } from './FindingsList'; +export { FindingScopeBadge } from './FindingScopeBadge'; export { FindingStatusBadge } from './FindingStatusBadge'; diff --git a/apps/app/src/hooks/use-findings-api.ts b/apps/app/src/hooks/use-findings-api.ts index 25de84f70a..d1fa1aa21d 100644 --- a/apps/app/src/hooks/use-findings-api.ts +++ b/apps/app/src/hooks/use-findings-api.ts @@ -154,12 +154,10 @@ export function useFormTypeFindings( } /** - * Hook to fetch findings for a People-area scope (directory, devices, etc.) + * Org findings that have a People-area scope set (any non-null scope). Uses `GET /v1/findings?hasScope=true`. */ -export function useScopeFindings(scope: FindingScope | null, options: UseFindingsOptions = {}) { - const endpoint = scope ? `/v1/findings?scope=${encodeURIComponent(scope)}` : null; - - return useApiSWR(endpoint, { +export function useScopeFindings(options: UseFindingsOptions = {}) { + return useApiSWR('/v1/findings?hasScope=true', { ...options, refreshInterval: options.refreshInterval ?? DEFAULT_FINDINGS_POLLING_INTERVAL, }); @@ -420,7 +418,7 @@ export const FINDING_TYPE_LABELS: Record = { /** Labels for People-area finding scopes (see FindingsOverview). */ export const FINDING_SCOPE_LABELS: Record = { [FindingScope.people]: 'People', - [FindingScope.people_tasks]: 'People: Tasks', - [FindingScope.people_devices]: 'People: Devices', - [FindingScope.people_chart]: 'People: Chart', + [FindingScope.people_tasks]: 'Tasks', + [FindingScope.people_devices]: 'Devices', + [FindingScope.people_chart]: 'Chart', }; diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index b433ddd537..bf46b26868 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -13839,6 +13839,15 @@ ], "type": "string" } + }, + { + "name": "hasScope", + "required": false, + "in": "query", + "description": "When true, return all findings in the organization that have a scope value set", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -16472,6 +16481,62 @@ ] } }, + "/v1/integrations/tasks/{taskId}/checks/disconnect": { + "post": { + "operationId": "TaskIntegrationsController_disconnectCheckFromTask_v1", + "parameters": [ + { + "name": "taskId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "tags": [ + "Integrations" + ] + } + }, + "/v1/integrations/tasks/{taskId}/checks/reconnect": { + "post": { + "operationId": "TaskIntegrationsController_reconnectCheckToTask_v1", + "parameters": [ + { + "name": "taskId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "tags": [ + "Integrations" + ] + } + }, "/v1/integrations/tasks/{taskId}/runs": { "get": { "operationId": "TaskIntegrationsController_getTaskCheckRuns_v1", From 6702305ebff951af551e345260a5ed7882a72739 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 10 Apr 2026 13:57:06 -0400 Subject: [PATCH 24/29] fix(api): update deep link for people findings on finding notifier --- .../findings/finding-notifier.service.spec.ts | 4 ++-- .../src/findings/finding-notifier.service.ts | 18 +----------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/apps/api/src/findings/finding-notifier.service.spec.ts b/apps/api/src/findings/finding-notifier.service.spec.ts index 97192d0e92..f2afd6276b 100644 --- a/apps/api/src/findings/finding-notifier.service.spec.ts +++ b/apps/api/src/findings/finding-notifier.service.spec.ts @@ -222,7 +222,7 @@ describe('FindingNotifierService', () => { expect(novuTriggerMock).toHaveBeenCalledWith( expect.objectContaining({ payload: expect.objectContaining({ - findingUrl: 'https://app.trycomp.ai/org_123/people?tab=devices', + findingUrl: 'https://app.trycomp.ai/org_123/people?tab=findings', }), }), ); @@ -243,7 +243,7 @@ describe('FindingNotifierService', () => { expect(novuTriggerMock).toHaveBeenCalledWith( expect.objectContaining({ payload: expect.objectContaining({ - findingUrl: 'https://app.trycomp.ai/org_123/people?tab=people', + findingUrl: 'https://app.trycomp.ai/org_123/people?tab=findings', }), }), ); diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts index c2c312a301..a5d8a1b1e0 100644 --- a/apps/api/src/findings/finding-notifier.service.ts +++ b/apps/api/src/findings/finding-notifier.service.ts @@ -101,22 +101,6 @@ function getDocumentContextTitle( return 'Document submission'; } -/** Matches People page `?tab=` query (see PeoplePageTabs). */ -function scopePeopleTabParam(scope: FindingScope): string { - switch (scope) { - case FindingScope.people: - return 'people'; - case FindingScope.people_tasks: - return 'tasks'; - case FindingScope.people_devices: - return 'devices'; - case FindingScope.people_chart: - return 'chart'; - default: - return 'people'; - } -} - function scopeContextTitle(scope: FindingScope): string { switch (scope) { case FindingScope.people: @@ -176,7 +160,7 @@ function buildFindingDeepLink(params: { return `${base}/${organizationId}/tasks/${taskId}`; } if (findingScope) { - return `${base}/${organizationId}/people?tab=${scopePeopleTabParam(findingScope)}`; + return `${base}/${organizationId}/people?tab=findings`; } if (evidenceSubmissionId && evidenceSubmissionFormType) { return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}`; From c05ce1eb56d4a49f1c72185ca6b7fdeff0854981 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 10 Apr 2026 13:58:09 -0400 Subject: [PATCH 25/29] fix(app): update deep link for people findings on overview --- .../overview/components/FindingsOverview.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx index c070aaefad..319fe585c9 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/FindingsOverview.tsx @@ -55,17 +55,8 @@ function findingHref(finding: FindingWithTask, organizationId: string): string { return `/${organizationId}/documents/${finding.evidenceSubmission.formType}?tab=findings`; } if (finding.scope) { - const peopleTab = - finding.scope === FindingScope.people - ? 'people' - : finding.scope === FindingScope.people_tasks - ? 'tasks' - : finding.scope === FindingScope.people_devices - ? 'devices' - : finding.scope === FindingScope.people_chart - ? 'chart' - : 'people'; - return `/${organizationId}/people?tab=${peopleTab}`; + // Findings list lives only on the People "Findings" tab (not devices/chart/tasks). + return `/${organizationId}/people?tab=findings`; } return `/${organizationId}/overview`; } From dc03a36143c93abcc3592dd2a551393b7e0ac4ac Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 10 Apr 2026 14:07:45 -0400 Subject: [PATCH 26/29] fix(app): remove unused line --- .../[orgId]/people/org-chart/components/OrgChartContent.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx index 3d7d690124..09c4bc6ccf 100644 --- a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx @@ -25,7 +25,6 @@ export function OrgChartContent({ chartData, members, }: OrgChartContentProps) { - // No chart exists yet - show empty state if (!chartData) { return ; From 43f2831185c2f6ff4f35b222e92fb3d34c2a858f Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 10 Apr 2026 14:36:44 -0400 Subject: [PATCH 27/29] fix(app): add scope filter on People Findings UI --- .../components/PeopleFindingsList.test.tsx | 6 + .../all/components/PeopleFindingsList.tsx | 152 ++++++++++++------ packages/docs/openapi.json | 59 +++++++ 3 files changed, 171 insertions(+), 46 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx index 63c1e20211..90cf4ed0a4 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx @@ -40,6 +40,12 @@ const mockFindings = [ const mockMutate = vi.fn(); vi.mock('@/hooks/use-findings-api', () => ({ + FINDING_SCOPE_LABELS: { + people: 'People', + people_tasks: 'People: Tasks', + people_devices: 'People: Devices', + people_chart: 'People: Chart', + }, useScopeFindings: () => ({ data: { data: mockFindings }, isLoading: false, diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx index 272ad9b12e..3926384d44 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx @@ -1,13 +1,20 @@ 'use client'; import { + FINDING_SCOPE_LABELS, useFindingActions, useScopeFindings, type Finding, } from '@/hooks/use-findings-api'; import { usePermissions } from '@/hooks/use-permissions'; -import { Button } from '@trycompai/design-system'; -import { FindingStatus } from '@db'; +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@trycompai/design-system'; +import { FindingScope, FindingStatus } from '@db'; import { ChevronDown, ChevronUp, @@ -21,6 +28,8 @@ import { FindingItem } from '../../../tasks/[taskId]/components/findings/Finding const INITIAL_DISPLAY_COUNT = 5; +const SCOPE_FILTER_ALL = 'all' as const; + interface PeopleFindingsListProps { isAuditor: boolean; isPlatformAdmin: boolean; @@ -46,16 +55,26 @@ export function PeopleFindingsList({ const { hasPermission } = usePermissions(); const [expandedId, setExpandedId] = useState(null); const [showAll, setShowAll] = useState(false); + const [scopeFilter, setScopeFilter] = useState( + SCOPE_FILTER_ALL, + ); const rawFindings = data?.data || []; + const scopeFilteredFindings = useMemo(() => { + if (scopeFilter === SCOPE_FILTER_ALL) { + return rawFindings; + } + return rawFindings.filter((f: Finding) => f.scope === scopeFilter); + }, [rawFindings, scopeFilter]); + const sortedFindings = useMemo(() => { - return [...rawFindings].sort((a: Finding, b: Finding) => { + return [...scopeFilteredFindings].sort((a: Finding, b: Finding) => { const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]; if (statusDiff !== 0) return statusDiff; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }); - }, [rawFindings]); + }, [scopeFilteredFindings]); const visibleFindings = showAll ? sortedFindings : sortedFindings.slice(0, INITIAL_DISPLAY_COUNT); const hiddenCount = sortedFindings.length - visibleFindings.length; @@ -104,10 +123,20 @@ export function PeopleFindingsList({ [deleteFinding, mutate], ); - const openFindingsCount = sortedFindings.filter( + const openFindingsCount = scopeFilteredFindings.filter( (f: Finding) => f.status === FindingStatus.open || f.status === FindingStatus.needs_revision, ).length; + const handleScopeFilterChange = useCallback((value: string | null) => { + if (value == null || value === SCOPE_FILTER_ALL) { + setScopeFilter(SCOPE_FILTER_ALL); + } else { + setScopeFilter(value as FindingScope); + } + setShowAll(false); + setExpandedId(null); + }, []); + if (isLoading) { return (

@@ -162,7 +191,7 @@ export function PeopleFindingsList({
- {sortedFindings.length === 0 ? ( + {rawFindings.length === 0 ? (

No findings for this area

@@ -171,48 +200,79 @@ export function PeopleFindingsList({ )}
) : ( -
- {visibleFindings.map((finding: Finding) => ( - setExpandedId(expandedId === finding.id ? null : finding.id)} - onStatusChange={(status, revisionNote) => - handleStatusChange(finding.id, status, revisionNote) - } - onDelete={() => handleDelete(finding.id)} - onViewHistory={onViewHistory ? () => onViewHistory(finding.id) : undefined} - /> - ))} - - {sortedFindings.length > INITIAL_DISPLAY_COUNT && ( -
- + <> +
+
+ +
+
+ + {sortedFindings.length === 0 ? ( +
+ +

No findings for this scope

+
+ ) : ( +
+ {visibleFindings.map((finding: Finding) => ( + + setExpandedId(expandedId === finding.id ? null : finding.id) + } + onStatusChange={(status, revisionNote) => + handleStatusChange(finding.id, status, revisionNote) + } + onDelete={() => handleDelete(finding.id)} + onViewHistory={onViewHistory ? () => onViewHistory(finding.id) : undefined} + /> + ))} + + {sortedFindings.length > INITIAL_DISPLAY_COUNT && ( +
+ +
+ )}
)} -
+ )}
diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index bf46b26868..7a39b23f6a 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -20349,6 +20349,65 @@ ] } }, + "/v1/admin/organizations/activity": { + "get": { + "operationId": "AdminOrganizationsController_activity_v1", + "parameters": [ + { + "name": "inactiveDays", + "required": false, + "in": "query", + "description": "Filter orgs with no session in N days (default: 90)", + "schema": { + "type": "string" + } + }, + { + "name": "hasAccess", + "required": false, + "in": "query", + "description": "Filter by hasAccess (true/false)", + "schema": { + "type": "string" + } + }, + { + "name": "onboarded", + "required": false, + "in": "query", + "description": "Filter by onboardingCompleted (true/false)", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "summary": "Organization activity report - shows last session per org (platform admin)", + "tags": [ + "Admin - Organizations" + ] + } + }, "/v1/admin/organizations/{id}": { "get": { "operationId": "AdminOrganizationsController_get_v1", From 502cf918fa0719c10832d2663e123438e8794cd1 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 10 Apr 2026 14:47:50 -0400 Subject: [PATCH 28/29] fix(db): update folder of add_finding script --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/db/prisma/migrations/{20260402120000_add_finding_scope => 20260410120000_add_finding_scope}/migration.sql (100%) diff --git a/packages/db/prisma/migrations/20260402120000_add_finding_scope/migration.sql b/packages/db/prisma/migrations/20260410120000_add_finding_scope/migration.sql similarity index 100% rename from packages/db/prisma/migrations/20260402120000_add_finding_scope/migration.sql rename to packages/db/prisma/migrations/20260410120000_add_finding_scope/migration.sql From 8ce096939aa2decfb4dd5ef0977060a5e9ce79f9 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 10 Apr 2026 14:57:35 -0400 Subject: [PATCH 29/29] fix(app): empty commit