Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5fa4861
fix(app): move 'Statement of Applicability' from Questionnaire to Doc…
chasprowebdev Apr 23, 2026
d8a9a8d
fix(app): update approval status after approving of 'Statement of App…
chasprowebdev Apr 23, 2026
4d6e854
fix(app): show approval status on Statement of Applicability card in …
chasprowebdev Apr 23, 2026
10c67f7
fix(api): include soa to documents score
chasprowebdev Apr 23, 2026
7364c58
fix(api): create endpoint to export soa into pdf
chasprowebdev Apr 23, 2026
e6731b2
fix(app): export Statement of Applicability as pdf
chasprowebdev Apr 23, 2026
1d3f903
fix(api): add metrics to SOA pdf document
chasprowebdev Apr 24, 2026
74fac2d
Merge branch 'main' of https://github.com/trycompai/comp into chas/mo…
chasprowebdev Apr 24, 2026
fd5ba8b
fix(app): add organizationId to frameworks SWR cache
chasprowebdev Apr 24, 2026
49c1673
fix(app): remove use of hasISO27001Framework on CompanyOverviewCards
chasprowebdev Apr 24, 2026
993e89b
fix(app): remove use of ai-vendor-questionnaire FF for SOA page
chasprowebdev Apr 24, 2026
589f285
fix(api): fix pagination overflow for long question blocks in soa pdf
chasprowebdev Apr 24, 2026
37e7d15
Merge branch 'main' into chas/move-statement-of-applicability
tofikwest Apr 24, 2026
ada6b5e
fix(api): correct SOA export classification for declined cases
chasprowebdev Apr 24, 2026
760c304
fix(app): use exact role checks instead of substring matching
chasprowebdev Apr 24, 2026
24f9791
fix(api): add coverage for SOA export endpoint
chasprowebdev Apr 24, 2026
3ab3698
Merge branch 'chas/move-statement-of-applicability' of https://github…
chasprowebdev Apr 24, 2026
94ad0ab
Merge branch 'main' of https://github.com/trycompai/comp into chas/mo…
chasprowebdev Apr 24, 2026
a5e8988
fix(framework-editor): add SOA document to ISO 27001 framework
chasprowebdev Apr 24, 2026
10fbfb1
Merge branch 'main' of https://github.com/trycompai/comp into chas/mo…
chasprowebdev Apr 24, 2026
2f12556
fix(app): handle serverApi.post errors to prevent infinite loading on…
chasprowebdev Apr 24, 2026
38642ba
fix(api): correct SOA completion logic based on approvedAt for SOA
chasprowebdev Apr 24, 2026
140db39
fix(app): avoid defaulting to 'Not approved' before SOA status loads
chasprowebdev Apr 24, 2026
fbd4b09
Merge branch 'main' of https://github.com/trycompai/comp into chas/mo…
chasprowebdev Apr 24, 2026
1296eeb
fix(db): add declined fields to SOADocument
chasprowebdev Apr 24, 2026
83a5619
fix(db): remove declined from SOADocumentStatus
chasprowebdev Apr 24, 2026
dafde6f
fix(api): update declineAt during SOA Document status changes
chasprowebdev Apr 24, 2026
59567c4
fix(app): update approval status text on soa
chasprowebdev Apr 24, 2026
4813da6
fix(api): update approval status text on soa pdf
chasprowebdev Apr 24, 2026
af47b4e
fix(app): update SOA Document Info based on status changes
chasprowebdev Apr 24, 2026
4489630
Merge branch 'main' of https://github.com/trycompai/comp into chas/mo…
chasprowebdev Apr 24, 2026
08971f9
fix(app): correct approvalStatusText handling of declinedAt
chasprowebdev Apr 24, 2026
f817d44
fix(app): correct SOA document info during the status changes
chasprowebdev Apr 24, 2026
7705eca
fix(api): update the soa pdf content
chasprowebdev Apr 24, 2026
068a607
Merge branch 'main' of https://github.com/trycompai/comp into chas/mo…
chasprowebdev Apr 24, 2026
959d571
fix(app): handle /v1/frameworks fetch errors before showing not found…
chasprowebdev Apr 24, 2026
b9a580f
fix(app): guard answers sync effect from clearing answersMap on parti…
chasprowebdev Apr 24, 2026
37fcd08
Merge branch 'main' into chas/move-statement-of-applicability
tofikwest Apr 27, 2026
bfd1f5f
fix(api): add non-empty validation for requirement fields in ExportSO…
chasprowebdev Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions apps/api/src/frameworks/frameworks-scores.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
toExternalEvidenceFormType,
} from '@trycompai/company';
import { db } from '@db';
import { ISO27001_FRAMEWORK_NAMES } from '../soa/utils/constants';
import { filterComplianceMembers } from '../utils/compliance-filters';

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

