Skip to content

Commit e56a698

Browse files
authored
Merge pull request #2648 from trycompai/chas/move-statement-of-applicability
CS-277 [Improvement] Statement of applicability changes
2 parents 413088b + bfd1f5f commit e56a698

36 files changed

Lines changed: 1367 additions & 284 deletions

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

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
toExternalEvidenceFormType,
66
} from '@trycompai/company';
77
import { db } from '@db';
8+
import { ISO27001_FRAMEWORK_NAMES } from '../soa/utils/constants';
89
import { filterComplianceMembers } from '../utils/compliance-filters';
910

1011
const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000;
@@ -185,11 +186,24 @@ export async function getOverviewScores(organizationId: string) {
185186
}
186187

187188
async function computeDocumentsScore(organizationId: string) {
188-
const groupedStatuses = await db.evidenceSubmission.groupBy({
189-
by: ['formType'],
190-
where: { organizationId },
191-
_max: { submittedAt: true },
192-
});
189+
const [groupedStatuses, isoFrameworkInstances] = await Promise.all([
190+
db.evidenceSubmission.groupBy({
191+
by: ['formType'],
192+
where: { organizationId },
193+
_max: { submittedAt: true },
194+
}),
195+
db.frameworkInstance.findMany({
196+
where: {
197+
organizationId,
198+
framework: {
199+
name: {
200+
in: ISO27001_FRAMEWORK_NAMES,
201+
},
202+
},
203+
},
204+
select: { frameworkId: true },
205+
}),
206+
]);
193207

194208
const statuses: Record<string, { lastSubmittedAt: string | null }> = {};
195209
for (const form of evidenceFormDefinitionList) {
@@ -204,8 +218,7 @@ async function computeDocumentsScore(organizationId: string) {
204218
const includedForms = evidenceFormDefinitionList.filter(
205219
(f) => !f.hidden && !f.optional,
206220
);
207-
const totalDocuments = includedForms.length;
208-
const outstandingDocuments = includedForms.reduce((count, form) => {
221+
const nonSOAOutstandingDocuments = includedForms.reduce((count, form) => {
209222
if (form.type === 'meeting') {
210223
const allMeetingsOutstanding = meetingSubTypeValues.every((subType) => {
211224
const lastSubmitted = statuses[subType]?.lastSubmittedAt;
@@ -223,6 +236,37 @@ async function computeDocumentsScore(organizationId: string) {
223236
return isOutstanding ? count + 1 : count;
224237
}, 0);
225238

239+
const isoFrameworkIds = isoFrameworkInstances
240+
.map((instance) => instance.frameworkId)
241+
.filter((id): id is string => !!id);
242+
const hasSOADocumentRequirement = isoFrameworkIds.length > 0;
243+
244+
let soaCompleted = false;
245+
if (hasSOADocumentRequirement) {
246+
const latestSOADocument = await db.sOADocument.findFirst({
247+
where: {
248+
organizationId,
249+
isLatest: true,
250+
frameworkId: { in: isoFrameworkIds },
251+
},
252+
select: {
253+
approvedAt: true,
254+
status: true,
255+
},
256+
orderBy: {
257+
updatedAt: 'desc',
258+
},
259+
});
260+
soaCompleted =
261+
latestSOADocument?.status === 'completed' &&
262+
!!latestSOADocument.approvedAt;
263+
}
264+
265+
const soaTotalDocuments = hasSOADocumentRequirement ? 1 : 0;
266+
const soaOutstandingDocuments = hasSOADocumentRequirement && !soaCompleted ? 1 : 0;
267+
const totalDocuments = includedForms.length + soaTotalDocuments;
268+
const outstandingDocuments = nonSOAOutstandingDocuments + soaOutstandingDocuments;
269+
226270
return {
227271
totalDocuments,
228272
completedDocuments: totalDocuments - outstandingDocuments,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
2+
3+
export class ExportSOADocumentDto {
4+
@IsString()
5+
@IsNotEmpty()
6+
documentId!: string;
7+
8+
@IsString()
9+
@IsNotEmpty()
10+
organizationId!: string;
11+
12+
@IsIn(['pdf'])
13+
format!: 'pdf';
14+
}
15+

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Test, TestingModule } from '@nestjs/testing';
22
import { BadRequestException } from '@nestjs/common';
3+
import type { Response } from 'express';
34
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
45
import { PermissionGuard } from '../auth/permission.guard';
56
import type { AuthContext } from '../auth/types';
@@ -9,6 +10,15 @@ import { SOAService } from './soa.service';
910
jest.mock('../auth/auth.server', () => ({
1011
auth: { api: { getSession: jest.fn() } },
1112
}));
13+
jest.mock('../auth/hybrid-auth.guard', () => ({
14+
HybridAuthGuard: class MockHybridAuthGuard {},
15+
}));
16+
jest.mock('../auth/permission.guard', () => ({
17+
PermissionGuard: class MockPermissionGuard {},
18+
}));
19+
jest.mock('./soa.service', () => ({
20+
SOAService: class MockSOAService {},
21+
}));
1222

1323
jest.mock('@trycompai/auth', () => ({
1424
statement: {},
@@ -37,6 +47,7 @@ describe('SOAController', () => {
3747
approveDocument: jest.fn(),
3848
declineDocument: jest.fn(),
3949
submitForApproval: jest.fn(),
50+
exportDocument: jest.fn(),
4051
};
4152

4253
const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
@@ -210,4 +221,40 @@ describe('SOAController', () => {
210221
expect(result).toEqual(submitted);
211222
});
212223
});
224+
225+
describe('exportDocument', () => {
226+
const dto = {
227+
documentId: 'doc_1',
228+
format: 'pdf',
229+
};
230+
231+
it('should call soaService.exportDocument, set headers, and send file buffer', async () => {
232+
const fileBuffer = Buffer.from('pdf-data');
233+
mockSOAService.exportDocument.mockResolvedValue({
234+
fileBuffer,
235+
mimeType: 'application/pdf',
236+
filename: 'soa-export.pdf',
237+
});
238+
const res = {
239+
setHeader: jest.fn(),
240+
send: jest.fn(),
241+
} as unknown as Response;
242+
243+
await controller.exportDocument(dto as never, res, 'org_123');
244+
245+
expect(soaService.exportDocument).toHaveBeenCalledWith({
246+
...dto,
247+
organizationId: 'org_123',
248+
});
249+
expect(res.setHeader).toHaveBeenCalledWith(
250+
'Content-Type',
251+
'application/pdf',
252+
);
253+
expect(res.setHeader).toHaveBeenCalledWith(
254+
'Content-Disposition',
255+
'attachment; filename="soa-export.pdf"',
256+
);
257+
expect(res.send).toHaveBeenCalledWith(fileBuffer);
258+
});
259+
});
213260
});

apps/api/src/soa/soa.controller.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { EnsureSOASetupDto } from './dto/ensure-soa-setup.dto';
2525
import { ApproveSOADocumentDto } from './dto/approve-soa-document.dto';
2626
import { DeclineSOADocumentDto } from './dto/decline-soa-document.dto';
2727
import { SubmitSOAForApprovalDto } from './dto/submit-soa-for-approval.dto';
28+
import { ExportSOADocumentDto } from './dto/export-soa-document.dto';
2829
import { syncOrganizationEmbeddings } from '@/vector-store/lib';
2930
import { OrganizationId } from '@/auth/auth-context.decorator';
3031
import { AuthContext } from '@/auth/auth-context.decorator';
@@ -395,4 +396,29 @@ export class SOAController {
395396
) {
396397
return this.soaService.submitForApproval(dto);
397398
}
399+
400+
@Post('export')
401+
@RequirePermission('audit', 'read')
402+
@ApiOperation({ summary: 'Export a SOA document' })
403+
@ApiConsumes('application/json')
404+
@ApiProduces('application/pdf')
405+
@ApiOkResponse({
406+
description: 'Export SOA document to PDF',
407+
})
408+
async exportDocument(
409+
@Body() dto: ExportSOADocumentDto,
410+
@Res({ passthrough: true }) res: Response,
411+
@OrganizationId() organizationId: string,
412+
): Promise<void> {
413+
dto.organizationId = organizationId;
414+
const result = await this.soaService.exportDocument(dto);
415+
416+
res.setHeader('Content-Type', result.mimeType);
417+
res.setHeader(
418+
'Content-Disposition',
419+
`attachment; filename="${result.filename}"`,
420+
);
421+
422+
res.send(result.fileBuffer);
423+
}
398424
}

0 commit comments

Comments
 (0)