diff --git a/apps/api/src/policies/policies.controller.spec.ts b/apps/api/src/policies/policies.controller.spec.ts index 17fb8d2258..c9de86abc1 100644 --- a/apps/api/src/policies/policies.controller.spec.ts +++ b/apps/api/src/policies/policies.controller.spec.ts @@ -56,7 +56,17 @@ jest.mock('@db', () => ({ FindingType: { soc2: 'soc2', iso27001: 'iso27001', + hipaa: 'hipaa', + gdpr: 'gdpr', + nist: 'nist', }, + FindingStatus: { + open: 'open', + closed: 'closed', + }, + PhaseCompletionType: {}, + TimelinePhaseStatus: {}, + TimelineStatus: {}, })); jest.mock('@trigger.dev/sdk', () => ({ @@ -394,14 +404,39 @@ describe('PoliciesController', () => { }); describe('getPolicyControls', () => { - it('should return mapped and all controls', async () => { + it('returns mapped and all controls with framework names derived from requirementsMapped', async () => { const { db } = require('@db'); const mappedControls = [ - { id: 'ctrl_1', name: 'Control 1', description: 'desc' }, + { + id: 'ctrl_1', + name: 'Control 1', + description: 'desc', + requirementsMapped: [ + { + frameworkInstance: { + id: 'fi_1', + framework: { id: 'fw_soc2', name: 'SOC 2' }, + customFramework: null, + }, + }, + { + frameworkInstance: { + id: 'fi_2', + framework: null, + customFramework: { id: 'cfw_1', name: 'Internal Policy' }, + }, + }, + ], + }, ]; const allControls = [ - { id: 'ctrl_1', name: 'Control 1', description: 'desc' }, - { id: 'ctrl_2', name: 'Control 2', description: 'desc2' }, + ...mappedControls, + { + id: 'ctrl_2', + name: 'Control 2', + description: 'desc2', + requirementsMapped: [], + }, ]; db.policy.findFirst.mockResolvedValue({ id: 'pol_1', @@ -415,12 +450,80 @@ describe('PoliciesController', () => { mockAuthContext, ); - expect(result.mappedControls).toEqual(mappedControls); - expect(result.allControls).toEqual(allControls); + expect(result.mappedControls).toEqual([ + { + id: 'ctrl_1', + name: 'Control 1', + description: 'desc', + frameworks: [ + { id: 'fw_soc2', name: 'SOC 2' }, + { id: 'cfw_1', name: 'Internal Policy' }, + ], + }, + ]); + expect(result.allControls).toEqual([ + { + id: 'ctrl_1', + name: 'Control 1', + description: 'desc', + frameworks: [ + { id: 'fw_soc2', name: 'SOC 2' }, + { id: 'cfw_1', name: 'Internal Policy' }, + ], + }, + { + id: 'ctrl_2', + name: 'Control 2', + description: 'desc2', + frameworks: [], + }, + ]); expect(result.authType).toBe('session'); }); - it('should return empty mappedControls when policy not found', async () => { + it('dedupes frameworks when the same FrameworkInstance is reachable via multiple RequirementMaps', async () => { + const { db } = require('@db'); + const controls = [ + { + id: 'ctrl_1', + name: 'Control 1', + description: 'desc', + requirementsMapped: [ + { + frameworkInstance: { + id: 'fi_1', + framework: { id: 'fw_soc2', name: 'SOC 2' }, + customFramework: null, + }, + }, + { + frameworkInstance: { + id: 'fi_1', + framework: { id: 'fw_soc2', name: 'SOC 2' }, + customFramework: null, + }, + }, + ], + }, + ]; + db.policy.findFirst.mockResolvedValue({ + id: 'pol_1', + controls, + }); + db.control.findMany.mockResolvedValue(controls); + + const result = await controller.getPolicyControls( + 'pol_1', + orgId, + mockAuthContext, + ); + + expect(result.mappedControls[0].frameworks).toEqual([ + { id: 'fw_soc2', name: 'SOC 2' }, + ]); + }); + + it('returns empty mappedControls when policy is not found', async () => { const { db } = require('@db'); db.policy.findFirst.mockResolvedValue(null); db.control.findMany.mockResolvedValue([]); @@ -433,6 +536,33 @@ describe('PoliciesController', () => { expect(result.mappedControls).toEqual([]); }); + + it('scopes the requirementsMapped query to the caller organization', async () => { + const { db } = require('@db'); + db.policy.findFirst.mockResolvedValue({ id: 'pol_1', controls: [] }); + db.control.findMany.mockResolvedValue([]); + + await controller.getPolicyControls('pol_1', orgId, mockAuthContext); + + expect(db.policy.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'pol_1', organizationId: orgId, archivedAt: null }, + select: expect.objectContaining({ + controls: expect.objectContaining({ + where: { archivedAt: null }, + select: expect.objectContaining({ + requirementsMapped: expect.objectContaining({ + where: { + archivedAt: null, + frameworkInstance: { organizationId: orgId }, + }, + }), + }), + }), + }), + }), + ); + }); }); describe('addPolicyControls', () => { @@ -724,4 +854,87 @@ describe('PoliciesController', () => { expect(result.data).toEqual(mockResult); }); }); + + describe('getPolicyEvidenceTasks', () => { + it('returns tasks grouped by control, excluding archived tasks', async () => { + const { db } = require('@db'); + db.policy.findFirst.mockResolvedValue({ + id: 'pol_1', + controls: [ + { + id: 'ctl_1', + name: 'Access Controls', + tasks: [ + { + id: 'tsk_1', + title: 'Enable 2FA', + status: 'in_progress', + frequency: 'monthly', + department: 'it', + automationStatus: 'MANUAL', + assigneeId: 'mem_1', + }, + ], + }, + { + id: 'ctl_2', + name: 'Monitoring', + tasks: [], + }, + ], + }); + + const result = await controller.getPolicyEvidenceTasks( + 'pol_1', + orgId, + mockAuthContext, + ); + + expect(db.policy.findFirst).toHaveBeenCalledWith({ + where: { id: 'pol_1', organizationId: orgId, archivedAt: null }, + select: expect.objectContaining({ + id: true, + controls: expect.objectContaining({ + where: { archivedAt: null, organizationId: orgId }, + select: expect.objectContaining({ + tasks: expect.objectContaining({ + where: { archivedAt: null, organizationId: orgId }, + }), + }), + }), + }), + }); + expect(result.data).toEqual([ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + tasks: [ + { + id: 'tsk_1', + title: 'Enable 2FA', + status: 'in_progress', + frequency: 'monthly', + department: 'it', + automationStatus: 'MANUAL', + assigneeId: 'mem_1', + }, + ], + }, + { + control: { id: 'ctl_2', name: 'Monitoring' }, + tasks: [], + }, + ]); + expect(result.count).toBe(1); + expect(result.authType).toBe('session'); + }); + + it('throws NotFoundException when policy is not in caller org', async () => { + const { db } = require('@db'); + db.policy.findFirst.mockResolvedValue(null); + + await expect( + controller.getPolicyEvidenceTasks('pol_404', orgId, mockAuthContext), + ).rejects.toThrow('Policy not found'); + }); + }); }); diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 52af8da998..105260e843 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -37,7 +37,7 @@ import { AuditRead } from '../audit/skip-audit-log.decorator'; import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; -import { RequirePermission } from '../auth/require-permission.decorator'; +import { RequirePermission, RequirePermissions } from '../auth/require-permission.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; import { CreatePolicyDto } from './dto/create-policy.dto'; import { UpdatePolicyDto } from './dto/update-policy.dto'; @@ -200,24 +200,144 @@ export class PoliciesController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { + const controlSelect = { + id: true, + name: true, + description: true, + requirementsMapped: { + where: { + archivedAt: null, + frameworkInstance: { organizationId }, + }, + select: { + frameworkInstance: { + select: { + id: true, + framework: { select: { id: true, name: true } }, + customFramework: { select: { id: true, name: true } }, + }, + }, + }, + }, + } as const; + const [policy, allControls] = await Promise.all([ db.policy.findFirst({ where: { id, organizationId, archivedAt: null }, select: { id: true, - controls: { where: { archivedAt: null }, select: { id: true, name: true, description: true } }, + controls: { where: { archivedAt: null }, select: controlSelect }, }, }), db.control.findMany({ where: { organizationId, archivedAt: null }, - select: { id: true, name: true, description: true }, + select: controlSelect, orderBy: { name: 'asc' }, }), ]); + type RawControl = { + id: string; + name: string; + description: string | null; + requirementsMapped: Array<{ + frameworkInstance: { + id: string; + framework: { id: string; name: string } | null; + customFramework: { id: string; name: string } | null; + } | null; + }>; + }; + + const transform = (controls: RawControl[]) => + controls.map((c) => { + const frameworks: Array<{ id: string; name: string }> = []; + const seen = new Set(); + for (const rm of c.requirementsMapped) { + const fi = rm.frameworkInstance; + if (!fi || seen.has(fi.id)) continue; + seen.add(fi.id); + const fw = fi.framework ?? fi.customFramework; + if (fw) frameworks.push({ id: fw.id, name: fw.name }); + } + return { + id: c.id, + name: c.name, + description: c.description, + frameworks, + }; + }); + + return { + mappedControls: transform(policy?.controls ?? []), + allControls: transform(allControls), + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Get(':id/evidence-tasks') + @RequirePermissions([ + { resource: 'policy', actions: ['read'] }, + { resource: 'task', actions: ['read'] }, + ]) + @ApiOperation({ summary: 'Get tasks that serve as evidence for a policy, grouped by control' }) + @ApiParam(POLICY_PARAMS.policyId) + async getPolicyEvidenceTasks( + @Param('id') id: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const policy = await db.policy.findFirst({ + where: { id, organizationId, archivedAt: null }, + select: { + id: true, + controls: { + where: { archivedAt: null, organizationId }, + select: { + id: true, + name: true, + tasks: { + where: { archivedAt: null, organizationId }, + select: { + id: true, + title: true, + status: true, + frequency: true, + department: true, + automationStatus: true, + assigneeId: true, + }, + orderBy: { title: 'asc' }, + }, + }, + orderBy: { name: 'asc' }, + }, + }, + }); + + if (!policy) { + throw new NotFoundException('Policy not found'); + } + + const data = policy.controls.map((control) => ({ + control: { id: control.id, name: control.name }, + tasks: control.tasks, + })); + + const uniqueTaskIds = new Set(); + for (const group of data) { + for (const task of group.tasks) uniqueTaskIds.add(task.id); + } + return { - mappedControls: policy?.controls ?? [], - allControls, + data, + count: uniqueTaskIds.size, authType: authContext.authType, ...(authContext.userId && { authenticatedUser: { diff --git a/apps/api/src/tasks/tasks.controller.spec.ts b/apps/api/src/tasks/tasks.controller.spec.ts index 2474d5fdd5..1de65bef28 100644 --- a/apps/api/src/tasks/tasks.controller.spec.ts +++ b/apps/api/src/tasks/tasks.controller.spec.ts @@ -10,7 +10,11 @@ import { AttachmentsService } from '../attachments/attachments.service'; jest.mock('@db', () => ({ ...jest.requireActual('@prisma/client'), - db: {}, + db: { + task: { + findFirst: jest.fn(), + }, + }, Prisma: { PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error { code: string; @@ -512,6 +516,151 @@ describe('TasksController', () => { }); }); + // ==================== GET TASK POLICIES ==================== + + describe('getTaskPolicies', () => { + it('returns policies grouped by control, excluding archived only (drafts included)', async () => { + const { db } = require('@db'); + db.task.findFirst.mockResolvedValue({ + id: 'tsk_1', + assigneeId: 'mem_other', + controls: [ + { + id: 'ctl_1', + name: 'Access Controls', + policies: [ + { + id: 'pol_1', + name: 'Authentication Policy', + status: 'published', + frequency: 'yearly', + department: 'it', + }, + { + id: 'pol_2', + name: 'Acceptable Use', + status: 'draft', + frequency: 'yearly', + department: 'it', + }, + ], + }, + ], + }); + + const result = await controller.getTaskPolicies( + orgId, + 'tsk_1', + authContext, + ); + + expect(db.task.findFirst).toHaveBeenCalledWith({ + where: { id: 'tsk_1', organizationId: orgId, archivedAt: null }, + select: expect.objectContaining({ + id: true, + assigneeId: true, + controls: expect.objectContaining({ + where: { archivedAt: null, organizationId: orgId }, + select: expect.objectContaining({ + policies: expect.objectContaining({ + where: { + archivedAt: null, + organizationId: orgId, + }, + }), + }), + }), + }), + }); + // Assert the policies where clause does NOT filter by status. + const callArgs = db.task.findFirst.mock.calls[0][0]; + expect(callArgs.select.controls.select.policies.where).not.toHaveProperty( + 'status', + ); + expect(result.data).toEqual([ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + policies: [ + { + id: 'pol_1', + name: 'Authentication Policy', + status: 'published', + frequency: 'yearly', + department: 'it', + }, + { + id: 'pol_2', + name: 'Acceptable Use', + status: 'draft', + frequency: 'yearly', + department: 'it', + }, + ], + }, + ]); + expect(result.count).toBe(2); + }); + + it('throws NotFoundException when task is not in caller org', async () => { + const { db } = require('@db'); + db.task.findFirst.mockResolvedValue(null); + + await expect( + controller.getTaskPolicies(orgId, 'tsk_404', authContext), + ).rejects.toThrow('Task not found'); + }); + + it('throws ForbiddenException for employee who is not the assignee', async () => { + const { db } = require('@db'); + db.task.findFirst.mockResolvedValue({ + id: 'tsk_1', + assigneeId: 'mem_other', + controls: [], + }); + + const employeeAuth: AuthContext = { + ...authContext, + userRoles: ['employee'], + memberId: 'mem_123', + }; + + await expect( + controller.getTaskPolicies(orgId, 'tsk_1', employeeAuth), + ).rejects.toThrow(ForbiddenException); + }); + + it('allows employee who is the assignee to read policies', async () => { + const { db } = require('@db'); + db.task.findFirst.mockResolvedValue({ + id: 'tsk_1', + assigneeId: 'mem_123', + controls: [ + { + id: 'ctl_1', + name: 'Access Controls', + policies: [], + }, + ], + }); + + const employeeAuth: AuthContext = { + ...authContext, + userRoles: ['employee'], + memberId: 'mem_123', + }; + + const result = await controller.getTaskPolicies( + orgId, + 'tsk_1', + employeeAuth, + ); + + expect(result.data).toEqual([ + { control: { id: 'ctl_1', name: 'Access Controls' }, policies: [] }, + ]); + }); + }); + // ==================== GET TASK ACTIVITY ==================== describe('getTaskActivity', () => { diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 61a4ae8e3b..3708b0323c 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -1,4 +1,4 @@ -import { AttachmentEntityType } from '@db'; +import { AttachmentEntityType, db } from '@db'; import { BadRequestException, Body, @@ -6,6 +6,7 @@ import { Delete, ForbiddenException, Get, + NotFoundException, Param, Patch, Post, @@ -28,7 +29,7 @@ import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; -import { RequirePermission } from '../auth/require-permission.decorator'; +import { RequirePermission, RequirePermissions } from '../auth/require-permission.decorator'; import type { AuthContext as AuthContextType } from '../auth/types'; import { buildTaskAssignmentFilter, @@ -630,6 +631,87 @@ export class TasksController { return task; } + @Get(':taskId/policies') + @UseGuards(PermissionGuard) + @RequirePermissions([ + { resource: 'task', actions: ['read'] }, + { resource: 'policy', actions: ['read'] }, + ]) + @ApiOperation({ + summary: 'Get policies that reference a task via shared controls', + }) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', + }) + async getTaskPolicies( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + @AuthContext() authContext: AuthContextType, + ) { + const task = await db.task.findFirst({ + where: { id: taskId, organizationId, archivedAt: null }, + select: { + id: true, + assigneeId: true, + controls: { + where: { archivedAt: null, organizationId }, + select: { + id: true, + name: true, + policies: { + where: { archivedAt: null, organizationId }, + select: { + id: true, + name: true, + status: true, + frequency: true, + department: true, + }, + orderBy: { name: 'asc' }, + }, + }, + orderBy: { name: 'asc' }, + }, + }, + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + if ( + !hasTaskAccess(task, authContext.memberId, authContext.userRoles, { + isApiKey: authContext.isApiKey, + }) + ) { + throw new ForbiddenException('You do not have access to this task'); + } + + const data = task.controls.map((control) => ({ + control: { id: control.id, name: control.name }, + policies: control.policies, + })); + + const uniquePolicyIds = new Set(); + for (const group of data) { + for (const policy of group.policies) uniquePolicyIds.add(policy.id); + } + + return { + data, + count: uniquePolicyIds.size, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + @Get(':taskId/activity') @UseGuards(PermissionGuard) @RequirePermission('task', 'read') diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.test.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.test.tsx deleted file mode 100644 index 9387934e6e..0000000000 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - setMockPermissions, - mockHasPermission, - ADMIN_PERMISSIONS, - AUDITOR_PERMISSIONS, -} from '@/test-utils/mocks/permissions'; - -// Mock usePermissions -vi.mock('@/hooks/use-permissions', () => ({ - usePermissions: () => ({ - permissions: {}, - hasPermission: mockHasPermission, - }), -})); - -// Mock next/navigation -vi.mock('next/navigation', () => ({ - useParams: vi.fn(() => ({ orgId: 'org-1', policyId: 'policy-1' })), -})); - -// Mock usePolicy hook -const mockAddControlMappings = vi.fn(); -const mockRemoveControlMapping = vi.fn(); -vi.mock('../hooks/usePolicy', () => ({ - usePolicy: () => ({ - addControlMappings: mockAddControlMappings, - removeControlMapping: mockRemoveControlMapping, - }), -})); - -// Mock sonner -vi.mock('sonner', () => ({ - toast: { - success: vi.fn(), - error: vi.fn(), - }, -})); - -// Mock SelectPills -vi.mock('@trycompai/ui/select-pills', () => ({ - SelectPills: ({ - disabled, - placeholder, - }: { - disabled: boolean; - placeholder: string; - }) => ( -
- {placeholder} -
- ), -})); - -import { PolicyControlMappings } from './PolicyControlMappings'; - -const allControls = [ - { id: 'ctrl-1', name: 'Control A' }, - { id: 'ctrl-2', name: 'Control B' }, -] as any[]; - -const mappedControls = [{ id: 'ctrl-1', name: 'Control A' }] as any[]; - -describe('PolicyControlMappings', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('admin permissions (policy:update)', () => { - beforeEach(() => { - setMockPermissions(ADMIN_PERMISSIONS); - }); - - it('renders the section title', () => { - render( - , - ); - expect(screen.getByText('Map Controls')).toBeInTheDocument(); - }); - - it('renders SelectPills as enabled for admin', () => { - render( - , - ); - const pills = screen.getByTestId('select-pills'); - expect(pills.getAttribute('data-disabled')).toBe('false'); - }); - - it('disables SelectPills when pending approval even for admin', () => { - render( - , - ); - const pills = screen.getByTestId('select-pills'); - expect(pills.getAttribute('data-disabled')).toBe('true'); - }); - }); - - describe('auditor permissions (no update)', () => { - beforeEach(() => { - setMockPermissions(AUDITOR_PERMISSIONS); - }); - - it('renders SelectPills as disabled for auditor', () => { - render( - , - ); - const pills = screen.getByTestId('select-pills'); - expect(pills.getAttribute('data-disabled')).toBe('true'); - }); - - it('still renders the section title for auditor', () => { - render( - , - ); - expect(screen.getByText('Map Controls')).toBeInTheDocument(); - }); - }); -}); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.tsx index 7f382c9dbd..b206195063 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.tsx @@ -1,38 +1,73 @@ 'use client'; +import { usePermissions } from '@/hooks/use-permissions'; import { apiClient } from '@/lib/api-client'; -import { SelectPills } from '@trycompai/ui/select-pills'; -import type { Control } from '@db'; -import { Section } from '@trycompai/design-system'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Badge, + buttonVariants, + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, + Section, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Add, Launch, Unlink } from '@trycompai/design-system/icons'; +import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; -import { usePolicy } from '../hooks/usePolicy'; -import { usePermissions } from '@/hooks/use-permissions'; import useSWR from 'swr'; +import { usePolicy } from '../hooks/usePolicy'; + +export type MappedControl = { + id: string; + name: string; + description: string | null; + frameworks: Array<{ id: string; name: string }>; +}; interface ControlsResponse { - mappedControls: Pick[]; - allControls: Pick[]; + mappedControls: MappedControl[]; + allControls: MappedControl[]; } -export const PolicyControlMappings = ({ +interface PolicyControlMappingsProps { + mappedControls: MappedControl[]; + allControls: MappedControl[]; + isPendingApproval: boolean; + onMutate?: () => void; +} + +export function PolicyControlMappings({ mappedControls: initialMapped, allControls: initialAll, isPendingApproval, onMutate, -}: { - mappedControls: Control[]; - allControls: Control[]; - isPendingApproval: boolean; - onMutate?: () => void; -}) => { +}: PolicyControlMappingsProps) { const { orgId, policyId } = useParams<{ orgId: string; policyId: string }>(); - const [loading, setLoading] = useState(false); const { hasPermission } = usePermissions(); - const canUpdate = hasPermission('policy', 'update'); + const canMutate = hasPermission('policy', 'update') && !isPendingApproval; - const { data, mutate: mutateControls } = useSWR( + const { data: controlsData, mutate: mutateControls } = useSWR( [`/v1/policies/${policyId}/controls`, orgId], async () => { const res = await apiClient.get( @@ -48,53 +83,191 @@ export const PolicyControlMappings = ({ }, ); - const mappedControls = data?.mappedControls ?? initialMapped; - const allControls = data?.allControls ?? initialAll; - const { addControlMappings, removeControlMapping } = usePolicy({ policyId, organizationId: orgId, }); - const mappedNames = mappedControls.map((c) => c.name); + const mappedControls = controlsData?.mappedControls ?? initialMapped; + const allControls = controlsData?.allControls ?? initialAll; + const mappedIds = new Set(mappedControls.map((c) => c.id)); + const availableControls = allControls.filter((c) => !mappedIds.has(c.id)); - const handleValueChange = async (selectedNames: string[]) => { - if (isPendingApproval || loading || !canUpdate) return; - setLoading(true); - const prevIds = mappedControls.map((c) => c.id); - const nextControls = allControls.filter((c) => selectedNames.includes(c.name)); - const nextIds = nextControls.map((c) => c.id); + const [addOpen, setAddOpen] = useState(false); + const [toRemove, setToRemove] = useState<{ id: string; name: string } | null>(null); + const [loading, setLoading] = useState(false); - const added = nextControls.filter((c) => !prevIds.includes(c.id)); - const removed = mappedControls.filter((c) => !nextIds.includes(c.id)); + const refreshAll = async () => { + await mutateControls(); + onMutate?.(); + }; + const handleAdd = async (id: string) => { + if (!canMutate || loading) return; + setLoading(true); + setAddOpen(false); try { - if (added.length > 0) { - await addControlMappings(added.map((c) => c.id)); - toast.success('Controls mapped successfully'); - } - if (removed.length > 0) { - await removeControlMapping(removed[0].id); - toast.success('Controls unmapped successfully'); - } - await mutateControls(); - onMutate?.(); + await addControlMappings([id]); + await refreshAll(); + toast.success('Control mapped successfully'); + } catch { + toast.error('Failed to map control'); + } finally { + setLoading(false); + } + }; + + const handleConfirmRemove = async () => { + if (!toRemove || !canMutate || loading) { + setToRemove(null); + return; + } + const target = toRemove; + setLoading(true); + try { + await removeControlMapping(target.id); + await refreshAll(); + toast.success('Control unmapped successfully'); } catch { - toast.error('Failed to update controls'); + toast.error('Failed to unmap control'); } finally { setLoading(false); + setToRemove(null); } }; return ( -
- ({ id: c.id, name: c.name }))} - value={mappedNames} - onValueChange={handleValueChange} - placeholder="Search controls..." - disabled={isPendingApproval || loading || !canUpdate} - /> +
+ + + Link control + + + + +
+ + No controls found. + {availableControls.map((c) => ( + handleAdd(c.id)} + > + {c.name} + + ))} + + + + + ) : undefined + } + > + {mappedControls.length === 0 ? ( + + No controls mapped yet. + + ) : ( + + + + Name + Frameworks + {canMutate && } + + + + {mappedControls.map((control) => ( + + + e.stopPropagation()} + className="group flex items-center justify-between gap-2" + > + + {control.name} + + + + + + {(control.frameworks ?? []).length === 0 ? ( + + — + + ) : ( +
+ {(control.frameworks ?? []).map((fw) => ( + + {fw.name} + + ))} +
+ )} +
+ {canMutate && ( + + + + )} +
+ ))} +
+
+ )} + + { + if (!open) setToRemove(null); + }} + > + + + Unlink control + + {toRemove ? ( + <> + Unlink {toRemove.name} from this policy? You can link it + again later. + + ) : null} + + + + setToRemove(null)}>Cancel + + Unlink + + + +
); -}; +} diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyEvidenceTasks.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyEvidenceTasks.tsx new file mode 100644 index 0000000000..90baa4eb26 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyEvidenceTasks.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { + Section, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Launch } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { usePolicyEvidenceTasks } from '../hooks/usePolicyEvidenceTasks'; + +const SECTION_TITLE = 'Evidence Tasks'; +const SECTION_DESCRIPTION = 'Tasks that implement this policy.'; + +interface TaskRow { + taskId: string; + taskTitle: string; + status: string; + frequency: string | null; + controlId: string; + controlName: string; +} + +function TaskStatusPill({ status }: { status: string }) { + const label = status.replace(/_/g, ' '); + const styles = (() => { + switch (status) { + case 'in_progress': + return 'bg-blue-500/15 text-blue-700 dark:text-blue-300'; + case 'in_review': + return 'bg-amber-500/15 text-amber-700 dark:text-amber-300'; + case 'done': + return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'; + case 'failed': + return 'bg-red-500/15 text-red-700 dark:text-red-300'; + case 'not_relevant': + case 'todo': + default: + return 'bg-muted text-muted-foreground'; + } + })(); + return ( + + {label} + + ); +} + +export function PolicyEvidenceTasks() { + const { orgId, policyId } = useParams<{ orgId: string; policyId: string }>(); + const { groups, isLoading, error } = usePolicyEvidenceTasks({ + policyId, + organizationId: orgId, + }); + + if (error) { + return ( +
+ Could not load evidence tasks. Please try again. +
+ ); + } + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + const rows: TaskRow[] = []; + for (const group of groups) { + for (const task of group.tasks) { + rows.push({ + taskId: task.id, + taskTitle: task.title, + status: task.status, + frequency: task.frequency, + controlId: group.control.id, + controlName: group.control.name, + }); + } + } + + if (rows.length === 0) { + return ( +
+ + No evidence tasks yet. Map a control with tasks attached above. + +
+ ); + } + + return ( +
+ + + + Task + Control + Status + Frequency + + + + {rows.map((row) => ( + + + e.stopPropagation()} + className="group flex items-center justify-between gap-2" + > + + {row.taskTitle} + + + + + + e.stopPropagation()} + className="group flex items-center justify-between gap-2" + > + + {row.controlName} + + + + + + + + +
+ + {row.frequency ?? '—'} + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx index 0c62f2be31..9e1c5b4069 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx @@ -1,5 +1,6 @@ -import type { Control, Member, Policy, PolicyVersion, User } from '@db'; +import type { Member, Policy, PolicyVersion, User } from '@db'; import type { AuditLogWithRelations } from '../data'; +import type { MappedControl } from './PolicyControlMappings'; import { PolicyPageTabs } from './PolicyPageTabs'; type PolicyVersionWithPublisher = PolicyVersion & { @@ -20,8 +21,8 @@ export default function PolicyPage({ }: { policy: (Policy & { approver: (Member & { user: User }) | null }) | null; assignees: (Member & { user: User })[]; - mappedControls: Control[]; - allControls: Control[]; + mappedControls: MappedControl[]; + allControls: MappedControl[]; isPendingApproval: boolean; policyId: string; organizationId: string; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.test.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.test.tsx index e8e3fca5f7..ee8013ebda 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.test.tsx @@ -92,6 +92,10 @@ vi.mock('./PolicyControlMappings', () => ({ PolicyControlMappings: () =>
, })); +vi.mock('./PolicyEvidenceTasks', () => ({ + PolicyEvidenceTasks: () =>
, +})); + vi.mock('./PolicyDeleteDialog', () => ({ PolicyDeleteDialog: ({ isOpen }: { isOpen: boolean }) => (
@@ -196,9 +200,6 @@ describe('PolicyPageTabs', () => { it('renders overview tab content by default', () => { render(); expect(screen.getByTestId('policy-settings-card')).toBeInTheDocument(); - expect( - screen.getByTestId('policy-control-mappings'), - ).toBeInTheDocument(); }); }); @@ -219,9 +220,6 @@ describe('PolicyPageTabs', () => { it('renders the overview tab content (child components handle their own gating)', () => { render(); expect(screen.getByTestId('policy-settings-card')).toBeInTheDocument(); - expect( - screen.getByTestId('policy-control-mappings'), - ).toBeInTheDocument(); }); it('still renders sheets/dialogs (they handle own permission checks)', () => { diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx index 6d284a21b6..76753f47ad 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { Control, Member, Policy, PolicyVersion, User } from '@db'; +import type { Member, Policy, PolicyVersion, User } from '@db'; import type { JSONContent } from '@tiptap/react'; import { Stack, Tabs, TabsContent, TabsList, TabsTrigger } from '@trycompai/design-system'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; @@ -14,8 +14,12 @@ import { usePolicy } from '../hooks/usePolicy'; import { usePolicyVersions } from '../hooks/usePolicyVersions'; import { PolicyAlerts } from './PolicyAlerts'; import { PolicyArchiveSheet } from './PolicyArchiveSheet'; -import { PolicyControlMappings } from './PolicyControlMappings'; +import { + PolicyControlMappings, + type MappedControl, +} from './PolicyControlMappings'; import { PolicyDeleteDialog } from './PolicyDeleteDialog'; +import { PolicyEvidenceTasks } from './PolicyEvidenceTasks'; import { PolicyOverviewSheet } from './PolicyOverviewSheet'; import { PolicySettingsCard } from './PolicySettingsCard'; import { PolicyVersionsTab } from './PolicyVersionsTab'; @@ -55,8 +59,8 @@ function sanitizePolicyContent(raw: unknown): JSONContent[] { interface PolicyPageTabsProps { policy: (Policy & { approver: (Member & { user: User }) | null }) | null; assignees: (Member & { user: User })[]; - mappedControls: Control[]; - allControls: Control[]; + mappedControls: MappedControl[]; + allControls: MappedControl[]; isPendingApproval: boolean; policyId: string; organizationId: string; @@ -183,6 +187,7 @@ export function PolicyPageTabs({ Overview Content + Mappings Versions Activity Comments @@ -196,12 +201,18 @@ export function PolicyPageTabs({ isPendingApproval={isPendingApproval} onMutate={mutate} /> + + + + + + diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/hooks/usePolicyEvidenceTasks.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/hooks/usePolicyEvidenceTasks.ts new file mode 100644 index 0000000000..3d1822e116 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/hooks/usePolicyEvidenceTasks.ts @@ -0,0 +1,63 @@ +'use client'; + +import { apiClient } from '@/lib/api-client'; +import useSWR from 'swr'; + +type TaskSummary = { + id: string; + title: string; + status: string; + frequency: string | null; + department: string | null; + automationStatus: 'AUTOMATED' | 'MANUAL'; + assigneeId: string | null; +}; + +export type PolicyEvidenceTaskGroup = { + control: { id: string; name: string }; + tasks: TaskSummary[]; +}; + +type ApiResponse = { + data: PolicyEvidenceTaskGroup[]; + count: number; +}; + +export const policyEvidenceTasksKey = (policyId: string, organizationId: string) => + ['/v1/policies/evidence-tasks', policyId, organizationId] as const; + +interface UsePolicyEvidenceTasksOptions { + policyId: string; + organizationId: string; + initialData?: { data: PolicyEvidenceTaskGroup[]; count: number } | null; +} + +export function usePolicyEvidenceTasks({ + policyId, + organizationId, + initialData, +}: UsePolicyEvidenceTasksOptions) { + const { data, error, isLoading, mutate } = useSWR( + policyEvidenceTasksKey(policyId, organizationId), + async () => { + const response = await apiClient.get( + `/v1/policies/${policyId}/evidence-tasks`, + ); + if (response.error) throw new Error(response.error); + return response.data ?? { data: [], count: 0 }; + }, + { + fallbackData: initialData ?? undefined, + revalidateOnMount: !initialData, + revalidateOnFocus: false, + }, + ); + + return { + groups: data?.data ?? [], + count: data?.count ?? 0, + isLoading: isLoading && !data, + error, + mutate, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx index 9bfe75f8f7..27d36eca27 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx @@ -4,7 +4,6 @@ import { serverApi } from '@/lib/api-server'; import { auth } from '@/utils/auth'; import type { AuditLog, - Control, Member, Organization, Policy, @@ -15,6 +14,7 @@ import { Breadcrumb, PageLayout } from '@trycompai/design-system'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import Link from 'next/link'; +import type { MappedControl } from './components/PolicyControlMappings'; import { PolicyHeaderActions } from './components/PolicyHeaderActions'; import PolicyPage from './components/PolicyPage'; import { PolicyStatusBadge } from './components/PolicyStatusBadge'; @@ -50,9 +50,10 @@ export default async function PolicyDetails({ await Promise.all([ serverApi.get(`/v1/policies/${policyId}`), serverApi.get<{ data: (Member & { user: User })[] }>('/v1/people'), - serverApi.get<{ mappedControls: Control[]; allControls: Control[] }>( - `/v1/policies/${policyId}/controls`, - ), + serverApi.get<{ + mappedControls: MappedControl[]; + allControls: MappedControl[]; + }>(`/v1/policies/${policyId}/controls`), serverApi.get<{ data: AuditLogWithRelations[] }>( `/v1/audit-logs?entityType=policy&entityId=${policyId}`, ), diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx index 76a0e39b3f..0adb3a8785 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx @@ -53,6 +53,7 @@ import { TaskAutomationStatusBadge } from './TaskAutomationStatusBadge'; import { TaskDeleteDialog } from './TaskDeleteDialog'; import { TaskIntegrationChecks } from './TaskIntegrationChecks'; import { TaskMainContent } from './TaskMainContent'; +import { TaskPolicies } from './TaskPolicies'; import { TaskPropertiesSidebar } from './TaskPropertiesSidebar'; type AutomationWithRuns = EvidenceAutomation & { @@ -121,6 +122,7 @@ export function SingleTask({ const canUpdateTask = hasPermission('task', 'update'); const canDeleteTask = hasPermission('task', 'delete'); + const canReadPolicy = hasPermission('policy', 'read'); const startEditingTitle = () => { if (!canUpdateTask) return; @@ -333,6 +335,7 @@ export function SingleTask({ Overview {task.automationStatus !== 'MANUAL' && Automations} + {canReadPolicy && Mappings} Comments Activity Settings @@ -349,6 +352,12 @@ export function SingleTask({ + + + + + + ({ + useParams: () => ({ orgId: 'org_1', taskId: 'tsk_1' }), +})); + +const mockHook = vi.fn(); +vi.mock('../hooks/use-task-policies', () => ({ + useTaskPolicies: (...args: unknown[]) => mockHook(...args), +})); + +const makePolicy = (overrides: Partial = {}) => ({ + id: 'pol_1', + name: 'Authentication Policy', + status: 'published', + frequency: 'yearly', + department: 'it', + ...overrides, +}); + +describe('TaskPolicies', () => { + beforeEach(() => { + mockHook.mockReset(); + }); + + it('renders one row per (control, policy) pair with policy and control names', () => { + mockHook.mockReturnValue({ + groups: [ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + policies: [makePolicy(), makePolicy({ id: 'pol_2', name: 'MFA Policy' })], + }, + ], + count: 2, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Authentication Policy')).toBeInTheDocument(); + expect(screen.getByText('MFA Policy')).toBeInTheDocument(); + // Control name appears once per row in the Control column. + expect(screen.getAllByText('Access Controls')).toHaveLength(2); + }); + + it('renders all policies returned by the API, including drafts', () => { + // The API is authoritative on what to show; the component does not filter + // by status. Both draft and published policies should appear. + mockHook.mockReturnValue({ + groups: [ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + policies: [ + makePolicy({ id: 'pol_1', status: 'published', name: 'Published One' }), + makePolicy({ id: 'pol_2', status: 'draft', name: 'Draft One' }), + ], + }, + ], + count: 2, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Published One')).toBeInTheDocument(); + expect(screen.getByText('Draft One')).toBeInTheDocument(); + }); + + it('shows empty state when task has no linked controls', () => { + mockHook.mockReturnValue({ groups: [], count: 0, isLoading: false }); + + render(); + + expect( + screen.getByText(/no policies reference this task/i), + ).toBeInTheDocument(); + }); + + it('renders policy and control cells as links that open in a new tab', () => { + mockHook.mockReturnValue({ + groups: [ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + policies: [makePolicy({ id: 'pol_42', name: 'Authentication Policy' })], + }, + ], + count: 1, + isLoading: false, + }); + + render(); + + const policyLink = screen.getByText('Authentication Policy').closest('a'); + expect(policyLink).not.toBeNull(); + expect(policyLink).toHaveAttribute('href', '/org_1/policies/pol_42'); + expect(policyLink).toHaveAttribute('target', '_blank'); + expect(policyLink).toHaveAttribute('rel', 'noopener noreferrer'); + + const controlLink = screen.getByText('Access Controls').closest('a'); + expect(controlLink).not.toBeNull(); + expect(controlLink).toHaveAttribute('href', '/org_1/controls/ctl_1'); + expect(controlLink).toHaveAttribute('target', '_blank'); + expect(controlLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders error state when the hook returns an error', () => { + mockHook.mockReturnValue({ + groups: [], + count: 0, + isLoading: false, + error: new Error('boom'), + }); + + render(); + + expect( + screen.getByText(/could not load policies/i), + ).toBeInTheDocument(); + expect( + screen.queryByText(/no policies reference this task/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPolicies.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPolicies.tsx new file mode 100644 index 0000000000..fa814f97a7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPolicies.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { + Section, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Launch } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { useTaskPolicies } from '../hooks/use-task-policies'; + +const SECTION_TITLE = 'Policies'; +const SECTION_DESCRIPTION = 'Policies this task implements.'; + +interface PolicyRow { + policyId: string; + policyName: string; + status: string; + frequency: string | null; + controlId: string; + controlName: string; +} + +function PolicyStatusPill({ status }: { status: string }) { + const label = status.replace(/_/g, ' '); + const styles = (() => { + switch (status) { + case 'published': + return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'; + case 'needs_review': + return 'bg-amber-500/15 text-amber-700 dark:text-amber-300'; + case 'draft': + case 'archived': + default: + return 'bg-muted text-muted-foreground'; + } + })(); + return ( + + {label} + + ); +} + +export function TaskPolicies() { + const { orgId, taskId } = useParams<{ orgId: string; taskId: string }>(); + const { groups, isLoading, error } = useTaskPolicies({ + taskId, + organizationId: orgId, + }); + + if (error) { + return ( +
+ Could not load policies. Please try again. +
+ ); + } + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + const rows: PolicyRow[] = []; + for (const group of groups) { + for (const policy of group.policies) { + rows.push({ + policyId: policy.id, + policyName: policy.name, + status: policy.status, + frequency: policy.frequency, + controlId: group.control.id, + controlName: group.control.name, + }); + } + } + + if (rows.length === 0) { + return ( +
+ + No policies reference this task through its mapped controls. + +
+ ); + } + + return ( +
+ + + + Policy + Control + Status + Frequency + + + + {rows.map((row) => ( + + + e.stopPropagation()} + className="group flex items-center justify-between gap-2" + > + + {row.policyName} + + + + + + e.stopPropagation()} + className="group flex items-center justify-between gap-2" + > + + {row.controlName} + + + + + + + + +
+ + {row.frequency ?? '—'} + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-policies.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-policies.ts new file mode 100644 index 0000000000..35ac6b6fc2 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-policies.ts @@ -0,0 +1,61 @@ +'use client'; + +import { apiClient } from '@/lib/api-client'; +import useSWR from 'swr'; + +type PolicySummary = { + id: string; + name: string; + status: string; + frequency: string | null; + department: string | null; +}; + +export type TaskPolicyGroup = { + control: { id: string; name: string }; + policies: PolicySummary[]; +}; + +type ApiResponse = { + data: TaskPolicyGroup[]; + count: number; +}; + +export const taskPoliciesKey = (taskId: string, organizationId: string) => + ['/v1/tasks/policies', taskId, organizationId] as const; + +interface UseTaskPoliciesOptions { + taskId: string; + organizationId: string; + initialData?: { data: TaskPolicyGroup[]; count: number } | null; +} + +export function useTaskPolicies({ + taskId, + organizationId, + initialData, +}: UseTaskPoliciesOptions) { + const { data, error, isLoading, mutate } = useSWR( + taskPoliciesKey(taskId, organizationId), + async () => { + const response = await apiClient.get( + `/v1/tasks/${taskId}/policies`, + ); + if (response.error) throw new Error(response.error); + return response.data ?? { data: [], count: 0 }; + }, + { + fallbackData: initialData ?? undefined, + revalidateOnMount: !initialData, + revalidateOnFocus: false, + }, + ); + + return { + groups: data?.data ?? [], + count: data?.count ?? 0, + isLoading: isLoading && !data, + error, + mutate, + }; +} diff --git a/docs/superpowers/plans/2026-04-24-policy-evidence-tasks.md b/docs/superpowers/plans/2026-04-24-policy-evidence-tasks.md new file mode 100644 index 0000000000..24af799835 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-policy-evidence-tasks.md @@ -0,0 +1,1368 @@ +# Policy Evidence Tasks Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On the Policy Overview tab, show tasks that serve as evidence for the policy (grouped by Control). On the Task Overview tab, mirror it with policies grouped by Control. Read-only, zero schema changes, transitive via the existing `Policy.controls` ↔ `Task.controls` M2M. + +**Architecture:** Two new GET endpoints compute the transitive set at query time via Prisma `include`. Two SWR hooks + two React components surface the result. Linking remains managed by the existing Policy↔Control mapping UI. + +**Tech Stack:** NestJS + Prisma (API), Next.js + SWR + `@trycompai/design-system` (app), Jest (API tests), Vitest + testing-library/react (app tests). + +**Spec:** `docs/superpowers/specs/2026-04-24-policy-evidence-tasks-design.md` + +--- + +## File Structure + +### Created + +- `apps/api/src/policies/policies.controller.spec.ts` — new `describe('getPolicyEvidenceTasks')` block (modify) +- `apps/api/src/tasks/tasks.controller.spec.ts` — new `describe('getTaskPolicies')` block (modify) +- `apps/app/src/app/(app)/[orgId]/policies/[policyId]/hooks/usePolicyEvidenceTasks.ts` +- `apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyEvidenceTasks.tsx` +- `apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyEvidenceTasks.test.tsx` +- `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-policies.ts` +- `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPolicies.tsx` +- `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPolicies.test.tsx` + +### Modified + +- `apps/api/src/policies/policies.controller.ts` — add `GET :id/evidence-tasks` +- `apps/api/src/tasks/tasks.controller.ts` — add `GET :taskId/policies` +- `apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx` — render `` in the Overview tab +- `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx` — render `` in the Overview tab + +--- + +## Shared Types + +Both endpoints return the same grouped shape. Define these in each controller test using the types below and mirror them in the frontend hooks. Copy-paste verbatim; do not DRY across packages unless the codebase already shares a types package for API responses. + +```ts +// Group shape from GET /v1/policies/:id/evidence-tasks +type PolicyEvidenceTaskGroup = { + control: { id: string; name: string }; + tasks: Array<{ + id: string; + title: string; + status: 'todo' | 'in_progress' | 'in_review' | 'done' | 'not_relevant' | 'failed'; + frequency: 'monthly' | 'quarterly' | 'yearly' | null; + department: string | null; + automationStatus: 'AUTOMATED' | 'MANUAL'; + assigneeId: string | null; + }>; +}; + +// Group shape from GET /v1/tasks/:taskId/policies +type TaskPolicyGroup = { + control: { id: string; name: string }; + policies: Array<{ + id: string; + name: string; + status: 'draft' | 'published' | 'needs_review' | 'archived'; + frequency: 'monthly' | 'quarterly' | 'yearly' | null; + department: string | null; + }>; +}; +``` + +Note: use the actual `TaskStatus`, `Frequency`, `Department`, `AutomationStatus`, `PolicyStatus` enums from `@db` inside the real code. The literal unions above are for copy-paste clarity. + +--- + +## Task 1: API — `GET /v1/policies/:id/evidence-tasks` + +**Files:** +- Modify: `apps/api/src/policies/policies.controller.ts` +- Modify: `apps/api/src/policies/policies.controller.spec.ts` + +### Step 1.1: Write the failing test + +Open `apps/api/src/policies/policies.controller.spec.ts` and add a new describe block. Scroll to the bottom of the existing `describe('PoliciesController', ...)` body, right before the final `});` closing brace. Add the following block verbatim, replacing `orgId`, `controller`, `mockAuthContext` references with whatever names the file already uses (the existing `describe('getPolicyControls', ...)` block shows the convention). + +- [ ] **Step 1.1:** Append the test block + +```typescript +describe('getPolicyEvidenceTasks', () => { + it('returns tasks grouped by control, excluding archived tasks', async () => { + const { db } = require('@db'); + db.policy.findFirst.mockResolvedValue({ + id: 'pol_1', + controls: [ + { + id: 'ctl_1', + name: 'Access Controls', + tasks: [ + { + id: 'tsk_1', + title: 'Enable 2FA', + status: 'in_progress', + frequency: 'monthly', + department: 'it', + automationStatus: 'MANUAL', + assigneeId: 'mem_1', + }, + ], + }, + { + id: 'ctl_2', + name: 'Monitoring', + tasks: [], + }, + ], + }); + + const result = await controller.getPolicyEvidenceTasks( + 'pol_1', + orgId, + mockAuthContext, + ); + + expect(db.policy.findFirst).toHaveBeenCalledWith({ + where: { id: 'pol_1', organizationId: orgId, archivedAt: null }, + select: expect.objectContaining({ + id: true, + controls: expect.objectContaining({ + where: { archivedAt: null }, + }), + }), + }); + expect(result.data).toEqual([ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + tasks: [ + { + id: 'tsk_1', + title: 'Enable 2FA', + status: 'in_progress', + frequency: 'monthly', + department: 'it', + automationStatus: 'MANUAL', + assigneeId: 'mem_1', + }, + ], + }, + { + control: { id: 'ctl_2', name: 'Monitoring' }, + tasks: [], + }, + ]); + expect(result.count).toBe(1); + expect(result.authType).toBe('session'); + }); + + it('throws NotFoundException when policy is not in caller org', async () => { + const { db } = require('@db'); + db.policy.findFirst.mockResolvedValue(null); + + await expect( + controller.getPolicyEvidenceTasks('pol_404', orgId, mockAuthContext), + ).rejects.toThrow('Policy not found'); + }); +}); +``` + +- [ ] **Step 1.2:** Run the test and confirm failure + +```bash +cd apps/api && npx jest src/policies/policies.controller.spec.ts -t "getPolicyEvidenceTasks" +``` + +Expected: both tests fail with `TypeError: controller.getPolicyEvidenceTasks is not a function`. + +### Step 1.3: Implement the endpoint + +Open `apps/api/src/policies/policies.controller.ts`. Find the existing `getPolicyControls` method (search for `@Get(':id/controls')`). Insert the new endpoint immediately after `getPolicyControls`, before the next method. Ensure `NotFoundException` is imported from `@nestjs/common` (it is already imported in most controllers; add it to the import line if missing). + +- [ ] **Step 1.3:** Add the endpoint + +```typescript +@Get(':id/evidence-tasks') +@RequirePermission('policy', 'read') +@ApiOperation({ summary: 'Get tasks that serve as evidence for a policy, grouped by control' }) +@ApiParam(POLICY_PARAMS.policyId) +async getPolicyEvidenceTasks( + @Param('id') id: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, +) { + const policy = await db.policy.findFirst({ + where: { id, organizationId, archivedAt: null }, + select: { + id: true, + controls: { + where: { archivedAt: null }, + select: { + id: true, + name: true, + tasks: { + where: { archivedAt: null }, + select: { + id: true, + title: true, + status: true, + frequency: true, + department: true, + automationStatus: true, + assigneeId: true, + }, + orderBy: { title: 'asc' }, + }, + }, + orderBy: { name: 'asc' }, + }, + }, + }); + + if (!policy) { + throw new NotFoundException('Policy not found'); + } + + const data = policy.controls.map((control) => ({ + control: { id: control.id, name: control.name }, + tasks: control.tasks, + })); + + const uniqueTaskIds = new Set(); + for (const group of data) { + for (const task of group.tasks) uniqueTaskIds.add(task.id); + } + + return { + data, + count: uniqueTaskIds.size, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; +} +``` + +- [ ] **Step 1.4:** Run the test and confirm it passes + +```bash +cd apps/api && npx jest src/policies/policies.controller.spec.ts -t "getPolicyEvidenceTasks" +``` + +Expected: both tests PASS. + +- [ ] **Step 1.5:** Commit + +```bash +git add apps/api/src/policies/policies.controller.ts apps/api/src/policies/policies.controller.spec.ts +git commit -m "feat(api): add GET /v1/policies/:id/evidence-tasks" +``` + +--- + +## Task 2: API — `GET /v1/tasks/:taskId/policies` + +**Files:** +- Modify: `apps/api/src/tasks/tasks.controller.ts` +- Modify: `apps/api/src/tasks/tasks.controller.spec.ts` + +### Step 2.1: Write the failing test + +Append to `apps/api/src/tasks/tasks.controller.spec.ts`, after the existing `describe('getTask', ...)` block: + +- [ ] **Step 2.1:** Add the test block + +```typescript +describe('getTaskPolicies', () => { + it('returns policies grouped by control, filtering drafts and archived', async () => { + const { db } = require('@db'); + db.task.findFirst.mockResolvedValue({ + id: 'tsk_1', + controls: [ + { + id: 'ctl_1', + name: 'Access Controls', + policies: [ + { + id: 'pol_1', + name: 'Authentication Policy', + status: 'published', + frequency: 'yearly', + department: 'it', + }, + ], + }, + ], + }); + + const result = await controller.getTaskPolicies( + orgId, + 'tsk_1', + authContext, + ); + + expect(db.task.findFirst).toHaveBeenCalledWith({ + where: { id: 'tsk_1', organizationId: orgId, archivedAt: null }, + select: expect.objectContaining({ + id: true, + controls: expect.objectContaining({ + where: { archivedAt: null }, + }), + }), + }); + expect(result.data).toEqual([ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + policies: [ + { + id: 'pol_1', + name: 'Authentication Policy', + status: 'published', + frequency: 'yearly', + department: 'it', + }, + ], + }, + ]); + expect(result.count).toBe(1); + }); + + it('throws NotFoundException when task is not in caller org', async () => { + const { db } = require('@db'); + db.task.findFirst.mockResolvedValue(null); + + await expect( + controller.getTaskPolicies(orgId, 'tsk_404', authContext), + ).rejects.toThrow('Task not found'); + }); +}); +``` + +Ensure the `jest.mock('@db', ...)` block at the top of the file includes `task: { findFirst: jest.fn(), ... }`. Add `findFirst: jest.fn(),` to the existing `task` mock entry if missing. + +- [ ] **Step 2.2:** Run the test and confirm failure + +```bash +cd apps/api && npx jest src/tasks/tasks.controller.spec.ts -t "getTaskPolicies" +``` + +Expected: fails with `controller.getTaskPolicies is not a function`. + +### Step 2.3: Implement the endpoint + +In `apps/api/src/tasks/tasks.controller.ts`, add the endpoint alongside the existing `getTask`. Use the `:taskId` param name to match the existing controller convention. + +- [ ] **Step 2.3:** Add the endpoint + +```typescript +@Get(':taskId/policies') +@UseGuards(PermissionGuard) +@RequirePermission('task', 'read') +@ApiOperation({ summary: 'Get policies that reference a task via shared controls' }) +@ApiParam({ name: 'taskId', description: 'Unique task identifier', example: 'tsk_abc123def456' }) +async getTaskPolicies( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + @AuthContext() authContext: AuthContextType, +) { + const task = await db.task.findFirst({ + where: { id: taskId, organizationId, archivedAt: null }, + select: { + id: true, + controls: { + where: { archivedAt: null }, + select: { + id: true, + name: true, + policies: { + where: { archivedAt: null }, + select: { + id: true, + name: true, + status: true, + frequency: true, + department: true, + }, + orderBy: { name: 'asc' }, + }, + }, + orderBy: { name: 'asc' }, + }, + }, + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + const data = task.controls.map((control) => ({ + control: { id: control.id, name: control.name }, + policies: control.policies, + })); + + const uniquePolicyIds = new Set(); + for (const group of data) { + for (const policy of group.policies) uniquePolicyIds.add(policy.id); + } + + return { + data, + count: uniquePolicyIds.size, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; +} +``` + +Ensure these imports exist at the top of the file: `NotFoundException` from `@nestjs/common`, and `db` from `@db`. If `db` is not yet imported (the existing controller may go through `TasksService`), add: + +```typescript +import { db } from '@db'; +``` + +- [ ] **Step 2.4:** Run the test and confirm it passes + +```bash +cd apps/api && npx jest src/tasks/tasks.controller.spec.ts -t "getTaskPolicies" +``` + +Expected: both tests PASS. + +- [ ] **Step 2.5:** Typecheck API + +```bash +npx turbo run typecheck --filter=@trycompai/api +``` + +Expected: no errors. + +- [ ] **Step 2.6:** Commit + +```bash +git add apps/api/src/tasks/tasks.controller.ts apps/api/src/tasks/tasks.controller.spec.ts +git commit -m "feat(api): add GET /v1/tasks/:taskId/policies" +``` + +--- + +## Task 3: Hook — `usePolicyEvidenceTasks` + +**Files:** +- Create: `apps/app/src/app/(app)/[orgId]/policies/[policyId]/hooks/usePolicyEvidenceTasks.ts` + +SWR wrappers don't need a dedicated test — the component test will exercise them. No red/green cycle here. + +- [ ] **Step 3.1:** Create the hook + +```typescript +'use client'; + +import { apiClient } from '@/lib/api-client'; +import useSWR from 'swr'; + +type TaskSummary = { + id: string; + title: string; + status: string; + frequency: string | null; + department: string | null; + automationStatus: 'AUTOMATED' | 'MANUAL'; + assigneeId: string | null; +}; + +export type PolicyEvidenceTaskGroup = { + control: { id: string; name: string }; + tasks: TaskSummary[]; +}; + +type ApiResponse = { + data: PolicyEvidenceTaskGroup[]; + count: number; +}; + +export const policyEvidenceTasksKey = (policyId: string, organizationId: string) => + ['/v1/policies/evidence-tasks', policyId, organizationId] as const; + +interface UsePolicyEvidenceTasksOptions { + policyId: string; + organizationId: string; + initialData?: { data: PolicyEvidenceTaskGroup[]; count: number } | null; +} + +export function usePolicyEvidenceTasks({ + policyId, + organizationId, + initialData, +}: UsePolicyEvidenceTasksOptions) { + const { data, error, isLoading, mutate } = useSWR( + policyEvidenceTasksKey(policyId, organizationId), + async () => { + const response = await apiClient.get( + `/v1/policies/${policyId}/evidence-tasks`, + ); + if (response.error) throw new Error(response.error); + return response.data ?? { data: [], count: 0 }; + }, + { + fallbackData: initialData ?? undefined, + revalidateOnMount: !initialData, + revalidateOnFocus: false, + }, + ); + + return { + groups: data?.data ?? [], + count: data?.count ?? 0, + isLoading: isLoading && !data, + error, + mutate, + }; +} +``` + +- [ ] **Step 3.2:** Commit + +```bash +git add apps/app/src/app/\(app\)/\[orgId\]/policies/\[policyId\]/hooks/usePolicyEvidenceTasks.ts +git commit -m "feat(app): add usePolicyEvidenceTasks hook" +``` + +--- + +## Task 4: Hook — `use-task-policies` + +**Files:** +- Create: `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-policies.ts` + +Uses kebab-case filename to match the existing task-hook convention (see `use-task.ts`, `use-task-automations.ts`). + +- [ ] **Step 4.1:** Create the hook + +```typescript +'use client'; + +import { apiClient } from '@/lib/api-client'; +import useSWR from 'swr'; + +type PolicySummary = { + id: string; + name: string; + status: string; + frequency: string | null; + department: string | null; +}; + +export type TaskPolicyGroup = { + control: { id: string; name: string }; + policies: PolicySummary[]; +}; + +type ApiResponse = { + data: TaskPolicyGroup[]; + count: number; +}; + +export const taskPoliciesKey = (taskId: string, organizationId: string) => + ['/v1/tasks/policies', taskId, organizationId] as const; + +interface UseTaskPoliciesOptions { + taskId: string; + organizationId: string; + initialData?: { data: TaskPolicyGroup[]; count: number } | null; +} + +export function useTaskPolicies({ + taskId, + organizationId, + initialData, +}: UseTaskPoliciesOptions) { + const { data, error, isLoading, mutate } = useSWR( + taskPoliciesKey(taskId, organizationId), + async () => { + const response = await apiClient.get( + `/v1/tasks/${taskId}/policies`, + ); + if (response.error) throw new Error(response.error); + return response.data ?? { data: [], count: 0 }; + }, + { + fallbackData: initialData ?? undefined, + revalidateOnMount: !initialData, + revalidateOnFocus: false, + }, + ); + + return { + groups: data?.data ?? [], + count: data?.count ?? 0, + isLoading: isLoading && !data, + error, + mutate, + }; +} +``` + +- [ ] **Step 4.2:** Commit + +```bash +git add apps/app/src/app/\(app\)/\[orgId\]/tasks/\[taskId\]/hooks/use-task-policies.ts +git commit -m "feat(app): add useTaskPolicies hook" +``` + +--- + +## Task 5: Component — `PolicyEvidenceTasks` + +**Files:** +- Create: `apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyEvidenceTasks.test.tsx` +- Create: `apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyEvidenceTasks.tsx` + +### Step 5.1: Write failing tests + +- [ ] **Step 5.1:** Create the test file + +```typescript +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PolicyEvidenceTasks } from './PolicyEvidenceTasks'; +import type { PolicyEvidenceTaskGroup } from '../hooks/usePolicyEvidenceTasks'; + +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_1', policyId: 'pol_1' }), +})); + +const mockHook = vi.fn(); +vi.mock('../hooks/usePolicyEvidenceTasks', () => ({ + usePolicyEvidenceTasks: (...args: unknown[]) => mockHook(...args), +})); + +const makeTask = (overrides: Partial = {}) => ({ + id: 'tsk_1', + title: 'Enable 2FA', + status: 'in_progress', + frequency: 'monthly', + department: 'it', + automationStatus: 'MANUAL' as const, + assigneeId: null, + ...overrides, +}); + +describe('PolicyEvidenceTasks', () => { + beforeEach(() => { + mockHook.mockReset(); + }); + + it('renders one section per control group with task rows', () => { + mockHook.mockReturnValue({ + groups: [ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + tasks: [makeTask(), makeTask({ id: 'tsk_2', title: 'Review access logs' })], + }, + { + control: { id: 'ctl_2', name: 'Monitoring' }, + tasks: [makeTask({ id: 'tsk_3', title: 'Check alerts' })], + }, + ], + count: 3, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Access Controls')).toBeInTheDocument(); + expect(screen.getByText('Monitoring')).toBeInTheDocument(); + expect(screen.getByText('Enable 2FA')).toBeInTheDocument(); + expect(screen.getByText('Review access logs')).toBeInTheDocument(); + expect(screen.getByText('Check alerts')).toBeInTheDocument(); + }); + + it('shows page-level empty state when policy has no controls', () => { + mockHook.mockReturnValue({ groups: [], count: 0, isLoading: false }); + + render(); + + expect( + screen.getByText(/map at least one control/i), + ).toBeInTheDocument(); + }); + + it('shows per-group empty state when a control has no tasks', () => { + mockHook.mockReturnValue({ + groups: [{ control: { id: 'ctl_1', name: 'Access Controls' }, tasks: [] }], + count: 0, + isLoading: false, + }); + + render(); + + expect( + screen.getByText(/no tasks attached to this control/i), + ).toBeInTheDocument(); + }); + + it('task row links to the task detail page', () => { + mockHook.mockReturnValue({ + groups: [ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + tasks: [makeTask({ id: 'tsk_42', title: 'Enable 2FA' })], + }, + ], + count: 1, + isLoading: false, + }); + + render(); + + const link = screen.getByRole('link', { name: /Enable 2FA/ }); + expect(link).toHaveAttribute('href', '/org_1/tasks/tsk_42'); + }); + + it('collapses groups with more than 5 tasks by default', async () => { + const manyTasks = Array.from({ length: 7 }, (_, i) => + makeTask({ id: `tsk_${i}`, title: `Task ${i}` }), + ); + mockHook.mockReturnValue({ + groups: [{ control: { id: 'ctl_1', name: 'Access Controls' }, tasks: manyTasks }], + count: 7, + isLoading: false, + }); + + render(); + + // By default, task titles should not be visible + expect(screen.queryByText('Task 0')).not.toBeInTheDocument(); + + const toggle = screen.getByRole('button', { name: /show 7 tasks/i }); + await userEvent.click(toggle); + + expect(screen.getByText('Task 0')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 5.2:** Run tests and confirm failure + +```bash +cd apps/app && npx vitest run src/app/\(app\)/\[orgId\]/policies/\[policyId\]/components/PolicyEvidenceTasks.test.tsx +``` + +Expected: all tests fail with `Cannot find module './PolicyEvidenceTasks'`. + +### Step 5.3: Implement the component + +- [ ] **Step 5.3:** Create the component + +```tsx +'use client'; + +import { + Badge, + Button, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + HStack, + Section, + Stack, + Text, +} from '@trycompai/design-system'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { + usePolicyEvidenceTasks, + type PolicyEvidenceTaskGroup, +} from '../hooks/usePolicyEvidenceTasks'; + +const COLLAPSE_THRESHOLD = 5; + +export function PolicyEvidenceTasks() { + const { orgId, policyId } = useParams<{ orgId: string; policyId: string }>(); + const { groups, count, isLoading } = usePolicyEvidenceTasks({ + policyId, + organizationId: orgId, + }); + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (groups.length === 0) { + return ( +
+ + Map at least one control to see the tasks that demonstrate this policy. + +
+ ); + } + + return ( +
+ + {groups.map((group) => ( + + ))} + +
+ ); +} + +function ControlGroup({ + group, + orgId, +}: { + group: PolicyEvidenceTaskGroup; + orgId: string; +}) { + const { control, tasks } = group; + + if (tasks.length === 0) { + return ( + + {control.name} + + No tasks attached to this control. + + + ); + } + + if (tasks.length > COLLAPSE_THRESHOLD) { + return ( + + + {control.name} + + + + + + + + + ); + } + + return ( + + {control.name} + + + ); +} + +function TaskList({ + tasks, + orgId, +}: { + tasks: PolicyEvidenceTaskGroup['tasks']; + orgId: string; +}) { + return ( + + {tasks.map((task) => ( + + + {task.title} + + {task.status} + {task.frequency ? {task.frequency} : null} + + + + ))} + + ); +} +``` + +- [ ] **Step 5.4:** Run tests and confirm they pass + +```bash +cd apps/app && npx vitest run src/app/\(app\)/\[orgId\]/policies/\[policyId\]/components/PolicyEvidenceTasks.test.tsx +``` + +Expected: all 5 tests PASS. + +- [ ] **Step 5.5:** Run the design-system audit + +```bash +# From the repo root +``` + +Invoke the `audit-design-system` skill. Fix any `@trycompai/ui` or `lucide-react` imports it surfaces. Re-run the test suite after any edits. + +- [ ] **Step 5.6:** Commit + +```bash +git add apps/app/src/app/\(app\)/\[orgId\]/policies/\[policyId\]/components/PolicyEvidenceTasks.tsx apps/app/src/app/\(app\)/\[orgId\]/policies/\[policyId\]/components/PolicyEvidenceTasks.test.tsx +git commit -m "feat(app): add PolicyEvidenceTasks component" +``` + +--- + +## Task 6: Component — `TaskPolicies` + +**Files:** +- Create: `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPolicies.test.tsx` +- Create: `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPolicies.tsx` + +Mirror of Task 5. Same mocking pattern, same collapse behavior, draft/archived policies must be filtered defensively in the component (the API already filters, but defense in depth — tested explicitly). + +### Step 6.1: Write failing tests + +- [ ] **Step 6.1:** Create the test file + +```typescript +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TaskPolicies } from './TaskPolicies'; +import type { TaskPolicyGroup } from '../hooks/use-task-policies'; + +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_1', taskId: 'tsk_1' }), +})); + +const mockHook = vi.fn(); +vi.mock('../hooks/use-task-policies', () => ({ + useTaskPolicies: (...args: unknown[]) => mockHook(...args), +})); + +const makePolicy = (overrides: Partial = {}) => ({ + id: 'pol_1', + name: 'Authentication Policy', + status: 'published', + frequency: 'yearly', + department: 'it', + ...overrides, +}); + +describe('TaskPolicies', () => { + beforeEach(() => { + mockHook.mockReset(); + }); + + it('renders one section per control group with policy rows', () => { + mockHook.mockReturnValue({ + groups: [ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + policies: [makePolicy(), makePolicy({ id: 'pol_2', name: 'MFA Policy' })], + }, + ], + count: 2, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Access Controls')).toBeInTheDocument(); + expect(screen.getByText('Authentication Policy')).toBeInTheDocument(); + expect(screen.getByText('MFA Policy')).toBeInTheDocument(); + }); + + it('shows empty state when task has no linked controls', () => { + mockHook.mockReturnValue({ groups: [], count: 0, isLoading: false }); + + render(); + + expect( + screen.getByText(/no policies reference this task/i), + ).toBeInTheDocument(); + }); + + it('filters out policies that are not published, defensively', () => { + mockHook.mockReturnValue({ + groups: [ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + policies: [ + makePolicy({ id: 'pol_1', status: 'published', name: 'Published One' }), + makePolicy({ id: 'pol_2', status: 'draft', name: 'Draft One' }), + makePolicy({ id: 'pol_3', status: 'archived', name: 'Archived One' }), + ], + }, + ], + count: 1, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Published One')).toBeInTheDocument(); + expect(screen.queryByText('Draft One')).not.toBeInTheDocument(); + expect(screen.queryByText('Archived One')).not.toBeInTheDocument(); + }); + + it('policy row links to the policy detail page', () => { + mockHook.mockReturnValue({ + groups: [ + { + control: { id: 'ctl_1', name: 'Access Controls' }, + policies: [makePolicy({ id: 'pol_42', name: 'Authentication Policy' })], + }, + ], + count: 1, + isLoading: false, + }); + + render(); + + const link = screen.getByRole('link', { name: /Authentication Policy/ }); + expect(link).toHaveAttribute('href', '/org_1/policies/pol_42'); + }); + + it('collapses groups with more than 5 policies by default', async () => { + const manyPolicies = Array.from({ length: 7 }, (_, i) => + makePolicy({ id: `pol_${i}`, name: `Policy ${i}` }), + ); + mockHook.mockReturnValue({ + groups: [{ control: { id: 'ctl_1', name: 'Access Controls' }, policies: manyPolicies }], + count: 7, + isLoading: false, + }); + + render(); + + expect(screen.queryByText('Policy 0')).not.toBeInTheDocument(); + + const toggle = screen.getByRole('button', { name: /show 7 policies/i }); + await userEvent.click(toggle); + + expect(screen.getByText('Policy 0')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6.2:** Run tests and confirm failure + +```bash +cd apps/app && npx vitest run src/app/\(app\)/\[orgId\]/tasks/\[taskId\]/components/TaskPolicies.test.tsx +``` + +Expected: all tests fail with `Cannot find module './TaskPolicies'`. + +### Step 6.3: Implement the component + +- [ ] **Step 6.3:** Create the component + +```tsx +'use client'; + +import { + Badge, + Button, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + HStack, + Section, + Stack, + Text, +} from '@trycompai/design-system'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { + useTaskPolicies, + type TaskPolicyGroup, +} from '../hooks/use-task-policies'; + +const COLLAPSE_THRESHOLD = 5; + +export function TaskPolicies() { + const { orgId, taskId } = useParams<{ orgId: string; taskId: string }>(); + const { groups, count, isLoading } = useTaskPolicies({ + taskId, + organizationId: orgId, + }); + + // Defensive filter: never render non-published policies even if the API + // regresses and returns them. + const visibleGroups = groups + .map((group) => ({ + ...group, + policies: group.policies.filter((p) => p.status === 'published'), + })) + .filter((group) => group.policies.length > 0); + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (visibleGroups.length === 0) { + return ( +
+ No policies reference this task through its mapped controls. +
+ ); + } + + return ( +
+ + {visibleGroups.map((group) => ( + + ))} + +
+ ); +} + +function ControlGroup({ + group, + orgId, +}: { + group: TaskPolicyGroup; + orgId: string; +}) { + const { control, policies } = group; + + if (policies.length > COLLAPSE_THRESHOLD) { + return ( + + + {control.name} + + + + + + + + + ); + } + + return ( + + {control.name} + + + ); +} + +function PolicyList({ + policies, + orgId, +}: { + policies: TaskPolicyGroup['policies']; + orgId: string; +}) { + return ( + + {policies.map((policy) => ( + + + {policy.name} + + {policy.status} + {policy.frequency ? {policy.frequency} : null} + + + + ))} + + ); +} +``` + +- [ ] **Step 6.4:** Run tests and confirm they pass + +```bash +cd apps/app && npx vitest run src/app/\(app\)/\[orgId\]/tasks/\[taskId\]/components/TaskPolicies.test.tsx +``` + +Expected: all 5 tests PASS. + +- [ ] **Step 6.5:** Run the design-system audit + +Invoke the `audit-design-system` skill. Fix any flagged imports. + +- [ ] **Step 6.6:** Commit + +```bash +git add apps/app/src/app/\(app\)/\[orgId\]/tasks/\[taskId\]/components/TaskPolicies.tsx apps/app/src/app/\(app\)/\[orgId\]/tasks/\[taskId\]/components/TaskPolicies.test.tsx +git commit -m "feat(app): add TaskPolicies component" +``` + +--- + +## Task 7: Integrate into Policy Overview tab + +**Files:** +- Modify: `apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx` + +- [ ] **Step 7.1:** Open the file and find the Overview `TabsContent` block + +Search for `` (around line 191). Inside it, find the `` render and insert `` **immediately after** it, inside the same parent stack. + +Add the import at the top of the file: + +```typescript +import { PolicyEvidenceTasks } from './PolicyEvidenceTasks'; +``` + +And in the Overview body, after ``: + +```tsx + +``` + +- [ ] **Step 7.2:** Typecheck the app + +```bash +npx turbo run typecheck --filter=@trycompai/app +``` + +Expected: no errors. + +- [ ] **Step 7.3:** Commit + +```bash +git add apps/app/src/app/\(app\)/\[orgId\]/policies/\[policyId\]/components/PolicyPageTabs.tsx +git commit -m "feat(app): render PolicyEvidenceTasks on policy overview" +``` + +--- + +## Task 8: Integrate into Task Overview tab + +**Files:** +- Modify: `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx` + +- [ ] **Step 8.1:** Open the file and find the Overview `TabsContent` block + +Search for `` (around line 320). At the bottom of the overview content (after any existing related-controls block), add ``. + +Add the import at the top: + +```typescript +import { TaskPolicies } from './TaskPolicies'; +``` + +Inside the overview `TabsContent`, below the existing content: + +```tsx + +``` + +- [ ] **Step 8.2:** Typecheck the app + +```bash +npx turbo run typecheck --filter=@trycompai/app +``` + +Expected: no errors. + +- [ ] **Step 8.3:** Commit + +```bash +git add apps/app/src/app/\(app\)/\[orgId\]/tasks/\[taskId\]/components/SingleTask.tsx +git commit -m "feat(app): render TaskPolicies on task overview" +``` + +--- + +## Task 9: Final verification + +- [ ] **Step 9.1:** Full typecheck + +```bash +npx turbo run typecheck --filter=@trycompai/app --filter=@trycompai/api +``` + +Expected: no errors. + +- [ ] **Step 9.2:** Full API test suite for touched modules + +```bash +cd apps/api && npx jest src/policies src/tasks +``` + +Expected: all tests PASS. + +- [ ] **Step 9.3:** Full app test suite for touched files + +```bash +cd apps/app && npx vitest run src/app/\(app\)/\[orgId\]/policies src/app/\(app\)/\[orgId\]/tasks +``` + +Expected: all tests PASS. + +- [ ] **Step 9.4:** Manual smoke — policy → evidence + +Start the app (`bun run --filter '@trycompai/app' dev:no-trigger`) and the API (`bun run --filter '@trycompai/api' dev:no-trigger`). Log in, then: + +1. Open a policy that already has mapped controls → see "Evidence Tasks" section below "Map Controls". Tasks are grouped by control. +2. Click a task row → land on the task page, see "Policies" section listing the original policy. +3. Open a policy with no controls mapped → see the nudge text. +4. Map a new control to a policy → the Evidence Tasks section updates on its next SWR revalidation. + +- [ ] **Step 9.5:** Manual smoke — archive behavior + +1. Archive a task via the task settings tab → refresh the policy page. The task disappears from Evidence Tasks. +2. Change a policy status back to `draft` → refresh the task page. The policy disappears from the Policies section. + +- [ ] **Step 9.6:** Final commit (if audit-design-system made changes) + +If the audit skill edited any files in later iterations, commit those now: + +```bash +git add -A +git commit -m "chore(app): design system audit fixes" +``` + +- [ ] **Step 9.7:** Push the branch and open a PR + +Ask the user before pushing. Once approved: + +```bash +git push -u origin mariano/sale-48-policies-evidence +gh pr create --title "feat: show evidence tasks on policies and linked policies on tasks (SALE-48)" --body "..." +``` diff --git a/docs/superpowers/specs/2026-04-24-policy-evidence-tasks-design.md b/docs/superpowers/specs/2026-04-24-policy-evidence-tasks-design.md new file mode 100644 index 0000000000..9a2ccde50f --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-policy-evidence-tasks-design.md @@ -0,0 +1,225 @@ +# Policy Evidence Tasks — Design + +**Linear:** [SALE-48 — Policies: see the evidence linked to each policy](https://linear.app/compai/issue/SALE-48/policies-see-the-evidence-linked-to-each-policy) +**Branch:** `mariano/sale-48-policies-evidence` +**Date:** 2026-04-24 + +## Problem + +Trial users (Secureframe and Drata migrations) expect the Policy page to answer: *"What evidence do we have to demonstrate this policy is implemented?"* Today the answer requires clicking out to each mapped Control, opening it, and reading its Tasks. That's the "5 layers of digging" complaint in the ticket. + +The inverse navigation is also missing: from a Task, there is no way to see which policies it demonstrates. + +## Goal + +- On a Policy page, show the Tasks that serve as evidence for that policy, grouped by the Control that connects them — one click deep from Overview. +- On a Task page, mirror it: show the Policies that reference this task via shared controls. +- Read-only surfaces. Linking is still managed through the existing Policy ↔ Control mapping. + +## Non-goals + +- Adding a direct `Policy ↔ Task` relation. Decided against — see *"Why transitive via Control"* below. +- Editing tasks from the policy page or vice versa. Navigation only. +- Pagination. Bounded list sizes make this unnecessary. +- Exposing draft/archived policies outside their own detail page. + +## Why transitive via Control (not a direct M2M) + +The existing schema already encodes the relationship: + +- `Policy.controls` (M2M) and `Task.controls` (M2M) are both implicit relations through `Control`. +- `Control` is the compliance anchor — it's what gets mapped to `RequirementMap → FrameworkInstance → Framework`. Audit traceability flows through Control. +- A Task that is "evidence for" a Policy, by definition, implements a Control that the Policy satisfies. The catalog mirrors this: `FrameworkEditorControlTemplate` joins `PolicyTemplate` and `TaskTemplate` through itself, not directly. + +A direct `Policy ↔ Task` relation would: + +1. Allow users to link a task to a policy without sharing a control, silently breaking the framework mapping. +2. Create two sources of truth for "this task demonstrates this policy." +3. Require framework-sync logic to reason about an extra edge when archiving templates. + +If ad-hoc citation is ever needed, that is a separate "see also" relation — out of scope here. + +## Architecture + +**No schema changes.** Two new read-only API endpoints compute the transitive set at query time. Two new UI sections render the result, one on the Policy Overview tab and one on the Task Overview tab. + +### Derivation + +For a given policy: + +``` +policy.controls (not archived) + └─ each control's tasks (not archived) + → group by control, return { control, tasks[] }[] +``` + +For a given task (inverse): + +``` +task.controls (not archived) + └─ each control's policies (not archived, status = published) + → group by control, return { control, policies[] }[] +``` + +The UI groups by Control so the user sees *why* each task/policy is related (which control bridges them). Dedupe is intentionally not performed server-side: a task attached to two controls on the same policy shows under both groups. + +### Scoping & safety + +- Org scoping via authenticated session on every query (`organizationId` filter on root). +- `archivedAt: null` on Policy, Task, and Control joins — framework-sync archives cascade here. +- Task → Policies endpoint filters `Policy.status = 'published'`. Policy → Tasks does not filter by status (the user is viewing their own policy). +- Both endpoints gate with `@RequirePermission('policy','read')` / `('task','read')`. AuditLogInterceptor handles read logging automatically. + +### Performance + +Policies and tasks typically have <10 controls; controls typically have <20 of each side. A single Prisma `include` is sufficient. No caching, no materialized view, no pagination. + +## API + +### `GET /v1/policies/:id/evidence-tasks` + +**Controller:** `apps/api/src/policies/policies.controller.ts` +**Guards:** `HybridAuthGuard`, `PermissionGuard` with `@RequirePermission('policy', 'read')` + +**Response:** + +```ts +{ + data: Array<{ + control: { id: string; name: string }; + tasks: Array<{ + id: string; + title: string; + status: TaskStatus; + frequency: Frequency | null; + department: Department | null; + automationStatus: AutomationStatus; + assigneeId: string | null; + }>; + }>; + count: number; // total task rows across groups + authType: string; + authenticatedUser: { ... }; +} +``` + +Returns `404` when policy is not in the caller's org. + +### `GET /v1/tasks/:id/policies` + +**Controller:** `apps/api/src/tasks/tasks.controller.ts` (or equivalent single-task controller) +**Guards:** `HybridAuthGuard`, `PermissionGuard` with `@RequirePermission('task', 'read')` + +**Response** (mirror shape): + +```ts +{ + data: Array<{ + control: { id: string; name: string }; + policies: Array<{ + id: string; + name: string; + status: PolicyStatus; + frequency: Frequency | null; + department: Department | null; + }>; + }>; + count: number; + authType: string; + authenticatedUser: { ... }; +} +``` + +## Frontend + +### New components + +- `apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyEvidenceTasks.tsx` +- `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPolicies.tsx` + +### New hooks + +- `apps/app/src/hooks/usePolicyEvidenceTasks.ts` — SWR over `GET /v1/policies/:id/evidence-tasks`, accepts `fallbackData`. +- `apps/app/src/hooks/useTaskPolicies.ts` — SWR over `GET /v1/tasks/:id/policies`, accepts `fallbackData`. + +### Touched files + +- `apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx` — render `` on Overview, directly below `PolicyControlMappings`. +- `apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx` — render `` on Overview, below any related-controls block. + +### Rendering + +**Policy Evidence Tasks section:** + +- Section header: "Evidence Tasks" with count pill, subtitle *"Tasks attached to the controls mapped to this policy."* +- One block per Control group: + - Control name header. + - Task rows listing title, status badge, frequency, automation icon, assignee avatar. + - Row click → `/[orgId]/tasks/[taskId]`. + - If group has >5 tasks, collapse by default (`Collapsible` from design system), show "Show N tasks". + - Per-group empty state: *"No tasks attached to this control."* with a link to the control detail page. +- Page-level empty state (policy has no controls mapped): *"Map at least one control to see the tasks that demonstrate this policy."* with an in-page anchor to `PolicyControlMappings`. + +**Task Policies section:** mirror structure. Each row shows policy name, status badge, frequency. Row click → `/[orgId]/policies/[policyId]`. + +### Design system + +- Use `@trycompai/design-system` primitives: `Stack`, `HStack`, `Text`, `Badge`, `Button`, `Collapsible`. +- Icons via `@trycompai/design-system/icons` (Carbon). Never `lucide-react`. +- `Text` / `Stack` / `HStack` / `Badge` / `Button` do not accept `className` — wrap in `
` when custom styling is needed. +- Run `audit-design-system` skill after UI is complete. + +### Permission gating + +Both sections render behind the existing page-level permission guard (`requireRoutePermission`). No per-row gating — all rows are links, no mutations. + +## Testing + +### API (Jest) + +**`policies.controller.spec.ts`** — new describe block for `/evidence-tasks`: + +- Returns grouped-by-control shape for a policy with 2 controls. +- Archived tasks excluded. +- Archived controls excluded. +- Org scoping: a user in another org gets 404. +- Task attached to two controls on the same policy appears in both groups (intentional). +- Missing `policy:read` permission → 403 from `PermissionGuard`. + +**`tasks.controller.spec.ts`** — mirror describe block for `/policies`: + +- Excludes policies with `status !== 'published'`. +- Excludes archived policies. +- Org scoping + permission coverage. + +### App (Vitest + Testing Library) + +**`PolicyEvidenceTasks.test.tsx`:** + +- Renders one section per control group, with task rows under each. +- Page-level empty state when policy has zero controls. +- Per-group empty state when a control has zero tasks. +- Collapses groups with >5 tasks by default; expand reveals them. +- Task row has correct `href` to `/[orgId]/tasks/[taskId]`. +- Loading state renders skeleton. + +**`TaskPolicies.test.tsx`:** mirror — plus verify that any accidentally-returned draft/archived policies are filtered defensively in the component. + +### Manual smoke + +1. Open a policy with 2 mapped controls → see evidence tasks grouped by control. +2. Open an unmapped policy → see nudge to map a control. +3. Click a task → task page's Policies section shows the original policy. +4. Archive a task → task disappears from the policy page on refresh. +5. Unpublish a policy → policy disappears from the task page on refresh. + +## Rollout + +- Single PR containing backend, frontend, and tests. +- No migration, no feature flag — additive read-only surfaces gated by existing permissions. +- Typecheck: `npx turbo run typecheck --filter=@trycompai/api` and `--filter=@trycompai/app`. +- Run `audit-design-system` before committing UI. + +## Open questions + +None at spec time. Any new ones surface as plan items.