Skip to content

Commit 5e30bfb

Browse files
github-actions[bot]Marfuenclaude
authored
feat: add clear view of maps between policies<>controls<>tasks
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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 <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 60de920 commit 5e30bfb

18 files changed

Lines changed: 3013 additions & 220 deletions

apps/api/src/policies/policies.controller.spec.ts

Lines changed: 220 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,17 @@ jest.mock('@db', () => ({
5656
FindingType: {
5757
soc2: 'soc2',
5858
iso27001: 'iso27001',
59+
hipaa: 'hipaa',
60+
gdpr: 'gdpr',
61+
nist: 'nist',
5962
},
63+
FindingStatus: {
64+
open: 'open',
65+
closed: 'closed',
66+
},
67+
PhaseCompletionType: {},
68+
TimelinePhaseStatus: {},
69+
TimelineStatus: {},
6070
}));
6171

6272
jest.mock('@trigger.dev/sdk', () => ({
@@ -394,14 +404,39 @@ describe('PoliciesController', () => {
394404
});
395405

396406
describe('getPolicyControls', () => {
397-
it('should return mapped and all controls', async () => {
407+
it('returns mapped and all controls with framework names derived from requirementsMapped', async () => {
398408
const { db } = require('@db');
399409
const mappedControls = [
400-
{ id: 'ctrl_1', name: 'Control 1', description: 'desc' },
410+
{
411+
id: 'ctrl_1',
412+
name: 'Control 1',
413+
description: 'desc',
414+
requirementsMapped: [
415+
{
416+
frameworkInstance: {
417+
id: 'fi_1',
418+
framework: { id: 'fw_soc2', name: 'SOC 2' },
419+
customFramework: null,
420+
},
421+
},
422+
{
423+
frameworkInstance: {
424+
id: 'fi_2',
425+
framework: null,
426+
customFramework: { id: 'cfw_1', name: 'Internal Policy' },
427+
},
428+
},
429+
],
430+
},
401431
];
402432
const allControls = [
403-
{ id: 'ctrl_1', name: 'Control 1', description: 'desc' },
404-
{ id: 'ctrl_2', name: 'Control 2', description: 'desc2' },
433+
...mappedControls,
434+
{
435+
id: 'ctrl_2',
436+
name: 'Control 2',
437+
description: 'desc2',
438+
requirementsMapped: [],
439+
},
405440
];
406441
db.policy.findFirst.mockResolvedValue({
407442
id: 'pol_1',
@@ -415,12 +450,80 @@ describe('PoliciesController', () => {
415450
mockAuthContext,
416451
);
417452

418-
expect(result.mappedControls).toEqual(mappedControls);
419-
expect(result.allControls).toEqual(allControls);
453+
expect(result.mappedControls).toEqual([
454+
{
455+
id: 'ctrl_1',
456+
name: 'Control 1',
457+
description: 'desc',
458+
frameworks: [
459+
{ id: 'fw_soc2', name: 'SOC 2' },
460+
{ id: 'cfw_1', name: 'Internal Policy' },
461+
],
462+
},
463+
]);
464+
expect(result.allControls).toEqual([
465+
{
466+
id: 'ctrl_1',
467+
name: 'Control 1',
468+
description: 'desc',
469+
frameworks: [
470+
{ id: 'fw_soc2', name: 'SOC 2' },
471+
{ id: 'cfw_1', name: 'Internal Policy' },
472+
],
473+
},
474+
{
475+
id: 'ctrl_2',
476+
name: 'Control 2',
477+
description: 'desc2',
478+
frameworks: [],
479+
},
480+
]);
420481
expect(result.authType).toBe('session');
421482
});
422483

423-
it('should return empty mappedControls when policy not found', async () => {
484+
it('dedupes frameworks when the same FrameworkInstance is reachable via multiple RequirementMaps', async () => {
485+
const { db } = require('@db');
486+
const controls = [
487+
{
488+
id: 'ctrl_1',
489+
name: 'Control 1',
490+
description: 'desc',
491+
requirementsMapped: [
492+
{
493+
frameworkInstance: {
494+
id: 'fi_1',
495+
framework: { id: 'fw_soc2', name: 'SOC 2' },
496+
customFramework: null,
497+
},
498+
},
499+
{
500+
frameworkInstance: {
501+
id: 'fi_1',
502+
framework: { id: 'fw_soc2', name: 'SOC 2' },
503+
customFramework: null,
504+
},
505+
},
506+
],
507+
},
508+
];
509+
db.policy.findFirst.mockResolvedValue({
510+
id: 'pol_1',
511+
controls,
512+
});
513+
db.control.findMany.mockResolvedValue(controls);
514+
515+
const result = await controller.getPolicyControls(
516+
'pol_1',
517+
orgId,
518+
mockAuthContext,
519+
);
520+
521+
expect(result.mappedControls[0].frameworks).toEqual([
522+
{ id: 'fw_soc2', name: 'SOC 2' },
523+
]);
524+
});
525+
526+
it('returns empty mappedControls when policy is not found', async () => {
424527
const { db } = require('@db');
425528
db.policy.findFirst.mockResolvedValue(null);
426529
db.control.findMany.mockResolvedValue([]);
@@ -433,6 +536,33 @@ describe('PoliciesController', () => {
433536

434537
expect(result.mappedControls).toEqual([]);
435538
});
539+
540+
it('scopes the requirementsMapped query to the caller organization', async () => {
541+
const { db } = require('@db');
542+
db.policy.findFirst.mockResolvedValue({ id: 'pol_1', controls: [] });
543+
db.control.findMany.mockResolvedValue([]);
544+
545+
await controller.getPolicyControls('pol_1', orgId, mockAuthContext);
546+
547+
expect(db.policy.findFirst).toHaveBeenCalledWith(
548+
expect.objectContaining({
549+
where: { id: 'pol_1', organizationId: orgId, archivedAt: null },
550+
select: expect.objectContaining({
551+
controls: expect.objectContaining({
552+
where: { archivedAt: null },
553+
select: expect.objectContaining({
554+
requirementsMapped: expect.objectContaining({
555+
where: {
556+
archivedAt: null,
557+
frameworkInstance: { organizationId: orgId },
558+
},
559+
}),
560+
}),
561+
}),
562+
}),
563+
}),
564+
);
565+
});
436566
});
437567

438568
describe('addPolicyControls', () => {
@@ -724,4 +854,87 @@ describe('PoliciesController', () => {
724854
expect(result.data).toEqual(mockResult);
725855
});
726856
});
857+
858+
describe('getPolicyEvidenceTasks', () => {
859+
it('returns tasks grouped by control, excluding archived tasks', async () => {
860+
const { db } = require('@db');
861+
db.policy.findFirst.mockResolvedValue({
862+
id: 'pol_1',
863+
controls: [
864+
{
865+
id: 'ctl_1',
866+
name: 'Access Controls',
867+
tasks: [
868+
{
869+
id: 'tsk_1',
870+
title: 'Enable 2FA',
871+
status: 'in_progress',
872+
frequency: 'monthly',
873+
department: 'it',
874+
automationStatus: 'MANUAL',
875+
assigneeId: 'mem_1',
876+
},
877+
],
878+
},
879+
{
880+
id: 'ctl_2',
881+
name: 'Monitoring',
882+
tasks: [],
883+
},
884+
],
885+
});
886+
887+
const result = await controller.getPolicyEvidenceTasks(
888+
'pol_1',
889+
orgId,
890+
mockAuthContext,
891+
);
892+
893+
expect(db.policy.findFirst).toHaveBeenCalledWith({
894+
where: { id: 'pol_1', organizationId: orgId, archivedAt: null },
895+
select: expect.objectContaining({
896+
id: true,
897+
controls: expect.objectContaining({
898+
where: { archivedAt: null, organizationId: orgId },
899+
select: expect.objectContaining({
900+
tasks: expect.objectContaining({
901+
where: { archivedAt: null, organizationId: orgId },
902+
}),
903+
}),
904+
}),
905+
}),
906+
});
907+
expect(result.data).toEqual([
908+
{
909+
control: { id: 'ctl_1', name: 'Access Controls' },
910+
tasks: [
911+
{
912+
id: 'tsk_1',
913+
title: 'Enable 2FA',
914+
status: 'in_progress',
915+
frequency: 'monthly',
916+
department: 'it',
917+
automationStatus: 'MANUAL',
918+
assigneeId: 'mem_1',
919+
},
920+
],
921+
},
922+
{
923+
control: { id: 'ctl_2', name: 'Monitoring' },
924+
tasks: [],
925+
},
926+
]);
927+
expect(result.count).toBe(1);
928+
expect(result.authType).toBe('session');
929+
});
930+
931+
it('throws NotFoundException when policy is not in caller org', async () => {
932+
const { db } = require('@db');
933+
db.policy.findFirst.mockResolvedValue(null);
934+
935+
await expect(
936+
controller.getPolicyEvidenceTasks('pol_404', orgId, mockAuthContext),
937+
).rejects.toThrow('Policy not found');
938+
});
939+
});
727940
});

0 commit comments

Comments
 (0)