From 5fa48613aeff3f618b0f86d6ecbd818170ab30bf Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 11:40:10 -0400 Subject: [PATCH 01/30] fix(app): move 'Statement of Applicability' from Questionnaire to Documents --- .../components/CompanyOverviewCards.tsx | 34 ++++ .../documents/components/SOAOverviewCard.tsx | 49 +++++ .../components/ApplicableSwatch.tsx | 0 .../components/CreateSOADocument.test.tsx | 2 +- .../components/CreateSOADocument.tsx | 4 +- .../components/EditableSOAFields.tsx | 2 +- .../components/SOADocumentInfo.tsx | 0 .../components/SOAFrameworkTable.tsx | 2 +- .../components/SOAFrameworkTabs.tsx | 2 +- .../components/SOAMobileRow.tsx | 0 .../components/SOAPendingApprovalAlert.tsx | 0 .../components/SOATable.tsx | 0 .../components/SOATableRow.tsx | 0 .../StatementOfApplicabilitySection.tsx | 86 +++++++++ .../components/SubmitApprovalDialog.tsx | 0 .../components/index.ts | 1 + .../components/soa-field-types.ts | 0 .../hooks/useSOAAutoFill.ts | 0 .../hooks/useSOADocument.ts | 0 .../statement-of-applicability/page.tsx | 181 ++++++++++++++++++ .../statement-of-applicability}/types.ts | 0 .../components/QuestionnaireTabs.tsx | 92 --------- .../app/(app)/[orgId]/questionnaire/page.tsx | 144 +------------- .../(app)/[orgId]/questionnaire/soa/page.tsx | 5 +- 24 files changed, 361 insertions(+), 243 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/ApplicableSwatch.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/CreateSOADocument.test.tsx (98%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/CreateSOADocument.tsx (93%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/EditableSOAFields.tsx (99%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/SOADocumentInfo.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/SOAFrameworkTable.tsx (99%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/SOAFrameworkTabs.tsx (99%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/SOAMobileRow.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/SOAPendingApprovalAlert.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/SOATable.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/SOATableRow.tsx (100%) create mode 100644 apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/SubmitApprovalDialog.tsx (100%) create mode 100644 apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/index.ts rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/components/soa-field-types.ts (100%) rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/hooks/useSOAAutoFill.ts (100%) rename apps/app/src/app/(app)/[orgId]/{questionnaire => documents/statement-of-applicability}/hooks/useSOADocument.ts (100%) create mode 100644 apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx rename apps/app/src/app/(app)/[orgId]/{questionnaire/soa => documents/statement-of-applicability}/types.ts (100%) diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx index ee969a944..f0fce75b1 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx @@ -18,10 +18,24 @@ import Link from 'next/link'; import { useMemo } from 'react'; import useSWR from 'swr'; import { evidenceFormDefinitionList, meetingSubTypeValues } from '../forms'; +import { SOAOverviewCard } from './SOAOverviewCard'; type FormStatuses = Record; +type FrameworkListResponse = { + data: Array<{ + id: string; + frameworkId: string; + framework: { + id: string; + name: string; + description: string | null; + visible: boolean; + }; + }>; +}; const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; +const ISO27001_NAMES = ['ISO 27001', 'iso27001', 'ISO27001']; const MEETING_SUB_TYPES = meetingSubTypeValues; const MEETING_ALL_TYPES = new Set([...MEETING_SUB_TYPES, 'meeting']); @@ -106,6 +120,16 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin ); const { data: findingsResponse } = useOrganizationFindings(); + const { data: frameworksResponse } = useSWR( + '/v1/frameworks', + async (endpoint: string) => { + const response = await api.get(endpoint); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load frameworks'); + } + return response.data; + }, + ); const activeIssueCounts = useMemo(() => { const counts: Record = {}; @@ -141,8 +165,18 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin return map; }, [visibleForms]); + const hasISO27001Framework = useMemo(() => { + const frameworks = frameworksResponse?.data ?? []; + return frameworks.some( + (frameworkInstance) => + !!frameworkInstance.framework?.name && + ISO27001_NAMES.includes(frameworkInstance.framework.name), + ); + }, [frameworksResponse]); + return ( + {hasISO27001Framework && } {Array.from(categories.entries()).map(([category, forms]) => (
diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx new file mode 100644 index 000000000..b4d2018e4 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx @@ -0,0 +1,49 @@ +import { + Badge, + Card, + CardDescription, + CardHeader, + CardTitle, + Text, +} from '@trycompai/design-system'; +import Link from 'next/link'; + +const STATEMENT_OF_APPLICABILITY_FORM = { + type: 'statement-of-applicability', + title: 'Statement of Applicability', + description: + "Auto-complete Statement of Applicability for ISO 27001. Generate answers based on your organization's policies and documentation.", +} as const; + +interface SOAOverviewCardProps { + organizationId: string; +} + +export function SOAOverviewCard({ + organizationId, +}: SOAOverviewCardProps) { + const form = STATEMENT_OF_APPLICABILITY_FORM; + + return ( +
+
+ + {form.title} + + 1 +
+
+ + + + {form.title} +
+ {form.description} +
+
+
+ +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/ApplicableSwatch.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/ApplicableSwatch.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/ApplicableSwatch.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/ApplicableSwatch.tsx diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.test.tsx similarity index 98% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.test.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.test.tsx index 5260abbd6..2c8756fc5 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.test.tsx @@ -18,7 +18,7 @@ vi.mock('@/hooks/use-permissions', () => ({ // Mock createSOADocument const mockCreateSOADocument = vi.fn(); -vi.mock('../../hooks/useSOADocument', () => ({ +vi.mock('../hooks/useSOADocument', () => ({ createSOADocument: (...args: any[]) => mockCreateSOADocument(...args), })); diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.tsx similarity index 93% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.tsx index 3bb979c5e..bac605459 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/CreateSOADocument.tsx @@ -7,7 +7,7 @@ import { Plus, Loader2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; -import { createSOADocument } from '../../hooks/useSOADocument'; +import { createSOADocument } from '../hooks/useSOADocument'; interface CreateSOADocumentProps { frameworkId: string; @@ -31,7 +31,7 @@ export function CreateSOADocument({ try { const result = await createSOADocument({ frameworkId, organizationId }); toast.success('SOA document created successfully'); - router.push(`/${organizationId}/questionnaire/soa/${result.id}`); + router.push(`/${organizationId}/documents/statement-of-applicability/${result.id}`); } catch (error) { toast.error( error instanceof Error ? error.message : 'An error occurred while creating the SOA document', diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx similarity index 99% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx index 7a081d629..aa2dc5d14 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx @@ -20,7 +20,7 @@ import { } from '@trycompai/ui/dialog'; import { X, Loader2, Edit2 } from 'lucide-react'; import { toast } from 'sonner'; -import { useSOADocument } from '../../hooks/useSOADocument'; +import { useSOADocument } from '../hooks/useSOADocument'; import { ApplicableReadOnlyDisplay, ApplicableSwatchRow } from './ApplicableSwatch'; import type { SOAFieldSavePayload } from './soa-field-types'; diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOADocumentInfo.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOADocumentInfo.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTable.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx similarity index 99% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTable.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx index fbfd9f0fd..0c10696b5 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx @@ -4,7 +4,7 @@ import { Card } from '@trycompai/ui'; import { useState, useMemo, useEffect } from 'react'; import { toast } from 'sonner'; import { useSOAAutoFill } from '../hooks/useSOAAutoFill'; -import { useSOADocument } from '../../hooks/useSOADocument'; +import { useSOADocument } from '../hooks/useSOADocument'; import type { Member, User } from '@db'; import { SOADocumentInfo } from './SOADocumentInfo'; import { SOAPendingApprovalAlert } from './SOAPendingApprovalAlert'; diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTabs.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx similarity index 99% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTabs.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx index 2cdf8814c..bb9071a83 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTabs.tsx @@ -5,7 +5,7 @@ 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 } from '../hooks/useSOADocument'; import type { FrameworkWithSOAData } from '../types'; interface SOAFrameworkTabsProps { diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAMobileRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAMobileRow.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAPendingApprovalAlert.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAPendingApprovalAlert.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAPendingApprovalAlert.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAPendingApprovalAlert.tsx diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOATable.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATable.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOATable.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATable.tsx diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOATableRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOATableRow.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx new file mode 100644 index 000000000..6f6161958 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx @@ -0,0 +1,86 @@ +import { PageHeader, Text } from '@trycompai/design-system'; +import { SOAFrameworkTable } from './SOAFrameworkTable'; + +type SOAFrameworkTableProps = Parameters[0]; + +export interface SOAData { + framework: SOAFrameworkTableProps['framework']; + configuration: SOAFrameworkTableProps['configuration']; + document: SOAFrameworkTableProps['document']; + isFullyRemote: boolean; + canApprove: boolean; + approver: SOAFrameworkTableProps['approver']; + isPendingApproval: boolean; + canCurrentUserApprove: boolean; + currentMemberId: string | null; + ownerAdminMembers: SOAFrameworkTableProps['ownerAdminMembers']; +} + +interface StatementOfApplicabilitySectionProps { + organizationId: string; + soaData?: SOAData | null; + soaError?: string | null; +} + +function SectionHeader() { + return ( + <> + +
+ + Auto-complete Statement of Applicability for ISO 27001. Generate answers based on your + organization's policies and documentation. + +
+ + ); +} + +export function StatementOfApplicabilitySection({ + organizationId, + soaData, + soaError, +}: StatementOfApplicabilitySectionProps) { + if (soaError) { + return ( +
+ +
+ {soaError} +
+
+ ); + } + + if (soaData) { + return ( +
+ + [0]['approver']} + isPendingApproval={soaData.isPendingApproval} + canCurrentUserApprove={soaData.canCurrentUserApprove} + currentMemberId={soaData.currentMemberId} + ownerAdminMembers={ + soaData.ownerAdminMembers as Parameters[0]['ownerAdminMembers'] + } + /> +
+ ); + } + + return ( +
+ +
+
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SubmitApprovalDialog.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SubmitApprovalDialog.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SubmitApprovalDialog.tsx rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SubmitApprovalDialog.tsx diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/index.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/index.ts new file mode 100644 index 000000000..56e5d52b1 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/index.ts @@ -0,0 +1 @@ +export { StatementOfApplicabilitySection, type SOAData } from './StatementOfApplicabilitySection'; diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/soa-field-types.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-field-types.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/soa-field-types.ts rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-field-types.ts diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/hooks/useSOAAutoFill.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/hooks/useSOAAutoFill.ts rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useSOADocument.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useSOADocument.ts rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts 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 new file mode 100644 index 000000000..c098e0034 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/page.tsx @@ -0,0 +1,181 @@ +import { getFeatureFlags } from '@/app/posthog'; +import { serverApi } from '@/lib/api-server'; +import { auth } from '@/utils/auth'; +import { Breadcrumb, PageLayout } from '@trycompai/design-system'; +import { headers } from 'next/headers'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { StatementOfApplicabilitySection, type SOAData } from './components'; + +const ISO27001_NAMES = ['ISO 27001', 'iso27001', 'ISO27001']; + +interface FrameworkApiResponse { + data: Array<{ + id: string; + frameworkId: string; + framework: { + id: string; + name: string; + description: string | null; + visible: boolean; + }; + }>; +} + +interface PeopleApiResponse { + data: Array<{ + id: string; + role: string; + userId: string; + deactivated: boolean; + user: { + id: string; + name: string | null; + email: string; + image: string | null; + }; + }>; +} + +interface ContextApiResponse { + data: Array<{ + id: string; + question: string; + answer: string | null; + tags: string[]; + createdAt: string; + updatedAt: string; + }>; +} + +export default async function StatementOfApplicabilityPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = await params; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id || !session?.session?.activeOrganizationId) { + return notFound(); + } + + const flags = await getFeatureFlags(session.user.id); + const isFeatureEnabled = flags['ai-vendor-questionnaire'] === true; + + if (!isFeatureEnabled) { + return notFound(); + } + + const organizationId = session.session.activeOrganizationId; + + const [frameworksResult, peopleResult, contextResult] = await Promise.all([ + serverApi.get('/v1/frameworks'), + serverApi.get('/v1/people'), + serverApi.get('/v1/context'), + ]); + + const frameworks = frameworksResult.data?.data ?? []; + const isoFrameworkInstance = frameworks.find( + (fi) => fi.framework?.name && ISO27001_NAMES.includes(fi.framework.name), + ); + + const people = peopleResult.data?.data ?? []; + const contextEntries = contextResult.data?.data ?? []; + + let soaData: SOAData | null = null; + let soaError: string | null = null; + + if (isoFrameworkInstance) { + try { + const { frameworkId, framework } = isoFrameworkInstance; + + const setupResult = await serverApi.post<{ + success: boolean; + error?: string; + configuration: Record | null; + document: Record | null; + }>('/v1/soa/ensure-setup', { frameworkId, organizationId }); + + const configuration = setupResult.data?.configuration; + const document = setupResult.data?.document; + + if (configuration && document) { + let approver = null; + const approverId = document.approverId as string | undefined; + if (approverId) { + approver = people.find((p) => p.id === approverId) ?? null; + } + + const currentMember = + people.find((p) => p.userId === session.user.id && !p.deactivated) ?? null; + + const canApprove = currentMember + ? currentMember.role.includes('owner') || currentMember.role.includes('admin') + : false; + + const isPendingApproval = document.status === 'needs_review'; + const canCurrentUserApprove = isPendingApproval && approverId === currentMember?.id; + + const ownerAdminMembers = people + .filter( + (p) => + !p.deactivated && (p.role.includes('owner') || p.role.includes('admin')), + ) + .sort((a, b) => (a.user?.name ?? '').localeCompare(b.user?.name ?? '')); + + let isFullyRemote = false; + const teamWorkContext = contextEntries.find((c) => + c.question?.toLowerCase().includes('how does your team work'), + ); + if (teamWorkContext?.answer) { + const answerLower = teamWorkContext.answer.toLowerCase(); + isFullyRemote = + answerLower.includes('fully remote') || answerLower.includes('fully-remote'); + } + + soaData = { + framework, + configuration, + document, + isFullyRemote, + canApprove, + approver: approver ? { ...approver, user: approver.user } : null, + isPendingApproval, + canCurrentUserApprove, + currentMemberId: currentMember?.id || null, + ownerAdminMembers, + } as SOAData; + } + } catch (error) { + console.error('Failed to setup SOA:', error); + soaError = 'Failed to setup SOA. Please try again later.'; + } + } else { + soaError = + 'ISO 27001 framework not found. Please add ISO 27001 framework to your organization to get started.'; + } + + return ( + + }, + }, + { label: 'Statement of Applicability', isCurrent: true }, + ]} + /> + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/types.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/types.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/questionnaire/soa/types.ts rename to apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/types.ts diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/components/QuestionnaireTabs.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/components/QuestionnaireTabs.tsx index 48af923c8..189512d6b 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/components/QuestionnaireTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/components/QuestionnaireTabs.tsx @@ -8,14 +8,12 @@ import { TabsContent, TabsList, TabsTrigger, - Text, } from '@trycompai/design-system'; import { AdditionalDocumentsSection } from '../knowledge-base/additional-documents/components'; import { KnowledgeBaseHeader } from '../knowledge-base/components/KnowledgeBaseHeader'; import { ContextSection } from '../knowledge-base/context/components'; import { ManualAnswersSection } from '../knowledge-base/manual-answers/components'; import { PublishedPoliciesSection } from '../knowledge-base/published-policies/components'; -import { SOAFrameworkTable } from '../soa/components/SOAFrameworkTable'; import { QuestionnaireOverview } from '../start_page/components'; import type { ContextEntry, @@ -25,31 +23,11 @@ import type { QuestionnaireListItem, } from './types'; -// Use type inference from SOAFrameworkTable props -type SOAFrameworkTableProps = Parameters[0]; - -interface SOAData { - framework: SOAFrameworkTableProps['framework']; - configuration: SOAFrameworkTableProps['configuration']; - document: SOAFrameworkTableProps['document']; - isFullyRemote: boolean; - canApprove: boolean; - approver: SOAFrameworkTableProps['approver']; - isPendingApproval: boolean; - canCurrentUserApprove: boolean; - currentMemberId: string | null; - ownerAdminMembers: SOAFrameworkTableProps['ownerAdminMembers']; -} - interface QuestionnaireTabsProps { organizationId: string; // Questionnaires tab questionnaires: QuestionnaireListItem[]; hasPublishedPolicies: boolean; - // SOA tab (conditional) - showSOATab: boolean; - soaData?: SOAData | null; - soaError?: string | null; // Knowledge Base tab policies: PublishedPolicy[]; contextEntries: ContextEntry[]; @@ -61,9 +39,6 @@ export function QuestionnaireTabs({ organizationId, questionnaires, hasPublishedPolicies, - showSOATab, - soaData, - soaError, policies, contextEntries, manualAnswers, @@ -116,7 +91,6 @@ export function QuestionnaireTabs({ tabs={ Security Questionnaire - {showSOATab && Statement of Applicability} Knowledge Base } @@ -128,72 +102,6 @@ export function QuestionnaireTabs({ - {/* SOA Tab (conditional) */} - {showSOATab && ( - - {soaError ? ( -
-
-

- Statement of Applicability -

- - Auto-complete Statement of Applicability for ISO 27001. Generate answers based - on your organization's policies and documentation. - -
-
- {soaError} -
-
- ) : soaData ? ( -
-
-

- Statement of Applicability -

- - Auto-complete Statement of Applicability for ISO 27001. Generate answers based - on your organization's policies and documentation. - -
- [0]['approver']} - isPendingApproval={soaData.isPendingApproval} - canCurrentUserApprove={soaData.canCurrentUserApprove} - currentMemberId={soaData.currentMemberId} - ownerAdminMembers={ - soaData.ownerAdminMembers as Parameters< - typeof SOAFrameworkTable - >[0]['ownerAdminMembers'] - } - /> -
- ) : ( -
-
-

- Statement of Applicability -

- - Auto-complete Statement of Applicability for ISO 27001. Generate answers based - on your organization's policies and documentation. - -
-
-
-
-
- )} - - )} - {/* Knowledge Base Tab */} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/page.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/page.tsx index 5fd0b08b2..59c00c84d 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/page.tsx @@ -5,8 +5,6 @@ import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; import { QuestionnaireTabs } from './components/QuestionnaireTabs'; -const ISO27001_NAMES = ['ISO 27001', 'iso27001', 'ISO27001']; - interface PolicyApiResponse { data: Array<{ id: string; @@ -40,34 +38,6 @@ interface QuestionnaireApiResponse { }>; } -interface FrameworkApiResponse { - data: Array<{ - id: string; - frameworkId: string; - framework: { - id: string; - name: string; - description: string | null; - visible: boolean; - }; - }>; -} - -interface PeopleApiResponse { - data: Array<{ - id: string; - role: string; - userId: string; - deactivated: boolean; - user: { - id: string; - name: string | null; - email: string; - image: string | null; - }; - }>; -} - interface ContextApiResponse { data: Array<{ id: string; @@ -101,13 +71,7 @@ interface KBDocumentApiResponse { updatedAt: string; } -export default async function SecurityQuestionnairePage({ - params, -}: { - params: Promise<{ orgId: string }>; -}) { - const { orgId } = await params; - +export default async function SecurityQuestionnairePage() { const session = await auth.api.getSession({ headers: await headers(), }); @@ -129,16 +93,12 @@ export default async function SecurityQuestionnairePage({ const [ policiesResult, questionnairesResult, - frameworksResult, - peopleResult, contextResult, manualAnswersResult, kbDocumentsResult, ] = await Promise.all([ serverApi.get('/v1/policies'), serverApi.get('/v1/questionnaire'), - serverApi.get('/v1/frameworks'), - serverApi.get('/v1/people'), serverApi.get('/v1/context'), serverApi.get('/v1/knowledge-base/manual-answers'), serverApi.get('/v1/knowledge-base/documents'), @@ -154,18 +114,6 @@ export default async function SecurityQuestionnairePage({ // Questionnaires list const questionnaires = questionnairesResult.data?.data ?? []; - // Check ISO 27001 framework - const frameworks = frameworksResult.data?.data ?? []; - const isoFrameworkInstance = frameworks.find((fi) => { - return fi.framework?.name && ISO27001_NAMES.includes(fi.framework.name); - }); - - const hasISO27001 = !!isoFrameworkInstance; - const showSOATab = hasISO27001; - - // People data - const people = peopleResult.data?.data ?? []; - // Context data const contextEntries = contextResult.data?.data ?? []; @@ -177,101 +125,11 @@ export default async function SecurityQuestionnairePage({ ? kbDocumentsResult.data : []; - // Build SOA data if needed - let soaData = null; - let soaError: string | null = null; - - if (showSOATab && isoFrameworkInstance) { - try { - const { frameworkId, framework } = isoFrameworkInstance; - - const setupResult = await serverApi.post<{ - success: boolean; - error?: string; - configuration: Record | null; - document: Record | null; - }>('/v1/soa/ensure-setup', { frameworkId, organizationId }); - - const configuration = setupResult.data?.configuration; - const document = setupResult.data?.document; - - if (configuration && document) { - // Find approver from people list - let approver = null; - const approverId = document.approverId as string | undefined; - if (approverId) { - approver = - people.find((p) => p.id === approverId) ?? null; - } - - // Find current member - const currentMember = - people.find( - (p) => p.userId === session.user.id && !p.deactivated, - ) ?? null; - - const canApprove = currentMember - ? currentMember.role.includes('owner') || - currentMember.role.includes('admin') - : false; - - const isPendingApproval = document.status === 'needs_review'; - const canCurrentUserApprove = - isPendingApproval && approverId === currentMember?.id; - - // Filter owner/admin members - const ownerAdminMembers = people - .filter( - (p) => - !p.deactivated && - (p.role.includes('owner') || p.role.includes('admin')), - ) - .sort((a, b) => - (a.user?.name ?? '').localeCompare(b.user?.name ?? ''), - ); - - // Check if fully remote from context - let isFullyRemote = false; - const teamWorkContext = contextEntries.find((c) => - c.question?.toLowerCase().includes('how does your team work'), - ); - if (teamWorkContext?.answer) { - const answerLower = teamWorkContext.answer.toLowerCase(); - isFullyRemote = - answerLower.includes('fully remote') || - answerLower.includes('fully-remote'); - } - - soaData = { - framework, - configuration, - document, - isFullyRemote, - canApprove, - approver: approver ? { ...approver, user: approver.user } : null, - isPendingApproval, - canCurrentUserApprove, - currentMemberId: currentMember?.id || null, - ownerAdminMembers, - }; - } - } catch (error) { - console.error('Failed to setup SOA:', error); - soaError = 'Failed to setup SOA. Please try again later.'; - } - } else if (showSOATab && !isoFrameworkInstance) { - soaError = - 'ISO 27001 framework not found. Please add ISO 27001 framework to your organization to get started.'; - } - return ( [0]['soaData']} - soaError={soaError} policies={publishedPolicies} contextEntries={contextEntries} manualAnswers={manualAnswers} diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/page.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/page.tsx index f16119300..97478811c 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/page.tsx @@ -4,8 +4,9 @@ interface SOAPageProps { params: Promise<{ orgId: string }>; } -// Redirect to main questionnaire page - SOA is now a tab +// Redirect to the Statement of Applicability page under Documents. +// SOA was previously a tab on the Questionnaires page; it now lives under Documents. export default async function SOAPage({ params }: SOAPageProps) { const { orgId } = await params; - redirect(`/${orgId}/questionnaire`); + redirect(`/${orgId}/documents/statement-of-applicability`); } From d8a9a8d88b58c9afc41b7cd13323ddf080ff5c61 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 13:27:44 -0400 Subject: [PATCH 02/30] fix(app): update approval status after approving of 'Statement of Applicability' --- .../components/SOAFrameworkTable.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx index 0c10696b5..02dfae7b3 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx @@ -105,17 +105,17 @@ export function SOAFrameworkTable({ ); }); - // Update answersMap when document changes + // Update answersMap when the live document changes useEffect(() => { setAnswersMap( new Map( - (document?.answers || []).map((answer: { questionId: string; answer: string | null; answerVersion: number }) => [ + (resolvedDocument?.answers || []).map((answer: { questionId: string; answer: string | null; answerVersion: number }) => [ answer.questionId, { answer: answer.answer, answerVersion: answer.answerVersion }, ]) ) ); - }, [document?.answers]); + }, [resolvedDocument?.answers]); const handleAnswerUpdate = (questionId: string, payload: SOAFieldSavePayload) => { setAnswersMap((prev) => { @@ -190,10 +190,8 @@ export function SOAFrameworkTable({ ); } - // The document comes from the Prisma SOADocument type which has all necessary fields. - // We cast to the SOADocumentInfo's expected type for the info panel. - const docForInfo = document as unknown as SOADocumentInfoDocument; - const approverId = (document as Record).approverId as string | null | undefined; + // Use the resolved SWR document so approval status updates instantly without page refresh. + const docForInfo = resolvedDocument as unknown as SOADocumentInfoDocument; const handleAutoFill = async () => { if (!document) return; @@ -278,7 +276,7 @@ export function SOAFrameworkTable({ isFullyRemote={isFullyRemote} isExpanded={isExpanded} onToggleExpand={() => setIsExpanded(!isExpanded)} - documentId={document.id} + documentId={resolvedDocument?.id ?? document.id} isPendingApproval={derivedIsPendingApproval} organizationId={organizationId} onAnswerUpdate={handleAnswerUpdate} From 4d6e85486d9b9ef5339397e6a9d74ebcdc973df1 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 14:18:56 -0400 Subject: [PATCH 03/30] fix(app): show approval status on Statement of Applicability card in documents --- .../components/CompanyOverviewCards.tsx | 13 ++- .../documents/components/SOAOverviewCard.tsx | 89 +++++++++++++++++++ 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx index f0fce75b1..2cb3ec7bf 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx @@ -165,18 +165,25 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin return map; }, [visibleForms]); - const hasISO27001Framework = useMemo(() => { + const iso27001Framework = useMemo(() => { const frameworks = frameworksResponse?.data ?? []; - return frameworks.some( + return frameworks.find( (frameworkInstance) => !!frameworkInstance.framework?.name && ISO27001_NAMES.includes(frameworkInstance.framework.name), ); }, [frameworksResponse]); + const hasISO27001Framework = !!iso27001Framework; + const iso27001FrameworkId = iso27001Framework?.frameworkId ?? null; return ( - {hasISO27001Framework && } + {hasISO27001Framework && iso27001FrameworkId && ( + + )} {Array.from(categories.entries()).map(([category, forms]) => (
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 b4d2018e4..a9d2666a4 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx @@ -1,12 +1,16 @@ import { Badge, Card, + CardContent, CardDescription, CardHeader, CardTitle, Text, } from '@trycompai/design-system'; +import { api } from '@/lib/api-client'; import Link from 'next/link'; +import { useMemo } from 'react'; +import useSWR from 'swr'; const STATEMENT_OF_APPLICABILITY_FORM = { type: 'statement-of-applicability', @@ -17,12 +21,94 @@ const STATEMENT_OF_APPLICABILITY_FORM = { interface SOAOverviewCardProps { organizationId: string; + iso27001FrameworkId: string; +} + +type SOASetupResponse = { + success: boolean; + configuration: Record | null; + document: { + status?: string | null; + approvedAt?: string | Date | null; + approverId?: string | null; + declinedAt?: string | Date | null; + } | null; +}; + +type SOAApprovalStatus = 'Approved' | 'Declined' | 'Pending' | 'Not approved'; + +function SOAApprovalStatusBadge({ status }: { status: SOAApprovalStatus }) { + const statusConfig: Record< + SOAApprovalStatus, + { label: SOAApprovalStatus; className: string } + > = { + Approved: { + label: 'Approved', + className: + 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400', + }, + Pending: { + label: 'Pending', + className: + 'bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-400', + }, + Declined: { + label: 'Declined', + className: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400', + }, + 'Not approved': { + label: 'Not approved', + className: + 'bg-slate-100 text-slate-800 dark:bg-slate-950/30 dark:text-slate-400', + }, + }; + + const { label, className } = statusConfig[status]; + return ( + + {label} + + ); } export function SOAOverviewCard({ organizationId, + iso27001FrameworkId, }: SOAOverviewCardProps) { const form = STATEMENT_OF_APPLICABILITY_FORM; + const { data: soaSetupResponse } = useSWR( + ['/v1/soa/ensure-setup', organizationId, iso27001FrameworkId], + async ([endpoint, orgId, frameworkId]: readonly [string, string, string]) => { + const response = await api.post(endpoint, { + organizationId: orgId, + frameworkId, + }); + if (response.error || !response.data) { + throw new Error(response.error ?? 'Failed to load SOA status'); + } + return response.data; + }, + { + revalidateOnFocus: true, + }, + ); + + const document = soaSetupResponse?.document; + const approvalStatus = useMemo(() => { + if (!document) return 'Not approved'; + if (document.approvedAt) return 'Approved'; + if (document.declinedAt) return 'Declined'; + if ( + document.status === 'needs_review' || + document.status === 'pending_approval' || + !!document.approverId + ) { + return 'Pending'; + } + return 'Not approved'; + }, [document]); return (
@@ -41,6 +127,9 @@ export function SOAOverviewCard({ {form.description}
+ + +
From 10c67f7bf49169082240cf7c415beb9d23068d4b Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 15:43:26 -0400 Subject: [PATCH 04/30] fix(api): include soa to documents score --- .../frameworks/frameworks-scores.helper.ts | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/apps/api/src/frameworks/frameworks-scores.helper.ts b/apps/api/src/frameworks/frameworks-scores.helper.ts index b62974278..456f8c5f2 100644 --- a/apps/api/src/frameworks/frameworks-scores.helper.ts +++ b/apps/api/src/frameworks/frameworks-scores.helper.ts @@ -5,6 +5,7 @@ import { toExternalEvidenceFormType, } from '@trycompai/company'; import { db } from '@db'; +import { ISO27001_FRAMEWORK_NAMES } from '../soa/utils/constants'; import { filterComplianceMembers } from '../utils/compliance-filters'; const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000; @@ -185,11 +186,24 @@ export async function getOverviewScores(organizationId: string) { } async function computeDocumentsScore(organizationId: string) { - const groupedStatuses = await db.evidenceSubmission.groupBy({ - by: ['formType'], - where: { organizationId }, - _max: { submittedAt: true }, - }); + const [groupedStatuses, isoFrameworkInstances] = await Promise.all([ + db.evidenceSubmission.groupBy({ + by: ['formType'], + where: { organizationId }, + _max: { submittedAt: true }, + }), + db.frameworkInstance.findMany({ + where: { + organizationId, + framework: { + name: { + in: ISO27001_FRAMEWORK_NAMES, + }, + }, + }, + select: { frameworkId: true }, + }), + ]); const statuses: Record = {}; for (const form of evidenceFormDefinitionList) { @@ -204,8 +218,7 @@ async function computeDocumentsScore(organizationId: string) { const includedForms = evidenceFormDefinitionList.filter( (f) => !f.hidden && !f.optional, ); - const totalDocuments = includedForms.length; - const outstandingDocuments = includedForms.reduce((count, form) => { + const nonSOAOutstandingDocuments = includedForms.reduce((count, form) => { if (form.type === 'meeting') { const allMeetingsOutstanding = meetingSubTypeValues.every((subType) => { const lastSubmitted = statuses[subType]?.lastSubmittedAt; @@ -223,6 +236,34 @@ async function computeDocumentsScore(organizationId: string) { return isOutstanding ? count + 1 : count; }, 0); + const isoFrameworkIds = isoFrameworkInstances + .map((instance) => instance.frameworkId) + .filter((id): id is string => !!id); + const hasSOADocumentRequirement = isoFrameworkIds.length > 0; + + let soaCompleted = false; + if (hasSOADocumentRequirement) { + const latestSOADocument = await db.sOADocument.findFirst({ + where: { + organizationId, + isLatest: true, + frameworkId: { in: isoFrameworkIds }, + }, + select: { + approvedAt: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + soaCompleted = !!latestSOADocument?.approvedAt; + } + + const soaTotalDocuments = hasSOADocumentRequirement ? 1 : 0; + const soaOutstandingDocuments = hasSOADocumentRequirement && !soaCompleted ? 1 : 0; + const totalDocuments = includedForms.length + soaTotalDocuments; + const outstandingDocuments = nonSOAOutstandingDocuments + soaOutstandingDocuments; + return { totalDocuments, completedDocuments: totalDocuments - outstandingDocuments, From 7364c58c7cdc9801061a4df6f2ca95d6a346254d Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 16:50:43 -0400 Subject: [PATCH 05/30] fix(api): create endpoint to export soa into pdf --- .../src/soa/dto/export-soa-document.dto.ts | 13 ++ apps/api/src/soa/soa.controller.ts | 26 ++++ apps/api/src/soa/soa.service.ts | 60 +++++++ apps/api/src/soa/utils/export-generator.ts | 146 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 apps/api/src/soa/dto/export-soa-document.dto.ts create mode 100644 apps/api/src/soa/utils/export-generator.ts diff --git a/apps/api/src/soa/dto/export-soa-document.dto.ts b/apps/api/src/soa/dto/export-soa-document.dto.ts new file mode 100644 index 000000000..84c62cbad --- /dev/null +++ b/apps/api/src/soa/dto/export-soa-document.dto.ts @@ -0,0 +1,13 @@ +import { IsIn, IsString } from 'class-validator'; + +export class ExportSOADocumentDto { + @IsString() + documentId!: string; + + @IsString() + organizationId!: string; + + @IsIn(['pdf']) + format!: 'pdf'; +} + diff --git a/apps/api/src/soa/soa.controller.ts b/apps/api/src/soa/soa.controller.ts index 64d1bed7c..451c93476 100644 --- a/apps/api/src/soa/soa.controller.ts +++ b/apps/api/src/soa/soa.controller.ts @@ -25,6 +25,7 @@ import { EnsureSOASetupDto } from './dto/ensure-soa-setup.dto'; import { ApproveSOADocumentDto } from './dto/approve-soa-document.dto'; import { DeclineSOADocumentDto } from './dto/decline-soa-document.dto'; import { SubmitSOAForApprovalDto } from './dto/submit-soa-for-approval.dto'; +import { ExportSOADocumentDto } from './dto/export-soa-document.dto'; import { syncOrganizationEmbeddings } from '@/vector-store/lib'; import { OrganizationId } from '@/auth/auth-context.decorator'; import { AuthContext } from '@/auth/auth-context.decorator'; @@ -395,4 +396,29 @@ export class SOAController { ) { return this.soaService.submitForApproval(dto); } + + @Post('export') + @RequirePermission('audit', 'read') + @ApiOperation({ summary: 'Export a SOA document' }) + @ApiConsumes('application/json') + @ApiProduces('application/pdf') + @ApiOkResponse({ + description: 'Export SOA document to PDF', + }) + async exportDocument( + @Body() dto: ExportSOADocumentDto, + @Res({ passthrough: true }) res: Response, + @OrganizationId() organizationId: string, + ): Promise { + dto.organizationId = organizationId; + const result = await this.soaService.exportDocument(dto); + + res.setHeader('Content-Type', result.mimeType); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${result.filename}"`, + ); + + res.send(result.fileBuffer); + } } diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index aa085444a..ca5ac33c2 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -13,9 +13,14 @@ import { EnsureSOASetupDto } from './dto/ensure-soa-setup.dto'; import { ApproveSOADocumentDto } from './dto/approve-soa-document.dto'; import { DeclineSOADocumentDto } from './dto/decline-soa-document.dto'; import { SubmitSOAForApprovalDto } from './dto/submit-soa-for-approval.dto'; +import { ExportSOADocumentDto } from './dto/export-soa-document.dto'; import type { SimilarContentResult } from '@/vector-store/lib'; import { loadISOConfig } from './utils/transform-iso-config'; import { ISO27001_FRAMEWORK_NAMES } from './utils/constants'; +import { + generateSOAExportFile, + type SOAExportQuestion, +} from './utils/export-generator'; import { batchSearchSOAQuestions, generateSOAAnswerWithRAG, @@ -437,6 +442,61 @@ export class SOAService { return { success: true, data: updatedDocument }; } + async exportDocument(dto: ExportSOADocumentDto): Promise<{ + fileBuffer: Buffer; + mimeType: string; + filename: string; + }> { + const document = await db.sOADocument.findFirst({ + where: { + id: dto.documentId, + organizationId: dto.organizationId, + }, + include: { + configuration: true, + framework: { + select: { name: true }, + }, + answers: { + where: { isLatestAnswer: true }, + select: { + questionId: true, + answer: true, + }, + }, + }, + }); + + if (!document) { + throw new NotFoundException('SOA document not found'); + } + + const questions = + (document.configuration.questions as unknown as SOAQuestion[]) ?? []; + const answersByQuestionId = new Map( + document.answers.map((answer) => [answer.questionId, answer.answer]), + ); + + const exportQuestions: SOAExportQuestion[] = questions.map((question) => ({ + id: question.id, + text: question.text, + columnMapping: { + title: question.columnMapping?.title ?? null, + control_objective: question.columnMapping?.control_objective ?? null, + isApplicable: question.columnMapping?.isApplicable ?? null, + justification: question.columnMapping?.justification ?? null, + }, + answer: answersByQuestionId.get(question.id) ?? null, + })); + + return generateSOAExportFile( + exportQuestions, + document.framework.name || 'ISO 27001', + document.version, + dto.format, + ); + } + // Auto-fill related methods (delegating to utilities) async checkIfFullyRemote(organizationId: string): Promise { diff --git a/apps/api/src/soa/utils/export-generator.ts b/apps/api/src/soa/utils/export-generator.ts new file mode 100644 index 000000000..d75b45721 --- /dev/null +++ b/apps/api/src/soa/utils/export-generator.ts @@ -0,0 +1,146 @@ +import { jsPDF } from 'jspdf'; + +export type SOAExportFormat = 'pdf'; + +export interface SOAExportQuestion { + id: string; + text: string; + columnMapping: { + title: string | null; + control_objective: string | null; + isApplicable: boolean | null; + justification: string | null; + }; + answer: string | null; +} + +export interface SOAExportResult { + fileBuffer: Buffer; + mimeType: string; + filename: string; +} + +export function generateSOAExportFile( + questions: SOAExportQuestion[], + frameworkName: string, + version: number, + format: SOAExportFormat = 'pdf', +): SOAExportResult { + if (format !== 'pdf') { + throw new Error(`Unsupported SOA export format: ${format}`); + } + + return { + fileBuffer: generateSOAPDF(questions, frameworkName, version), + mimeType: 'application/pdf', + filename: `statement-of-applicability-${sanitizeFrameworkName(frameworkName)}-v${version}.pdf`, + }; +} + +function generateSOAPDF( + questions: SOAExportQuestion[], + frameworkName: string, + version: number, +): Buffer { + const pdf = new jsPDF(); + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const margin = 20; + const contentWidth = pageWidth - margin * 2; + const lineHeight = 7; + let y = margin; + + const ensureSpace = (requiredHeight: number) => { + if (y + requiredHeight > pageHeight - margin) { + pdf.addPage(); + y = margin; + } + }; + + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(16); + pdf.text('Statement of Applicability', margin, y); + y += lineHeight * 1.8; + + pdf.setFont('helvetica', 'normal'); + pdf.setFontSize(10); + pdf.text(`Framework: ${frameworkName}`, margin, y); + y += lineHeight; + pdf.text(`Version: v${version}`, margin, y); + y += lineHeight; + pdf.text(`Exported: ${new Date().toLocaleDateString()}`, margin, y); + y += lineHeight * 2; + + pdf.setFontSize(11); + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + const mapped = question.columnMapping ?? { + title: null, + control_objective: null, + isApplicable: null, + justification: null, + }; + + const isApplicableLabel = + mapped.isApplicable === true + ? 'Yes' + : mapped.isApplicable === false + ? 'No' + : 'N/A'; + + const justification = + typeof mapped.justification === 'string' && mapped.justification.trim() + ? mapped.justification + : question.answer || 'No justification provided'; + + const title = `${i + 1}. ${mapped.title || question.text || 'Untitled Control'}`; + const objective = mapped.control_objective + ? `Objective: ${mapped.control_objective}` + : null; + const applicability = `Applicable: ${isApplicableLabel}`; + const justificationText = `Justification: ${justification}`; + + const titleLines = pdf.splitTextToSize(title, contentWidth); + const objectiveLines = objective + ? pdf.splitTextToSize(objective, contentWidth) + : []; + const applicabilityLines = pdf.splitTextToSize(applicability, contentWidth); + const justificationLines = pdf.splitTextToSize( + justificationText, + contentWidth, + ); + const blockHeight = + (titleLines.length + + objectiveLines.length + + applicabilityLines.length + + justificationLines.length) * + lineHeight + + lineHeight * 1.5; + + ensureSpace(blockHeight); + + pdf.setFont('helvetica', 'bold'); + pdf.text(titleLines, margin, y); + y += titleLines.length * lineHeight; + + pdf.setFont('helvetica', 'normal'); + if (objectiveLines.length > 0) { + pdf.text(objectiveLines, margin, y); + y += objectiveLines.length * lineHeight; + } + pdf.text(applicabilityLines, margin, y); + y += applicabilityLines.length * lineHeight; + pdf.text(justificationLines, margin, y); + y += justificationLines.length * lineHeight + lineHeight * 0.5; + } + + return Buffer.from(pdf.output('arraybuffer')); +} + +function sanitizeFrameworkName(frameworkName: string): string { + return (frameworkName || 'soa') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +} + From e6731b2bed8b50b8e50b4fffdecf61eaafe9e9bd Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 16:54:39 -0400 Subject: [PATCH 06/30] fix(app): export Statement of Applicability as pdf --- .../StatementOfApplicabilitySection.tsx | 66 +++++++++++++++++-- .../hooks/useSOADocument.ts | 66 ++++++++++++++++++- 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx index 6f6161958..7c496e409 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/StatementOfApplicabilitySection.tsx @@ -1,4 +1,8 @@ -import { PageHeader, Text } from '@trycompai/design-system'; +'use client'; + +import { Button, PageHeader, Text } from '@trycompai/design-system'; +import { Download } from '@trycompai/design-system/icons'; +import { useSOADocument } from '../hooks/useSOADocument'; import { SOAFrameworkTable } from './SOAFrameworkTable'; type SOAFrameworkTableProps = Parameters[0]; @@ -22,10 +26,31 @@ interface StatementOfApplicabilitySectionProps { soaError?: string | null; } -function SectionHeader() { +function SectionHeader({ + onExport, + isExporting, + canExport, +}: { + onExport: () => void; + isExporting: boolean; + canExport: boolean; +}) { return ( <> - + } + onClick={onExport} + disabled={!canExport || isExporting} + > + {isExporting ? 'Exporting...' : 'Export PDF'} + + } + />
Auto-complete Statement of Applicability for ISO 27001. Generate answers based on your @@ -41,10 +66,27 @@ export function StatementOfApplicabilitySection({ soaData, soaError, }: StatementOfApplicabilitySectionProps) { + const soaDocumentId = + ((soaData?.document as { id?: string | null } | null | undefined)?.id ?? + null); + const { handleExport, isExporting } = useSOADocument({ + documentId: soaDocumentId, + organizationId, + fallbackData: + (soaData?.document as Parameters[0]['fallbackData']) ?? + null, + }); + if (soaError) { return (
- + { + void handleExport('pdf'); + }} + isExporting={isExporting} + canExport={false} + />
{soaError}
@@ -55,7 +97,13 @@ export function StatementOfApplicabilitySection({ if (soaData) { return (
- + { + void handleExport('pdf'); + }} + isExporting={isExporting} + canExport={!!soaDocumentId} + /> - + { + void handleExport('pdf'); + }} + isExporting={isExporting} + canExport={false} + />
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 790d5ecc6..f74d261fd 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 @@ -1,8 +1,10 @@ 'use client'; import useSWR from 'swr'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { api } from '@/lib/api-client'; +import { env } from '@/env.mjs'; +import { toast } from 'sonner'; interface SOADocumentData { id: string; @@ -28,6 +30,7 @@ function buildKey(documentId: string | null) { } export function useSOADocument({ documentId, organizationId, fallbackData }: UseSOADocumentOptions) { + const [isExporting, setIsExporting] = useState(false); const { data, error, isLoading, mutate } = useSWR( buildKey(documentId), null, @@ -123,15 +126,76 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use return true; }; + const handleExport = async (format: 'pdf' = 'pdf'): Promise => { + if (!documentId) { + toast.error('No SOA document to export'); + return; + } + + setIsExporting(true); + try { + const response = await fetch( + `${env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'}/v1/soa/export`, + { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + documentId, + organizationId, + format, + }), + }, + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || 'Failed to export SOA document'); + } + + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = `statement-of-applicability.${format}`; + if (contentDisposition) { + const match = contentDisposition.match(/filename="(.+)"/); + if (match) { + filename = match[1]; + } + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + toast.success(`Exported as ${filename}`); + } catch (error) { + console.error('SOA export error:', error); + toast.error( + error instanceof Error ? error.message : 'Failed to export SOA document', + ); + } finally { + setIsExporting(false); + } + }; + return { document: data ?? null, error, isLoading, + isExporting, mutate, saveAnswer, approve, decline, submitForApproval, + handleExport, }; } From 1d3f903915a5bc6932020065d768fa32d5e2ee66 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 21:49:26 -0400 Subject: [PATCH 07/30] fix(api): add metrics to SOA pdf document --- apps/api/src/soa/soa.service.ts | 26 ++++++++++++ apps/api/src/soa/utils/export-generator.ts | 49 +++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index ca5ac33c2..8749449a9 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -19,6 +19,7 @@ import { loadISOConfig } from './utils/transform-iso-config'; import { ISO27001_FRAMEWORK_NAMES } from './utils/constants'; import { generateSOAExportFile, + type SOAExportMetadata, type SOAExportQuestion, } from './utils/export-generator'; import { @@ -457,6 +458,16 @@ export class SOAService { framework: { select: { name: true }, }, + approver: { + select: { + user: { + select: { + name: true, + email: true, + }, + }, + }, + }, answers: { where: { isLatestAnswer: true }, select: { @@ -481,6 +492,7 @@ export class SOAService { id: question.id, text: question.text, columnMapping: { + closure: question.columnMapping?.closure ?? null, title: question.columnMapping?.title ?? null, control_objective: question.columnMapping?.control_objective ?? null, isApplicable: question.columnMapping?.isApplicable ?? null, @@ -489,10 +501,24 @@ export class SOAService { answer: answersByQuestionId.get(question.id) ?? null, })); + const exportMetadata: SOAExportMetadata = { + preparedBy: (document.preparedBy as string | null) ?? null, + answeredQuestions: document.answeredQuestions, + totalQuestions: document.totalQuestions, + approvedAt: document.approvedAt ?? null, + declinedAt: (document as { declinedAt?: Date | null }).declinedAt ?? null, + status: document.status, + approverName: + document.approver?.user?.name || + document.approver?.user?.email || + null, + }; + return generateSOAExportFile( exportQuestions, document.framework.name || 'ISO 27001', document.version, + exportMetadata, dto.format, ); } diff --git a/apps/api/src/soa/utils/export-generator.ts b/apps/api/src/soa/utils/export-generator.ts index d75b45721..f53f6c0be 100644 --- a/apps/api/src/soa/utils/export-generator.ts +++ b/apps/api/src/soa/utils/export-generator.ts @@ -6,6 +6,7 @@ export interface SOAExportQuestion { id: string; text: string; columnMapping: { + closure?: string | null; title: string | null; control_objective: string | null; isApplicable: boolean | null; @@ -14,6 +15,16 @@ export interface SOAExportQuestion { answer: string | null; } +export interface SOAExportMetadata { + preparedBy: string | null; + answeredQuestions: number; + totalQuestions: number; + approvedAt?: Date | string | null; + declinedAt?: Date | string | null; + status?: string | null; + approverName?: string | null; +} + export interface SOAExportResult { fileBuffer: Buffer; mimeType: string; @@ -24,6 +35,7 @@ export function generateSOAExportFile( questions: SOAExportQuestion[], frameworkName: string, version: number, + metadata: SOAExportMetadata, format: SOAExportFormat = 'pdf', ): SOAExportResult { if (format !== 'pdf') { @@ -31,7 +43,7 @@ export function generateSOAExportFile( } return { - fileBuffer: generateSOAPDF(questions, frameworkName, version), + fileBuffer: generateSOAPDF(questions, frameworkName, version, metadata), mimeType: 'application/pdf', filename: `statement-of-applicability-${sanitizeFrameworkName(frameworkName)}-v${version}.pdf`, }; @@ -41,6 +53,7 @@ function generateSOAPDF( questions: SOAExportQuestion[], frameworkName: string, version: number, + metadata: SOAExportMetadata, ): Buffer { const pdf = new jsPDF(); const pageWidth = pdf.internal.pageSize.getWidth(); @@ -68,6 +81,29 @@ function generateSOAPDF( y += lineHeight; pdf.text(`Version: v${version}`, margin, y); y += lineHeight; + const progressPercentage = + metadata.totalQuestions > 0 + ? Math.round((metadata.answeredQuestions / metadata.totalQuestions) * 100) + : 0; + pdf.text( + `Progress: ${metadata.answeredQuestions} / ${metadata.totalQuestions} (${progressPercentage}%)`, + margin, + y, + ); + y += lineHeight; + pdf.text(`Prepared by: ${metadata.preparedBy || 'Comp AI'}`, margin, y); + y += lineHeight; + const approvalStatusText = metadata.approvedAt + ? `Approved on ${new Date(metadata.approvedAt).toLocaleDateString()}` + : metadata.status === 'needs_review' && metadata.declinedAt + ? `Declined on ${new Date(metadata.declinedAt).toLocaleDateString()}` + : metadata.approverName + ? 'Pending approval' + : 'Not approved'; + pdf.text(`Approval status: ${approvalStatusText}`, margin, y); + y += lineHeight; + pdf.text(`Approved by: ${metadata.approverName || 'N/A'}`, margin, y); + y += lineHeight; pdf.text(`Exported: ${new Date().toLocaleDateString()}`, margin, y); y += lineHeight * 2; @@ -94,6 +130,9 @@ function generateSOAPDF( : question.answer || 'No justification provided'; const title = `${i + 1}. ${mapped.title || question.text || 'Untitled Control'}`; + const closure = mapped.closure + ? `Closure: ${mapped.closure}` + : null; const objective = mapped.control_objective ? `Objective: ${mapped.control_objective}` : null; @@ -101,6 +140,9 @@ function generateSOAPDF( const justificationText = `Justification: ${justification}`; const titleLines = pdf.splitTextToSize(title, contentWidth); + const closureLines = closure + ? pdf.splitTextToSize(closure, contentWidth) + : []; const objectiveLines = objective ? pdf.splitTextToSize(objective, contentWidth) : []; @@ -111,6 +153,7 @@ function generateSOAPDF( ); const blockHeight = (titleLines.length + + closureLines.length + objectiveLines.length + applicabilityLines.length + justificationLines.length) * @@ -124,6 +167,10 @@ function generateSOAPDF( y += titleLines.length * lineHeight; pdf.setFont('helvetica', 'normal'); + if (closureLines.length > 0) { + pdf.text(closureLines, margin, y); + y += closureLines.length * lineHeight; + } if (objectiveLines.length > 0) { pdf.text(objectiveLines, margin, y); y += objectiveLines.length * lineHeight; From fd5ba8b72596babf5d55c485296559fe2bc69a54 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 21:58:57 -0400 Subject: [PATCH 08/30] fix(app): add organizationId to frameworks SWR cache --- .../[orgId]/documents/components/CompanyOverviewCards.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx index 2cb3ec7bf..72f909980 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx @@ -121,8 +121,8 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin const { data: findingsResponse } = useOrganizationFindings(); const { data: frameworksResponse } = useSWR( - '/v1/frameworks', - async (endpoint: string) => { + ['/v1/frameworks', organizationId] as const, + async ([endpoint]: readonly [string, string]) => { const response = await api.get(endpoint); if (response.error || !response.data) { throw new Error(response.error ?? 'Failed to load frameworks'); From 49c16732b2748e15a93d2354f2cb172980a9914d Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 22:04:26 -0400 Subject: [PATCH 09/30] fix(app): remove use of hasISO27001Framework on CompanyOverviewCards --- .../[orgId]/documents/components/CompanyOverviewCards.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx index 72f909980..b617918b7 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyOverviewCards.tsx @@ -173,12 +173,11 @@ export function CompanyOverviewCards({ organizationId }: { organizationId: strin ISO27001_NAMES.includes(frameworkInstance.framework.name), ); }, [frameworksResponse]); - const hasISO27001Framework = !!iso27001Framework; const iso27001FrameworkId = iso27001Framework?.frameworkId ?? null; return ( - {hasISO27001Framework && iso27001FrameworkId && ( + {iso27001FrameworkId && ( Date: Thu, 23 Apr 2026 22:05:09 -0400 Subject: [PATCH 10/30] fix(app): remove use of ai-vendor-questionnaire FF for SOA page --- .../[orgId]/documents/statement-of-applicability/page.tsx | 8 -------- 1 file changed, 8 deletions(-) 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 c098e0034..002cdc4b8 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,4 +1,3 @@ -import { getFeatureFlags } from '@/app/posthog'; import { serverApi } from '@/lib/api-server'; import { auth } from '@/utils/auth'; import { Breadcrumb, PageLayout } from '@trycompai/design-system'; @@ -63,13 +62,6 @@ export default async function StatementOfApplicabilityPage({ return notFound(); } - const flags = await getFeatureFlags(session.user.id); - const isFeatureEnabled = flags['ai-vendor-questionnaire'] === true; - - if (!isFeatureEnabled) { - return notFound(); - } - const organizationId = session.session.activeOrganizationId; const [frameworksResult, peopleResult, contextResult] = await Promise.all([ From 589f28516c5c296e3f6f0b5844b5de02c77a5562 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 23:24:57 -0400 Subject: [PATCH 11/30] fix(api): fix pagination overflow for long question blocks in soa pdf --- apps/api/src/soa/utils/export-generator.ts | 47 +++++++++------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/apps/api/src/soa/utils/export-generator.ts b/apps/api/src/soa/utils/export-generator.ts index f53f6c0be..fdeacb22a 100644 --- a/apps/api/src/soa/utils/export-generator.ts +++ b/apps/api/src/soa/utils/export-generator.ts @@ -69,6 +69,18 @@ function generateSOAPDF( y = margin; } }; + const writeLines = ( + lines: string[], + fontStyle: 'normal' | 'bold' = 'normal', + ) => { + if (lines.length === 0) return; + pdf.setFont('helvetica', fontStyle); + for (const line of lines) { + ensureSpace(lineHeight); + pdf.text(line, margin, y); + y += lineHeight; + } + }; pdf.setFont('helvetica', 'bold'); pdf.setFontSize(16); @@ -151,34 +163,13 @@ function generateSOAPDF( justificationText, contentWidth, ); - const blockHeight = - (titleLines.length + - closureLines.length + - objectiveLines.length + - applicabilityLines.length + - justificationLines.length) * - lineHeight + - lineHeight * 1.5; - - ensureSpace(blockHeight); - - pdf.setFont('helvetica', 'bold'); - pdf.text(titleLines, margin, y); - y += titleLines.length * lineHeight; - - pdf.setFont('helvetica', 'normal'); - if (closureLines.length > 0) { - pdf.text(closureLines, margin, y); - y += closureLines.length * lineHeight; - } - if (objectiveLines.length > 0) { - pdf.text(objectiveLines, margin, y); - y += objectiveLines.length * lineHeight; - } - pdf.text(applicabilityLines, margin, y); - y += applicabilityLines.length * lineHeight; - pdf.text(justificationLines, margin, y); - y += justificationLines.length * lineHeight + lineHeight * 0.5; + writeLines(titleLines, 'bold'); + writeLines(closureLines); + writeLines(objectiveLines); + writeLines(applicabilityLines); + writeLines(justificationLines); + ensureSpace(lineHeight * 0.5); + y += lineHeight * 0.5; } return Buffer.from(pdf.output('arraybuffer')); From ada6b5e4515092087c58288fd6049ef0caac5b00 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 10:48:35 -0400 Subject: [PATCH 12/30] fix(api): correct SOA export classification for declined cases --- apps/api/src/soa/utils/export-generator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/soa/utils/export-generator.ts b/apps/api/src/soa/utils/export-generator.ts index fdeacb22a..02ae96088 100644 --- a/apps/api/src/soa/utils/export-generator.ts +++ b/apps/api/src/soa/utils/export-generator.ts @@ -107,7 +107,7 @@ function generateSOAPDF( y += lineHeight; const approvalStatusText = metadata.approvedAt ? `Approved on ${new Date(metadata.approvedAt).toLocaleDateString()}` - : metadata.status === 'needs_review' && metadata.declinedAt + : metadata.status === 'declined' && metadata.declinedAt ? `Declined on ${new Date(metadata.declinedAt).toLocaleDateString()}` : metadata.approverName ? 'Pending approval' From 760c3048e171cee420201ad537217aed7e24411c Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 10:51:26 -0400 Subject: [PATCH 13/30] fix(app): use exact role checks instead of substring matching --- .../documents/statement-of-applicability/page.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 002cdc4b8..0e003a34e 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,4 +1,5 @@ import { serverApi } from '@/lib/api-server'; +import { parseRolesString } from '@/lib/permissions'; import { auth } from '@/utils/auth'; import { Breadcrumb, PageLayout } from '@trycompai/design-system'; import { headers } from 'next/headers'; @@ -105,9 +106,10 @@ export default async function StatementOfApplicabilityPage({ const currentMember = people.find((p) => p.userId === session.user.id && !p.deactivated) ?? null; - const canApprove = currentMember - ? currentMember.role.includes('owner') || currentMember.role.includes('admin') - : false; + const currentMemberRoles = parseRolesString(currentMember?.role); + const canApprove = currentMemberRoles.some( + (role) => role === 'owner' || role === 'admin', + ); const isPendingApproval = document.status === 'needs_review'; const canCurrentUserApprove = isPendingApproval && approverId === currentMember?.id; @@ -115,7 +117,10 @@ export default async function StatementOfApplicabilityPage({ const ownerAdminMembers = people .filter( (p) => - !p.deactivated && (p.role.includes('owner') || p.role.includes('admin')), + !p.deactivated && + parseRolesString(p.role).some( + (role) => role === 'owner' || role === 'admin', + ), ) .sort((a, b) => (a.user?.name ?? '').localeCompare(b.user?.name ?? '')); From 24f97917b9be58f1b5d218415012b654507dd913 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 10:54:36 -0400 Subject: [PATCH 14/30] fix(api): add coverage for SOA export endpoint --- apps/api/src/soa/soa.controller.spec.ts | 47 ++++++++++ apps/api/src/soa/soa.service.spec.ts | 111 ++++++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/apps/api/src/soa/soa.controller.spec.ts b/apps/api/src/soa/soa.controller.spec.ts index f2dd111ce..83fa7db00 100644 --- a/apps/api/src/soa/soa.controller.spec.ts +++ b/apps/api/src/soa/soa.controller.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; +import type { Response } from 'express'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; import type { AuthContext } from '../auth/types'; @@ -9,6 +10,15 @@ import { SOAService } from './soa.service'; jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, })); +jest.mock('../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class MockHybridAuthGuard {}, +})); +jest.mock('../auth/permission.guard', () => ({ + PermissionGuard: class MockPermissionGuard {}, +})); +jest.mock('./soa.service', () => ({ + SOAService: class MockSOAService {}, +})); jest.mock('@trycompai/auth', () => ({ statement: {}, @@ -37,6 +47,7 @@ describe('SOAController', () => { approveDocument: jest.fn(), declineDocument: jest.fn(), submitForApproval: jest.fn(), + exportDocument: jest.fn(), }; const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; @@ -210,4 +221,40 @@ describe('SOAController', () => { expect(result).toEqual(submitted); }); }); + + describe('exportDocument', () => { + const dto = { + documentId: 'doc_1', + format: 'pdf', + }; + + it('should call soaService.exportDocument, set headers, and send file buffer', async () => { + const fileBuffer = Buffer.from('pdf-data'); + mockSOAService.exportDocument.mockResolvedValue({ + fileBuffer, + mimeType: 'application/pdf', + filename: 'soa-export.pdf', + }); + const res = { + setHeader: jest.fn(), + send: jest.fn(), + } as unknown as Response; + + await controller.exportDocument(dto as never, res, 'org_123'); + + expect(soaService.exportDocument).toHaveBeenCalledWith({ + ...dto, + organizationId: 'org_123', + }); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'application/pdf', + ); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + 'attachment; filename="soa-export.pdf"', + ); + expect(res.send).toHaveBeenCalledWith(fileBuffer); + }); + }); }); diff --git a/apps/api/src/soa/soa.service.spec.ts b/apps/api/src/soa/soa.service.spec.ts index 8c87ad045..c9e5e7afb 100644 --- a/apps/api/src/soa/soa.service.spec.ts +++ b/apps/api/src/soa/soa.service.spec.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { db } from '@db'; import { SOAService } from './soa.service'; +import { generateSOAExportFile } from './utils/export-generator'; jest.mock('@db', () => ({ db: { @@ -51,8 +52,12 @@ jest.mock('./utils/soa-storage', () => ({ updateDocumentAnsweredCount: jest.fn(), checkIfFullyRemote: jest.fn(), })); +jest.mock('./utils/export-generator', () => ({ + generateSOAExportFile: jest.fn(), +})); const mockDb = jest.mocked(db); +const mockGenerateSOAExportFile = jest.mocked(generateSOAExportFile); describe('SOAService', () => { let service: SOAService; @@ -298,4 +303,110 @@ describe('SOAService', () => { expect(result.success).toBe(true); }); }); + + describe('exportDocument', () => { + const dto = { + documentId: 'doc-1', + organizationId: 'org-1', + format: 'pdf' as const, + }; + + it('throws NotFoundException when document not found', async () => { + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null); + + await expect(service.exportDocument(dto)).rejects.toThrow(NotFoundException); + }); + + it('maps document data and delegates to generateSOAExportFile', async () => { + const generated = { + fileBuffer: Buffer.from('pdf'), + mimeType: 'application/pdf', + filename: 'statement-of-applicability-iso-27001-v2.pdf', + }; + mockGenerateSOAExportFile.mockReturnValue(generated); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc-1', + organizationId: 'org-1', + preparedBy: 'Compliance Lead', + answeredQuestions: 3, + totalQuestions: 5, + approvedAt: null, + declinedAt: new Date('2026-04-20T00:00:00.000Z'), + status: 'declined', + version: 2, + framework: { name: 'ISO 27001' }, + configuration: { + questions: [ + { + id: 'q-1', + text: 'Control 1', + columnMapping: { + closure: 'A.5', + title: 'Control title', + control_objective: 'Objective', + isApplicable: true, + justification: 'Mapped justification', + }, + }, + { + id: 'q-2', + text: 'Control 2', + columnMapping: {}, + }, + ], + }, + approver: { + user: { + name: 'Approver Name', + email: 'approver@example.com', + }, + }, + answers: [{ questionId: 'q-2', answer: 'Fallback answer' }], + }); + + const result = await service.exportDocument(dto); + + expect(mockGenerateSOAExportFile).toHaveBeenCalledWith( + [ + { + id: 'q-1', + text: 'Control 1', + columnMapping: { + closure: 'A.5', + title: 'Control title', + control_objective: 'Objective', + isApplicable: true, + justification: 'Mapped justification', + }, + answer: null, + }, + { + id: 'q-2', + text: 'Control 2', + columnMapping: { + closure: null, + title: null, + control_objective: null, + isApplicable: null, + justification: null, + }, + answer: 'Fallback answer', + }, + ], + 'ISO 27001', + 2, + { + preparedBy: 'Compliance Lead', + answeredQuestions: 3, + totalQuestions: 5, + approvedAt: null, + declinedAt: new Date('2026-04-20T00:00:00.000Z'), + status: 'declined', + approverName: 'Approver Name', + }, + 'pdf', + ); + expect(result).toEqual(generated); + }); + }); }); From a5e898876c990a0f947ee53150d2a165f08c8491 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 11:47:12 -0400 Subject: [PATCH 15/30] fix(framework-editor): add SOA document to ISO 27001 framework --- .../(pages)/controls/document-type-options.ts | 5 +++++ .../(pages)/documents/DocumentControlsCell.tsx | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/framework-editor/app/(pages)/controls/document-type-options.ts b/apps/framework-editor/app/(pages)/controls/document-type-options.ts index 332eb364b..32b81c582 100644 --- a/apps/framework-editor/app/(pages)/controls/document-type-options.ts +++ b/apps/framework-editor/app/(pages)/controls/document-type-options.ts @@ -10,6 +10,11 @@ export const DOCUMENT_TYPE_OPTIONS: MultiSelectOption[] = [ { value: 'rbac_matrix', label: 'RBAC Matrix', category: 'Security' }, { value: 'infrastructure_inventory', label: 'Infrastructure Inventory', category: 'Security' }, { value: 'network_diagram', label: 'Network Diagram', category: 'Security' }, + { + value: 'statement_of_applicability', + label: 'Statement of Applicability', + category: 'SOA', + }, { value: 'tabletop_exercise', label: 'Incident Response Tabletop Exercise', category: 'Security' }, { value: 'whistleblower_report', label: 'Whistleblower Report', category: 'People' }, { value: 'employee_performance_evaluation', label: 'Employee Performance Evaluation', category: 'People' }, diff --git a/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx b/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx index e52c35ee1..38b520944 100644 --- a/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx +++ b/apps/framework-editor/app/(pages)/documents/DocumentControlsCell.tsx @@ -24,6 +24,7 @@ export function DocumentControlsCell({ onControlLinked, onControlUnlinked, }: DocumentControlsCellProps) { + const isSOADocument = documentType === 'statement_of_applicability'; const [isExpanded, setIsExpanded] = useState(false); const [isSearching, setIsSearching] = useState(false); const [search, setSearch] = useState(''); @@ -67,6 +68,11 @@ export function DocumentControlsCell({ const handleLink = useCallback( async (control: { id: string; name: string }) => { + if (isSOADocument) { + toast.info('Linking controls is disabled for Statement of Applicability'); + return; + } + try { const current = await apiClient(`/control-template/${control.id}`); const currentTypes: string[] = Array.isArray(current.documentTypes) @@ -86,7 +92,7 @@ export function DocumentControlsCell({ setSearch(''); setIsSearching(false); }, - [documentType, onControlLinked], + [documentType, isSOADocument, onControlLinked], ); const handleUnlink = useCallback( @@ -211,12 +217,18 @@ export function DocumentControlsCell({ )} + {isSOADocument && ( +

+ Linking controls is disabled for Statement of Applicability. +

+ )}
); From 2f12556fddfdaa68e2eb489b3bf88087572b1c1a Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 12:52:51 -0400 Subject: [PATCH 16/30] fix(app): handle serverApi.post errors to prevent infinite loading on SOA page --- .../documents/statement-of-applicability/page.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 0e003a34e..e41b65802 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 @@ -93,10 +93,15 @@ export default async function StatementOfApplicabilityPage({ document: Record | null; }>('/v1/soa/ensure-setup', { frameworkId, organizationId }); - const configuration = setupResult.data?.configuration; - const document = setupResult.data?.document; + const setupData = setupResult.data; + if (!setupData?.success) { + soaError = setupData?.error || 'Failed to setup SOA. Please try again later.'; + } + + const configuration = setupData?.configuration; + const document = setupData?.document; - if (configuration && document) { + if (!soaError && configuration && document) { let approver = null; const approverId = document.approverId as string | undefined; if (approverId) { @@ -146,6 +151,9 @@ export default async function StatementOfApplicabilityPage({ currentMemberId: currentMember?.id || null, ownerAdminMembers, } as SOAData; + } else if (!soaError) { + soaError = + 'SOA setup did not return required configuration data. Please try again later.'; } } catch (error) { console.error('Failed to setup SOA:', error); From 38642badc3b477f4e98a2ca01956b25806a9d3b1 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 12:55:19 -0400 Subject: [PATCH 17/30] fix(api): correct SOA completion logic based on approvedAt for SOA --- apps/api/src/frameworks/frameworks-scores.helper.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/src/frameworks/frameworks-scores.helper.ts b/apps/api/src/frameworks/frameworks-scores.helper.ts index c76aeb522..ea843a86d 100644 --- a/apps/api/src/frameworks/frameworks-scores.helper.ts +++ b/apps/api/src/frameworks/frameworks-scores.helper.ts @@ -251,12 +251,15 @@ async function computeDocumentsScore(organizationId: string) { }, select: { approvedAt: true, + status: true, }, orderBy: { updatedAt: 'desc', }, }); - soaCompleted = !!latestSOADocument?.approvedAt; + soaCompleted = + latestSOADocument?.status === 'completed' && + !!latestSOADocument.approvedAt; } const soaTotalDocuments = hasSOADocumentRequirement ? 1 : 0; From 140db39d9d7864fc0c6276b8462972fd44d32d8d Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 13:03:08 -0400 Subject: [PATCH 18/30] fix(app): avoid defaulting to 'Not approved' before SOA status loads --- .../documents/components/SOAOverviewCard.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 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 a9d2666a4..110b8b1ee 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx @@ -35,13 +35,29 @@ type SOASetupResponse = { } | null; }; -type SOAApprovalStatus = 'Approved' | 'Declined' | 'Pending' | 'Not approved'; +type SOAApprovalStatus = + | 'Approved' + | 'Declined' + | 'Pending' + | 'Not approved' + | 'Loading' + | 'Unavailable'; function SOAApprovalStatusBadge({ status }: { status: SOAApprovalStatus }) { const statusConfig: Record< SOAApprovalStatus, { label: SOAApprovalStatus; className: string } > = { + Loading: { + label: 'Loading', + className: + 'bg-slate-100 text-slate-700 dark:bg-slate-950/30 dark:text-slate-300', + }, + Unavailable: { + label: 'Unavailable', + className: + 'bg-slate-100 text-slate-700 dark:bg-slate-950/30 dark:text-slate-300', + }, Approved: { label: 'Approved', className: @@ -78,7 +94,8 @@ export function SOAOverviewCard({ iso27001FrameworkId, }: SOAOverviewCardProps) { const form = STATEMENT_OF_APPLICABILITY_FORM; - const { data: soaSetupResponse } = useSWR( + const { data: soaSetupResponse, error: soaSetupError, isLoading: isLoadingSOASetup } = + useSWR( ['/v1/soa/ensure-setup', organizationId, iso27001FrameworkId], async ([endpoint, orgId, frameworkId]: readonly [string, string, string]) => { const response = await api.post(endpoint, { @@ -97,6 +114,8 @@ export function SOAOverviewCard({ const document = soaSetupResponse?.document; const approvalStatus = useMemo(() => { + if (isLoadingSOASetup) return 'Loading'; + if (soaSetupError || !soaSetupResponse?.success) return 'Unavailable'; if (!document) return 'Not approved'; if (document.approvedAt) return 'Approved'; if (document.declinedAt) return 'Declined'; @@ -108,7 +127,7 @@ export function SOAOverviewCard({ return 'Pending'; } return 'Not approved'; - }, [document]); + }, [document, isLoadingSOASetup, soaSetupError, soaSetupResponse?.success]); return (
From 1296eeb109f00aecbbf816a2cf636871801d784e Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 13:45:39 -0400 Subject: [PATCH 19/30] fix(db): add declined fields to SOADocument --- .../migration.sql | 6 ++++++ packages/db/prisma/schema/soa.prisma | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql diff --git a/packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql b/packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql new file mode 100644 index 000000000..72b7b8d63 --- /dev/null +++ b/packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql @@ -0,0 +1,6 @@ +-- AlterEnum: Add declined status for SOA document workflow +ALTER TYPE "SOADocumentStatus" ADD VALUE 'declined'; + +-- AlterTable: Track when SOA document was declined +ALTER TABLE "SOADocument" +ADD COLUMN "declinedAt" TIMESTAMP(3); diff --git a/packages/db/prisma/schema/soa.prisma b/packages/db/prisma/schema/soa.prisma index d3f28d386..34ab55684 100644 --- a/packages/db/prisma/schema/soa.prisma +++ b/packages/db/prisma/schema/soa.prisma @@ -53,7 +53,7 @@ model SOADocument { isLatest Boolean @default(true) // Whether this is the latest version // Document status - status SOADocumentStatus @default(draft) // draft, in_progress, completed + status SOADocumentStatus @default(draft) // draft, in_progress, needs_review, declined, completed // Document metadata totalQuestions Int @default(0) // Total number of questions in this document @@ -64,6 +64,7 @@ model SOADocument { approverId String? // Member ID who will approve this document (set when submitted for approval) approver Member? @relation("SOADocumentApprover", fields: [approverId], references: [id], onDelete: SetNull, onUpdate: Cascade) approvedAt DateTime? // When document was approved + declinedAt DateTime? // When document was declined // Dates completedAt DateTime? // When document was completed @@ -124,6 +125,7 @@ enum SOADocumentStatus { draft // Document is being created/edited in_progress // Document is being generated needs_review // Document is submitted for approval + declined // Document was declined by approver completed // Document is complete and approved } From 83a5619777e61efffef88d28115bf25cd2dbe670 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 14:23:12 -0400 Subject: [PATCH 20/30] fix(db): remove declined from SOADocumentStatus --- .../migration.sql | 3 --- packages/db/prisma/schema/soa.prisma | 3 +-- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql b/packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql index 72b7b8d63..d449df524 100644 --- a/packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql +++ b/packages/db/prisma/migrations/20260424134500_add_soa_declined_status_and_timestamp/migration.sql @@ -1,6 +1,3 @@ --- AlterEnum: Add declined status for SOA document workflow -ALTER TYPE "SOADocumentStatus" ADD VALUE 'declined'; - -- AlterTable: Track when SOA document was declined ALTER TABLE "SOADocument" ADD COLUMN "declinedAt" TIMESTAMP(3); diff --git a/packages/db/prisma/schema/soa.prisma b/packages/db/prisma/schema/soa.prisma index 34ab55684..562418a74 100644 --- a/packages/db/prisma/schema/soa.prisma +++ b/packages/db/prisma/schema/soa.prisma @@ -53,7 +53,7 @@ model SOADocument { isLatest Boolean @default(true) // Whether this is the latest version // Document status - status SOADocumentStatus @default(draft) // draft, in_progress, needs_review, declined, completed + status SOADocumentStatus @default(draft) // draft, in_progress, needs_review, completed // Document metadata totalQuestions Int @default(0) // Total number of questions in this document @@ -125,7 +125,6 @@ enum SOADocumentStatus { draft // Document is being created/edited in_progress // Document is being generated needs_review // Document is submitted for approval - declined // Document was declined by approver completed // Document is complete and approved } From dafde6f2400d6a7529ec3ea8bf525532cb48a76f Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 14:37:03 -0400 Subject: [PATCH 21/30] fix(api): update declineAt during SOA Document status changes --- apps/api/src/soa/soa.service.spec.ts | 108 ++++++++++++++++++++++++++ apps/api/src/soa/soa.service.ts | 4 + apps/api/src/soa/utils/soa-storage.ts | 2 + 3 files changed, 114 insertions(+) diff --git a/apps/api/src/soa/soa.service.spec.ts b/apps/api/src/soa/soa.service.spec.ts index c9e5e7afb..91b35aabb 100644 --- a/apps/api/src/soa/soa.service.spec.ts +++ b/apps/api/src/soa/soa.service.spec.ts @@ -212,6 +212,105 @@ describe('SOAService', () => { }); const result = await service.approveDocument(dto, userId); expect(result.success).toBe(true); + expect(mockDb.sOADocument.update).toHaveBeenCalledWith({ + where: { id: dto.documentId }, + data: expect.objectContaining({ + status: 'completed', + declinedAt: null, + }), + }); + }); + }); + + describe('declineDocument', () => { + const dto = { + documentId: 'doc-1', + organizationId: 'org-1', + reason: 'Needs changes', + }; + const userId = 'user-1'; + const ownerMember = { id: 'mem-1', role: 'owner' }; + + it('throws NotFoundException when member not found', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect(service.declineDocument(dto, userId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws ForbiddenException when user is not owner/admin', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ + id: 'mem-1', + role: 'employee', + }); + + await expect(service.declineDocument(dto, userId)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('throws NotFoundException when document not found', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(ownerMember); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null); + + await expect(service.declineDocument(dto, userId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('throws ForbiddenException when not pending approval for this user', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(ownerMember); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc-1', + approverId: 'other-member', + status: 'needs_review', + }); + + await expect(service.declineDocument(dto, userId)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('throws BadRequestException when not in needs_review status', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(ownerMember); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc-1', + approverId: 'mem-1', + status: 'draft', + }); + + await expect(service.declineDocument(dto, userId)).rejects.toThrow( + BadRequestException, + ); + }); + + it('declines document and sets declinedAt', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(ownerMember); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc-1', + approverId: 'mem-1', + status: 'needs_review', + }); + (mockDb.sOADocument.update as jest.Mock).mockResolvedValue({ + id: 'doc-1', + status: 'completed', + }); + + const result = await service.declineDocument(dto, userId); + + expect(result.success).toBe(true); + expect(mockDb.sOADocument.update).toHaveBeenCalledWith({ + where: { id: dto.documentId }, + data: expect.objectContaining({ + approverId: null, + approvedAt: null, + status: 'completed', + }), + }); + expect((mockDb.sOADocument.update as jest.Mock).mock.calls[0][0].data.declinedAt).toBeInstanceOf( + Date, + ); }); }); @@ -301,6 +400,15 @@ describe('SOAService', () => { }); const result = await service.submitForApproval(dto); expect(result.success).toBe(true); + expect(mockDb.sOADocument.update).toHaveBeenCalledWith({ + where: { id: dto.documentId }, + data: expect.objectContaining({ + approverId: dto.approverId, + status: 'needs_review', + approvedAt: null, + declinedAt: null, + }), + }); }); }); diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index 8749449a9..ef43aea18 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -346,6 +346,7 @@ export class SOAService { data: { status: 'completed', approvedAt: new Date(), + declinedAt: null, }, }); @@ -380,6 +381,7 @@ export class SOAService { approverId: null, approvedAt: null, status: 'completed', + declinedAt: new Date(), }, }); @@ -436,6 +438,8 @@ export class SOAService { where: { id: dto.documentId }, data: { approverId: dto.approverId, + approvedAt: null, + declinedAt: null, status: 'needs_review', }, }); diff --git a/apps/api/src/soa/utils/soa-storage.ts b/apps/api/src/soa/utils/soa-storage.ts index f36fa621d..e1e64309c 100644 --- a/apps/api/src/soa/utils/soa-storage.ts +++ b/apps/api/src/soa/utils/soa-storage.ts @@ -131,6 +131,7 @@ export async function updateDocumentAfterAutoFill( completedAt: answeredCount === totalQuestions ? new Date() : null, approverId: null, approvedAt: null, + declinedAt: null, }, }); } @@ -174,6 +175,7 @@ export async function updateDocumentAnsweredCount( // Clear approval when answers are edited approverId: null, approvedAt: null, + declinedAt: null, }, }); } From 59567c4ce6a1abd7f4765730a834270cfe0fa070 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 14:52:40 -0400 Subject: [PATCH 22/30] fix(app): update approval status text on soa --- .../statement-of-applicability/components/SOADocumentInfo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx index 0dbe4dd83..1f83cddc5 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx @@ -42,7 +42,7 @@ export function SOADocumentInfo({ const approvalStatusText = document.approvedAt ? `Approved on ${new Date(document.approvedAt).toLocaleDateString()}` - : document.status === 'needs_review' && document.declinedAt + : document.declinedAt ? `Declined on ${new Date(document.declinedAt).toLocaleDateString()}` : approver ? 'Pending approval' From 4813da66b12400c40975362fe27903c506749bcd Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 14:53:02 -0400 Subject: [PATCH 23/30] fix(api): update approval status text on soa pdf --- apps/api/src/soa/utils/export-generator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/soa/utils/export-generator.ts b/apps/api/src/soa/utils/export-generator.ts index 02ae96088..df2859e5a 100644 --- a/apps/api/src/soa/utils/export-generator.ts +++ b/apps/api/src/soa/utils/export-generator.ts @@ -107,7 +107,7 @@ function generateSOAPDF( y += lineHeight; const approvalStatusText = metadata.approvedAt ? `Approved on ${new Date(metadata.approvedAt).toLocaleDateString()}` - : metadata.status === 'declined' && metadata.declinedAt + : metadata.declinedAt ? `Declined on ${new Date(metadata.declinedAt).toLocaleDateString()}` : metadata.approverName ? 'Pending approval' From af47b4e75d104233f588e6fdead8c10763538cdf Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 15:10:08 -0400 Subject: [PATCH 24/30] fix(app): update SOA Document Info based on status changes --- .../components/SOAFrameworkTable.tsx | 69 ++++++++++++++++++- .../hooks/useSOAAutoFill.ts | 7 +- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx index 02dfae7b3..e2b591498 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx @@ -118,6 +118,12 @@ export function SOAFrameworkTable({ }, [resolvedDocument?.answers]); const handleAnswerUpdate = (questionId: string, payload: SOAFieldSavePayload) => { + const previousIsApplicable = + answersMap.get(questionId)?.savedIsApplicable ?? + processedResults.get(questionId)?.isApplicable ?? + questions.find((q) => q.id === questionId)?.columnMapping.isApplicable ?? + null; + setAnswersMap((prev) => { const newMap = new Map(prev); const existing = newMap.get(questionId); @@ -128,6 +134,40 @@ export function SOAFrameworkTable({ }); return newMap; }); + + void mutateSOADocument((current) => { + if (!current) return current; + + const totalQuestions = current.totalQuestions as number | undefined; + const currentAnsweredQuestions = current.answeredQuestions as number | undefined; + + if ( + typeof totalQuestions !== 'number' || + typeof currentAnsweredQuestions !== 'number' + ) { + return current; + } + + const nextIsApplicable = payload.isApplicable ?? null; + let answeredQuestions = currentAnsweredQuestions; + + if (previousIsApplicable === null && nextIsApplicable !== null) { + answeredQuestions += 1; + } else if (previousIsApplicable !== null && nextIsApplicable === null) { + answeredQuestions -= 1; + } + + answeredQuestions = Math.max(0, Math.min(totalQuestions, answeredQuestions)); + + return { + ...current, + answeredQuestions, + status: answeredQuestions === totalQuestions ? 'completed' : 'in_progress', + approverId: null, + approvedAt: null, + declinedAt: null, + }; + }, false); }; const [isSubmitApprovalDialogOpen, setIsSubmitApprovalDialogOpen] = useState(false); @@ -151,9 +191,32 @@ export function SOAFrameworkTable({ questions: questionsForHook, documentId: document?.id || '', organizationId, - onUpdate: () => { - // Revalidate SWR cache instead of full page reload - void mutateSOADocument(); + onUpdate: ({ total, answered } = {}) => { + // Keep SOA info card in sync immediately after auto-fill completion. + void mutateSOADocument((current) => { + if (!current) return current; + const totalQuestions = + typeof total === 'number' ? total : (current.totalQuestions as number | undefined); + const answeredQuestions = + typeof answered === 'number' + ? answered + : (current.answeredQuestions as number | undefined); + + if (typeof totalQuestions !== 'number' || typeof answeredQuestions !== 'number') { + return current; + } + + return { + ...current, + totalQuestions, + answeredQuestions, + status: + answeredQuestions === totalQuestions ? 'completed' : 'in_progress', + approverId: null, + approvedAt: null, + declinedAt: null, + }; + }, false); }, }); diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts index 4359b844a..63efa6961 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts @@ -17,7 +17,7 @@ interface UseSOAAutoFillProps { }>; documentId: string; organizationId: string; - onUpdate: () => void; + onUpdate: (payload?: { total?: number; answered?: number }) => void; } export function useSOAAutoFill({ questions, documentId, organizationId, onUpdate }: UseSOAAutoFillProps) { @@ -115,7 +115,10 @@ export function useSOAAutoFill({ questions, documentId, organizationId, onUpdate // All questions completed toast.success(`Auto-filled ${data.answered} questions`); setIsAutoFilling(false); - onUpdate(); + onUpdate({ + total: typeof data.total === 'number' ? data.total : undefined, + answered: typeof data.answered === 'number' ? data.answered : undefined, + }); } else if (data.type === 'error') { toast.error(data.error || 'Failed to auto-fill SOA'); setIsAutoFilling(false); From 08971f944f918cec00208749c63fba7beab2b4d4 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 15:25:32 -0400 Subject: [PATCH 25/30] fix(app): correct approvalStatusText handling of declinedAt --- .../statement-of-applicability/components/SOADocumentInfo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx index 1f83cddc5..0dbe4dd83 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx @@ -42,7 +42,7 @@ export function SOADocumentInfo({ const approvalStatusText = document.approvedAt ? `Approved on ${new Date(document.approvedAt).toLocaleDateString()}` - : document.declinedAt + : document.status === 'needs_review' && document.declinedAt ? `Declined on ${new Date(document.declinedAt).toLocaleDateString()}` : approver ? 'Pending approval' From f817d4465da29ab7f868b5271e45b632feb070f2 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 15:56:48 -0400 Subject: [PATCH 26/30] fix(app): correct SOA document info during the status changes --- .../documents/components/SOAOverviewCard.tsx | 1 - .../components/SOADocumentInfo.tsx | 11 ++++++---- .../components/SOAFrameworkTable.tsx | 6 ++++-- .../hooks/useSOADocument.ts | 20 +++++++++---------- 4 files changed, 20 insertions(+), 18 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 110b8b1ee..62aa66464 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/SOAOverviewCard.tsx @@ -121,7 +121,6 @@ export function SOAOverviewCard({ if (document.declinedAt) return 'Declined'; if ( document.status === 'needs_review' || - document.status === 'pending_approval' || !!document.approverId ) { return 'Pending'; diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx index 0dbe4dd83..35ca9848c 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOADocumentInfo.tsx @@ -42,9 +42,9 @@ export function SOADocumentInfo({ const approvalStatusText = document.approvedAt ? `Approved on ${new Date(document.approvedAt).toLocaleDateString()}` - : document.status === 'needs_review' && document.declinedAt + : document.declinedAt ? `Declined on ${new Date(document.declinedAt).toLocaleDateString()}` - : approver + : document.status === 'needs_review' ? 'Pending approval' : 'Not approved'; @@ -93,13 +93,16 @@ export function SOADocumentInfo({ )} - {approver && !document.approvedAt && document.status !== 'needs_review' && ( + {approver && + !document.approvedAt && + !document.declinedAt && + document.status === 'needs_review' && ( <>
)} - {document.status === 'needs_review' && document.declinedAt && ( + {document.declinedAt && ( <>
m.id === derivedApproverId) ?? approver - : approver; + : null; const derivedCanCurrentUserApprove = derivedIsPendingApproval && derivedApproverId === currentMemberId; const columns = configuration.columns as SOAColumn[]; 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 f74d261fd..91fa1d7e6 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 @@ -70,14 +70,13 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use if (response.error) throw new Error(response.error); if (!response.data?.success) throw new Error('Failed to save answer'); - await mutate(); return true; }; const approve = async (): Promise => { if (!documentId) throw new Error('No document ID'); - const response = await api.post<{ success: boolean; data?: unknown }>( + const response = await api.post<{ success: boolean; data?: SOADocumentData }>( '/v1/soa/approve', { organizationId, documentId }, ); @@ -85,8 +84,8 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use if (response.error) throw new Error(response.error || 'Failed to approve SOA document'); if (!response.data?.success) throw new Error('Failed to approve SOA document'); - if (data) { - await mutate({ ...data, status: 'approved', approvedAt: new Date().toISOString() }, false); + if (response.data?.data) { + await mutate(response.data.data, false); } return true; }; @@ -94,7 +93,7 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use const decline = async (): Promise => { if (!documentId) throw new Error('No document ID'); - const response = await api.post<{ success: boolean; data?: unknown }>( + const response = await api.post<{ success: boolean; data?: SOADocumentData }>( '/v1/soa/decline', { organizationId, documentId }, ); @@ -102,8 +101,8 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use if (response.error) throw new Error(response.error || 'Failed to decline SOA document'); if (!response.data?.success) throw new Error('Failed to decline SOA document'); - if (data) { - await mutate({ ...data, status: 'needs_review', declinedAt: new Date().toISOString() }, false); + if (response.data?.data) { + await mutate(response.data.data, false); } return true; }; @@ -111,7 +110,7 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use const submitForApproval = async (approverId: string): Promise => { if (!documentId) throw new Error('No document ID'); - const response = await api.post<{ success: boolean; data?: unknown }>( + const response = await api.post<{ success: boolean; data?: SOADocumentData }>( '/v1/soa/submit-for-approval', { organizationId, documentId, approverId }, ); @@ -119,9 +118,8 @@ export function useSOADocument({ documentId, organizationId, fallbackData }: Use if (response.error) throw new Error(response.error || 'Failed to submit for approval'); if (!response.data?.success) throw new Error('Failed to submit for approval'); - // Optimistically update cached document status - if (data) { - await mutate({ ...data, status: 'pending_approval', approverId }, false); + if (response.data?.data) { + await mutate(response.data.data, false); } return true; }; From 7705eca723b1208ef95650cebdc2e103f3e6cb2d Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 15:57:27 -0400 Subject: [PATCH 27/30] fix(api): update the soa pdf content --- apps/api/src/soa/utils/export-generator.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/api/src/soa/utils/export-generator.ts b/apps/api/src/soa/utils/export-generator.ts index df2859e5a..f1fc30fc8 100644 --- a/apps/api/src/soa/utils/export-generator.ts +++ b/apps/api/src/soa/utils/export-generator.ts @@ -109,12 +109,19 @@ function generateSOAPDF( ? `Approved on ${new Date(metadata.approvedAt).toLocaleDateString()}` : metadata.declinedAt ? `Declined on ${new Date(metadata.declinedAt).toLocaleDateString()}` - : metadata.approverName + : metadata.status === 'needs_review' ? 'Pending approval' : 'Not approved'; + const approvalActorLabel = metadata.approvedAt + ? 'Approved by' + : metadata.declinedAt + ? 'Declined by' + : metadata.status === 'needs_review' + ? 'Pending approval by' + : 'Approver'; pdf.text(`Approval status: ${approvalStatusText}`, margin, y); y += lineHeight; - pdf.text(`Approved by: ${metadata.approverName || 'N/A'}`, margin, y); + pdf.text(`${approvalActorLabel}: ${metadata.approverName || 'N/A'}`, margin, y); y += lineHeight; pdf.text(`Exported: ${new Date().toLocaleDateString()}`, margin, y); y += lineHeight * 2; From 959d5717cf190e7cad2a5ab6c239f5823e9bedd2 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 16:26:19 -0400 Subject: [PATCH 28/30] fix(app): handle /v1/frameworks fetch errors before showing not found message on SOA --- .../documents/statement-of-applicability/page.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 e41b65802..a9a9ed12a 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 @@ -71,6 +71,13 @@ export default async function StatementOfApplicabilityPage({ serverApi.get('/v1/context'), ]); + let soaData: SOAData | null = null; + let soaError: string | null = null; + + if (frameworksResult.error) { + soaError = 'Failed to load frameworks. Please try again later.'; + } + const frameworks = frameworksResult.data?.data ?? []; const isoFrameworkInstance = frameworks.find( (fi) => fi.framework?.name && ISO27001_NAMES.includes(fi.framework.name), @@ -79,10 +86,7 @@ export default async function StatementOfApplicabilityPage({ const people = peopleResult.data?.data ?? []; const contextEntries = contextResult.data?.data ?? []; - let soaData: SOAData | null = null; - let soaError: string | null = null; - - if (isoFrameworkInstance) { + if (!soaError && isoFrameworkInstance) { try { const { frameworkId, framework } = isoFrameworkInstance; @@ -159,7 +163,7 @@ export default async function StatementOfApplicabilityPage({ console.error('Failed to setup SOA:', error); soaError = 'Failed to setup SOA. Please try again later.'; } - } else { + } else if (!soaError) { soaError = 'ISO 27001 framework not found. Please add ISO 27001 framework to your organization to get started.'; } From b9a580f70c84ca7500cc654574876366596c620b Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 16:28:39 -0400 Subject: [PATCH 29/30] fix(app): guard answers sync effect from clearing answersMap on partial data in SOA page --- .../components/SOAFrameworkTable.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx index 675cd0bef..21866e0fa 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx @@ -109,9 +109,12 @@ export function SOAFrameworkTable({ // Update answersMap when the live document changes useEffect(() => { + if (!Array.isArray(resolvedDocument?.answers)) { + return; + } setAnswersMap( new Map( - (resolvedDocument?.answers || []).map((answer: { questionId: string; answer: string | null; answerVersion: number }) => [ + resolvedDocument.answers.map((answer: { questionId: string; answer: string | null; answerVersion: number }) => [ answer.questionId, { answer: answer.answer, answerVersion: answer.answerVersion }, ]) From bfd1f5f19898540b35d257fd4966e07c03536fb5 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Mon, 27 Apr 2026 14:10:29 -0400 Subject: [PATCH 30/30] fix(api): add non-empty validation for requirement fields in ExportSOADocumentDto --- apps/api/src/soa/dto/export-soa-document.dto.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/src/soa/dto/export-soa-document.dto.ts b/apps/api/src/soa/dto/export-soa-document.dto.ts index 84c62cbad..498d79fe2 100644 --- a/apps/api/src/soa/dto/export-soa-document.dto.ts +++ b/apps/api/src/soa/dto/export-soa-document.dto.ts @@ -1,10 +1,12 @@ -import { IsIn, IsString } from 'class-validator'; +import { IsIn, IsNotEmpty, IsString } from 'class-validator'; export class ExportSOADocumentDto { @IsString() + @IsNotEmpty() documentId!: string; @IsString() + @IsNotEmpty() organizationId!: string; @IsIn(['pdf'])