From 5e30bfb7099acfa408ffe3b9b03b03c6abc3fc58 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:06:27 -0400 Subject: [PATCH] feat: add clear view of maps between policies<>controls<>tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(sale-48): add design spec for policy evidence tasks Transitive Policy<->Task linking via Control. Read-only surfaces on Policy Overview and Task Overview, no schema changes. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(sale-48): add implementation plan for policy evidence tasks TDD task-by-task plan covering the 2 API endpoints, 2 hooks, and 2 components, with integration and verification steps. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(api): add GET /v1/policies/:id/evidence-tasks * feat(api): add GET /v1/tasks/:taskId/policies * feat(app): add usePolicyEvidenceTasks hook * feat(app): add useTaskPolicies hook Co-Authored-By: Claude Opus 4.7 (1M context) * feat(app): add PolicyEvidenceTasks component * feat(app): add TaskPolicies component * feat(app): render PolicyEvidenceTasks on policy overview * feat(app): render TaskPolicies on task overview * fix(app): show visible-policy count and pluralize collapse labels * fix(sale-48): address cubic review feedback - Guard getTaskPolicies with hasTaskAccess to prevent cross-assignment leakage - Scope nested control/task/policy joins by organizationId for defense in depth - Render error state in PolicyEvidenceTasks/TaskPolicies on fetch failure - Dedupe visible policy count by policy ID - Drop unreachable ternary in PolicyEvidenceTasks * feat(app): clean up Evidence Tasks UI with design-system primitives Rewrites PolicyEvidenceTasks and TaskPolicies to use Section, Item/ItemGroup, Heading, Empty, and Badge primitives from @trycompai/design-system. Each control sub-group now has a proper bordered header with count badge, and rows render as outlined Item links instead of bare anchors. The page-level empty state uses Empty with a Document icon instead of plain Text. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): require both policy:read and task:read on join endpoints Both /policies/:id/evidence-tasks and /tasks/:taskId/policies cross resource boundaries (return data from the *other* side). Single-resource permission gating let an employee with policy:read but no task:read read task fields through the policy join endpoint, and vice versa. Switch to RequirePermissions to require both. * style(app): tighten Evidence Tasks visual hierarchy Wrap PolicyEvidenceTasks and TaskPolicies blocks in a Card surface so the content no longer floats. Replace H5 control headings with small uppercase muted captions and a count separator. Hide empty control groups from the main render and aggregate them into a single muted footer line ("Controls without tasks: ..."). Drop the oversized Empty icon for the page-level empty state in favor of a single muted line. Tighten group gap from 6 to 4. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(app): show draft policies on task overview The published-only filter hid Draft policies from the task page even though the user can see drafts on the policy list. Now both endpoints treat policy status uniformly: only archived rows are excluded. * feat(app): move task policies to dedicated Mappings tab * feat(app): redesign control mappings to separate view from unmap Clicking a mapped control now navigates to the control detail page. Removing a mapping requires clicking the × and confirming via dialog. Replaces SelectPills (@trycompai/ui legacy) with native design-system primitives: Popover + Command palette for adding, chips with explicit remove buttons, AlertDialog for confirmation. * style(app): polish Mappings tab — titles above cards, rename Controls, drop empty-group footer * fix(app): repair Add controls trigger + lighten Evidence Tasks list Apply Button class names to PopoverTrigger directly instead of base-ui render prop, which wasn't propagating styles. Drop card chrome from task/policy rows in favor of a flat list with hover, muted status text, and inline frequency separator. * style(app): wrap each Mappings group in a bordered list container Hairline dividers were too faint at /40 opacity. Wrap each control group's row list in a single rounded border with full-opacity dividers between rows. No per-row card chrome. * style(app): convert mapped controls to list to match Evidence Tasks Same bordered container + hairline dividers + click-to-navigate row pattern as the Evidence Tasks list. × button appears on hover only. The Mappings tab now reads as one consistent surface. * feat(app): unify policy Mappings into one nested list Collapse the redundant Controls + Evidence Tasks sections into a single list grouped by Control. Each control row expands to show its tasks indented underneath, with colored status pills. Eliminates the two duplicate renderings of the same control names. * feat(app): convert Mappings to flat DS tables Three flat DS Tables matching the rest of the app's chrome (policies index, risks, members): Controls and Evidence Tasks on the policy Mappings tab; Policies on the task Mappings tab. Drops the unified nested list in favor of clear column-aligned data. * style(app): tighten Controls table header — drop Tasks column, inline Link control action Co-Authored-By: Claude Opus 4.7 (1M context) * style(app): unlink action — broken-chain icon, destructive color, "Unlink" wording Replace Close icon with Carbon Unlink, tint destructive red, and update the AlertDialog title/copy/confirm button to match the new verb. Co-Authored-By: Claude Opus 4.7 (1M context) * style(app): tighten Mappings section descriptions * fix(app): wrap Mappings table title cell in real Link for reliable navigation * style(app): breathing room between search input and control list in Link control popover * fix(app): use variant prop on AlertDialogAction (className not accepted) * style(app): move Mappings tab after Automations on evidence page * fix(app): gate evidence Mappings tab behind policy:read The /v1/tasks/:taskId/policies endpoint requires both task:read AND policy:read. Hiding the Mappings tab from users without policy:read prevents them from opening a tab that fails to load. Also drop a stale 'status: published' filter from the implementation plan doc to match what was actually shipped. * feat(api): include framework names per control in /v1/policies/:id/controls Extend the controls endpoint to derive each control's enabled-framework set from RequirementMap -> FrameworkInstance, so the Policy Mappings tab can render framework badges without a follow-up round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(app): show framework badges in Controls table Add a Frameworks column to the Policy Mappings tab Controls table so users can see at a glance which org-enabled frameworks each control supports. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(app): make each entity column on Mappings tables independently clickable Each name cell is a Link with target=_blank and a Launch icon that fades in on hover. Removes the row-level onClick (now redundant) so users can click Task to go to task, Control to go to control, etc., each opening in a new tab. * fix(app): guard frameworks rendering against missing field * style(app): always show Launch icon and make full table cell clickable Switch each entity-name Link from inline-flex to flex justify-between so the entire cell becomes the click target. Drop the hover-fade on the Launch icon — keep it always visible to clearly signal the cell is a link, with a subtle color shift on hover. * fix(app): fall back to overview if requested tab is unavailable Bookmarked URLs like ?tab=mappings would render a blank page for users without policy:read (the tab's trigger and content are hidden behind a permission check). Validate the requested tab against availability and fall back to overview otherwise. Same logic applied to ?tab=automations for manual tasks. * fix(app): don't gate Mappings tab content on async permission data Custom-role users with policy:read got stuck on Overview when reopening a bookmarked ?tab=mappings link, because the fallback fired before permissions resolved. Drop the URL fallback and the TabsContent gate. Trigger remains hidden until canReadPolicy resolves, but bookmarked URLs render the tab content directly. Users without permission see TaskPolicies' own error state if they hit the URL by accident. --------- Co-authored-by: Mariano Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/policies/policies.controller.spec.ts | 227 ++- apps/api/src/policies/policies.controller.ts | 130 +- apps/api/src/tasks/tasks.controller.spec.ts | 151 +- apps/api/src/tasks/tasks.controller.ts | 86 +- .../components/PolicyControlMappings.test.tsx | 139 -- .../components/PolicyControlMappings.tsx | 271 +++- .../components/PolicyEvidenceTasks.tsx | 168 ++ .../[policyId]/components/PolicyPage.tsx | 7 +- .../components/PolicyPageTabs.test.tsx | 10 +- .../[policyId]/components/PolicyPageTabs.tsx | 19 +- .../hooks/usePolicyEvidenceTasks.ts | 63 + .../[orgId]/policies/[policyId]/page.tsx | 9 +- .../tasks/[taskId]/components/SingleTask.tsx | 9 + .../[taskId]/components/TaskPolicies.test.tsx | 126 ++ .../[taskId]/components/TaskPolicies.tsx | 164 ++ .../tasks/[taskId]/hooks/use-task-policies.ts | 61 + .../plans/2026-04-24-policy-evidence-tasks.md | 1368 +++++++++++++++++ ...2026-04-24-policy-evidence-tasks-design.md | 225 +++ 18 files changed, 3013 insertions(+), 220 deletions(-) delete mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyEvidenceTasks.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/policies/[policyId]/hooks/usePolicyEvidenceTasks.ts create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPolicies.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPolicies.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-policies.ts create mode 100644 docs/superpowers/plans/2026-04-24-policy-evidence-tasks.md create mode 100644 docs/superpowers/specs/2026-04-24-policy-evidence-tasks-design.md 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.