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/finding-notifier.service.spec.ts b/apps/api/src/findings/finding-notifier.service.spec.ts index 9ba8a4e873..f2afd6276b 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,49 @@ describe('FindingNotifierService', () => { }), ); }); + + it('builds People page URLs with tab query 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(mockedDb.task.findUnique).not.toHaveBeenCalled(); + expect(mockedDb.evidenceSubmission.findUnique).not.toHaveBeenCalled(); + + expect(novuTriggerMock).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + findingUrl: 'https://app.trycomp.ai/org_123/people?tab=findings', + }), + }), + ); + }); + + 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?tab=findings', + }), + }), + ); + }); }); }); diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts index a7fff542bc..a5d8a1b1e0 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,76 @@ function getDocumentContextTitle( return 'Document submission'; } +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 (taskId) { + return `${base}/${organizationId}/tasks/${taskId}`; + } + if (findingScope) { + return `${base}/${organizationId}/people?tab=findings`; + } + if (evidenceSubmissionId && evidenceSubmissionFormType) { + return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}/submissions/${evidenceSubmissionId}`; + } + if (evidenceSubmissionFormType) { + return `${base}/${organizationId}/documents/${evidenceSubmissionFormType}`; + } + return `${base}/${organizationId}/overview`; +} + // ============================================================================ // Service // ============================================================================ @@ -116,7 +187,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 { @@ -129,26 +201,34 @@ export class FindingNotifierService { findingType, actorUserId, actorName, + 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'); 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 +255,7 @@ export class FindingNotifierService { actorUserId, actorName, findingCreatorMemberId, + findingScope, } = params; this.logger.log( @@ -197,9 +278,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, @@ -214,7 +298,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 { @@ -226,25 +311,29 @@ export class FindingNotifierService { evidenceSubmissionSubmittedById, actorUserId, actorName, + 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'); return; } - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); await this.sendNotifications({ ...params, @@ -259,7 +348,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 { @@ -271,25 +361,29 @@ export class FindingNotifierService { evidenceSubmissionSubmittedById, actorUserId, actorName, + 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'); return; } - const contextTitle = - taskTitle ?? - getDocumentContextTitle(evidenceSubmissionFormType, evidenceSubmissionId); + const contextTitle = resolveFindingContextTitle({ + taskTitle, + evidenceSubmissionFormType, + evidenceSubmissionId, + findingScope, + }); await this.sendNotifications({ ...params, @@ -328,6 +422,7 @@ export class FindingNotifierService { heading, message, newStatus, + findingScope, } = params; // Fetch organization name @@ -337,15 +432,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; @@ -589,6 +688,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). 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 2f0d3dc9ed..e0cdc950a4 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,19 @@ export class FindingsController { example: 'access-request', enum: evidenceFormTypeSchema.options, }) + @ApiQuery({ + name: 'scope', + required: false, + 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', @@ -84,19 +97,26 @@ export class FindingsController { @Query('taskId') taskId: string, @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].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, or evidenceFormType 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, or evidenceFormType', + 'Provide only one target: taskId, evidenceSubmissionId, evidenceFormType, scope, or hasScope=true', ); } @@ -109,6 +129,22 @@ 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( + `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..e11d1f0c26 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,41 @@ 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 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 */ @@ -225,19 +261,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 +346,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 +367,7 @@ export class FindingsService { taskTitle: task?.title, evidenceSubmissionId: evidenceSubmission?.id, evidenceSubmissionFormType: resolvedFormType, + findingScope: createDto.scope, content: createDto.content, type: createDto.type ?? FindingType.soc2, }); @@ -346,6 +386,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, @@ -356,7 +397,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); } @@ -516,6 +559,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, 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..319fe585c9 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,47 @@ 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) { + // Findings list lives only on the People "Findings" tab (not devices/chart/tasks). + return `/${organizationId}/people?tab=findings`; + } + return `/${organizationId}/overview`; +} + function FindingsList({ findings, organizationId, @@ -40,12 +81,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 +89,7 @@ function FindingsList({

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..5922f54f97 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindings.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useState } from 'react'; + +import { FindingHistoryPanel } from '../../../tasks/[taskId]/components/findings/FindingHistoryPanel'; +import { PeopleFindingsList } from './PeopleFindingsList'; + +export interface PeopleFindingsProps { + isAuditor: boolean; + isPlatformAdmin: boolean; + isAdminOrOwner: boolean; +} + +export function PeopleFindings({ + isAuditor, + isPlatformAdmin, + isAdminOrOwner, +}: 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..90cf4ed0a4 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.test.tsx @@ -0,0 +1,119 @@ +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', () => ({ + FINDING_SCOPE_LABELS: { + people: 'People', + people_tasks: 'People: Tasks', + people_devices: 'People: Devices', + people_chart: 'People: Chart', + }, + 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 = { + 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..3926384d44 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PeopleFindingsList.tsx @@ -0,0 +1,280 @@ +'use client'; + +import { + FINDING_SCOPE_LABELS, + useFindingActions, + useScopeFindings, + type Finding, +} from '@/hooks/use-findings-api'; +import { usePermissions } from '@/hooks/use-permissions'; +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@trycompai/design-system'; +import { FindingScope, FindingStatus } from '@db'; +import { + ChevronDown, + ChevronUp, + WarningAlt, + WarningAltFilled, +} from '@trycompai/design-system/icons'; +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'; + +const INITIAL_DISPLAY_COUNT = 5; + +const SCOPE_FILTER_ALL = 'all' as const; + +interface PeopleFindingsListProps { + 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({ + isAuditor, + isPlatformAdmin, + isAdminOrOwner, + onViewHistory, +}: PeopleFindingsListProps) { + const { data, isLoading, error, mutate } = useScopeFindings(); + const { updateFinding, deleteFinding } = useFindingActions(); + 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 [...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(); + }); + }, [scopeFilteredFindings]); + + 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 = 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 ( +
+
+
+ ); + } + + if (error) { + return ( +
+ + Failed to load findings +
+ ); + } + + return ( +
+
+
+
+
+ +
+
+

Findings

+

+ Audit findings and issues requiring attention +

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

No findings for this area

+ {canCreateFinding && ( +

Create a finding to flag an issue

+ )} +
+ ) : ( + <> +
+
+ +
+
+ + {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/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 1b1249871c..4302827f20 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -10,6 +10,7 @@ import { TabsTrigger, } from '@trycompai/design-system'; import { Add } from '@trycompai/design-system/icons'; +import { useSearchParams } from 'next/navigation'; import type { ReactNode } from 'react'; import { useState } from 'react'; import { InviteMembersModal } from '../all/components/InviteMembersModal'; @@ -19,6 +20,7 @@ interface PeoplePageTabsProps { employeeTasksContent: ReactNode | null; devicesContent: ReactNode; orgChartContent: ReactNode; + findingsContent: ReactNode; roleMappingContent: ReactNode | null; showRoleMapping: boolean; showEmployeeTasks: boolean; @@ -27,22 +29,57 @@ interface PeoplePageTabsProps { organizationId: string; } +/** ?tab= value → Radix tab value */ +function tabParamToInternal( + tabParam: string | null, + showEmployeeTasks: boolean, + showRoleMapping: boolean, +): string { + if (!tabParam || tabParam === 'people') { + return 'people'; + } + if (tabParam === 'tasks') { + return showEmployeeTasks ? 'employee-tasks' : 'people'; + } + if (tabParam === 'devices') { + return 'devices'; + } + if (tabParam === 'chart') { + return 'org-chart'; + } + if (tabParam === 'findings') { + return 'findings'; + } + if (tabParam === 'role-mapping') { + return showRoleMapping ? 'role-mapping' : 'people'; + } + return 'people'; +} + export function PeoplePageTabs({ peopleContent, employeeTasksContent, devicesContent, orgChartContent, roleMappingContent, + findingsContent, showRoleMapping, showEmployeeTasks, canInviteUsers, canManageMembers, organizationId, }: PeoplePageTabsProps) { + const searchParams = useSearchParams(); const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + const defaultTab = tabParamToInternal( + searchParams.get('tab'), + showEmployeeTasks, + showRoleMapping, + ); + return ( - + Tasks} Devices Chart + Findings {showRoleMapping && Role Mapping} } @@ -77,6 +115,7 @@ export function PeoplePageTabs({ {showRoleMapping && ( {roleMappingContent} )} + {findingsContent} } + findingsContent={ + + } showRoleMapping={false} roleMappingContent={null} showEmployeeTasks={showEmployeeTasks} 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..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 @@ -10,6 +10,7 @@ interface CreateFindingButtonProps { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; + showScope?: boolean; onSuccess?: () => void; } @@ -17,6 +18,7 @@ export function CreateFindingButton({ taskId, evidenceSubmissionId, evidenceFormType, + showScope = false, onSuccess, }: CreateFindingButtonProps) { const [open, setOpen] = useState(false); @@ -30,6 +32,7 @@ export function CreateFindingButton({ taskId={taskId} evidenceSubmissionId={evidenceSubmissionId} evidenceFormType={evidenceFormType} + 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 ed582387d9..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,15 +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 { 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, @@ -41,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', @@ -51,6 +53,7 @@ interface CreateFindingSheetProps { taskId?: string; evidenceSubmissionId?: string; evidenceFormType?: EvidenceFormType; + showScope?: boolean; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; @@ -60,6 +63,7 @@ export function CreateFindingSheet({ taskId, evidenceSubmissionId, evidenceFormType, + showScope = false, open, onOpenChange, onSuccess, @@ -76,6 +80,7 @@ export function CreateFindingSheet({ resolver: zodResolver(createFindingSchema), defaultValues: { type: FindingType.soc2, + scope: FindingScope.people, templateId: null, content: '', }, @@ -113,13 +118,19 @@ export function CreateFindingSheet({ taskId, evidenceSubmissionId, evidenceFormType, + 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'); @@ -127,7 +138,7 @@ export function CreateFindingSheet({ setIsSubmitting(false); } }, - [createFinding, taskId, evidenceSubmissionId, evidenceFormType, onOpenChange, form, onSuccess], + [createFinding, taskId, evidenceSubmissionId, evidenceFormType, showScope, onOpenChange, form, onSuccess], ); const handleTemplateChange = useCallback( @@ -177,6 +188,34 @@ export function CreateFindingSheet({ )} /> + {showScope && ( + ( + + Scope + + + + )} + /> + )} + + {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 1eaa0d8ff9..d1fa1aa21d 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 { FindingScope, FindingType, type FindingStatus } 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,16 @@ export function useFormTypeFindings( }); } +/** + * Org findings that have a People-area scope set (any non-null scope). Uses `GET /v1/findings?hasScope=true`. + */ +export function useScopeFindings(options: UseFindingsOptions = {}) { + return useApiSWR('/v1/findings?hasScope=true', { + ...options, + refreshInterval: options.refreshInterval ?? DEFAULT_FINDINGS_POLLING_INTERVAL, + }); +} + /** * Hook to fetch all findings for an organization */ @@ -402,3 +414,11 @@ export const FINDING_TYPE_LABELS: Record = { 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]: 'Tasks', + [FindingScope.people_devices]: 'Devices', + [FindingScope.people_chart]: 'Chart', +}; diff --git a/packages/db/prisma/migrations/20260410120000_add_finding_scope/migration.sql b/packages/db/prisma/migrations/20260410120000_add_finding_scope/migration.sql new file mode 100644 index 0000000000..5b5fad77f2 --- /dev/null +++ b/packages/db/prisma/migrations/20260410120000_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 diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index c985d60c58..7a39b23f6a 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -13824,6 +13824,30 @@ ], "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" + } + }, + { + "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": { @@ -16457,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", @@ -20269,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", @@ -24586,6 +24725,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)",