Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

you can ignore it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Understood—I'll ignore this one. Thanks for the update.

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