Skip to content

Commit 33a7b90

Browse files
github-actions[bot]Marfuenclaude
authored
fix(app): bubble up artifact-based progress through control/requirement/framework views (CS-316)
* fix(app): bubble up artifact-based progress through control/requirement/framework views (CS-316) Make progress and compliance reflect underlying artifact completion rather than only fully-satisfied controls, and surface documents alongside policies and tasks throughout the framework view. - Add Documents column to per-requirement controls table - Align EvidenceSubmissionInfo with API field name (submittedAt) so the 6-month freshness check actually fires - Compute requirement status from all control statuses, not just satisfied count - Compute requirement compliance % from average of per-control progress (policies + tasks + documents) - Show aggregate framework % across deduplicated artifacts in the framework progress bar - Mirror the framework view on the requirement page: top progress bar, per-control compliance bar, status next to compliance, paginated controls table, narrower description column - Paginate the requirements table with the DS Table built-in pagination (10/25/50/100) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(app): address cubic review on PR #2695 - Tighten getControlProgressPercent task-filter test: the unrelated task is now `todo` so a missing filter would surface as 50% instead of silently passing at 100%. - Per-control Documents column on the requirement page: reuse getRequirementArtifactCounts so document counts honour the same 6-month freshness rule as the status badge and compliance bar (previously any historical submission counted as completed). Policies and tasks counts also flow through the same aggregator for consistency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(api): bubble up artifact-based progress in framework compliance score Previously computeFrameworkComplianceScore was binary on full-control satisfaction: a control with one in-progress task pulled the framework to 0%. The frameworks list and overview pages consume this score, so they showed Not Started even when most underlying policies/tasks/documents were already done. - Rewrite computeFrameworkComplianceScore to count unique artifacts (policies by id, tasks by id, document types by formType) across the framework and weight each one equally. - Documents only count as completed when their latest submission is within 6 months (matches the freshness rule used elsewhere). - Drop the now-unused hasAnyArtifact / isControlCompleted helpers. - Tests cover empty frameworks, all-complete, partial progress (vs. old 0%), document freshness, and cross-control deduplication. Also stub frameworkInstance.findMany / sOADocument.findFirst so the existing getOverviewScores tests run again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Mariano <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1782c1 commit 33a7b90

2 files changed

Lines changed: 161 additions & 56 deletions

File tree

apps/api/src/frameworks/frameworks-scores.helper.spec.ts

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ jest.mock('@db', () => ({
55
member: { findMany: jest.fn() },
66
onboarding: { findUnique: jest.fn() },
77
organization: { findUnique: jest.fn() },
8-
frameworkInstance: { findFirst: jest.fn() },
8+
frameworkInstance: { findFirst: jest.fn(), findMany: jest.fn() },
99
employeeTrainingVideoCompletion: { findMany: jest.fn() },
1010
device: { findMany: jest.fn() },
1111
fleetPolicyResult: { findMany: jest.fn() },
1212
evidenceSubmission: { groupBy: jest.fn() },
1313
finding: { findMany: jest.fn() },
14+
sOADocument: { findFirst: jest.fn() },
1415
},
1516
}));
1617

