Skip to content
22 changes: 22 additions & 0 deletions apps/api/src/soa/soa.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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',
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/soa/soa.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,24 @@ 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,
) {
dto.organizationId = organizationId;
return this.soaService.getSetup(dto);
Comment thread
chasprowebdev marked this conversation as resolved.
}

@Post('approve')
@HttpCode(HttpStatus.OK)
@RequirePermission('audit', 'update')
Expand Down
58 changes: 58 additions & 0 deletions apps/api/src/soa/soa.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
41 changes: 41 additions & 0 deletions apps/api/src/soa/soa.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,47 @@ export class SOAService {
return { success: true, configuration, document };
}

async getSetup(dto: EnsureSOASetupDto) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: getSetup duplicates existing SOA setup lookup logic instead of reusing a shared helper, which increases drift and maintenance risk.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/soa/soa.service.ts, line 322:

<comment>`getSetup` duplicates existing SOA setup lookup logic instead of reusing a shared helper, which increases drift and maintenance risk.</comment>

<file context>
@@ -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 },
</file context>
Fix with Cubic

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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<SOASetupResponse>(
['/v1/soa/ensure-setup', organizationId, iso27001FrameworkId],
[soaEndpoint, organizationId, iso27001FrameworkId],
async ([endpoint, orgId, frameworkId]: readonly [string, string, string]) => {
const response = await api.post<SOASetupResponse>(endpoint, {
organizationId: orgId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,6 +24,8 @@ export function SOAFrameworkTabs({ frameworksWithSOAData, organizationId }: SOAF
const [frameworkData, setFrameworkData] = useState<Map<string, typeof frameworksWithSOAData[0]>>(
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 = () => {
Expand Down Expand Up @@ -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 });
Comment thread
chasprowebdev marked this conversation as resolved.

if (result.error) {
toast.error(result.error);
Expand Down Expand Up @@ -160,8 +165,15 @@ export function SOAFrameworkTabs({ frameworksWithSOAData, organizationId }: SOAF
organizationId={organizationId}
/>
) : (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center rounded-lg border">
<p className="text-muted-foreground">
Statement of Applicability has not been set up yet.
</p>
<p className="text-xs text-muted-foreground">
{canCreateSetup
? 'Switch tabs or refresh to retry creating the setup.'
: 'Ask an admin to start the Statement of Applicability for this framework.'}
</p>
</div>
)}
</TabsContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null;
document?: Record<string, unknown> | null;
error?: string;
}> {
const response = await api.post<{
success: boolean;
configuration?: Record<string, unknown> | null;
document?: Record<string, unknown> | 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<SOASetupResult> {
const response = await api.post<SOASetupResult>('/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<SOASetupResult> {
const response = await api.post<SOASetupResult>('/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;
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Using get-setup for read-only users can produce valid null setup data, but the existing flow still converts that into an error state. This will show a failure message for auditors when setup is simply missing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx, line 97:

<comment>Using `get-setup` for read-only users can produce valid `null` setup data, but the existing flow still converts that into an error state. This will show a failure message for auditors when setup is simply missing.</comment>

<file context>
@@ -90,12 +91,19 @@ export default async function StatementOfApplicabilityPage({
+      const userPermissions = await resolveCurrentUserPermissions(organizationId);
+      const canCreateSetup =
+        !!userPermissions && hasPermission(userPermissions, 'audit', 'create');
+      const setupEndpoint = canCreateSetup
+        ? '/v1/soa/ensure-setup'
+        : '/v1/soa/get-setup';
</file context>
Fix with Cubic

? '/v1/soa/ensure-setup'
: '/v1/soa/get-setup';

const setupResult = await serverApi.post<{
success: boolean;
error?: string;
configuration: Record<string, unknown> | null;
document: Record<string, unknown> | null;
}>('/v1/soa/ensure-setup', { frameworkId, organizationId });
}>(setupEndpoint, { frameworkId, organizationId });

const setupData = setupResult.data;
if (!setupData?.success) {
Expand Down
Loading