From dd5a801749128a3b5a60198682ebd3f3a33417f4 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 25 May 2026 13:36:12 -0400 Subject: [PATCH 1/5] fix(api): update permission of ensure-setup endpoint to allow auditor to access SoA --- apps/api/src/soa/soa.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/soa/soa.controller.ts b/apps/api/src/soa/soa.controller.ts index 451c93476e..c7ffc39163 100644 --- a/apps/api/src/soa/soa.controller.ts +++ b/apps/api/src/soa/soa.controller.ts @@ -329,7 +329,7 @@ export class SOAController { @Post('ensure-setup') @HttpCode(HttpStatus.OK) - @RequirePermission('audit', 'create') + @RequirePermission('audit', 'read') @ApiOperation({ summary: 'Ensure SOA configuration and document exist' }) @ApiConsumes('application/json') @ApiOkResponse({ From c1e62158573d8adfb5135691ce0c87d15d2d7926 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 25 May 2026 15:33:36 -0400 Subject: [PATCH 2/5] feat(api): create get-setup endpoint for audit:read permission --- apps/api/src/soa/soa.controller.spec.ts | 22 ++++++++++ apps/api/src/soa/soa.controller.ts | 19 +++++++- apps/api/src/soa/soa.service.spec.ts | 58 +++++++++++++++++++++++++ apps/api/src/soa/soa.service.ts | 41 +++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/apps/api/src/soa/soa.controller.spec.ts b/apps/api/src/soa/soa.controller.spec.ts index 83fa7db005..af8735cdd3 100644 --- a/apps/api/src/soa/soa.controller.spec.ts +++ b/apps/api/src/soa/soa.controller.spec.ts @@ -44,6 +44,7 @@ describe('SOAController', () => { updateDocumentAfterAutoFill: jest.fn(), createDocument: jest.fn(), ensureSetup: jest.fn(), + getSetup: jest.fn(), approveDocument: jest.fn(), declineDocument: jest.fn(), submitForApproval: jest.fn(), @@ -147,6 +148,27 @@ describe('SOAController', () => { }); }); + describe('getSetup', () => { + const dto = { + organizationId: 'org_123', + frameworkId: 'fw_1', + }; + + it('should call soaService.getSetup with dto', async () => { + const setupResult = { + success: true, + configuration: { id: 'cfg_1' }, + document: { id: 'doc_1' }, + }; + mockSOAService.getSetup.mockResolvedValue(setupResult); + + const result = await controller.getSetup(dto as never, 'org_123'); + + expect(soaService.getSetup).toHaveBeenCalledWith(dto); + expect(result).toEqual(setupResult); + }); + }); + describe('approveDocument', () => { const dto = { documentId: 'doc_1', diff --git a/apps/api/src/soa/soa.controller.ts b/apps/api/src/soa/soa.controller.ts index c7ffc39163..43bd5700e2 100644 --- a/apps/api/src/soa/soa.controller.ts +++ b/apps/api/src/soa/soa.controller.ts @@ -329,7 +329,7 @@ export class SOAController { @Post('ensure-setup') @HttpCode(HttpStatus.OK) - @RequirePermission('audit', 'read') + @RequirePermission('audit', 'create') @ApiOperation({ summary: 'Ensure SOA configuration and document exist' }) @ApiConsumes('application/json') @ApiOkResponse({ @@ -342,6 +342,23 @@ export class SOAController { return this.soaService.ensureSetup(dto); } + @Post('get-setup') + @HttpCode(HttpStatus.OK) + @RequirePermission('audit', 'read') + @ApiOperation({ + summary: 'Read SOA configuration and document without creating either', + }) + @ApiConsumes('application/json') + @ApiOkResponse({ + description: 'Setup returned (configuration/document may be null)', + }) + async getSetup( + @Body() dto: EnsureSOASetupDto, + @OrganizationId() organizationId: string, + ) { + return this.soaService.getSetup(dto); + } + @Post('approve') @HttpCode(HttpStatus.OK) @RequirePermission('audit', 'update') diff --git a/apps/api/src/soa/soa.service.spec.ts b/apps/api/src/soa/soa.service.spec.ts index 91b35aabb8..8a9b69ff3b 100644 --- a/apps/api/src/soa/soa.service.spec.ts +++ b/apps/api/src/soa/soa.service.spec.ts @@ -145,6 +145,64 @@ describe('SOAService', () => { }); }); + describe('getSetup', () => { + const dto = { frameworkId: 'fw-1', organizationId: 'org-1' }; + + it('throws NotFoundException when framework not found', async () => { + mockDb.frameworkEditorFramework.findUnique.mockResolvedValue(null); + await expect(service.getSetup(dto)).rejects.toThrow(NotFoundException); + }); + + it('returns success:false for non-ISO 27001 framework', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ id: 'fw-1', name: 'SOC 2' }); + const result = await service.getSetup(dto); + expect(result.success).toBe(false); + expect(result.error).toContain('ISO 27001'); + }); + + it('returns nulls without creating when configuration and document are missing', async () => { + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ id: 'fw-1', name: 'ISO 27001' }); + ( + mockDb.sOAFrameworkConfiguration.findFirst as jest.Mock + ).mockResolvedValue(null); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await service.getSetup(dto); + + expect(result.success).toBe(true); + expect(result.configuration).toBeNull(); + expect(result.document).toBeNull(); + expect(mockDb.sOAFrameworkConfiguration.create).not.toHaveBeenCalled(); + expect(mockDb.sOADocument.create).not.toHaveBeenCalled(); + }); + + it('returns existing configuration and document without mutating', async () => { + const config = { id: 'cfg-1', questions: [{ id: 'q1' }] }; + const doc = { id: 'doc-1', answers: [] }; + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ id: 'fw-1', name: 'ISO 27001' }); + ( + mockDb.sOAFrameworkConfiguration.findFirst as jest.Mock + ).mockResolvedValue(config); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(doc); + + const result = await service.getSetup(dto); + + expect(result).toEqual({ + success: true, + configuration: config, + document: doc, + }); + expect(mockDb.sOAFrameworkConfiguration.create).not.toHaveBeenCalled(); + expect(mockDb.sOADocument.create).not.toHaveBeenCalled(); + }); + }); + describe('approveDocument', () => { const dto = { documentId: 'doc-1', organizationId: 'org-1' }; const userId = 'user-1'; diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index ef43aea18b..f5e2f34bf0 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -319,6 +319,47 @@ export class SOAService { return { success: true, configuration, document }; } + async getSetup(dto: EnsureSOASetupDto) { + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: dto.frameworkId }, + }); + + if (!framework) { + throw new NotFoundException('Framework not found'); + } + + const isISO27001 = ISO27001_FRAMEWORK_NAMES.includes(framework.name); + + if (!isISO27001) { + return { + success: false, + error: 'Only ISO 27001 framework is currently supported', + configuration: null, + document: null, + }; + } + + const configuration = await db.sOAFrameworkConfiguration.findFirst({ + where: { + frameworkId: dto.frameworkId, + isLatest: true, + }, + }); + + const document = await db.sOADocument.findFirst({ + where: { + frameworkId: dto.frameworkId, + organizationId: dto.organizationId, + isLatest: true, + }, + include: { + answers: { where: { isLatestAnswer: true } }, + }, + }); + + return { success: true, configuration, document }; + } + async approveDocument(dto: ApproveSOADocumentDto, userId: string) { const member = await this.validateOwnerOrAdmin(dto.organizationId, userId); From 0256e81cb904db4f7913b496d0521efa7fd5b783 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 25 May 2026 15:35:00 -0400 Subject: [PATCH 3/5] fix(app): use get-setup endpoint for auditor role on SoA --- .../documents/components/SOAOverviewCard.tsx | 7 +++- .../components/SOAFrameworkTabs.tsx | 9 +++-- .../hooks/useSOADocument.ts | 34 ++++++++++++------- .../statement-of-applicability/page.tsx | 12 +++++-- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx index 62aa664646..8c45498b31 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx @@ -8,6 +8,7 @@ import { Text, } from '@trycompai/design-system'; import { api } from '@/lib/api-client'; +import { usePermissions } from '@/hooks/use-permissions'; import Link from 'next/link'; import { useMemo } from 'react'; import useSWR from 'swr'; @@ -94,9 +95,13 @@ export function SOAOverviewCard({ iso27001FrameworkId, }: SOAOverviewCardProps) { const form = STATEMENT_OF_APPLICABILITY_FORM; + const { hasPermission } = usePermissions(); + const soaEndpoint = hasPermission('audit', 'create') + ? '/v1/soa/ensure-setup' + : '/v1/soa/get-setup'; const { data: soaSetupResponse, error: soaSetupError, isLoading: isLoadingSOASetup } = useSWR( - ['/v1/soa/ensure-setup', organizationId, iso27001FrameworkId], + [soaEndpoint, organizationId, iso27001FrameworkId], async ([endpoint, orgId, frameworkId]: readonly [string, string, string]) => { const response = await api.post(endpoint, { organizationId: orgId, diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx index bb9071a839..24f0036d08 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx @@ -5,7 +5,8 @@ import { useState, useTransition } from 'react'; import { toast } from 'sonner'; import { Loader2, ShieldCheck } from 'lucide-react'; import { SOAFrameworkTable } from './SOAFrameworkTable'; -import { ensureSOASetup } from '../hooks/useSOADocument'; +import { ensureSOASetup, getSOASetup } from '../hooks/useSOADocument'; +import { usePermissions } from '@/hooks/use-permissions'; import type { FrameworkWithSOAData } from '../types'; interface SOAFrameworkTabsProps { @@ -23,6 +24,8 @@ export function SOAFrameworkTabs({ frameworksWithSOAData, organizationId }: SOAF const [frameworkData, setFrameworkData] = useState>( new Map(frameworksWithSOAData.map((fw) => [fw.frameworkId, fw])) ); + const { hasPermission } = usePermissions(); + const canCreateSetup = hasPermission('audit', 'create'); // Set active tab to first supported framework with data, or first framework const getInitialTab = () => { @@ -55,7 +58,9 @@ export function SOAFrameworkTabs({ frameworksWithSOAData, organizationId }: SOAF startTransition(async () => { try { - const result = await ensureSOASetup({ frameworkId, organizationId }); + const result = canCreateSetup + ? await ensureSOASetup({ frameworkId, organizationId }) + : await getSOASetup({ frameworkId, organizationId }); if (result.error) { toast.error(result.error); diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts index 91fa1d7e63..dacd34a9ff 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts @@ -215,25 +215,35 @@ export async function createSOADocument(params: { return response.data.data; } -/** Standalone helper: ensure SOA setup for a framework */ -export async function ensureSOASetup(params: { - frameworkId: string; - organizationId: string; -}): Promise<{ +type SOASetupResult = { success: boolean; configuration?: Record | null; document?: Record | null; error?: string; -}> { - const response = await api.post<{ - success: boolean; - configuration?: Record | null; - document?: Record | null; - error?: string; - }>('/v1/soa/ensure-setup', params); +}; + +/** Standalone helper: ensure SOA setup for a framework (creates if missing). */ +export async function ensureSOASetup(params: { + frameworkId: string; + organizationId: string; +}): Promise { + const response = await api.post('/v1/soa/ensure-setup', params); if (response.error) throw new Error(response.error || 'Failed to setup SOA'); if (!response.data) throw new Error('Failed to setup SOA'); return response.data; } + +/** Standalone helper: read SOA setup for a framework without creating anything. */ +export async function getSOASetup(params: { + frameworkId: string; + organizationId: string; +}): Promise { + const response = await api.post('/v1/soa/get-setup', params); + + if (response.error) throw new Error(response.error || 'Failed to load SOA'); + if (!response.data) throw new Error('Failed to load SOA'); + + return response.data; +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx index a9a9ed12a8..3f41a96ba8 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx @@ -1,5 +1,6 @@ import { serverApi } from '@/lib/api-server'; -import { parseRolesString } from '@/lib/permissions'; +import { hasPermission, parseRolesString } from '@/lib/permissions'; +import { resolveCurrentUserPermissions } from '@/lib/permissions.server'; import { auth } from '@/utils/auth'; import { Breadcrumb, PageLayout } from '@trycompai/design-system'; import { headers } from 'next/headers'; @@ -90,12 +91,19 @@ export default async function StatementOfApplicabilityPage({ try { const { frameworkId, framework } = isoFrameworkInstance; + const userPermissions = await resolveCurrentUserPermissions(organizationId); + const canCreateSetup = + !!userPermissions && hasPermission(userPermissions, 'audit', 'create'); + const setupEndpoint = canCreateSetup + ? '/v1/soa/ensure-setup' + : '/v1/soa/get-setup'; + const setupResult = await serverApi.post<{ success: boolean; error?: string; configuration: Record | null; document: Record | null; - }>('/v1/soa/ensure-setup', { frameworkId, organizationId }); + }>(setupEndpoint, { frameworkId, organizationId }); const setupData = setupResult.data; if (!setupData?.success) { From 5108839ecfe8988f96cadac8ddc42bb1b47d3fe3 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 25 May 2026 16:27:10 -0400 Subject: [PATCH 4/5] fix(api): pass trusted organization to getSetup service --- apps/api/src/soa/soa.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/soa/soa.controller.ts b/apps/api/src/soa/soa.controller.ts index 43bd5700e2..4817d3967b 100644 --- a/apps/api/src/soa/soa.controller.ts +++ b/apps/api/src/soa/soa.controller.ts @@ -356,6 +356,7 @@ export class SOAController { @Body() dto: EnsureSOASetupDto, @OrganizationId() organizationId: string, ) { + dto.organizationId = organizationId; return this.soaService.getSetup(dto); } From eace5265e2b94843343727a9502b4f19b150ba6e Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 25 May 2026 16:28:40 -0400 Subject: [PATCH 5/5] fix(app): show empty state instead of spinner when soa setup missing --- .../components/SOAFrameworkTabs.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx index 24f0036d08..499d33bacd 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx @@ -165,8 +165,15 @@ export function SOAFrameworkTabs({ frameworksWithSOAData, organizationId }: SOAF organizationId={organizationId} /> ) : ( -
- +
+

+ Statement of Applicability has not been set up yet. +

+

+ {canCreateSetup + ? 'Switch tabs or refresh to retry creating the setup.' + : 'Ask an admin to start the Statement of Applicability for this framework.'} +

)}