@@ -20,7 +21,12 @@ jest.mock('../utils/compliance-filters', () => ({
2021

2122
import { db } from '@db';
2223
import { filterComplianceMembers } from '../utils/compliance-filters';
23-
import { getOverviewScores } from './frameworks-scores.helper';
24+
import {
25+
computeFrameworkComplianceScore,
26+
getOverviewScores,
27+
} from './frameworks-scores.helper';
28+
29+
const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000;
2430

2531
const mockDb = db as jest.Mocked<typeof db>;
2632
const mockFilterComplianceMembers =
@@ -42,6 +48,8 @@ describe('frameworks-scores.helper', () => {
4248
(mockDb.fleetPolicyResult.findMany as jest.Mock).mockResolvedValue([]);
4349
(mockDb.evidenceSubmission.groupBy as jest.Mock).mockResolvedValue([]);
4450
(mockDb.finding.findMany as jest.Mock).mockResolvedValue([]);
51+
(mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([]);
52+
((mockDb as any).sOADocument.findFirst as jest.Mock).mockResolvedValue(null);
4553
});
4654

4755
it('requires installed device for people completion when device agent step is enabled', async () => {
@@ -240,6 +248,107 @@ describe('frameworks-scores.helper', () => {
240248
});
241249
});
242250

251+
describe('computeFrameworkComplianceScore', () => {
252+
it('returns 0 when the framework has no artifacts', () => {
253+
expect(
254+
computeFrameworkComplianceScore({ controls: [] }, [], []),
255+
).toBe(0);
256+
});
257+
258+
it('returns 100 when every artifact across the framework is complete', () => {
259+
const framework = {
260+
controls: [
261+
{
262+
id: 'c1',
263+
policies: [{ id: 'p1', status: 'published' }],
264+
controlDocumentTypes: [],
265+
},
266+
],
267+
};
268+
const tasks = [
269+
{ id: 't1', status: 'done', controls: [{ id: 'c1' }] },
270+
];
271+
expect(computeFrameworkComplianceScore(framework, tasks, [])).toBe(100);
272+
});
273+
274+
it('weights every artifact equally instead of treating partial controls as 0%', () => {
275+
const framework = {
276+
controls: [
277+
{
278+
id: 'c1',
279+
policies: [{ id: 'p1', status: 'published' }],
280+
controlDocumentTypes: [{ formType: 'access_control_policy' }],
281+
},
282+
{
283+
id: 'c2',
284+
policies: [{ id: 'p2', status: 'draft' }],
285+
controlDocumentTypes: [],
286+
},
287+
],
288+
};
289+
const tasks = [
290+
{ id: 't1', status: 'done', controls: [{ id: 'c1' }] },
291+
{ id: 't2', status: 'todo', controls: [{ id: 'c2' }] },
292+
];
293+
// 5 unique artifacts (2 policies, 2 tasks, 1 doc type), 2 completed → 40%
294+
// The old binary-completion implementation would have returned 0%
295+
// because no control is fully satisfied.
296+
expect(computeFrameworkComplianceScore(framework, tasks, [])).toBe(40);
297+
});
298+
299+
it('only treats a document as completed when its latest submission is within 6 months', () => {
300+
const framework = {
301+
controls: [
302+
{
303+
id: 'c1',
304+
policies: [],
305+
controlDocumentTypes: [
306+
{ formType: 'access_control_policy' },
307+
{ formType: 'incident_response_plan' },
308+
],
309+
},
310+
],
311+
};
312+
const recent = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
313+
const stale = new Date(Date.now() - SIX_MONTHS_MS - 24 * 60 * 60 * 1000);
314+
const submissions = [
315+
{ formType: 'access_control_policy', submittedAt: recent },
316+
{ formType: 'incident_response_plan', submittedAt: stale },
317+
];
318+
expect(computeFrameworkComplianceScore(framework, [], submissions)).toBe(
319+
50,
320+
);
321+
});
322+
323+
it('deduplicates artifacts shared across controls', () => {
324+
const framework = {
325+
controls: [
326+
{
327+
id: 'c1',
328+
policies: [{ id: 'p1', status: 'published' }],
329+
controlDocumentTypes: [{ formType: 'access_control_policy' }],
330+
},
331+
{
332+
id: 'c2',
333+
policies: [{ id: 'p1', status: 'published' }],
334+
controlDocumentTypes: [{ formType: 'access_control_policy' }],
335+
},
336+
],
337+
};
338+
const sharedTask = {
339+
id: 't1',
340+
status: 'done',
341+
controls: [{ id: 'c1' }, { id: 'c2' }],
342+
};
343+
// Without dedup: 6 artifacts (2 policies, 2 tasks, 2 docs), 4 completed → 67%
344+
// With dedup: 2 unique artifacts (1 policy, 1 task), 2 completed; 1 unmet doc → 67%
345+
// Wait: 1 policy (done) + 1 task (done) + 1 doc (no submission, not fresh) = 2/3 = 67%
346+
expect(computeFrameworkComplianceScore(framework, [sharedTask], [])).toBe(
347+
67,
348+
);
349+
});
350+
});
351+
243352
it('skips security training requirement when security training step is disabled', async () => {
244353
const members: Array<{
245354
id: string;

apps/api/src/frameworks/frameworks-scores.helper.ts

Lines changed: 50 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -327,74 +327,70 @@ interface EvidenceSubmissionForScoring {
327327
submittedAt: Date | string;
328328
}
329329

330-
function hasAnyArtifact(
331-
control: ControlForScoring,
330+
export function computeFrameworkComplianceScore(
331+
framework: FrameworkWithControlsForScoring,
332332
tasks: TaskWithControls[],
333-
): boolean {
334-
const policies = control.policies ?? [];
335-
const documentTypes = control.controlDocumentTypes ?? [];
336-
const controlTasks = tasks.filter((t) =>
337-
t.controls.some((c) => c.id === control.id),
338-
);
339-
return (
340-
policies.length > 0 || controlTasks.length > 0 || documentTypes.length > 0
341-
);
342-
}
333+
evidenceSubmissions: EvidenceSubmissionForScoring[] = [],
334+
): number {
335+
const controls = framework.controls ?? [];
336+
if (controls.length === 0) return 0;
343337

344-
function isControlCompleted(
345-
control: ControlForScoring,
346-
tasks: TaskWithControls[],
347-
evidenceSubmissions: EvidenceSubmissionForScoring[],
348-
): boolean {
349-
const policies = control.policies ?? [];
350-
const documentTypes = control.controlDocumentTypes ?? [];
351-
const controlTasks = tasks.filter((t) =>
352-
t.controls.some((c) => c.id === control.id),
353-
);
338+
const controlIds = new Set(controls.map((c) => c.id));
354339

355-
const policiesComplete =
356-
policies.length === 0 ||
357-
policies.every((p) => p.status === 'published');
340+
// Bubble up artifact-based progress: count every unique policy, task and
341+
// document type across the framework, then weight each artifact equally.
342+
// Previously this was binary on full-control satisfaction, so a control
343+
// with one in-progress task pulled the whole framework to 0%.
344+
const policiesById = new Map<string, { id: string; status: string }>();
345+
for (const control of controls) {
346+
for (const policy of control.policies ?? []) {
347+
policiesById.set(policy.id, policy);
348+
}
349+
}
358350

359-
const tasksComplete =
360-
controlTasks.length === 0 ||
361-
controlTasks.every(
362-
(t) => t.status === 'done' || t.status === 'not_relevant',
363-
);
351+
const tasksById = new Map<string, TaskWithControls>();
352+
for (const task of tasks) {
353+
if (task.controls.some((c) => controlIds.has(c.id))) {
354+
tasksById.set(task.id, task);
355+
}
356+
}
364357

365-
let documentsComplete = true;
366-
if (documentTypes.length > 0) {
358+
const documentFormTypes = new Set<string>();
359+
for (const control of controls) {
360+
for (const dt of control.controlDocumentTypes ?? []) {
361+
documentFormTypes.add(dt.formType);
362+
}
363+
}
364+
365+
const totalArtifacts =
366+
policiesById.size + tasksById.size + documentFormTypes.size;
367+
if (totalArtifacts === 0) return 0;
368+
369+
const policiesCompleted = Array.from(policiesById.values()).filter(
370+
(p) => p.status === 'published',
371+
).length;
372+
const tasksCompleted = Array.from(tasksById.values()).filter(
373+
(t) => t.status === 'done' || t.status === 'not_relevant',
374+
).length;
375+
376+
let documentsCompleted = 0;
377+
if (documentFormTypes.size > 0 && evidenceSubmissions.length > 0) {
367378
const sorted = [...evidenceSubmissions].sort(
368379
(a, b) =>
369380
new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(),
370381
);
371382
const now = Date.now();
372-
for (const dt of documentTypes) {
373-
const latest = sorted.find((es) => es.formType === dt.formType);
383+
for (const formType of documentFormTypes) {
384+
const latest = sorted.find((es) => es.formType === formType);
374385
if (
375-
!latest ||
376-
now - new Date(latest.submittedAt).getTime() > SIX_MONTHS_MS
386+
latest &&
387+
now - new Date(latest.submittedAt).getTime() <= SIX_MONTHS_MS
377388
) {
378-
documentsComplete = false;
379-
break;
389+
documentsCompleted++;
380390
}
381391
}
382392
}
383393

384-
return policiesComplete && tasksComplete && documentsComplete;
385-
}
386-
387-
export function computeFrameworkComplianceScore(
388-
framework: FrameworkWithControlsForScoring,
389-
tasks: TaskWithControls[],
390-
evidenceSubmissions: EvidenceSubmissionForScoring[] = [],
391-
): number {
392-
const controls = (framework.controls ?? []).filter((c) =>
393-
hasAnyArtifact(c, tasks),
394-
);
395-
if (controls.length === 0) return 0;
396-
const completed = controls.filter((c) =>
397-
isControlCompleted(c, tasks, evidenceSubmissions),
398-
).length;
399-
return Math.round((completed / controls.length) * 100);
394+
const completed = policiesCompleted + tasksCompleted + documentsCompleted;
395+
return Math.round((completed / totalArtifacts) * 100);
400396
}

0 commit comments

Comments
 (0)