Skip to content

Commit b4d565c

Browse files
authored
Merge pull request #2650 from trycompai/main
[comp] Production Deploy
2 parents 5dac1fa + 3ae8f5e commit b4d565c

99 files changed

Lines changed: 8366 additions & 317 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { OrgChartModule } from './org-chart/org-chart.module';
4141
import { TrainingModule } from './training/training.module';
4242
import { EvidenceFormsModule } from './evidence-forms/evidence-forms.module';
4343
import { FrameworksModule } from './frameworks/frameworks.module';
44+
import { FrameworkVersionsModule } from './framework-editor-versions/framework-versions.module';
4445
import { AuditModule } from './audit/audit.module';
4546
import { ControlsModule } from './controls/controls.module';
4647
import { RolesModule } from './roles/roles.module';
@@ -104,6 +105,7 @@ import { TimelinesModule } from './timelines/timelines.module';
104105
OrgChartModule,
105106
EvidenceFormsModule,
106107
FrameworksModule,
108+
FrameworkVersionsModule,
107109
RolesModule,
108110
AuditModule,
109111
ControlsModule,

apps/api/src/assistant-chat/assistant-chat-tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function buildTools(ctx: ToolContext) {
6060
}),
6161
execute: async ({ status }: { status?: 'draft' | 'published' }) => {
6262
const policies = await db.policy.findMany({
63-
where: { organizationId: ctx.organizationId, status },
63+
where: { organizationId: ctx.organizationId, isArchived: false, archivedAt: null, status },
6464
select: { id: true, name: true, description: true, department: true },
6565
});
6666
return policies.length === 0

apps/api/src/controls/controls.service.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import { CreateControlDto } from './dto/create-control.dto';
88

99
const controlInclude = {
1010
policies: {
11+
where: { archivedAt: null },
1112
select: { status: true, id: true, name: true },
1213
},
1314
tasks: {
15+
where: { archivedAt: null },
1416
select: { id: true, title: true, status: true },
1517
},
1618
requirementsMapped: {
19+
where: { archivedAt: null },
1720
include: {
1821
frameworkInstance: {
1922
include: { framework: true, customFramework: true },
@@ -42,6 +45,7 @@ export class ControlsService {
4245
) {
4346
const where: Prisma.ControlWhereInput = {
4447
organizationId,
48+
archivedAt: null,
4549
...(options.name && {
4650
name: { contains: options.name, mode: Prisma.QueryMode.insensitive },
4751
}),
@@ -72,10 +76,11 @@ export class ControlsService {
7276
const control = await db.control.findUnique({
7377
where: { id: controlId, organizationId },
7478
include: {
75-
policies: true,
76-
tasks: true,
79+
policies: { where: { archivedAt: null } },
80+
tasks: { where: { archivedAt: null } },
7781
controlDocumentTypes: true,
7882
requirementsMapped: {
83+
where: { archivedAt: null },
7984
include: {
8085
frameworkInstance: {
8186
include: { framework: true, customFramework: true },
@@ -145,12 +150,12 @@ export class ControlsService {
145150
async getOptions(organizationId: string) {
146151
const [policies, tasks, frameworkInstances] = await Promise.all([
147152
db.policy.findMany({
148-
where: { organizationId },
153+
where: { organizationId, isArchived: false, archivedAt: null },
149154
select: { id: true, name: true },
150155
orderBy: { name: 'asc' },
151156
}),
152157
db.task.findMany({
153-
where: { organizationId },
158+
where: { organizationId, archivedAt: null },
154159
select: { id: true, title: true },
155160
orderBy: { title: 'asc' },
156161
}),
@@ -300,8 +305,11 @@ export class ControlsService {
300305
): Promise<string[]> {
301306
if (!policyIds || policyIds.length === 0) return [];
302307
const uniqueIds = Array.from(new Set(policyIds));
308+
// Exclude both user-archived (isArchived) and sync-archived (archivedAt)
309+
// policies. Checking only archivedAt would let user-archived policies
310+
// get re-linked to a control and surface back through the UI.
303311
const policies = await db.policy.findMany({
304-
where: { id: { in: uniqueIds }, organizationId },
312+
where: { id: { in: uniqueIds }, organizationId, archivedAt: null, isArchived: false },
305313
select: { id: true },
306314
});
307315
if (policies.length !== uniqueIds.length) {
@@ -317,7 +325,7 @@ export class ControlsService {
317325
if (!taskIds || taskIds.length === 0) return [];
318326
const uniqueIds = Array.from(new Set(taskIds));
319327
const tasks = await db.task.findMany({
320-
where: { id: { in: uniqueIds }, organizationId },
328+
where: { id: { in: uniqueIds }, organizationId, archivedAt: null },
321329
select: { id: true },
322330
});
323331
if (tasks.length !== uniqueIds.length) {
@@ -426,7 +434,7 @@ export class ControlsService {
426434
await this.ensureControl(controlId, organizationId);
427435

428436
const policies = await db.policy.findMany({
429-
where: { id: { in: policyIds }, organizationId },
437+
where: { id: { in: policyIds }, organizationId, archivedAt: null },
430438
select: { id: true },
431439
});
432440
if (policies.length === 0) {
@@ -449,7 +457,7 @@ export class ControlsService {
449457
await this.ensureControl(controlId, organizationId);
450458

451459
const tasks = await db.task.findMany({
452-
where: { id: { in: taskIds }, organizationId },
460+
where: { id: { in: taskIds }, organizationId, archivedAt: null },
453461
select: { id: true },
454462
});
455463
if (tasks.length === 0) {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
2+
3+
export class PublishVersionDto {
4+
// Semver major.minor.patch. Accepts things like "1.0.0", "2.3.11".
5+
@IsString()
6+
@Matches(/^\d+\.\d+\.\d+$/, { message: 'version must be MAJOR.MINOR.PATCH' })
7+
version!: string;
8+
9+
@IsOptional()
10+
@IsString()
11+
@MaxLength(10_000)
12+
releaseNotes?: string;
13+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { buildManifestForFramework } from './framework-manifest-builder';
2+
3+
jest.mock('@db', () => ({
4+
db: {
5+
frameworkEditorFramework: { findUnique: jest.fn() },
6+
},
7+
}));
8+
import { db } from '@db';
9+
10+
describe('buildManifestForFramework', () => {
11+
beforeEach(() => jest.clearAllMocks());
12+
13+
it('produces a manifest with framework, requirements, controls, policies, tasks', async () => {
14+
// Shape of the mocked result reflects the REAL schema: requirements -> controlTemplates -> policyTemplates/taskTemplates.
15+
(db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({
16+
id: 'frk_soc2',
17+
name: 'SOC 2',
18+
version: 'TSC 2017 (rev 2022)',
19+
description: null,
20+
requirements: [
21+
{
22+
id: 'frk_rq_cc61',
23+
identifier: 'CC6.1',
24+
name: 'Logical Access',
25+
description: 'x',
26+
controlTemplates: [
27+
{
28+
id: 'frk_ct_logical_access',
29+
name: 'Logical Access Controls',
30+
description: 'desc',
31+
requirements: [{ id: 'frk_rq_cc61' }],
32+
policyTemplates: [
33+
{ id: 'frk_pt_acc', name: 'Access Policy', description: null, content: [{}], frequency: 'yearly', department: 'it' },
34+
],
35+
taskTemplates: [
36+
{ id: 'frk_tt_rev', name: 'Review Access', description: 'Review quarterly', frequency: 'quarterly', department: 'it' },
37+
],
38+
documentTypes: ['rbac_matrix'],
39+
},
40+
],
41+
},
42+
],
43+
});
44+
45+
const manifest = await buildManifestForFramework('frk_soc2');
46+
47+
expect(manifest.framework.id).toBe('frk_soc2');
48+
expect(manifest.framework.catalogVersion).toBe('TSC 2017 (rev 2022)');
49+
expect(manifest.requirements).toHaveLength(1);
50+
expect(manifest.requirements[0].identifier).toBe('CC6.1');
51+
expect(manifest.controls).toHaveLength(1);
52+
expect(manifest.controls[0].id).toBe('frk_ct_logical_access');
53+
expect(manifest.controls[0].requirementIds).toEqual(['frk_rq_cc61']);
54+
expect(manifest.controls[0].policyIds).toEqual(['frk_pt_acc']);
55+
expect(manifest.controls[0].taskIds).toEqual(['frk_tt_rev']);
56+
expect(manifest.policies).toHaveLength(1);
57+
expect(manifest.tasks).toHaveLength(1);
58+
});
59+
60+
it('dedupes controls/policies/tasks that appear under multiple requirements', async () => {
61+
(db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({
62+
id: 'frk_iso',
63+
name: 'ISO 27001',
64+
version: '2022',
65+
description: null,
66+
requirements: [
67+
{
68+
id: 'rq_a', identifier: 'A', name: 'A', description: null,
69+
controlTemplates: [
70+
{
71+
id: 'ct_shared', name: 'Shared', description: 'd',
72+
requirements: [{ id: 'rq_a' }, { id: 'rq_b' }],
73+
policyTemplates: [{ id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null }],
74+
taskTemplates: [{ id: 'tt_shared', name: 'T', description: '', frequency: null, department: null }],
75+
documentTypes: [],
76+
},
77+
],
78+
},
79+
{
80+
id: 'rq_b', identifier: 'B', name: 'B', description: null,
81+
controlTemplates: [
82+
{
83+
id: 'ct_shared', name: 'Shared', description: 'd',
84+
requirements: [{ id: 'rq_a' }, { id: 'rq_b' }],
85+
policyTemplates: [{ id: 'pt_shared', name: 'P', description: null, content: [], frequency: null, department: null }],
86+
taskTemplates: [{ id: 'tt_shared', name: 'T', description: '', frequency: null, department: null }],
87+
documentTypes: [],
88+
},
89+
],
90+
},
91+
],
92+
});
93+
94+
const manifest = await buildManifestForFramework('frk_iso');
95+
96+
expect(manifest.controls).toHaveLength(1);
97+
expect(manifest.controls[0].requirementIds.sort()).toEqual(['rq_a', 'rq_b']);
98+
expect(manifest.policies).toHaveLength(1);
99+
expect(manifest.tasks).toHaveLength(1);
100+
});
101+
102+
it('throws when framework not found', async () => {
103+
(db.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue(null);
104+
await expect(buildManifestForFramework('missing')).rejects.toThrow('Framework not found');
105+
});
106+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { NotFoundException } from '@nestjs/common';
2+
import { db } from '@db';
3+
import type {
4+
FrameworkManifest,
5+
ManifestControl,
6+
ManifestPolicy,
7+
ManifestTask,
8+
} from '../frameworks/framework-versioning/manifest.types';
9+
10+
export async function buildManifestForFramework(frameworkId: string): Promise<FrameworkManifest> {
11+
const framework = await db.frameworkEditorFramework.findUnique({
12+
where: { id: frameworkId },
13+
include: {
14+
requirements: {
15+
include: {
16+
controlTemplates: {
17+
include: {
18+
requirements: { select: { id: true } },
19+
policyTemplates: true,
20+
taskTemplates: true,
21+
},
22+
},
23+
},
24+
},
25+
},
26+
});
27+
28+
if (!framework) throw new NotFoundException('Framework not found');
29+
30+
// Collect all unique control templates across all requirements, deduped by id.
31+
const controlsMap = new Map<string, ManifestControl>();
32+
const policiesMap = new Map<string, ManifestPolicy>();
33+
const tasksMap = new Map<string, ManifestTask>();
34+
35+
// A control template's `requirements` relation spans every framework that
36+
// has mapped it — filter down to requirements belonging to THIS framework
37+
// so the manifest doesn't reference IDs that aren't in its own `requirements`.
38+
const ownRequirementIds = new Set(framework.requirements.map((r) => r.id));
39+
40+
for (const req of framework.requirements) {
41+
for (const ct of req.controlTemplates) {
42+
if (!controlsMap.has(ct.id)) {
43+
controlsMap.set(ct.id, {
44+
id: ct.id,
45+
name: ct.name,
46+
description: ct.description,
47+
requirementIds: ct.requirements
48+
.map((r) => r.id)
49+
.filter((id) => ownRequirementIds.has(id)),
50+
policyIds: ct.policyTemplates.map((p) => p.id),
51+
taskIds: ct.taskTemplates.map((t) => t.id),
52+
documentTypes: [...ct.documentTypes],
53+
});
54+
}
55+
for (const pt of ct.policyTemplates) {
56+
if (!policiesMap.has(pt.id)) {
57+
policiesMap.set(pt.id, {
58+
id: pt.id,
59+
name: pt.name,
60+
description: pt.description,
61+
content: pt.content,
62+
frequency: pt.frequency,
63+
department: pt.department,
64+
});
65+
}
66+
}
67+
for (const tt of ct.taskTemplates) {
68+
if (!tasksMap.has(tt.id)) {
69+
tasksMap.set(tt.id, {
70+
id: tt.id,
71+
name: tt.name,
72+
description: tt.description,
73+
frequency: tt.frequency,
74+
department: tt.department,
75+
});
76+
}
77+
}
78+
}
79+
}
80+
81+
return {
82+
framework: {
83+
id: framework.id,
84+
name: framework.name,
85+
catalogVersion: framework.version,
86+
description: framework.description,
87+
},
88+
requirements: framework.requirements.map((r) => ({
89+
id: r.id,
90+
identifier: r.identifier,
91+
name: r.name,
92+
description: r.description,
93+
})),
94+
controls: [...controlsMap.values()],
95+
policies: [...policiesMap.values()],
96+
tasks: [...tasksMap.values()],
97+
};
98+
}

0 commit comments

Comments
 (0)