Skip to content

Commit c23cff4

Browse files
authored
Merge pull request #3016 from trycompai/main
[comp] Production Deploy
2 parents 6efbe95 + ce14a42 commit c23cff4

8 files changed

Lines changed: 193 additions & 19 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ describe('SOAController', () => {
4444
updateDocumentAfterAutoFill: jest.fn(),
4545
createDocument: jest.fn(),
4646
ensureSetup: jest.fn(),
47+
getSetup: jest.fn(),
4748
approveDocument: jest.fn(),
4849
declineDocument: jest.fn(),
4950
submitForApproval: jest.fn(),
@@ -147,6 +148,27 @@ describe('SOAController', () => {
147148
});
148149
});
149150

151+
describe('getSetup', () => {
152+
const dto = {
153+
organizationId: 'org_123',
154+
frameworkId: 'fw_1',
155+
};
156+
157+
it('should call soaService.getSetup with dto', async () => {
158+
const setupResult = {
159+
success: true,
160+
configuration: { id: 'cfg_1' },
161+
document: { id: 'doc_1' },
162+
};
163+
mockSOAService.getSetup.mockResolvedValue(setupResult);
164+
165+
const result = await controller.getSetup(dto as never, 'org_123');
166+
167+
expect(soaService.getSetup).toHaveBeenCalledWith(dto);
168+
expect(result).toEqual(setupResult);
169+
});
170+
});
171+
150172
describe('approveDocument', () => {
151173
const dto = {
152174
documentId: 'doc_1',

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,24 @@ export class SOAController {
342342
return this.soaService.ensureSetup(dto);
343343
}
344344

345+
@Post('get-setup')
346+
@HttpCode(HttpStatus.OK)
347+
@RequirePermission('audit', 'read')
348+
@ApiOperation({
349+
summary: 'Read SOA configuration and document without creating either',
350+
})
351+
@ApiConsumes('application/json')
352+
@ApiOkResponse({
353+
description: 'Setup returned (configuration/document may be null)',
354+
})
355+
async getSetup(
356+
@Body() dto: EnsureSOASetupDto,
357+
@OrganizationId() organizationId: string,
358+
) {
359+
dto.organizationId = organizationId;
360+
return this.soaService.getSetup(dto);
361+
}
362+
345363
@Post('approve')
346364
@HttpCode(HttpStatus.OK)
347365
@RequirePermission('audit', 'update')

‎apps/api/src/soa/soa.service.spec.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,64 @@ describe('SOAService', () => {
145145
});
146146
});
147147

148+
describe('getSetup', () => {
149+
const dto = { frameworkId: 'fw-1', organizationId: 'org-1' };
150+
151+
it('throws NotFoundException when framework not found', async () => {
152+
mockDb.frameworkEditorFramework.findUnique.mockResolvedValue(null);
153+
await expect(service.getSetup(dto)).rejects.toThrow(NotFoundException);
154+
});
155+
156+
it('returns success:false for non-ISO 27001 framework', async () => {
157+
(
158+
mockDb.frameworkEditorFramework.findUnique as jest.Mock
159+
).mockResolvedValue({ id: 'fw-1', name: 'SOC 2' });
160+
const result = await service.getSetup(dto);
161+
expect(result.success).toBe(false);
162+
expect(result.error).toContain('ISO 27001');
163+
});
164+
165+
it('returns nulls without creating when configuration and document are missing', async () => {
166+
(
167+
mockDb.frameworkEditorFramework.findUnique as jest.Mock
168+
).mockResolvedValue({ id: 'fw-1', name: 'ISO 27001' });
169+
(
170+
mockDb.sOAFrameworkConfiguration.findFirst as jest.Mock
171+
).mockResolvedValue(null);
172+
(mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null);
173+
174+
const result = await service.getSetup(dto);
175+
176+
expect(result.success).toBe(true);
177+
expect(result.configuration).toBeNull();
178+
expect(result.document).toBeNull();
179+
expect(mockDb.sOAFrameworkConfiguration.create).not.toHaveBeenCalled();
180+
expect(mockDb.sOADocument.create).not.toHaveBeenCalled();
181+
});
182+
183+
it('returns existing configuration and document without mutating', async () => {
184+
const config = { id: 'cfg-1', questions: [{ id: 'q1' }] };
185+
const doc = { id: 'doc-1', answers: [] };
186+
(
187+
mockDb.frameworkEditorFramework.findUnique as jest.Mock
188+
).mockResolvedValue({ id: 'fw-1', name: 'ISO 27001' });
189+
(
190+
mockDb.sOAFrameworkConfiguration.findFirst as jest.Mock
191+
).mockResolvedValue(config);
192+
(mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(doc);
193+
194+
const result = await service.getSetup(dto);
195+
196+
expect(result).toEqual({
197+
success: true,
198+
configuration: config,
199+
document: doc,
200+
});
201+
expect(mockDb.sOAFrameworkConfiguration.create).not.toHaveBeenCalled();
202+
expect(mockDb.sOADocument.create).not.toHaveBeenCalled();
203+
});
204+
});
205+
148206
describe('approveDocument', () => {
149207
const dto = { documentId: 'doc-1', organizationId: 'org-1' };
150208
const userId = 'user-1';

‎apps/api/src/soa/soa.service.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,47 @@ export class SOAService {
319319
return { success: true, configuration, document };
320320
}
321321

322+
async getSetup(dto: EnsureSOASetupDto) {
323+
const framework = await db.frameworkEditorFramework.findUnique({
324+
where: { id: dto.frameworkId },
325+
});
326+
327+
if (!framework) {
328+
throw new NotFoundException('Framework not found');
329+
}
330+
331+
const isISO27001 = ISO27001_FRAMEWORK_NAMES.includes(framework.name);
332+
333+
if (!isISO27001) {
334+
return {
335+
success: false,
336+
error: 'Only ISO 27001 framework is currently supported',
337+
configuration: null,
338+
document: null,
339+
};
340+
}
341+
342+
const configuration = await db.sOAFrameworkConfiguration.findFirst({
343+
where: {
344+
frameworkId: dto.frameworkId,
345+
isLatest: true,
346+
},
347+
});
348+
349+
const document = await db.sOADocument.findFirst({
350+
where: {
351+
frameworkId: dto.frameworkId,
352+
organizationId: dto.organizationId,
353+
isLatest: true,
354+
},
355+
include: {
356+
answers: { where: { isLatestAnswer: true } },
357+
},
358+
});
359+
360+
return { success: true, configuration, document };
361+
}
362+
322363
async approveDocument(dto: ApproveSOADocumentDto, userId: string) {
323364
const member = await this.validateOwnerOrAdmin(dto.organizationId, userId);
324365

‎apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Text,
99
} from '@trycompai/design-system';
1010
import { api } from '@/lib/api-client';
11+
import { usePermissions } from '@/hooks/use-permissions';
1112
import Link from 'next/link';
1213
import { useMemo } from 'react';
1314
import useSWR from 'swr';
@@ -94,9 +95,13 @@ export function SOAOverviewCard({
9495
iso27001FrameworkId,
9596
}: SOAOverviewCardProps) {
9697
const form = STATEMENT_OF_APPLICABILITY_FORM;
98+
const { hasPermission } = usePermissions();
99+
const soaEndpoint = hasPermission('audit', 'create')
100+
? '/v1/soa/ensure-setup'
101+
: '/v1/soa/get-setup';
97102
const { data: soaSetupResponse, error: soaSetupError, isLoading: isLoadingSOASetup } =
98103
useSWR<SOASetupResponse>(
99-
['/v1/soa/ensure-setup', organizationId, iso27001FrameworkId],
104+
[soaEndpoint, organizationId, iso27001FrameworkId],
100105
async ([endpoint, orgId, frameworkId]: readonly [string, string, string]) => {
101106
const response = await api.post<SOASetupResponse>(endpoint, {
102107
organizationId: orgId,

‎apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { useState, useTransition } from 'react';
55
import { toast } from 'sonner';
66
import { Loader2, ShieldCheck } from 'lucide-react';
77
import { SOAFrameworkTable } from './SOAFrameworkTable';
8-
import { ensureSOASetup } from '../hooks/useSOADocument';
8+
import { ensureSOASetup, getSOASetup } from '../hooks/useSOADocument';
9+
import { usePermissions } from '@/hooks/use-permissions';
910
import type { FrameworkWithSOAData } from '../types';
1011

1112
interface SOAFrameworkTabsProps {
@@ -23,6 +24,8 @@ export function SOAFrameworkTabs({ frameworksWithSOAData, organizationId }: SOAF
2324
const [frameworkData, setFrameworkData] = useState<Map<string, typeof frameworksWithSOAData[0]>>(
2425
new Map(frameworksWithSOAData.map((fw) => [fw.frameworkId, fw]))
2526
);
27+
const { hasPermission } = usePermissions();
28+
const canCreateSetup = hasPermission('audit', 'create');
2629

2730
// Set active tab to first supported framework with data, or first framework
2831
const getInitialTab = () => {
@@ -55,7 +58,9 @@ export function SOAFrameworkTabs({ frameworksWithSOAData, organizationId }: SOAF
5558

5659
startTransition(async () => {
5760
try {
58-
const result = await ensureSOASetup({ frameworkId, organizationId });
61+
const result = canCreateSetup
62+
? await ensureSOASetup({ frameworkId, organizationId })
63+
: await getSOASetup({ frameworkId, organizationId });
5964

6065
if (result.error) {
6166
toast.error(result.error);
@@ -160,8 +165,15 @@ export function SOAFrameworkTabs({ frameworksWithSOAData, organizationId }: SOAF
160165
organizationId={organizationId}
161166
/>
162167
) : (
163-
<div className="flex items-center justify-center py-12">
164-
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
168+
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center rounded-lg border">
169+
<p className="text-muted-foreground">
170+
Statement of Applicability has not been set up yet.
171+
</p>
172+
<p className="text-xs text-muted-foreground">
173+
{canCreateSetup
174+
? 'Switch tabs or refresh to retry creating the setup.'
175+
: 'Ask an admin to start the Statement of Applicability for this framework.'}
176+
</p>
165177
</div>
166178
)}
167179
</TabsContent>

‎apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -215,25 +215,35 @@ export async function createSOADocument(params: {
215215
return response.data.data;
216216
}
217217

218-
/** Standalone helper: ensure SOA setup for a framework */
219-
export async function ensureSOASetup(params: {
220-
frameworkId: string;
221-
organizationId: string;
222-
}): Promise<{
218+
type SOASetupResult = {
223219
success: boolean;
224220
configuration?: Record<string, unknown> | null;
225221
document?: Record<string, unknown> | null;
226222
error?: string;
227-
}> {
228-
const response = await api.post<{
229-
success: boolean;
230-
configuration?: Record<string, unknown> | null;
231-
document?: Record<string, unknown> | null;
232-
error?: string;
233-
}>('/v1/soa/ensure-setup', params);
223+
};
224+
225+
/** Standalone helper: ensure SOA setup for a framework (creates if missing). */
226+
export async function ensureSOASetup(params: {
227+
frameworkId: string;
228+
organizationId: string;
229+
}): Promise<SOASetupResult> {
230+
const response = await api.post<SOASetupResult>('/v1/soa/ensure-setup', params);
234231

235232
if (response.error) throw new Error(response.error || 'Failed to setup SOA');
236233
if (!response.data) throw new Error('Failed to setup SOA');
237234

238235
return response.data;
239236
}
237+
238+
/** Standalone helper: read SOA setup for a framework without creating anything. */
239+
export async function getSOASetup(params: {
240+
frameworkId: string;
241+
organizationId: string;
242+
}): Promise<SOASetupResult> {
243+
const response = await api.post<SOASetupResult>('/v1/soa/get-setup', params);
244+
245+
if (response.error) throw new Error(response.error || 'Failed to load SOA');
246+
if (!response.data) throw new Error('Failed to load SOA');
247+
248+
return response.data;
249+
}

‎apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { serverApi } from '@/lib/api-server';
2-
import { parseRolesString } from '@/lib/permissions';
2+
import { hasPermission, parseRolesString } from '@/lib/permissions';
3+
import { resolveCurrentUserPermissions } from '@/lib/permissions.server';
34
import { auth } from '@/utils/auth';
45
import { Breadcrumb, PageLayout } from '@trycompai/design-system';
56
import { headers } from 'next/headers';
@@ -90,12 +91,19 @@ export default async function StatementOfApplicabilityPage({
9091
try {
9192
const { frameworkId, framework } = isoFrameworkInstance;
9293

94+
const userPermissions = await resolveCurrentUserPermissions(organizationId);
95+
const canCreateSetup =
96+
!!userPermissions && hasPermission(userPermissions, 'audit', 'create');
97+
const setupEndpoint = canCreateSetup
98+
? '/v1/soa/ensure-setup'
99+
: '/v1/soa/get-setup';
100+
93101
const setupResult = await serverApi.post<{
94102
success: boolean;
95103
error?: string;
96104
configuration: Record<string, unknown> | null;
97105
document: Record<string, unknown> | null;
98-
}>('/v1/soa/ensure-setup', { frameworkId, organizationId });
106+
}>(setupEndpoint, { frameworkId, organizationId });
99107

100108
const setupData = setupResult.data;
101109
if (!setupData?.success) {

0 commit comments

Comments
 (0)