From 5fa48613aeff3f618b0f86d6ecbd818170ab30bf Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 23 Apr 2026 11:40:10 -0400 Subject: [PATCH 01/40] 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 ee969a9448..f0fce75b14 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 0000000000..b4d2018e42 --- /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 5260abbd6c..2c8756fc52 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 3bb979c5ea..bac6054593 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 7a081d6291..aa2dc5d144 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 fbfd9f0fdb..0c10696b59 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 2cdf8814c6..bb9071a839 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 0000000000..6f61619587 --- /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 0000000000..56e5d52b1e --- /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 0000000000..c098e0034b --- /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 48af923c83..189512d6b1 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 5fd0b08b2c..59c00c84dd 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 f161193001..97478811ce 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/40] 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 0c10696b59..02dfae7b31 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/40] 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 f0fce75b14..2cb3ec7bf1 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 b4d2018e42..a9d2666a4a 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/40] 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 b629742787..456f8c5f28 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/40] 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 0000000000..84c62cbade --- /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 64d1bed7ca..451c93476e 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 aa085444ac..ca5ac33c21 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 0000000000..d75b45721e --- /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/40] 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 6f61619587..7c496e4090 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 790d5ecc6d..f74d261fd2 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/40] 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 ca5ac33c21..8749449a92 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 d75b45721e..f53f6c0bed 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/40] 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 2cb3ec7bf1..72f909980f 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/40] 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 72f909980f..b617918b73 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/40] 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 c098e0034b..002cdc4b8d 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/40] 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 f53f6c0bed..fdeacb22af 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/40] 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 fdeacb22af..02ae96088e 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/40] 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 002cdc4b8d..0e003a34e1 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/40] 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 f2dd111cef..83fa7db005 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 8c87ad045f..c9e5e7afbe 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/40] 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 332eb364b6..32b81c582c 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 e52c35ee11..38b5209445 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/40] 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 0e003a34e1..e41b658023 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/40] 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 c76aeb522d..ea843a86d5 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/40] 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 a9d2666a4a..110b8b1ee6 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 46b9575c4798bb9cada8b609954e0add20e1ba1e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:38:35 -0400 Subject: [PATCH 19/40] feat: allow selecting which policies to download when clicking download all * feat(policies): allow filtering download-all by policyIds * feat(policies): parse policyIds query param on download-all endpoint * fix(policies): use typed mockAuthContext in controller spec * feat(policies): add policy download picker sheet * feat(policies): open download picker sheet from Download All button * chore(policies): import icons from design-system re-export * fix(policies): reset picker selection on reopen and accept array policyIds Addresses cubic review on PR #2672: - PolicyDownloadSheet: reset selection to current policy IDs whenever the sheet opens or the policies prop changes, so reopens and upstream data refreshes don't leave stale or deleted IDs selected. - Controller: accept repeated-key array form (?policyIds=a&policyIds=b) in addition to comma-separated, and flatten both into a single deduped string[]. --------- Co-authored-by: Mariano --- .../src/policies/policies.controller.spec.ts | 102 +++++++++ apps/api/src/policies/policies.controller.ts | 25 ++- apps/api/src/policies/policies.service.ts | 8 +- apps/app/package.json | 1 + .../components/PolicyDownloadSheet.test.tsx | 206 ++++++++++++++++++ .../all/components/PolicyDownloadSheet.tsx | 179 +++++++++++++++ .../all/components/PolicyPageActions.test.tsx | 16 ++ .../all/components/PolicyPageActions.tsx | 42 +--- bun.lock | 3 + 9 files changed, 548 insertions(+), 34 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.tsx diff --git a/apps/api/src/policies/policies.controller.spec.ts b/apps/api/src/policies/policies.controller.spec.ts index 767fbcba3d..17fb8d2258 100644 --- a/apps/api/src/policies/policies.controller.spec.ts +++ b/apps/api/src/policies/policies.controller.spec.ts @@ -53,6 +53,10 @@ jest.mock('@db', () => ({ draft: 'draft', published: 'published', }, + FindingType: { + soc2: 'soc2', + iso27001: 'iso27001', + }, })); jest.mock('@trigger.dev/sdk', () => ({ @@ -188,10 +192,12 @@ describe('PoliciesController', () => { const result = await controller.downloadAllPolicies( orgId, mockAuthContext, + undefined, ); expect(policiesService.downloadAllPoliciesPdf).toHaveBeenCalledWith( orgId, + undefined, ); expect(result).toEqual({ url: 'https://s3.example.com/bundle.pdf', @@ -199,6 +205,102 @@ describe('PoliciesController', () => { authenticatedUser: { id: 'usr_123', email: 'test@example.com' }, }); }); + + it('parses comma-separated policyIds and passes an array to the service', async () => { + const mockResult = { downloadUrl: 'https://s3/signed', name: 'all-policies', policyCount: 2 }; + mockPoliciesService.downloadAllPoliciesPdf.mockResolvedValue(mockResult); + + await controller.downloadAllPolicies( + orgId, + mockAuthContext, + 'p1, p2 ,p3', + ); + + expect(policiesService.downloadAllPoliciesPdf).toHaveBeenCalledWith( + orgId, + ['p1', 'p2', 'p3'], + ); + }); + + it('dedupes policyIds and strips empty entries', async () => { + const mockResult = { downloadUrl: 'https://s3/signed', name: 'all-policies', policyCount: 1 }; + mockPoliciesService.downloadAllPoliciesPdf.mockResolvedValue(mockResult); + + await controller.downloadAllPolicies( + orgId, + mockAuthContext, + 'p1,,p1,p2,', + ); + + expect(policiesService.downloadAllPoliciesPdf).toHaveBeenCalledWith( + orgId, + ['p1', 'p2'], + ); + }); + + it('passes undefined when policyIds query is missing', async () => { + const mockResult = { downloadUrl: 'https://s3/signed', name: 'all-policies', policyCount: 10 }; + mockPoliciesService.downloadAllPoliciesPdf.mockResolvedValue(mockResult); + + await controller.downloadAllPolicies( + orgId, + mockAuthContext, + undefined, + ); + + expect(policiesService.downloadAllPoliciesPdf).toHaveBeenCalledWith( + orgId, + undefined, + ); + }); + + it('passes undefined when policyIds query is an empty string', async () => { + const mockResult = { downloadUrl: 'https://s3/signed', name: 'all-policies', policyCount: 10 }; + mockPoliciesService.downloadAllPoliciesPdf.mockResolvedValue(mockResult); + + await controller.downloadAllPolicies( + orgId, + mockAuthContext, + '', + ); + + expect(policiesService.downloadAllPoliciesPdf).toHaveBeenCalledWith( + orgId, + undefined, + ); + }); + + it('handles repeated-key array form (policyIds=a&policyIds=b)', async () => { + const mockResult = { downloadUrl: 'https://s3/signed', name: 'all-policies', policyCount: 2 }; + mockPoliciesService.downloadAllPoliciesPdf.mockResolvedValue(mockResult); + + await controller.downloadAllPolicies( + orgId, + mockAuthContext, + ['p1', 'p2'], + ); + + expect(policiesService.downloadAllPoliciesPdf).toHaveBeenCalledWith( + orgId, + ['p1', 'p2'], + ); + }); + + it('handles mixed array form where each value itself contains commas', async () => { + const mockResult = { downloadUrl: 'https://s3/signed', name: 'all-policies', policyCount: 3 }; + mockPoliciesService.downloadAllPoliciesPdf.mockResolvedValue(mockResult); + + await controller.downloadAllPolicies( + orgId, + mockAuthContext, + ['p1,p2', 'p3'], + ); + + expect(policiesService.downloadAllPoliciesPdf).toHaveBeenCalledWith( + orgId, + ['p1', 'p2', 'p3'], + ); + }); }); describe('getPolicy', () => { diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 3f65834bf7..52af8da998 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -72,6 +72,22 @@ import { } from './schemas/version-responses'; import { PolicyResponseDto } from './dto/policy-responses.dto'; +function parsePolicyIdsParam( + raw: string | string[] | undefined, +): string[] | undefined { + if (!raw) return undefined; + const values = Array.isArray(raw) ? raw : [raw]; + const ids = Array.from( + new Set( + values + .flatMap((value) => value.split(',')) + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ), + ); + return ids.length > 0 ? ids : undefined; +} + @ApiTags('Policies') @ApiExtraModels(PolicyResponseDto) @Controller({ path: 'policies', version: '1' }) @@ -154,9 +170,14 @@ export class PoliciesController { async downloadAllPolicies( @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, + @Query('policyIds') policyIdsParam?: string | string[], ) { - const result = - await this.policiesService.downloadAllPoliciesPdf(organizationId); + const policyIds = parsePolicyIdsParam(policyIdsParam); + + const result = await this.policiesService.downloadAllPoliciesPdf( + organizationId, + policyIds, + ); return { ...result, diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 225342add5..08c0228830 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -1268,7 +1268,10 @@ export class PoliciesService { /** * Download all published policies as a single PDF bundle (no watermark) */ - async downloadAllPoliciesPdf(organizationId: string) { + async downloadAllPoliciesPdf( + organizationId: string, + policyIds?: string[], + ) { // Get organization info const organization = await db.organization.findUnique({ where: { id: organizationId }, @@ -1285,6 +1288,9 @@ export class PoliciesService { organizationId, isArchived: false, archivedAt: null, + ...(policyIds && policyIds.length > 0 + ? { id: { in: policyIds } } + : {}), }, select: { id: true, diff --git a/apps/app/package.json b/apps/app/package.json index cfe571db22..671b522e2a 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -150,6 +150,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@trigger.dev/build": "4.4.3", "@types/d3": "^7.4.3", "@types/jspdf": "^2.0.0", diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.test.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.test.tsx new file mode 100644 index 0000000000..71fe48411e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.test.tsx @@ -0,0 +1,206 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Policy } from '@db'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/api-client', () => ({ + api: { get: vi.fn() }, +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +import { api } from '@/lib/api-client'; +import { toast } from 'sonner'; +import { PolicyDownloadSheet } from './PolicyDownloadSheet'; + +const policies: Array> = [ + { id: 'p1', name: 'Security Policy', status: 'published' }, + { id: 'p2', name: 'Privacy Policy', status: 'needs_review' }, + { id: 'p3', name: 'Draft Policy', status: 'draft' }, +]; + +const renderSheet = ( + overrides: Partial> = {}, +) => + render( + , + ); + +describe('PolicyDownloadSheet', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders one checkbox per policy and all are checked by default', () => { + renderSheet(); + + expect(screen.getByLabelText(/security policy/i)).toBeChecked(); + expect(screen.getByLabelText(/privacy policy/i)).toBeChecked(); + expect(screen.getByLabelText(/draft policy/i)).toBeChecked(); + }); + + it('shows a status badge for each policy', () => { + renderSheet(); + expect(screen.getByText(/published/i)).toBeInTheDocument(); + expect(screen.getByText(/needs review/i)).toBeInTheDocument(); + // "Draft" badge text (distinct from "Draft Policy" name) — use exact match + expect(screen.getByText(/^draft$/i)).toBeInTheDocument(); + }); + + it('shows "Download 3 policies" by default and enables the button', () => { + renderSheet(); + const btn = screen.getByRole('button', { name: /download 3 policies/i }); + expect(btn).toBeEnabled(); + }); + + it('updates the count when a policy is unchecked', async () => { + const user = userEvent.setup(); + renderSheet(); + + await user.click(screen.getByLabelText(/draft policy/i)); + + expect( + screen.getByRole('button', { name: /download 2 policies/i }), + ).toBeEnabled(); + }); + + it('disables the download button when zero are selected', async () => { + const user = userEvent.setup(); + renderSheet(); + + await user.click(screen.getByLabelText(/select all/i)); + + expect(screen.getByRole('button', { name: /download/i })).toBeDisabled(); + }); + + it('select all toggles every policy', async () => { + const user = userEvent.setup(); + renderSheet(); + + await user.click(screen.getByLabelText(/select all/i)); + expect(screen.getByLabelText(/security policy/i)).not.toBeChecked(); + + await user.click(screen.getByLabelText(/select all/i)); + expect(screen.getByLabelText(/security policy/i)).toBeChecked(); + }); + + it('calls the API with selected policyIds and triggers download', async () => { + const user = userEvent.setup(); + vi.mocked(api.get).mockResolvedValue({ + data: { + downloadUrl: 'https://s3/signed.pdf', + name: 'all-policies', + policyCount: 2, + }, + status: 200, + }); + + const onOpenChange = vi.fn(); + renderSheet({ onOpenChange }); + + await user.click(screen.getByLabelText(/draft policy/i)); + await user.click( + screen.getByRole('button', { name: /download 2 policies/i }), + ); + + expect(api.get).toHaveBeenCalledWith( + '/v1/policies/download-all?policyIds=p1%2Cp2', + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('re-selects all current policies when reopened, dropping stale IDs', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + + const { rerender } = render( + , + ); + + // Deselect one policy while open + await user.click(screen.getByLabelText(/draft policy/i)); + expect( + screen.getByRole('button', { name: /download 2 policies/i }), + ).toBeEnabled(); + + // Close and reopen — selection should reset to all + rerender( + , + ); + rerender( + , + ); + + expect(screen.getByLabelText(/draft policy/i)).toBeChecked(); + expect( + screen.getByRole('button', { name: /download 3 policies/i }), + ).toBeEnabled(); + }); + + it('updates selection when the policies prop changes while open', () => { + const { rerender } = render( + , + ); + + const newPolicies: typeof policies = [ + { id: 'p1', name: 'Security Policy', status: 'published' }, + { id: 'p4', name: 'New Policy', status: 'draft' }, + ]; + + rerender( + , + ); + + // New policy is checked by default; stale IDs are dropped + expect(screen.getByLabelText(/new policy/i)).toBeChecked(); + expect(screen.queryByLabelText(/privacy policy/i)).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /download 2 policies/i }), + ).toBeEnabled(); + }); + + it('shows a toast and keeps the sheet open on API error', async () => { + const user = userEvent.setup(); + vi.mocked(api.get).mockResolvedValue({ + error: 'boom', + status: 500, + }); + + const onOpenChange = vi.fn(); + renderSheet({ onOpenChange }); + + await user.click( + screen.getByRole('button', { name: /download 3 policies/i }), + ); + + expect(toast.error).toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.tsx new file mode 100644 index 0000000000..92f9e50215 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { api } from '@/lib/api-client'; +import type { Policy, PolicyStatus } from '@db'; +import { + Badge, + Button, + Checkbox, + ScrollArea, + Sheet, + SheetBody, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + Stack, + Text, +} from '@trycompai/design-system'; +import { useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; + +interface PolicyDownloadSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + policies: Pick[]; +} + +type BadgeVariant = 'default' | 'secondary' | 'outline'; + +const STATUS_LABEL: Record = { + published: 'Published', + needs_review: 'Needs review', + draft: 'Draft', +}; + +const STATUS_VARIANT: Record = { + published: 'default', + needs_review: 'secondary', + draft: 'outline', +}; + +export function PolicyDownloadSheet({ + open, + onOpenChange, + policies, +}: PolicyDownloadSheetProps) { + const allIds = useMemo(() => policies.map((p) => p.id), [policies]); + const [selected, setSelected] = useState>(() => new Set(allIds)); + const [isDownloading, setIsDownloading] = useState(false); + + // Reset selection to all current policies whenever the sheet opens or the + // underlying policy list changes, so reopens and prop refreshes don't leave + // stale or deleted IDs in the selection. + useEffect(() => { + if (!open) return; + setSelected(new Set(allIds)); + }, [open, allIds]); + + const handleToggle = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const allChecked = selected.size === allIds.length && allIds.length > 0; + const someChecked = selected.size > 0 && !allChecked; + + const handleToggleAll = () => { + setSelected((prev) => + prev.size === allIds.length ? new Set() : new Set(allIds), + ); + }; + + const handleDownload = async () => { + if (selected.size === 0) return; + setIsDownloading(true); + try { + const ids = Array.from(selected).join(','); + const url = `/v1/policies/download-all?policyIds=${encodeURIComponent(ids)}`; + const res = await api.get<{ + downloadUrl: string; + name: string; + policyCount: number; + }>(url); + + if (res.error || !res.data?.downloadUrl) { + toast.error('Failed to generate PDF. Please try again.'); + return; + } + + const link = document.createElement('a'); + link.href = res.data.downloadUrl; + link.download = `${res.data.name ?? 'policies'}.pdf`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + onOpenChange(false); + } catch { + toast.error('Failed to download policies.'); + } finally { + setIsDownloading(false); + } + }; + + const count = selected.size; + const buttonLabel = + count === 0 + ? 'Download' + : `Download ${count} ${count === 1 ? 'policy' : 'policies'}`; + + return ( + + + + Download policies + + + +
+ + + Select all ({selected.size} of {allIds.length} selected) + +
+ + + {policies.map((policy) => { + const statusKey = policy.status ?? 'draft'; + return ( +
+ handleToggle(policy.id)} + aria-label={policy.name ?? 'Policy'} + /> +
+ {policy.name} + + {STATUS_LABEL[statusKey] ?? statusKey} + +
+
+ ); + })} +
+
+
+
+ + + + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyPageActions.test.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyPageActions.test.tsx index 3846332b4a..2e7c36656b 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyPageActions.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyPageActions.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { setMockPermissions, @@ -20,6 +21,12 @@ vi.mock('@/components/sheets/create-policy-sheet', () => ({ CreatePolicySheet: () =>
, })); +// Mock PolicyDownloadSheet — only renders when open +vi.mock('./PolicyDownloadSheet', () => ({ + PolicyDownloadSheet: ({ open }: { open: boolean }) => + open ?
: null, +})); + // Mock api client vi.mock('@/lib/api-client', () => ({ api: { get: vi.fn() }, @@ -72,6 +79,15 @@ describe('PolicyPageActions', () => { screen.getByRole('button', { name: /download all/i }), ).toBeInTheDocument(); }); + + it('opens the download sheet when Download All is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /download all/i })); + + expect(screen.getByTestId('policy-download-sheet')).toBeInTheDocument(); + }); }); describe('auditor permissions (no policy:create)', () => { diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyPageActions.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyPageActions.tsx index d3b64c3299..2d75c61993 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyPageActions.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyPageActions.tsx @@ -1,14 +1,13 @@ 'use client'; import { CreatePolicySheet } from '@/components/sheets/create-policy-sheet'; -import { api } from '@/lib/api-client'; -import { Add, Download } from '@carbon/icons-react'; +import { usePermissions } from '@/hooks/use-permissions'; +import { Add, Download } from '@trycompai/design-system/icons'; import type { Policy } from '@db'; import { Button, HStack } from '@trycompai/design-system'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react'; -import { toast } from 'sonner'; -import { usePermissions } from '@/hooks/use-permissions'; +import { PolicyDownloadSheet } from './PolicyDownloadSheet'; interface PolicyPageActionsProps { policies: Policy[]; @@ -18,33 +17,10 @@ export function PolicyPageActions({ policies }: PolicyPageActionsProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const [isDownloadingAll, setIsDownloadingAll] = useState(false); + const [isDownloadSheetOpen, setIsDownloadSheetOpen] = useState(false); const { hasPermission } = usePermissions(); - const handleDownloadAll = async () => { - setIsDownloadingAll(true); - try { - const res = await api.get<{ downloadUrl: string; name: string; policyCount: number }>( - '/v1/policies/download-all', - ); - - if (res.error || !res.data?.downloadUrl) { - toast.error('Failed to generate PDF. Please try again.'); - return; - } - - const link = document.createElement('a'); - link.href = res.data.downloadUrl; - link.download = `${res.data.name ?? 'all-policies'}.pdf`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } catch { - toast.error('Failed to download policies.'); - } finally { - setIsDownloadingAll(false); - } - }; + const handleOpenDownloadSheet = () => setIsDownloadSheetOpen(true); const handleCreatePolicy = () => { const params = new URLSearchParams(searchParams.toString()); @@ -59,8 +35,7 @@ export function PolicyPageActions({ policies }: PolicyPageActionsProps) { @@ -72,6 +47,11 @@ export function PolicyPageActions({ policies }: PolicyPageActionsProps) { )} + ); } diff --git a/bun.lock b/bun.lock index 0a775b615e..ad5b423ad8 100644 --- a/bun.lock +++ b/bun.lock @@ -364,6 +364,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@trigger.dev/build": "4.4.3", "@types/d3": "^7.4.3", "@types/jspdf": "^2.0.0", @@ -2483,6 +2484,8 @@ "@testing-library/react": ["@testing-library/react@16.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw=="], + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + "@thallesp/nestjs-better-auth": ["@thallesp/nestjs-better-auth@2.4.0", "", { "peerDependencies": { "@nestjs/common": "^11.1.6", "@nestjs/core": "^11.1.6", "@nestjs/graphql": "^13.1.0", "@nestjs/websockets": "^11.1.6", "better-auth": ">=1.3.8 <2.0.0", "express": "^5.1.0", "graphql": "^16.11.0", "typescript": "^5.9.2" }, "optionalPeers": ["@nestjs/graphql", "@nestjs/websockets", "graphql"] }, "sha512-JSmtXEoYFDTMWyLr6mlXiqlDwurGnlnN52Hwe80AHYuiy7dfNJMIz9X5PfvFjSXs5tcklXoyrgTcm6OYDfQIpw=="], "@tiptap/core": ["@tiptap/core@3.22.3", "", { "peerDependencies": { "@tiptap/pm": "^3.22.3" } }, "sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q=="], From 1296eeb109f00aecbbf816a2cf636871801d784e Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 13:45:39 -0400 Subject: [PATCH 20/40] 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 0000000000..72b7b8d639 --- /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 d3f28d386f..34ab556847 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 21/40] 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 72b7b8d639..d449df524c 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 34ab556847..562418a743 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 22/40] 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 c9e5e7afbe..91b35aabb8 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 8749449a92..ef43aea18b 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 f36fa621d9..e1e64309c7 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 23/40] 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 0dbe4dd839..1f83cddc5f 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 24/40] 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 02ae96088e..df2859e5ac 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 25/40] 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 02dfae7b31..e2b591498a 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 4359b844a7..63efa69619 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 26/40] 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 1f83cddc5f..0dbe4dd839 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 8c3780071afa6584ad31dedb3b91d2817c62d15f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:29:01 -0400 Subject: [PATCH 27/40] chore(policies): add search + status grouping to download picker (#2676) Co-authored-by: Mariano --- .../components/PolicyDownloadSheet.test.tsx | 80 ++++++++- .../all/components/PolicyDownloadSheet.tsx | 169 +++++++++++++----- 2 files changed, 201 insertions(+), 48 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.test.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.test.tsx index 71fe48411e..9d6f34b099 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.test.tsx @@ -46,12 +46,86 @@ describe('PolicyDownloadSheet', () => { expect(screen.getByLabelText(/draft policy/i)).toBeChecked(); }); - it('shows a status badge for each policy', () => { + it('shows a status section header for each policy group', () => { renderSheet(); + // Status labels now appear as section headers with counts, e.g. "Published (1)" expect(screen.getByText(/published/i)).toBeInTheDocument(); expect(screen.getByText(/needs review/i)).toBeInTheDocument(); - // "Draft" badge text (distinct from "Draft Policy" name) — use exact match - expect(screen.getByText(/^draft$/i)).toBeInTheDocument(); + // "Draft (1)" section header — distinct from the "Draft Policy" policy name + expect(screen.getByText(/^draft \(1\)$/i)).toBeInTheDocument(); + }); + + it('groups policies by status with section headers and counts', () => { + renderSheet(); + expect(screen.getByText(/^published \(1\)$/i)).toBeInTheDocument(); + expect(screen.getByText(/^needs review \(1\)$/i)).toBeInTheDocument(); + expect(screen.getByText(/^draft \(1\)$/i)).toBeInTheDocument(); + }); + + it('hides inline per-row status badge (status is implied by section)', () => { + renderSheet(); + // The old layout rendered a standalone "Published" badge next to each row. + // Section headers now use "Published (1)" wording, so an exact match should + // return nothing. + const badges = screen.queryAllByText(/^published$/i); + expect(badges).toHaveLength(0); + }); + + it('filters rows live as the user types in the search input', async () => { + const user = userEvent.setup(); + renderSheet(); + + const search = screen.getByRole('searchbox', { name: /search policies/i }); + await user.type(search, 'priva'); + + expect(screen.getByLabelText(/privacy policy/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/security policy/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/draft policy/i)).not.toBeInTheDocument(); + }); + + it('shows an empty state when search matches nothing', async () => { + const user = userEvent.setup(); + renderSheet(); + + const search = screen.getByRole('searchbox', { name: /search policies/i }); + await user.type(search, 'zzzzzz'); + + expect(screen.getByText(/no policies match/i)).toBeInTheDocument(); + }); + + it('preserves selection state for policies hidden by search', async () => { + const user = userEvent.setup(); + renderSheet(); + + // Start: all 3 selected. Deselect Draft Policy. + await user.click(screen.getByLabelText(/draft policy/i)); + expect( + screen.getByRole('button', { name: /download 2 policies/i }), + ).toBeEnabled(); + + // Search to hide Draft Policy entirely + const search = screen.getByRole('searchbox', { name: /search policies/i }); + await user.type(search, 'priva'); + + // Download button still says 2 — hidden-but-selected are still counted + expect( + screen.getByRole('button', { name: /download 2 policies/i }), + ).toBeEnabled(); + }); + + it('per-group select-all toggles only that group', async () => { + const user = userEvent.setup(); + renderSheet(); + + // Published group: currently 1 selected (p1). Click its group toggle to clear. + const publishedGroupToggle = screen.getByRole('checkbox', { + name: /toggle published group/i, + }); + await user.click(publishedGroupToggle); + + expect(screen.getByLabelText(/security policy/i)).not.toBeChecked(); + expect(screen.getByLabelText(/privacy policy/i)).toBeChecked(); + expect(screen.getByLabelText(/draft policy/i)).toBeChecked(); }); it('shows "Download 3 policies" by default and enables the button', () => { diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.tsx index 92f9e50215..d6a379daee 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyDownloadSheet.tsx @@ -3,10 +3,9 @@ import { api } from '@/lib/api-client'; import type { Policy, PolicyStatus } from '@db'; import { - Badge, Button, Checkbox, - ScrollArea, + Input, Sheet, SheetBody, SheetContent, @@ -25,20 +24,15 @@ interface PolicyDownloadSheetProps { policies: Pick[]; } -type BadgeVariant = 'default' | 'secondary' | 'outline'; +type PolicyRow = Pick; +const STATUS_ORDER: PolicyStatus[] = ['published', 'needs_review', 'draft']; const STATUS_LABEL: Record = { published: 'Published', needs_review: 'Needs review', draft: 'Draft', }; -const STATUS_VARIANT: Record = { - published: 'default', - needs_review: 'secondary', - draft: 'outline', -}; - export function PolicyDownloadSheet({ open, onOpenChange, @@ -47,15 +41,50 @@ export function PolicyDownloadSheet({ const allIds = useMemo(() => policies.map((p) => p.id), [policies]); const [selected, setSelected] = useState>(() => new Set(allIds)); const [isDownloading, setIsDownloading] = useState(false); + const [query, setQuery] = useState(''); - // Reset selection to all current policies whenever the sheet opens or the - // underlying policy list changes, so reopens and prop refreshes don't leave - // stale or deleted IDs in the selection. + // Reset selection + search whenever the sheet opens or the underlying policy + // list changes, so reopens and prop refreshes don't leave stale or deleted + // IDs in the selection. useEffect(() => { if (!open) return; setSelected(new Set(allIds)); + setQuery(''); }, [open, allIds]); + const normalizedQuery = query.trim().toLowerCase(); + const filteredPolicies = useMemo(() => { + if (!normalizedQuery) return policies; + return policies.filter((p) => + (p.name ?? '').toLowerCase().includes(normalizedQuery), + ); + }, [policies, normalizedQuery]); + + const groups = useMemo(() => { + const byStatus = new Map(); + for (const p of filteredPolicies) { + const status = (p.status ?? 'draft') as PolicyStatus; + const list = byStatus.get(status) ?? []; + list.push(p); + byStatus.set(status, list); + } + return STATUS_ORDER.filter((s) => (byStatus.get(s)?.length ?? 0) > 0).map( + (s) => ({ status: s, items: byStatus.get(s) ?? [] }), + ); + }, [filteredPolicies]); + + const visibleIds = useMemo( + () => filteredPolicies.map((p) => p.id), + [filteredPolicies], + ); + const visibleSelectedCount = visibleIds.filter((id) => + selected.has(id), + ).length; + const allVisibleChecked = + visibleIds.length > 0 && visibleSelectedCount === visibleIds.length; + const someVisibleChecked = + visibleSelectedCount > 0 && visibleSelectedCount < visibleIds.length; + const handleToggle = (id: string) => { setSelected((prev) => { const next = new Set(prev); @@ -65,13 +94,29 @@ export function PolicyDownloadSheet({ }); }; - const allChecked = selected.size === allIds.length && allIds.length > 0; - const someChecked = selected.size > 0 && !allChecked; + const handleToggleVisible = () => { + setSelected((prev) => { + const next = new Set(prev); + if (allVisibleChecked) { + for (const id of visibleIds) next.delete(id); + } else { + for (const id of visibleIds) next.add(id); + } + return next; + }); + }; - const handleToggleAll = () => { - setSelected((prev) => - prev.size === allIds.length ? new Set() : new Set(allIds), - ); + const handleToggleGroup = (groupIds: string[]) => { + const allChecked = groupIds.every((id) => selected.has(id)); + setSelected((prev) => { + const next = new Set(prev); + if (allChecked) { + for (const id of groupIds) next.delete(id); + } else { + for (const id of groupIds) next.add(id); + } + return next; + }); }; const handleDownload = async () => { @@ -111,6 +156,12 @@ export function PolicyDownloadSheet({ ? 'Download' : `Download ${count} ${count === 1 ? 'policy' : 'policies'}`; + const hasAnyResults = filteredPolicies.length > 0; + const totalDiffersFromVisible = count !== visibleSelectedCount; + const selectAllSuffix = totalDiffersFromVisible + ? `, ${count} total selected` + : ''; + return ( @@ -119,42 +170,70 @@ export function PolicyDownloadSheet({ -
+ setQuery(e.target.value)} + aria-label="Search policies" + /> + +
- Select all ({selected.size} of {allIds.length} selected) + Select all ({visibleSelectedCount} of {visibleIds.length} shown + {selectAllSuffix})
- - - {policies.map((policy) => { - const statusKey = policy.status ?? 'draft'; - return ( -
+ + {!hasAnyResults && No policies match "{query}".} + + {hasAnyResults && + groups.map((group) => { + const groupIds = group.items.map((p) => p.id); + const groupChecked = + groupIds.length > 0 && + groupIds.every((id) => selected.has(id)); + const groupSome = + !groupChecked && groupIds.some((id) => selected.has(id)); + + return ( +
+
handleToggle(policy.id)} - aria-label={policy.name ?? 'Policy'} + checked={groupChecked} + indeterminate={groupSome} + onCheckedChange={() => handleToggleGroup(groupIds)} + aria-label={`Toggle ${STATUS_LABEL[group.status]} group`} /> -
- {policy.name} - - {STATUS_LABEL[statusKey] ?? statusKey} - -
+ + {STATUS_LABEL[group.status]} ({group.items.length}) + +
+
+ {group.items.map((policy) => ( +
+ handleToggle(policy.id)} + aria-label={policy.name ?? 'Policy'} + /> + {policy.name} +
+ ))}
- ); - })} - - +
+ ); + })} From f817d4465da29ab7f868b5271e45b632feb070f2 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 24 Apr 2026 15:56:48 -0400 Subject: [PATCH 28/40] 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 110b8b1ee6..62aa664646 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 0dbe4dd839..35ca9848c2 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 f74d261fd2..91fa1d7e63 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 29/40] 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 df2859e5ac..f1fc30fc8f 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 30/40] 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 e41b658023..a9a9ed12a8 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 31/40] 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 675cd0bef6..21866e0fa7 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 32b210ee57f4b8eb0c900a961e53a1d7dc9e600e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:37:57 -0400 Subject: [PATCH 32/40] feat: add ability to set frequency on automations running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): add isDueToday scheduler helper * test(api): use .spec.ts suffix and fix misleading test title Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): harden isDueToday exhaustive check and document UTC contract Co-Authored-By: Claude Opus 4.7 (1M context) * feat(db): add per-automation scheduleFrequency and lastRunAt * refactor(api): remove stale schedule field from browserbase DTOs and update service The BrowserAutomation.schedule column was dropped in the previous commit. TypeScript did not catch the dangling DTO entries or updateBrowserAutomation param because object spread does not trigger excess-property checks, but Prisma would throw "Unknown argument" at runtime if a caller supplied the field. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(api): respect integrationScheduleFrequency in integration orchestrator * fix(api): retry integration task on transient execution error Previously, integrationLastRunAt was written whenever the check loop completed, even if one or more checks returned status='error' (meaning the check could not execute). On weekly+ schedules this would push the retry out a full period. Distinguish 'error' (infra issue, retry) from 'failed' (legitimate finding, ran successfully). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(api): respect scheduleFrequency in browser automation orchestrator * docs(api): clarify browser orchestrator retry comment Co-Authored-By: Claude Opus 4.7 (1M context) * feat(api): accept scheduleFrequency in automation and task PATCH endpoints * feat(app): add SchedulePicker component * feat(app): add schedule picker to browser automation config * feat(app): add schedule edit dialog for evidence automations * feat(app): add integration schedule picker on task detail * feat(app): show schedule summary on automation cards * fix(app,api): repair test fixtures and guard missing schedule frequency Updates test fixtures for Task schema additions (integrationScheduleFrequency, integrationLastRunAt, previousStatus, archivedAt, lastCompletedAt), adds TaskFrequency to the @db mock in browserbase.controller.spec so the new @IsEnum(TaskFrequency) DTO decorator resolves at module load, and fixes an undefined-index access in AutomationOverview when the scheduleFrequency field is missing on stale client types. Co-Authored-By: Claude Opus 4.7 (1M context) * style(api): apply prettier to SALE-49 files Co-Authored-By: Claude Opus 4.7 (1M context) * fix: address cubic review findings on schedule UI and DTOs - TaskIntegrationChecks: compute "Next run" from scheduleFrequency + lastRunAt rather than hardcoding daily 6 AM UTC (was misleading for non-daily schedules). - Task response DTOs (swagger.dto.ts + task-responses.dto.ts): expose integrationScheduleFrequency and integrationLastRunAt so API consumers see them in generated documentation. - ScheduleSummary: render locale-agnostic YYYY-MM-DD and anchor "now" to UTC midnight to prevent server/client hydration mismatches. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(app): daily period is 1 day in TaskIntegrationChecks next-run calc daily: 0 caused the "next run" display to always skip to tomorrow even when the task was due today. Align with ScheduleSummary's PERIOD_DAYS map. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): include integrationScheduleFrequency in createTask response The TaskResponseDto now declares integrationScheduleFrequency as a required field; the createTask service method must populate it from the persisted row to satisfy the contract. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(app): inline schedule picker on automation overview, render label not enum Replaces EditScheduleDialog with an inline SchedulePicker on the automation Settings tab — fewer clicks, no Dialog focus-trap quirks when interacting with the DS Select. Also forces SelectValue to render the human label (e.g. "Daily") rather than falling back to the raw enum string when re-opened. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(app): metrics schedule + next-run reflect frequency in user's TZ - MetricsSection now takes scheduleFrequency + lastRunAt and re-derives the Schedule and Next Run labels per change. - "Every day at 9:00 AM UTC" → "Every day at 4:00 AM EST" (or whatever the user's locale resolves), and the same TZ is shown on Next Run for consistency. - Both labels are computed client-only to avoid SSR/CSR drift across timezones; placeholder em-dash during SSR. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(app): use @trycompai/ui Select in SchedulePicker The DS Select uses base-ui internals whose portaled popover fights with the Radix-based Dialog focus trap (closes immediately on click in BrowserAutomationConfigDialog). @trycompai/ui Select is Radix-based like the Dialog, so the two co-operate. Same API shape so callers don't change. Test mock updated accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(app): show next-run as next tick (not now + period) when never run For null lastRunAt the orchestrator's isDueToday short-circuits to true, so the automation runs on the very next 09:00 UTC tick. The previous math added a full period to "now", over-projecting a never-run weekly automation a full week into the future. Apply the same fix to ScheduleSummary on automation cards. Also tighten the MetricsSection test's UTC-absence assertion to use a word-boundary regex so it can't pass on a label that legitimately contains "UTC". Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Mariano Co-authored-by: Claude Opus 4.7 (1M context) --- .../browserbase.controller.spec.ts | 16 +- .../browserbase/browserbase.service.spec.ts | 89 ++- .../src/browserbase/browserbase.service.ts | 32 +- .../src/browserbase/dto/browserbase.dto.ts | 27 +- .../tasks/automations/automations.service.ts | 7 +- .../automations/dto/update-automation.dto.ts | 13 +- apps/api/src/tasks/dto/swagger.dto.ts | 28 + apps/api/src/tasks/dto/task-responses.dto.ts | 14 + apps/api/src/tasks/schemas/task.schemas.ts | 1 + apps/api/src/tasks/tasks.controller.ts | 11 + apps/api/src/tasks/tasks.service.ts | 12 +- .../run-browser-automation.ts | 18 + .../run-browser-automations-schedule.spec.ts | 118 ++++ .../run-browser-automations-schedule.ts | 62 +- .../run-integration-checks-schedule.spec.ts | 117 ++++ .../run-integration-checks-schedule.ts | 42 +- .../run-task-integration-checks.ts | 17 + .../src/trigger/shared/is-due-today.spec.ts | 141 +++++ apps/api/src/trigger/shared/is-due-today.ts | 51 ++ .../hooks/use-task-automation.ts | 13 +- .../components/AutomationOverview.tsx | 44 +- .../components/MetricsSection.test.tsx | 152 +++-- .../overview/components/MetricsSection.tsx | 147 +++-- .../tasks/[taskId]/components/SingleTask.tsx | 16 + .../[taskId]/components/TaskAutomations.tsx | 5 + .../components/TaskIntegrationChecks.tsx | 73 ++- .../browser-automations/AutomationItem.tsx | 9 + .../BrowserAutomationConfigDialog.tsx | 42 +- .../[orgId]/tasks/[taskId]/hooks/types.ts | 4 + .../[orgId]/tasks/[taskId]/hooks/use-task.ts | 3 +- .../[taskId]/hooks/useBrowserAutomations.ts | 3 + .../ModernSingleStatusTaskList.test.tsx | 34 +- .../tasks/components/TaskList.test.tsx | 5 + .../src/components/schedule-picker.test.tsx | 91 +++ apps/app/src/components/schedule-picker.tsx | 49 ++ .../src/components/schedule-summary.test.tsx | 22 + apps/app/src/components/schedule-summary.tsx | 60 ++ .../migration.sql | 18 + packages/db/prisma/schema/auth.prisma | 28 +- packages/db/prisma/schema/automation.prisma | 12 +- .../prisma/schema/browserbase-context.prisma | 121 ++-- packages/db/prisma/schema/control.prisma | 14 +- packages/db/prisma/schema/device.prisma | 14 +- .../prisma/schema/dynamic-integration.prisma | 150 ++--- .../db/prisma/schema/framework-editor.prisma | 4 +- .../db/prisma/schema/framework-version.prisma | 20 +- .../prisma/schema/integration-platform.prisma | 544 +++++++++--------- .../prisma/schema/integration-sync-log.prisma | 6 +- .../prisma/schema/notification-policy.prisma | 8 +- .../prisma/schema/organization-billing.prisma | 2 +- packages/db/prisma/schema/organization.prisma | 38 +- packages/db/prisma/schema/policy.prisma | 6 +- .../prisma/schema/remediation-action.prisma | 48 +- .../db/prisma/schema/remediation-batch.prisma | 32 +- .../security-penetration-test-run.prisma | 10 +- packages/db/prisma/schema/soa.prisma | 4 +- packages/db/prisma/schema/task-item.prisma | 28 +- packages/db/prisma/schema/task.prisma | 22 +- packages/db/prisma/schema/timeline.prisma | 110 ++-- 59 files changed, 2048 insertions(+), 779 deletions(-) create mode 100644 apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts create mode 100644 apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts create mode 100644 apps/api/src/trigger/shared/is-due-today.spec.ts create mode 100644 apps/api/src/trigger/shared/is-due-today.ts create mode 100644 apps/app/src/components/schedule-picker.test.tsx create mode 100644 apps/app/src/components/schedule-picker.tsx create mode 100644 apps/app/src/components/schedule-summary.test.tsx create mode 100644 apps/app/src/components/schedule-summary.tsx create mode 100644 packages/db/prisma/migrations/20260424164600_custom_task_schedules/migration.sql diff --git a/apps/api/src/browserbase/browserbase.controller.spec.ts b/apps/api/src/browserbase/browserbase.controller.spec.ts index 1713edeffa..6e8cbe75e0 100644 --- a/apps/api/src/browserbase/browserbase.controller.spec.ts +++ b/apps/api/src/browserbase/browserbase.controller.spec.ts @@ -10,6 +10,13 @@ jest.mock('@db', () => ({ } }, }, + TaskFrequency: { + daily: 'daily', + weekly: 'weekly', + monthly: 'monthly', + quarterly: 'quarterly', + yearly: 'yearly', + }, })); jest.mock('../auth/auth.server', () => ({ @@ -35,7 +42,9 @@ import { PermissionGuard } from '../auth/permission.guard'; describe('BrowserbaseController.redirectToScreenshot', () => { let controller: BrowserbaseController; - let service: jest.Mocked>; + let service: jest.Mocked< + Pick + >; beforeEach(async () => { service = { @@ -73,7 +82,10 @@ describe('BrowserbaseController.redirectToScreenshot', () => { organizationId: 'org_1', download: false, }); - expect(res.redirect).toHaveBeenCalledWith(302, 'https://s3.example.com/fresh-signed'); + expect(res.redirect).toHaveBeenCalledWith( + 302, + 'https://s3.example.com/fresh-signed', + ); }); it('passes download=true to the service when the query param is "true"', async () => { diff --git a/apps/api/src/browserbase/browserbase.service.spec.ts b/apps/api/src/browserbase/browserbase.service.spec.ts index 5a06feaf39..7f8cdba32f 100644 --- a/apps/api/src/browserbase/browserbase.service.spec.ts +++ b/apps/api/src/browserbase/browserbase.service.spec.ts @@ -8,6 +8,17 @@ jest.mock('@db', () => ({ browserAutomationRun: { findUnique: jest.fn(), }, + browserAutomation: { + create: jest.fn(), + update: jest.fn(), + }, + }, + TaskFrequency: { + daily: 'daily', + weekly: 'weekly', + monthly: 'monthly', + quarterly: 'quarterly', + yearly: 'yearly', }, })); @@ -17,7 +28,7 @@ jest.mock('@/app/s3', () => ({ BUCKET_NAME: 'test-bucket', })); -import { db } from '@db'; +import { db, TaskFrequency } from '@db'; import { getSignedUrl } from '@/app/s3'; describe('BrowserbaseService.getScreenshotRedirectUrl', () => { @@ -126,3 +137,79 @@ describe('BrowserbaseService.getScreenshotRedirectUrl', () => { ); }); }); + +describe('BrowserbaseService schedule frequency passthrough', () => { + let service: BrowserbaseService; + + beforeEach(async () => { + jest.clearAllMocks(); + const moduleRef = await Test.createTestingModule({ + providers: [BrowserbaseService], + }).compile(); + service = moduleRef.get(BrowserbaseService); + }); + + it('forwards scheduleFrequency when creating a browser automation', async () => { + (db.browserAutomation.create as jest.Mock).mockResolvedValue({ + id: 'bau_1', + }); + + await service.createBrowserAutomation({ + taskId: 'tsk_1', + name: 'name', + targetUrl: 'https://example.com', + instruction: 'click', + scheduleFrequency: TaskFrequency.weekly, + }); + + expect(db.browserAutomation.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ scheduleFrequency: 'weekly' }), + }), + ); + }); + + it('omits scheduleFrequency when creating without the field', async () => { + (db.browserAutomation.create as jest.Mock).mockResolvedValue({ + id: 'bau_1', + }); + + await service.createBrowserAutomation({ + taskId: 'tsk_1', + name: 'name', + targetUrl: 'https://example.com', + instruction: 'click', + }); + + const call = (db.browserAutomation.create as jest.Mock).mock.calls[0][0]; + expect(call.data).not.toHaveProperty('scheduleFrequency'); + }); + + it('forwards scheduleFrequency when updating a browser automation', async () => { + (db.browserAutomation.update as jest.Mock).mockResolvedValue({ + id: 'bau_1', + }); + + await service.updateBrowserAutomation('bau_1', { + scheduleFrequency: TaskFrequency.monthly, + }); + + expect(db.browserAutomation.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'bau_1' }, + data: expect.objectContaining({ scheduleFrequency: 'monthly' }), + }), + ); + }); + + it('omits scheduleFrequency when updating without the field', async () => { + (db.browserAutomation.update as jest.Mock).mockResolvedValue({ + id: 'bau_1', + }); + + await service.updateBrowserAutomation('bau_1', { name: 'renamed' }); + + const call = (db.browserAutomation.update as jest.Mock).mock.calls[0][0]; + expect(call.data).not.toHaveProperty('scheduleFrequency'); + }); +}); diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 8e388fbc13..da81adadea 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -3,9 +3,13 @@ import Browserbase from '@browserbasehq/sdk'; // Lazy-imported in createStagehand() to avoid Node v25 crash // (SlowBuffer.prototype was removed — @browserbasehq/stagehand bundles buffer-equal-constant-time which uses it) type Stagehand = import('@browserbasehq/stagehand').Stagehand; -import { db } from '@db'; +import { db, TaskFrequency } from '@db'; import { z } from 'zod'; -import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; import { BUCKET_NAME, getSignedUrl, s3Client } from '@/app/s3'; import { renderOverlay } from './screenshot-overlay'; import { isNoPageError, toRunErrorMessage } from './run-error-formatter'; @@ -353,7 +357,7 @@ export class BrowserbaseService { targetUrl: string; instruction: string; evaluationCriteria?: string; - schedule?: string; + scheduleFrequency?: TaskFrequency; }) { return db.browserAutomation.create({ data: { @@ -363,8 +367,10 @@ export class BrowserbaseService { targetUrl: data.targetUrl, instruction: data.instruction, evaluationCriteria: normalizeCriteria(data.evaluationCriteria), - schedule: data.schedule, isEnabled: true, // Enable by default so scheduled runs work + ...(data.scheduleFrequency !== undefined + ? { scheduleFrequency: data.scheduleFrequency } + : {}), }, }); } @@ -402,11 +408,11 @@ export class BrowserbaseService { targetUrl?: string; instruction?: string; evaluationCriteria?: string; - schedule?: string; isEnabled?: boolean; + scheduleFrequency?: TaskFrequency; }, ) { - const { evaluationCriteria, ...rest } = data; + const { evaluationCriteria, scheduleFrequency, ...rest } = data; return db.browserAutomation.update({ where: { id: automationId }, data: { @@ -414,6 +420,7 @@ export class BrowserbaseService { ...(evaluationCriteria !== undefined ? { evaluationCriteria: normalizeCriteria(evaluationCriteria) } : {}), + ...(scheduleFrequency !== undefined ? { scheduleFrequency } : {}), }, }); } @@ -848,10 +855,15 @@ export class BrowserbaseService { capturedAt: new Date(), }); } catch (overlayErr) { - this.logger.warn('Screenshot overlay render failed; uploading raw image', { - error: - overlayErr instanceof Error ? overlayErr.message : String(overlayErr), - }); + this.logger.warn( + 'Screenshot overlay render failed; uploading raw image', + { + error: + overlayErr instanceof Error + ? overlayErr.message + : String(overlayErr), + }, + ); } // Optional evaluation: if the automation was configured with diff --git a/apps/api/src/browserbase/dto/browserbase.dto.ts b/apps/api/src/browserbase/dto/browserbase.dto.ts index 00b637cf20..2a7334a782 100644 --- a/apps/api/src/browserbase/dto/browserbase.dto.ts +++ b/apps/api/src/browserbase/dto/browserbase.dto.ts @@ -1,11 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { + IsEnum, IsNotEmpty, IsOptional, IsString, IsBoolean, IsUrl, } from 'class-validator'; +import { TaskFrequency } from '@db'; import { IsSafeUrl } from '../validators/url-safety.validator'; // ===== Session DTOs ===== @@ -90,10 +92,13 @@ export class CreateBrowserAutomationDto { @IsOptional() evaluationCriteria?: string; - @ApiPropertyOptional({ description: 'Cron schedule expression' }) - @IsString() + @ApiPropertyOptional({ + enum: TaskFrequency, + description: 'Automation schedule cadence', + }) + @IsEnum(TaskFrequency) @IsOptional() - schedule?: string; + scheduleFrequency?: TaskFrequency; } export class UpdateBrowserAutomationDto { @@ -127,15 +132,18 @@ export class UpdateBrowserAutomationDto { @IsOptional() evaluationCriteria?: string; - @ApiPropertyOptional({ description: 'Cron schedule expression' }) - @IsString() - @IsOptional() - schedule?: string; - @ApiPropertyOptional({ description: 'Whether automation is enabled' }) @IsBoolean() @IsOptional() isEnabled?: boolean; + + @ApiPropertyOptional({ + enum: TaskFrequency, + description: 'Automation schedule cadence', + }) + @IsEnum(TaskFrequency) + @IsOptional() + scheduleFrequency?: TaskFrequency; } // ===== Response DTOs ===== @@ -186,9 +194,6 @@ export class BrowserAutomationResponseDto { @ApiProperty() isEnabled: boolean; - @ApiPropertyOptional() - schedule?: string; - @ApiProperty() createdAt: Date; diff --git a/apps/api/src/tasks/automations/automations.service.ts b/apps/api/src/tasks/automations/automations.service.ts index 3afedd380f..86a6fd8ee9 100644 --- a/apps/api/src/tasks/automations/automations.service.ts +++ b/apps/api/src/tasks/automations/automations.service.ts @@ -87,12 +87,17 @@ export class AutomationsService { throw new NotFoundException('Automation not found'); } + const { scheduleFrequency, ...rest } = updateAutomationDto; + // Update the automation const automation = await db.evidenceAutomation.update({ where: { id: automationId, }, - data: updateAutomationDto, + data: { + ...rest, + ...(scheduleFrequency !== undefined ? { scheduleFrequency } : {}), + }, }); return { diff --git a/apps/api/src/tasks/automations/dto/update-automation.dto.ts b/apps/api/src/tasks/automations/dto/update-automation.dto.ts index 6c5e87ef47..96ce844067 100644 --- a/apps/api/src/tasks/automations/dto/update-automation.dto.ts +++ b/apps/api/src/tasks/automations/dto/update-automation.dto.ts @@ -1,5 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsOptional, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsEnum } from 'class-validator'; +import { TaskFrequency } from '@db'; export class UpdateAutomationDto { @ApiProperty({ @@ -35,4 +36,12 @@ export class UpdateAutomationDto { @IsString() @IsOptional() evaluationCriteria?: string; + + @ApiPropertyOptional({ + enum: TaskFrequency, + description: 'Automation schedule cadence', + }) + @IsEnum(TaskFrequency) + @IsOptional() + scheduleFrequency?: TaskFrequency; } diff --git a/apps/api/src/tasks/dto/swagger.dto.ts b/apps/api/src/tasks/dto/swagger.dto.ts index 19c73df0a5..76b21023ea 100644 --- a/apps/api/src/tasks/dto/swagger.dto.ts +++ b/apps/api/src/tasks/dto/swagger.dto.ts @@ -29,6 +29,14 @@ export class CreateTaskDto { }) frequency?: string; + @ApiProperty({ + description: + 'Cadence for running the integration check attached to this task', + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + required: false, + }) + integrationScheduleFrequency?: string; + @ApiProperty({ description: 'Department assignment', enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], @@ -80,6 +88,14 @@ export class UpdateTaskDto { }) frequency?: string; + @ApiProperty({ + description: + 'Cadence for running the integration check attached to this task', + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + required: false, + }) + integrationScheduleFrequency?: string; + @ApiProperty({ description: 'Department assignment', enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], @@ -172,6 +188,18 @@ export class TaskResponseDto { }) frequency: string | null; + @ApiProperty({ + description: 'Cadence for running the integration check attached to this task', + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + }) + integrationScheduleFrequency: string; + + @ApiProperty({ + description: 'Last successful integration check run timestamp', + nullable: true, + }) + integrationLastRunAt: Date | null; + @ApiProperty({ description: 'Department assignment', enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], diff --git a/apps/api/src/tasks/dto/task-responses.dto.ts b/apps/api/src/tasks/dto/task-responses.dto.ts index 3870fe3075..d80661ccf5 100644 --- a/apps/api/src/tasks/dto/task-responses.dto.ts +++ b/apps/api/src/tasks/dto/task-responses.dto.ts @@ -85,4 +85,18 @@ export class TaskResponseDto { required: false, }) taskTemplateId?: string | null; + + @ApiProperty({ + description: 'Cadence for running the integration check attached to this task', + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + example: 'daily', + }) + integrationScheduleFrequency: string; + + @ApiProperty({ + description: 'Last successful integration check run timestamp', + nullable: true, + required: false, + }) + integrationLastRunAt?: Date | null; } diff --git a/apps/api/src/tasks/schemas/task.schemas.ts b/apps/api/src/tasks/schemas/task.schemas.ts index 5380d90987..907a29ef45 100644 --- a/apps/api/src/tasks/schemas/task.schemas.ts +++ b/apps/api/src/tasks/schemas/task.schemas.ts @@ -30,6 +30,7 @@ export const CreateTaskSchema = z.object({ description: z.string().min(1, 'Description is required'), status: TaskStatusSchema.optional().default('todo'), frequency: TaskFrequencySchema.optional(), + integrationScheduleFrequency: TaskFrequencySchema.optional(), department: DepartmentsSchema.optional().default('none'), order: z.number().int().min(0).optional().default(0), assigneeId: z.string().optional(), diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 47984b0a99..61a4ae8e3b 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -712,6 +712,13 @@ export class TasksController { enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], example: 'monthly', }, + integrationScheduleFrequency: { + type: 'string', + enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], + example: 'daily', + description: + 'Cadence for running the integration check attached to this task', + }, department: { type: 'string', enum: ['none', 'admin', 'gov', 'hr', 'it', 'itsm', 'qms'], @@ -754,6 +761,7 @@ export class TasksController { assigneeId?: string | null; approverId?: string | null; frequency?: string; + integrationScheduleFrequency?: string; department?: string; reviewDate?: string; }, @@ -789,6 +797,9 @@ export class TasksController { assigneeId: body.assigneeId, approverId: body.approverId, frequency: body.frequency as TaskFrequency | undefined, + integrationScheduleFrequency: body.integrationScheduleFrequency as + | TaskFrequency + | undefined, department: body.department, reviewDate: parsedReviewDate, }, diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index cc4fda1350..5a3e57d00c 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -532,8 +532,8 @@ export class TasksService { organizationId, timelinesService: this.timelinesService, }).catch((err) => { - this.logger.warn('timeline auto-complete check failed', err); - }); + this.logger.warn('timeline auto-complete check failed', err); + }); return { deletedCount: result.count }; } catch (error) { @@ -558,6 +558,7 @@ export class TasksService { assigneeId?: string | null; approverId?: string | null; frequency?: TaskFrequency; + integrationScheduleFrequency?: TaskFrequency; department?: string; reviewDate?: Date | null; }, @@ -592,6 +593,7 @@ export class TasksService { assigneeId?: string | null; approverId?: string | null; frequency?: TaskFrequency; + integrationScheduleFrequency?: TaskFrequency; department?: string; reviewDate?: Date | null; } = {}; @@ -651,6 +653,10 @@ export class TasksService { updateData.frequency, ); } + if (updateData.integrationScheduleFrequency !== undefined) { + dataToUpdate.integrationScheduleFrequency = + updateData.integrationScheduleFrequency; + } if (updateData.department !== undefined) { dataToUpdate.department = updateData.department; } @@ -879,6 +885,8 @@ export class TasksService { createdAt: task.createdAt, updatedAt: task.updatedAt, taskTemplateId: task.taskTemplateId, + integrationScheduleFrequency: task.integrationScheduleFrequency, + integrationLastRunAt: task.integrationLastRunAt, }; } catch (error) { console.error('Error creating task:', error); diff --git a/apps/api/src/trigger/browser-automation/run-browser-automation.ts b/apps/api/src/trigger/browser-automation/run-browser-automation.ts index 5b9bb5a8c6..26b54945a2 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automation.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automation.ts @@ -292,6 +292,7 @@ export const runBrowserAutomation = task({ runId: result.runId, error: result.error, needsReauth: result.needsReauth, + evaluationStatus: result.evaluationStatus, }); // Mark task as failed if auth issue @@ -325,6 +326,23 @@ export const runBrowserAutomation = task({ } } + // Record a successful run on the automation so the orchestrator's + // schedule filter (`isDueToday`) can skip it on the next tick. "Executed" + // here means the automation actually ran — including runs whose evaluation + // legitimately returned `fail`. We skip the write when the automation + // genuinely couldn't execute (e.g. `needsReauth` / missing browser context + // / other transient infra errors) so the next orchestrator tick retries + // instead of waiting a full schedule period. + const executed = + result.success === true || result.evaluationStatus === 'fail'; + + if (executed) { + await db.browserAutomation.update({ + where: { id: automationId }, + data: { lastRunAt: new Date() }, + }); + } + return { success: result.success, runId: result.runId, diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts new file mode 100644 index 0000000000..2ae7fb5a7d --- /dev/null +++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.spec.ts @@ -0,0 +1,118 @@ +import { TaskFrequency } from '@trycompai/db'; +import { filterDueAutomations } from './run-browser-automations-schedule'; + +// Mock @db at the module boundary so importing the orchestrator does not try +// to connect to Postgres. We never call the scheduled `run` function itself +// (it's wrapped in `schedules.task({...})` and not independently invokable), +// we only exercise the pure helper it uses. +jest.mock('@db', () => ({ + db: { + browserAutomation: { findMany: jest.fn(), update: jest.fn() }, + }, + TaskFrequency: { + daily: 'daily', + weekly: 'weekly', + monthly: 'monthly', + quarterly: 'quarterly', + yearly: 'yearly', + }, +})); + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + schedules: { + task: (config: unknown) => config, + }, +})); + +jest.mock('./run-browser-automation', () => ({ + runBrowserAutomation: { batchTrigger: jest.fn() }, +})); + +const atUtc = (iso: string) => new Date(`${iso}T00:00:00.000Z`); + +describe('filterDueAutomations (browser automation orchestrator)', () => { + const now = atUtc('2026-04-24'); + + it('returns only automations whose schedule says they are due', () => { + const candidateAutomations = [ + // Daily → always due + { + id: 'ba_daily', + name: 'Daily login check', + taskId: 'tsk_a', + scheduleFrequency: TaskFrequency.daily, + lastRunAt: atUtc('2026-04-23'), + }, + // Weekly, ran 3 days ago → NOT due + { + id: 'ba_weekly_recent', + name: 'Recent weekly', + taskId: 'tsk_b', + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: atUtc('2026-04-21'), + }, + // Weekly, ran 10 days ago → due + { + id: 'ba_weekly_stale', + name: 'Stale weekly', + taskId: 'tsk_c', + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: atUtc('2026-04-14'), + }, + ]; + + const due = filterDueAutomations({ + automations: candidateAutomations, + now, + }); + + expect(due.map((a) => a.id)).toEqual(['ba_daily', 'ba_weekly_stale']); + }); + + it('treats a null lastRunAt as due (first run)', () => { + const candidateAutomations = [ + { + id: 'ba_never_run', + name: 'Never run', + taskId: 'tsk_a', + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: null as Date | null, + }, + ]; + + const due = filterDueAutomations({ + automations: candidateAutomations, + now, + }); + + expect(due).toHaveLength(1); + expect(due[0]?.id).toBe('ba_never_run'); + }); + + it('returns an empty array when nothing is due', () => { + const candidateAutomations = [ + { + id: 'ba_monthly_recent', + name: 'Recent monthly', + taskId: 'tsk_a', + scheduleFrequency: TaskFrequency.monthly, + lastRunAt: atUtc('2026-04-10'), + }, + { + id: 'ba_quarterly_recent', + name: 'Recent quarterly', + taskId: 'tsk_b', + scheduleFrequency: TaskFrequency.quarterly, + lastRunAt: atUtc('2026-03-01'), + }, + ]; + + const due = filterDueAutomations({ + automations: candidateAutomations, + now, + }); + + expect(due).toEqual([]); + }); +}); diff --git a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts index 429a6260ab..dedd4b5660 100644 --- a/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts +++ b/apps/api/src/trigger/browser-automation/run-browser-automations-schedule.ts @@ -1,6 +1,29 @@ -import { db } from '@db'; +import { db, TaskFrequency } from '@db'; import { logger, schedules } from '@trigger.dev/sdk'; import { runBrowserAutomation } from './run-browser-automation'; +import { isDueToday } from '../shared/is-due-today'; + +/** + * Pure helper extracted for unit testing. Filters a list of candidate + * automations down to those whose schedule says they are due at `now`. + * + * Kept in-memory (Shape A from the plan) because the single source of truth + * for schedule math is `isDueToday`; duplicating it in SQL would create drift. + */ +export function filterDueAutomations< + T extends { + scheduleFrequency: TaskFrequency; + lastRunAt: Date | null; + }, +>({ automations, now }: { automations: T[]; now: Date }): T[] { + return automations.filter((a) => + isDueToday({ + scheduleFrequency: a.scheduleFrequency, + lastRunAt: a.lastRunAt, + now, + }), + ); +} /** * Daily scheduled task (orchestrator) that finds all enabled browser automations @@ -16,10 +39,17 @@ export const browserAutomationsSchedule = schedules.task({ lastRun: payload.lastTimestamp, }); + const now = new Date(); + // Find all enabled browser automations - const automations = await db.browserAutomation.findMany({ + const candidateAutomations = await db.browserAutomation.findMany({ where: { isEnabled: true }, - include: { + select: { + id: true, + name: true, + taskId: true, + scheduleFrequency: true, + lastRunAt: true, task: { select: { id: true, @@ -30,12 +60,34 @@ export const browserAutomationsSchedule = schedules.task({ }, }); - if (automations.length === 0) { + if (candidateAutomations.length === 0) { logger.info('No enabled browser automations found'); return { success: true, automationsTriggered: 0 }; } - logger.info(`Found ${automations.length} enabled browser automations`); + logger.info( + `Found ${candidateAutomations.length} enabled browser automations`, + ); + + // Filter by the automation's schedule. `lastRunAt` is only written when + // the automation actually executed (including legitimate 'fail' verdicts) + // inside `runBrowserAutomation`, so infra-level failures naturally retry + // on the next orchestrator tick (the "crude retry" behavior). + const automations = filterDueAutomations({ + automations: candidateAutomations, + now, + }); + + if (automations.length < candidateAutomations.length) { + logger.info( + `Skipped ${candidateAutomations.length - automations.length} automation(s) not due yet`, + ); + } + + if (automations.length === 0) { + logger.info('No browser automations due today'); + return { success: true, automationsTriggered: 0 }; + } // Build payloads for batch triggering const triggerPayloads = automations.map((automation) => ({ diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts new file mode 100644 index 0000000000..9343a609c9 --- /dev/null +++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.spec.ts @@ -0,0 +1,117 @@ +import { TaskFrequency } from '@trycompai/db'; +import { filterDueTasks } from './run-integration-checks-schedule'; + +// Mock @db at the module boundary so importing the orchestrator does not try +// to connect to Postgres. We never call the scheduled `run` function itself +// (it's wrapped in `schedules.task({...})` and not independently invokable), +// we only exercise the pure helper it uses. +jest.mock('@db', () => ({ + db: { + integrationConnection: { findMany: jest.fn() }, + task: { findMany: jest.fn(), update: jest.fn() }, + }, + TaskFrequency: { + daily: 'daily', + weekly: 'weekly', + monthly: 'monthly', + quarterly: 'quarterly', + yearly: 'yearly', + }, +})); + +jest.mock('@trycompai/integration-platform', () => ({ + getManifest: jest.fn(), +})); + +jest.mock('@trigger.dev/sdk', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + schedules: { + task: (config: unknown) => config, + }, +})); + +jest.mock('./run-task-integration-checks', () => ({ + runTaskIntegrationChecks: { batchTrigger: jest.fn() }, +})); + +const atUtc = (iso: string) => new Date(`${iso}T00:00:00.000Z`); + +describe('filterDueTasks (integration orchestrator)', () => { + const now = atUtc('2026-04-24'); + + it('returns only tasks whose schedule says they are due', () => { + const candidateTasks = [ + // Daily → always due + { + id: 'tsk_daily', + title: 'Daily check', + taskTemplateId: 'tpl_a', + integrationScheduleFrequency: TaskFrequency.daily, + integrationLastRunAt: atUtc('2026-04-23'), + }, + // Weekly, ran 2 days ago → NOT due + { + id: 'tsk_weekly_recent', + title: 'Recent weekly', + taskTemplateId: 'tpl_b', + integrationScheduleFrequency: TaskFrequency.weekly, + integrationLastRunAt: atUtc('2026-04-22'), + }, + // Weekly, ran 10 days ago → due + { + id: 'tsk_weekly_stale', + title: 'Stale weekly', + taskTemplateId: 'tpl_c', + integrationScheduleFrequency: TaskFrequency.weekly, + integrationLastRunAt: atUtc('2026-04-14'), + }, + ]; + + const dueTasks = filterDueTasks({ tasks: candidateTasks, now }); + + expect(dueTasks.map((t) => t.id)).toEqual([ + 'tsk_daily', + 'tsk_weekly_stale', + ]); + }); + + it('treats a null integrationLastRunAt as due (first run)', () => { + const candidateTasks = [ + { + id: 'tsk_never_run', + title: 'Never run', + taskTemplateId: 'tpl_a', + integrationScheduleFrequency: TaskFrequency.yearly, + integrationLastRunAt: null as Date | null, + }, + ]; + + const dueTasks = filterDueTasks({ tasks: candidateTasks, now }); + + expect(dueTasks).toHaveLength(1); + expect(dueTasks[0]?.id).toBe('tsk_never_run'); + }); + + it('returns an empty array when nothing is due', () => { + const candidateTasks = [ + { + id: 'tsk_monthly_recent', + title: 'Recent monthly', + taskTemplateId: 'tpl_a', + integrationScheduleFrequency: TaskFrequency.monthly, + integrationLastRunAt: atUtc('2026-04-10'), + }, + { + id: 'tsk_quarterly_recent', + title: 'Recent quarterly', + taskTemplateId: 'tpl_b', + integrationScheduleFrequency: TaskFrequency.quarterly, + integrationLastRunAt: atUtc('2026-03-01'), + }, + ]; + + const dueTasks = filterDueTasks({ tasks: candidateTasks, now }); + + expect(dueTasks).toEqual([]); + }); +}); diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts index 0a80d22fa6..600e03ef94 100644 --- a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts +++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts @@ -1,8 +1,31 @@ import { getManifest } from '@trycompai/integration-platform'; -import { db } from '@db'; +import { db, TaskFrequency } from '@db'; import { logger, schedules } from '@trigger.dev/sdk'; import { runTaskIntegrationChecks } from './run-task-integration-checks'; import { parseDisabledTaskChecks } from '../../integration-platform/utils/disabled-task-checks'; +import { isDueToday } from '../shared/is-due-today'; + +/** + * Pure helper extracted for unit testing. Filters a list of candidate tasks + * down to those whose schedule says they are due at `now`. + * + * Kept in-memory (Shape A from the plan) because the single source of truth + * for schedule math is `isDueToday`; duplicating it in SQL would create drift. + */ +export function filterDueTasks< + T extends { + integrationScheduleFrequency: TaskFrequency; + integrationLastRunAt: Date | null; + }, +>({ tasks, now }: { tasks: T[]; now: Date }): T[] { + return tasks.filter((t) => + isDueToday({ + scheduleFrequency: t.integrationScheduleFrequency, + lastRunAt: t.integrationLastRunAt, + now, + }), + ); +} /** * Daily scheduled task (orchestrator) that finds all tasks with integration checks @@ -18,6 +41,8 @@ export const integrationChecksSchedule = schedules.task({ lastRun: payload.lastTimestamp, }); + const now = new Date(); + // Get all active integration connections const activeConnections = await db.integrationConnection.findMany({ where: { status: 'active' }, @@ -63,7 +88,7 @@ export const integrationChecksSchedule = schedules.task({ } // Find tasks in this org that match these templates - const tasks = await db.task.findMany({ + const candidateTasks = await db.task.findMany({ where: { organizationId: connection.organizationId, taskTemplateId: { in: taskTemplateIds as string[] }, @@ -72,9 +97,22 @@ export const integrationChecksSchedule = schedules.task({ id: true, title: true, taskTemplateId: true, + integrationScheduleFrequency: true, + integrationLastRunAt: true, }, }); + // Filter by the task's integration schedule. `lastRunAt` is only written + // on success inside `runTaskIntegrationChecks`, so failures naturally + // retry on the next orchestrator tick (the "crude retry" behavior). + const tasks = filterDueTasks({ tasks: candidateTasks, now }); + + if (tasks.length < candidateTasks.length) { + logger.info( + `Skipped ${candidateTasks.length - tasks.length} task(s) not due yet for connection ${connection.id}`, + ); + } + // Per-task disabled checks are stored on the connection's metadata so // users can disconnect individual checks from individual tasks without // tearing down the whole integration. Resolve once per connection. diff --git a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts index 57aef35305..e2c4b404a5 100644 --- a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts +++ b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts @@ -319,6 +319,7 @@ export const runTaskIntegrationChecks = task({ let totalFindings = 0; let totalPassing = 0; let hasFailedChecks = false; + let hasExecutionErrors = false; // Run only the checks that apply to this task try { @@ -347,6 +348,9 @@ export const runTaskIntegrationChecks = task({ if (checkResult.status === 'failed' || checkResult.status === 'error') { hasFailedChecks = true; } + if (checkResult.status === 'error') { + hasExecutionErrors = true; + } // Store check run const checkRun = await db.integrationCheckRun.create({ @@ -415,6 +419,19 @@ export const runTaskIntegrationChecks = task({ data: { lastSyncAt: new Date() }, }); + // Record a successful run on the task so the orchestrator's schedule + // filter (`isDueToday`) can skip it on the next tick. "Successful" here + // means every check executed — including checks that legitimately found + // violations (`status: 'failed'`). We skip the write only when a check + // couldn't execute (`status: 'error'`, e.g. transient provider error), + // so the next orchestrator tick retries instead of waiting a full period. + if (!hasExecutionErrors) { + await db.task.update({ + where: { id: taskId }, + data: { integrationLastRunAt: new Date() }, + }); + } + // Update task status based on check results // If any findings or check failures, mark as failed // If all checks pass with no findings, mark as done (only if not already done) diff --git a/apps/api/src/trigger/shared/is-due-today.spec.ts b/apps/api/src/trigger/shared/is-due-today.spec.ts new file mode 100644 index 0000000000..3920031e2c --- /dev/null +++ b/apps/api/src/trigger/shared/is-due-today.spec.ts @@ -0,0 +1,141 @@ +import { TaskFrequency } from '@trycompai/db'; +import { isDueToday } from './is-due-today'; + +const atUtc = (iso: string) => new Date(`${iso}T00:00:00.000Z`); + +describe('isDueToday', () => { + const now = atUtc('2026-04-24'); + + describe('daily', () => { + it('returns true when lastRunAt is null', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.daily, + lastRunAt: null, + now, + }), + ).toBe(true); + }); + it('returns true even when it ran today', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.daily, + lastRunAt: atUtc('2026-04-24'), + now, + }), + ).toBe(true); + }); + }); + + describe('weekly', () => { + it('returns true when lastRunAt is null', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: null, + now, + }), + ).toBe(true); + }); + it('returns false when lastRunAt is 6 days ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: atUtc('2026-04-18'), + now, + }), + ).toBe(false); + }); + it('returns true when lastRunAt is exactly 7 days ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: atUtc('2026-04-17'), + now, + }), + ).toBe(true); + }); + it('returns true when lastRunAt is 14 days ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.weekly, + lastRunAt: atUtc('2026-04-10'), + now, + }), + ).toBe(true); + }); + }); + + describe('monthly', () => { + it('returns true when lastRunAt crossed a calendar-month boundary', () => { + // 2026-03-26 → 2026-04-24: different calendar month → due + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.monthly, + lastRunAt: atUtc('2026-03-26'), + now, + }), + ).toBe(true); + }); + it('returns false when lastRunAt is same calendar month', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.monthly, + lastRunAt: atUtc('2026-04-01'), + now, + }), + ).toBe(false); + }); + it('returns true when lastRunAt is null', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.monthly, + lastRunAt: null, + now, + }), + ).toBe(true); + }); + }); + + describe('quarterly', () => { + it('returns false when lastRunAt is 2 months ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.quarterly, + lastRunAt: atUtc('2026-02-24'), + now, + }), + ).toBe(false); + }); + it('returns true when lastRunAt is 3 months ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.quarterly, + lastRunAt: atUtc('2026-01-24'), + now, + }), + ).toBe(true); + }); + }); + + describe('yearly', () => { + it('returns false when lastRunAt is 11 months ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.yearly, + lastRunAt: atUtc('2025-05-24'), + now, + }), + ).toBe(false); + }); + it('returns true when lastRunAt is 12 months ago', () => { + expect( + isDueToday({ + scheduleFrequency: TaskFrequency.yearly, + lastRunAt: atUtc('2025-04-24'), + now, + }), + ).toBe(true); + }); + }); +}); diff --git a/apps/api/src/trigger/shared/is-due-today.ts b/apps/api/src/trigger/shared/is-due-today.ts new file mode 100644 index 0000000000..c38008311d --- /dev/null +++ b/apps/api/src/trigger/shared/is-due-today.ts @@ -0,0 +1,51 @@ +import { TaskFrequency } from '@trycompai/db'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function calendarMonthsBetween(earlier: Date, later: Date): number { + const years = later.getUTCFullYear() - earlier.getUTCFullYear(); + const months = later.getUTCMonth() - earlier.getUTCMonth(); + return years * 12 + months; +} + +/** + * Returns whether an automation with the given schedule is due to run at `now`. + * + * `now` and `lastRunAt` are treated as UTC instants — weekly math uses fixed + * 86_400_000-ms days, monthly/quarterly/yearly use UTC calendar buckets. Callers + * should pass real `Date` values (any instant works); do NOT pass "midnight in + * local time" expecting DST-aware behavior. + * + * `null` lastRunAt always returns `true` (first run). + */ +export function isDueToday({ + scheduleFrequency, + lastRunAt, + now, +}: { + scheduleFrequency: TaskFrequency; + lastRunAt: Date | null; + now: Date; +}): boolean { + if (scheduleFrequency === TaskFrequency.daily) return true; + if (lastRunAt === null) return true; + + switch (scheduleFrequency) { + case TaskFrequency.weekly: { + const days = Math.floor( + (now.getTime() - lastRunAt.getTime()) / MS_PER_DAY, + ); + return days >= 7; + } + case TaskFrequency.monthly: + return calendarMonthsBetween(lastRunAt, now) >= 1; + case TaskFrequency.quarterly: + return calendarMonthsBetween(lastRunAt, now) >= 3; + case TaskFrequency.yearly: + return calendarMonthsBetween(lastRunAt, now) >= 12; + default: { + const _exhaustive: never = scheduleFrequency; + throw new Error(`Unhandled TaskFrequency: ${String(_exhaustive)}`); + } + } +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation.ts index bf4bf7b43e..45bb3d6958 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/hooks/use-task-automation.ts @@ -1,4 +1,5 @@ import { api } from '@/lib/api-client'; +import type { TaskFrequency } from '@db'; import { useParams } from 'next/navigation'; import useSWR from 'swr'; @@ -13,15 +14,21 @@ interface TaskAutomationData { updatedAt: string; evaluationCriteria?: string; isEnabled: boolean; + scheduleFrequency?: TaskFrequency; + lastRunAt?: string | null; } +type UpdateAutomationPayload = Partial< + Pick +>; + interface UseTaskAutomationReturn { automation: TaskAutomationData | undefined; isLoading: boolean; isError: boolean; error: Error | undefined; mutate: () => Promise; - updateAutomation: (body: Partial>) => Promise; + updateAutomation: (body: UpdateAutomationPayload) => Promise; deleteAutomation: () => Promise; } @@ -71,9 +78,7 @@ export function useTaskAutomation(overrideAutomationId?: string): UseTaskAutomat }, ); - const updateAutomation = async ( - body: Partial>, - ) => { + const updateAutomation = async (body: UpdateAutomationPayload) => { const realId = data?.id || automationId; const response = await api.patch( `/v1/tasks/${taskId}/automations/${realId}`, diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/AutomationOverview.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/AutomationOverview.tsx index 7802a16024..329135a712 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/AutomationOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/AutomationOverview.tsx @@ -3,7 +3,13 @@ import { RecentAuditLogs } from '@/components/RecentAuditLogs'; import { useAuditLogs } from '@/hooks/use-audit-logs'; import { Button } from '@trycompai/ui/button'; -import type { EvidenceAutomation, EvidenceAutomationRun, EvidenceAutomationVersion, Task } from '@db'; +import type { + EvidenceAutomation, + EvidenceAutomationRun, + EvidenceAutomationVersion, + Task, + TaskFrequency, +} from '@db'; import { Breadcrumb, Button as DSButton, @@ -28,6 +34,7 @@ import { toggleAutomationEnabled, } from '../../../../automation/[automationId]/actions/task-automation-actions'; import { DeleteAutomationDialog } from '../../../../automation/[automationId]/components/AutomationSettingsDialogs'; +import { SchedulePicker } from '@/components/schedule-picker'; import { useTaskAutomation } from '../../../../automation/[automationId]/hooks/use-task-automation'; import { AutomationRunsCard } from '../../../../components/AutomationRunsCard'; import { useAutomationRuns } from '../hooks/use-automation-runs'; @@ -57,6 +64,7 @@ export function AutomationOverview({ }>(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isUpdatingSchedule, setIsUpdatingSchedule] = useState(false); const [isTogglingEnabled, setIsTogglingEnabled] = useState(false); const [isEditingName, setIsEditingName] = useState(false); const [nameValue, setNameValue] = useState(''); @@ -136,6 +144,19 @@ export function AutomationOverview({ } }; + const handleScheduleChange = async (value: TaskFrequency) => { + setIsUpdatingSchedule(true); + try { + await updateAutomation({ scheduleFrequency: value }); + toast.success('Schedule updated'); + await mutateAutomation(); + } catch { + toast.error('Failed to update schedule'); + } finally { + setIsUpdatingSchedule(false); + } + }; + const handleTestVersion = async () => { if (!selectedVersion) return; setIsTestingVersion(true); @@ -272,6 +293,8 @@ export function AutomationOverview({ @@ -372,6 +395,24 @@ export function AutomationOverview({
+ + + Schedule + + How often this automation runs + + +
+ +
+
+ +
+ Delete Automation @@ -400,6 +441,7 @@ export function AutomationOverview({ onOpenChange={setDeleteDialogOpen} onSuccess={mutateAutomation} /> + ); } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx index 805db6a275..578927f1b3 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MetricsSection } from './MetricsSection'; -describe('MetricsSection (CS-97)', () => { +describe('MetricsSection (SALE-49)', () => { beforeEach(() => { // shouldAdvanceTime lets React effects flush on their normal tick // while still letting us pin `new Date()` with vi.setSystemTime. @@ -13,88 +13,114 @@ describe('MetricsSection (CS-97)', () => { vi.useRealTimers(); }); - it('labels the schedule as 9:00 AM UTC (no ambiguous local time)', () => { - vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); - render(); - expect(screen.getByText('Every day at 9:00 AM UTC')).toBeInTheDocument(); - }); - - it('uses an SSR-safe placeholder for the next run (defers date formatting to post-mount)', () => { - // Verify the initial JSX does NOT synchronously format a Date — that's - // the property that keeps SSR and hydration outputs identical. We - // simulate "server-side" rendering with renderToString and assert the - // Next Run cell specifically contains the em-dash placeholder rather - // than a formatted weekday/time. We scope the assertion to the Next Run - // card because Success Rate also renders `—` when there are no runs. + it('uses an SSR-safe placeholder for schedule + next run (defers date formatting to post-mount)', () => { const { renderToString } = require('react-dom/server') as typeof import('react-dom/server'); vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); const html = renderToString( - , + , ); - // No weekday should appear anywhere in SSR output. + // No weekday and no UTC literal in SSR output — defers locale-dependent + // formatting to the client-only effect. expect(html).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/); + expect(html).not.toMatch(/UTC/); - // Locate the Next Run cell and assert its value paragraph contains `—`. - // Matches:

Next Run

- const nextRunCellMatch = html.match( - /Next Run[^<]*<\/p>\s*]*>([^<]*)<\/p>/, - ); - expect(nextRunCellMatch).not.toBeNull(); - expect(nextRunCellMatch?.[1]).toBe('—'); + // Both Schedule and Next Run cells should show the em-dash placeholder. + const scheduleCell = html.match(/Schedule[^<]*<\/p>\s*]*>([^<]*)<\/p>/); + const nextRunCell = html.match(/Next Run[^<]*<\/p>\s*]*>([^<]*)<\/p>/); + expect(scheduleCell?.[1]).toBe('—'); + expect(nextRunCell?.[1]).toBe('—'); }); - it('fills in the next-run label after mount with a concrete weekday, time, and timezone', async () => { + it('labels the daily schedule with the user\'s timezone (not UTC)', async () => { vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); - render(); + render( + , + ); - // Old hardcoded literals must never appear. - expect(screen.queryByText('Every Day 9:00 AM')).not.toBeInTheDocument(); - expect(screen.queryByText('Tomorrow 9:00 AM')).not.toBeInTheDocument(); + // After mount: "Every day at