async function computeDocumentsScore(organizationId: string) {
const groupedStatuses = await db.evidenceSubmission.groupBy({
by: ['formType'],
where: { organizationId },
_max: { submittedAt: true },
});
const [groupedStatuses, isoFrameworkInstances] = await Promise.all([
db.evidenceSubmission.groupBy({
by: ['formType'],
where: { organizationId },
_max: { submittedAt: true },
}),
db.frameworkInstance.findMany({
where: {
organizationId,
framework: {
name: {
in: ISO27001_FRAMEWORK_NAMES,
},
},
},
select: { frameworkId: true },
}),
]);

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

const isoFrameworkIds = isoFrameworkInstances
.map((instance) => instance.frameworkId)
.filter((id): id is string => !!id);
const hasSOADocumentRequirement = isoFrameworkIds.length > 0;

let soaCompleted = false;
if (hasSOADocumentRequirement) {
const latestSOADocument = await db.sOADocument.findFirst({
where: {
organizationId,
isLatest: true,
frameworkId: { in: isoFrameworkIds },
},
select: {
approvedAt: true,
status: true,
},
orderBy: {
updatedAt: 'desc',
},
});
soaCompleted =
latestSOADocument?.status === 'completed' &&
!!latestSOADocument.approvedAt;
}

const soaTotalDocuments = hasSOADocumentRequirement ? 1 : 0;
const soaOutstandingDocuments = hasSOADocumentRequirement && !soaCompleted ? 1 : 0;
const totalDocuments = includedForms.length + soaTotalDocuments;
const outstandingDocuments = nonSOAOutstandingDocuments + soaOutstandingDocuments;

return {
totalDocuments,
completedDocuments: totalDocuments - outstandingDocuments,
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/soa/dto/export-soa-document.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';

export class ExportSOADocumentDto {
@IsString()
Comment thread
chasprowebdev marked this conversation as resolved.
@IsNotEmpty()
documentId!: string;

@IsString()
@IsNotEmpty()
organizationId!: string;

@IsIn(['pdf'])
format!: 'pdf';
}

47 changes: 47 additions & 0 deletions apps/api/src/soa/soa.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import type { Response } from 'express';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import { PermissionGuard } from '../auth/permission.guard';
import type { AuthContext } from '../auth/types';
Expand All @@ -9,6 +10,15 @@ import { SOAService } from './soa.service';
jest.mock('../auth/auth.server', () => ({
auth: { api: { getSession: jest.fn() } },
}));
jest.mock('../auth/hybrid-auth.guard', () => ({
HybridAuthGuard: class MockHybridAuthGuard {},
}));
jest.mock('../auth/permission.guard', () => ({
PermissionGuard: class MockPermissionGuard {},
}));
jest.mock('./soa.service', () => ({
SOAService: class MockSOAService {},
}));

jest.mock('@trycompai/auth', () => ({
statement: {},
Expand Down Expand Up @@ -37,6 +47,7 @@ describe('SOAController', () => {
approveDocument: jest.fn(),
declineDocument: jest.fn(),
submitForApproval: jest.fn(),
exportDocument: jest.fn(),
};

const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
Expand Down Expand Up @@ -210,4 +221,40 @@ describe('SOAController', () => {
expect(result).toEqual(submitted);
});
});

describe('exportDocument', () => {
const dto = {
documentId: 'doc_1',
format: 'pdf',
};

it('should call soaService.exportDocument, set headers, and send file buffer', async () => {
const fileBuffer = Buffer.from('pdf-data');
mockSOAService.exportDocument.mockResolvedValue({
fileBuffer,
mimeType: 'application/pdf',
filename: 'soa-export.pdf',
});
const res = {
setHeader: jest.fn(),
send: jest.fn(),
} as unknown as Response;

await controller.exportDocument(dto as never, res, 'org_123');

expect(soaService.exportDocument).toHaveBeenCalledWith({
...dto,
organizationId: 'org_123',
});
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Type',
'application/pdf',
);
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="soa-export.pdf"',
);
expect(res.send).toHaveBeenCalledWith(fileBuffer);
});
});
});
26 changes: 26 additions & 0 deletions apps/api/src/soa/soa.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { EnsureSOASetupDto } from './dto/ensure-soa-setup.dto';
import { ApproveSOADocumentDto } from './dto/approve-soa-document.dto';
import { DeclineSOADocumentDto } from './dto/decline-soa-document.dto';
import { SubmitSOAForApprovalDto } from './dto/submit-soa-for-approval.dto';
import { ExportSOADocumentDto } from './dto/export-soa-document.dto';
import { syncOrganizationEmbeddings } from '@/vector-store/lib';
import { OrganizationId } from '@/auth/auth-context.decorator';
import { AuthContext } from '@/auth/auth-context.decorator';
Expand Down Expand Up @@ -395,4 +396,29 @@ export class SOAController {
) {
return this.soaService.submitForApproval(dto);
}

@Post('export')
Comment thread
chasprowebdev marked this conversation as resolved.
@RequirePermission('audit', 'read')
@ApiOperation({ summary: 'Export a SOA document' })
@ApiConsumes('application/json')
@ApiProduces('application/pdf')
@ApiOkResponse({
description: 'Export SOA document to PDF',
})
async exportDocument(
@Body() dto: ExportSOADocumentDto,
@Res({ passthrough: true }) res: Response,
@OrganizationId() organizationId: string,
): Promise<void> {
dto.organizationId = organizationId;
const result = await this.soaService.exportDocument(dto);

res.setHeader('Content-Type', result.mimeType);
res.setHeader(
'Content-Disposition',
`attachment; filename="${result.filename}"`,
);

res.send(result.fileBuffer);
}
}
Loading
Loading