Skip to content
Merged
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) {
Comment thread
chasprowebdev marked this conversation as resolved.
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
Comment thread
chasprowebdev marked this conversation as resolved.
? '/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