From e735c29781d9c6408237c602d79fecdf9669712b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Nov 2025 08:58:21 -0500 Subject: [PATCH 1/7] [dev] [Itsnotaka] daniel/ai-policy-edit (#1838) * feat(api): add AI chat endpoint for policy editing assistance, initial draft for ai policy edits * fix: type error * feat(policy-editor): integrate AI-assisted policy editing with markdown support * refactor(api): streamline POST function and enhance markdown guidelines * refactor(policy-editor): improve policy details layout and diff viewer integration * refactor(policy-editor): simplify policy details component and enhance AI assistant integration * refactor(policy-editor): remove unused AI assistant logic and simplify component structure * feat(ui): add new components to package.json for diff viewer and AI elements * chore: update lockfile * refactor(tsconfig): reorganize compiler options and update paths * fix(policies): resolve infinite loop in policy AI assistant * fix(api): update policy editing assistant instructions and tool usage --------- Co-authored-by: Daniel Fu Co-authored-by: Amp --- .husky/commit-msg | 3 - .../editor/components/PolicyDetails.tsx | 104 +++++++++- .../components/ai/policy-ai-assistant.tsx | 75 +++---- .../app/api/policies/[policyId]/chat/route.ts | 183 +++++++++++------- packages/ui/src/components/diff-viewer.tsx | 127 +++++++++--- 5 files changed, 338 insertions(+), 154 deletions(-) diff --git a/.husky/commit-msg b/.husky/commit-msg index 766bd7721..a78cc751d 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1 @@ -#!/usr/bin/env sh -. "$(dirname "$0")/_/husky.sh" - npx commitlint --edit $1 diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index ab897c2f8..ab303af21 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -1,15 +1,22 @@ 'use client'; import { PolicyEditor } from '@/components/editor/policy-editor'; +import { useChat } from '@ai-sdk/react'; import { Button } from '@comp/ui/button'; import { Card, CardContent } from '@comp/ui/card'; - import { DiffViewer } from '@comp/ui/diff-viewer'; import { validateAndFixTipTapContent } from '@comp/ui/editor'; import '@comp/ui/editor.css'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs'; import type { PolicyDisplayFormat } from '@db'; import type { JSONContent } from '@tiptap/react'; +import { + DefaultChatTransport, + getToolName, + isToolUIPart, + type ToolUIPart, + type UIMessage, +} from 'ai'; import { structuredPatch } from 'diff'; import { CheckCircle, Loader2, Sparkles, X } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; @@ -22,6 +29,50 @@ import { updatePolicy } from '../actions/update-policy'; import { markdownToTipTapJSON } from './ai/markdown-utils'; import { PolicyAiAssistant } from './ai/policy-ai-assistant'; +function mapChatErrorToMessage(error: unknown): string { + const e = error as { status?: number }; + const status = e?.status; + + if (status === 401 || status === 403) { + return "You don't have access to this policy's AI assistant."; + } + if (status === 404) { + return 'This policy could not be found. It may have been removed.'; + } + if (status === 429) { + return 'Too many requests. Please wait a moment and try again.'; + } + return 'The AI assistant is currently unavailable. Please try again.'; +} + +interface LatestProposal { + key: string; + content: string; + summary: string; +} + +function getLatestProposedPolicy(messages: UIMessage[]): LatestProposal | null { + const lastAssistantMessage = [...messages].reverse().find((m) => m.role === 'assistant'); + if (!lastAssistantMessage?.parts) return null; + + let latest: LatestProposal | null = null; + + lastAssistantMessage.parts.forEach((part, index) => { + if (!isToolUIPart(part) || getToolName(part) !== 'proposePolicy') return; + const toolPart = part as ToolUIPart; + const input = toolPart.input as { content?: string; summary?: string } | undefined; + if (!input?.content) return; + + latest = { + key: `${lastAssistantMessage.id}:${index}`, + content: input.content, + summary: input.summary ?? 'Proposing policy changes', + }; + }); + + return latest; +} + interface PolicyContentManagerProps { policyId: string; policyContent: JSONContent | JSONContent[]; @@ -46,10 +97,37 @@ export function PolicyContentManager({ return formattedContent; }); - const [proposedPolicyMarkdown, setProposedPolicyMarkdown] = useState(null); + const [dismissedProposalKey, setDismissedProposalKey] = useState(null); const [isApplying, setIsApplying] = useState(false); + const [chatErrorMessage, setChatErrorMessage] = useState(null); const isAiPolicyAssistantEnabled = useFeatureFlagEnabled('is-ai-policy-assistant-enabled'); + const { + messages, + status, + sendMessage: baseSendMessage, + } = useChat({ + transport: new DefaultChatTransport({ + api: `/api/policies/${policyId}/chat`, + }), + onError(error) { + console.error('Policy AI chat error:', error); + setChatErrorMessage(mapChatErrorToMessage(error)); + }, + }); + + const sendMessage = (payload: { text: string }) => { + setChatErrorMessage(null); + baseSendMessage(payload); + }; + + const latestProposal = useMemo(() => getLatestProposedPolicy(messages), [messages]); + + const activeProposal = + latestProposal && latestProposal.key !== dismissedProposalKey ? latestProposal : null; + + const proposedPolicyMarkdown = activeProposal?.content ?? null; + const switchFormat = useAction(switchPolicyDisplayFormatAction, { onError: () => toast.error('Failed to switch view.'), }); @@ -65,15 +143,17 @@ export function PolicyContentManager({ }, [currentPolicyMarkdown, proposedPolicyMarkdown]); async function applyProposedChanges() { - if (!proposedPolicyMarkdown) return; + if (!activeProposal) return; + + const { content, key } = activeProposal; setIsApplying(true); try { - const jsonContent = markdownToTipTapJSON(proposedPolicyMarkdown); + const jsonContent = markdownToTipTapJSON(content); await updatePolicy({ policyId, content: jsonContent }); setCurrentContent(jsonContent); setEditorKey((prev) => prev + 1); - setProposedPolicyMarkdown(null); + setDismissedProposalKey(key); toast.success('Policy updated with AI suggestions'); } catch (err) { console.error('Failed to apply changes:', err); @@ -141,8 +221,10 @@ export function PolicyContentManager({ {showAiAssistant && isAiPolicyAssistantEnabled && (
setShowAiAssistant(false)} />
@@ -151,10 +233,14 @@ export function PolicyContentManager({ - {proposedPolicyMarkdown && diffPatch && ( + {proposedPolicyMarkdown && diffPatch && activeProposal && (
- diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/policy-ai-assistant.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/policy-ai-assistant.tsx index 461f37139..561bdc973 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/policy-ai-assistant.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/ai/policy-ai-assistant.tsx @@ -9,58 +9,43 @@ import { import { Tool, ToolHeader } from '@comp/ui/ai-elements/tool'; import { Button } from '@comp/ui/button'; import { cn } from '@comp/ui/cn'; -import { DefaultChatTransport, getToolName, isToolUIPart } from 'ai'; -import type { ToolUIPart } from 'ai'; -import { useChat } from '@ai-sdk/react'; +import { + getToolName, + isToolUIPart, + type ChatStatus, + type ToolUIPart, + type UIMessage, +} from 'ai'; import { X } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; interface PolicyAiAssistantProps { - policyId: string; - onProposedPolicyChange?: (content: string | null) => void; + messages: UIMessage[]; + status: ChatStatus; + errorMessage?: string | null; + sendMessage: (payload: { text: string }) => void; close?: () => void; } export function PolicyAiAssistant({ - policyId, - onProposedPolicyChange, + messages, + status, + errorMessage, + sendMessage, close, }: PolicyAiAssistantProps) { const [input, setInput] = useState(''); - const lastProcessedToolCallRef = useRef(null); - - const { messages, status, error, sendMessage } = useChat({ - transport: new DefaultChatTransport({ - api: `/api/policies/${policyId}/chat`, - }), - }); const isLoading = status === 'streaming' || status === 'submitted'; - useEffect(() => { - const lastAssistantMessage = [...messages].reverse().find((m) => m.role === 'assistant'); - if (!lastAssistantMessage?.parts) return; - - for (const part of lastAssistantMessage.parts) { - if (isToolUIPart(part) && getToolName(part) === 'proposePolicy') { - const toolInput = part.input as { content: string; summary: string }; - - if (part.state === 'input-streaming') { - onProposedPolicyChange?.(toolInput?.content || ''); - continue; - } - - if (lastProcessedToolCallRef.current === part.toolCallId) { - continue; - } - - if (toolInput?.content) { - lastProcessedToolCallRef.current = part.toolCallId; - onProposedPolicyChange?.(toolInput.content); - } - } - } - }, [messages, onProposedPolicyChange]); + const hasActiveTool = messages.some( + (m) => + m.role === 'assistant' && + m.parts.some( + (p) => + isToolUIPart(p) && (p.state === 'input-streaming' || p.state === 'input-available'), + ), + ); const handleSubmit = () => { if (!input.trim()) return; @@ -108,10 +93,10 @@ export function PolicyAiAssistant({
); } - + if (isToolUIPart(part) && getToolName(part) === 'proposePolicy') { const toolPart = part as ToolUIPart; - const toolInput = part.input as { content: string; summary: string }; + const toolInput = toolPart.input as { content?: string; summary?: string }; return ( ); } - + return null; })}
)) )} - {isLoading && ( + {isLoading && !hasActiveTool && (
Thinking...
)} - {error && ( + {errorMessage && (
-

{error.message}

+

{errorMessage}

)} diff --git a/apps/app/src/app/api/policies/[policyId]/chat/route.ts b/apps/app/src/app/api/policies/[policyId]/chat/route.ts index 6e8759e86..c7aacd822 100644 --- a/apps/app/src/app/api/policies/[policyId]/chat/route.ts +++ b/apps/app/src/app/api/policies/[policyId]/chat/route.ts @@ -9,55 +9,68 @@ import { z } from 'zod'; export const maxDuration = 60; export async function POST(req: Request, { params }: { params: Promise<{ policyId: string }> }) { - const { policyId } = await params; - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user) { - return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); - } - - const organizationId = session.session.activeOrganizationId; - - if (!organizationId) { - return NextResponse.json({ message: 'No active organization' }, { status: 400 }); - } + try { + const { policyId } = await params; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user) { + return NextResponse.json( + { message: 'You must be signed in to use the AI assistant.' }, + { status: 401 }, + ); + } - const { messages }: { messages: Array } = await req.json(); + const organizationId = session.session.activeOrganizationId; - const member = await db.member.findFirst({ - where: { - userId: session.user.id, - organizationId, - deactivated: false, - }, - }); + if (!organizationId) { + return NextResponse.json( + { message: 'You need an active organization to use the AI assistant.' }, + { status: 400 }, + ); + } - if (!member) { - return NextResponse.json({ message: 'Not a member of this organization' }, { status: 403 }); - } + const { messages }: { messages: Array } = await req.json(); + + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId, + deactivated: false, + }, + }); + + if (!member) { + return NextResponse.json( + { message: "You don't have access to this policy's AI assistant." }, + { status: 403 }, + ); + } - const policy = await db.policy.findFirst({ - where: { - id: policyId, - organizationId, - }, - select: { - id: true, - name: true, - description: true, - content: true, - }, - }); - - if (!policy) { - return NextResponse.json({ message: 'Policy not found' }, { status: 404 }); - } + const policy = await db.policy.findFirst({ + where: { + id: policyId, + organizationId, + }, + select: { + id: true, + name: true, + description: true, + content: true, + }, + }); + + if (!policy) { + return NextResponse.json( + { message: 'This policy could not be found. It may have been removed.' }, + { status: 404 }, + ); + } - const policyContentText = convertPolicyContentToText(policy.content); + const policyContentText = convertPolicyContentToText(policy.content); - const systemPrompt = `You are an expert GRC (Governance, Risk, and Compliance) policy editor. You help users edit and improve their organizational policies to meet compliance requirements like SOC 2, ISO 27001, and GDPR. + const systemPrompt = `You are an expert GRC (Governance, Risk, and Compliance) policy editor. You help users edit and improve their organizational policies to meet compliance requirements like SOC 2, ISO 27001, and GDPR. Current Policy Name: ${policy.name} ${policy.description ? `Policy Description: ${policy.description}` : ''} @@ -67,11 +80,16 @@ Current Policy Content: ${policyContentText} --- +IMPORTANT: This assistant is ONLY for editing policies. You MUST always use one of the available tools. + Your role: -1. Help users understand and improve their policies -2. Suggest specific changes when asked -3. Ensure policies remain compliant with relevant frameworks -4. Maintain professional, clear language appropriate for official documentation +1. Edit and improve policies when asked +2. Ensure policies remain compliant with relevant frameworks +3. Maintain professional, clear language appropriate for official documentation + +TOOL USAGE (MANDATORY): +- If the user asks you to make changes, edits, or improvements: use the proposePolicy tool +- If the user asks a question or anything that is NOT an edit request: use the returnQuestion tool COMMUNICATION STYLE: - Be concise and direct. No lengthy explanations or preamble. @@ -81,6 +99,9 @@ COMMUNICATION STYLE: WHEN MAKING POLICY CHANGES: Use the proposePolicy tool immediately. State what you'll change in ONE sentence, then call the tool. +WHEN USER ASKS A QUESTION: +Use the returnQuestion tool immediately. Do not answer the question directly. + CRITICAL MARKDOWN FORMATTING RULES: - Every heading MUST have text after the # symbols (e.g., "## Section Title", never just "##") - Preserve the original document structure and all sections @@ -99,30 +120,52 @@ QUALITY CHECKLIST before submitting: Keep responses helpful and focused on the policy editing task.`; - const result = streamText({ - model: openai('gpt-5.1'), - system: systemPrompt, - messages: convertToModelMessages(messages), - tools: { - proposePolicy: tool({ - description: - 'Propose an updated version of the policy. Use this tool whenever the user asks you to make changes, edits, or improvements to the policy. You must provide the COMPLETE policy content, not just the changes.', - inputSchema: z.object({ - content: z - .string() - .describe( - 'The complete updated policy content in markdown format. Must include the entire policy, not just the changed sections.', - ), - summary: z - .string() - .describe('One to two sentences summarizing the changes. No bullet points.'), + const result = streamText({ + // we use 5.1 because it has the best context window for this task + model: openai('gpt-5.1'), + system: systemPrompt, + messages: convertToModelMessages(messages), + toolChoice: 'required', + tools: { + proposePolicy: tool({ + description: + 'Propose an updated version of the policy. Use this tool whenever the user asks you to make changes, edits, or improvements to the policy. You must provide the COMPLETE policy content, not just the changes.', + inputSchema: z.object({ + content: z + .string() + .describe( + 'The complete updated policy content in markdown format. Must include the entire policy, not just the changed sections.', + ), + summary: z + .string() + .describe('One to two sentences summarizing the changes. No bullet points.'), + }), + execute: async ({ summary }) => ({ success: true, summary }), }), - execute: async ({ summary }) => ({ success: true, summary }), - }), - }, - }); - - return result.toUIMessageStreamResponse(); + returnQuestion: tool({ + description: + 'Use this tool when the user asks a question instead of requesting an edit. This assistant is only for editing policies, not answering questions.', + inputSchema: z.object({ + question: z.string().describe('The question the user asked.'), + message: z + .string() + .describe( + 'A brief message explaining that this assistant is only for editing policies and suggesting they rephrase as an edit request.', + ), + }), + execute: async ({ question, message }) => ({ success: true, question, message }), + }), + }, + }); + + return result.toUIMessageStreamResponse(); + } catch (error) { + console.error('Policy chat route error:', error); + return NextResponse.json( + { message: 'The AI assistant is currently unavailable. Please try again.' }, + { status: 500 }, + ); + } } function convertPolicyContentToText(content: unknown): string { diff --git a/packages/ui/src/components/diff-viewer.tsx b/packages/ui/src/components/diff-viewer.tsx index 9aa69cda2..b2bf8e756 100644 --- a/packages/ui/src/components/diff-viewer.tsx +++ b/packages/ui/src/components/diff-viewer.tsx @@ -1,13 +1,7 @@ -import { Diff, Hunk } from './diff'; +'use client'; -import { - CollapsibleCard, - CollapsibleCardContent, - CollapsibleCardHeader, - CollapsibleCardTitle, -} from './collapsible-card'; - -import { parseDiff, type ParseOptions } from './diff/utils/parse'; +import { cn } from '../utils'; +import { parseDiff, type Hunk, type Line, type ParseOptions } from './diff/utils'; export function DiffViewer({ patch, @@ -19,24 +13,103 @@ export function DiffViewer({ const [file] = parseDiff(patch, options); if (!file) return null; + const hasChanges = file.hunks.some( + (hunk) => + hunk.type === 'hunk' && hunk.lines.some((line) => line.type !== 'normal' && hasContent(line)), + ); + if (!hasChanges) return null; + + return ( +
+ +
+ {file.hunks.map((hunk, i) => + hunk.type === 'hunk' ? ( + + ) : ( + + ), + )} +
+
+ ); +} + +function hasContent(line: Line): boolean { + const text = line.content + .map((seg) => seg.value) + .join('') + .trim(); + return text.length > 0; +} + +function DiffHeader() { + return ( +
+

Proposed Policy Updates

+
+ + + Text will be added + + + + Text will be removed + +
+
+ ); +} + +function AddBadge() { + return ( + + + Add + + ); +} + +function RemoveBadge() { + return ( + + − Remove + + ); +} + +function DiffHunk({ hunk }: { hunk: Hunk }) { + const visibleLines = hunk.lines.filter((line) => line.type === 'normal' || hasContent(line)); + + return ( +
+ {visibleLines.map((line, i) => ( + + ))} +
+ ); +} + +function DiffLine({ line }: { line: Line }) { + const text = line.content.map((seg) => seg.value).join(''); + + if (line.type === 'normal') { + return
{text}
; + } + + const isAdd = line.type === 'insert'; + + return ( +
+ {isAdd ? : } + {text} +
+ ); +} + +function SkippedSection({ count }: { count: number }) { return ( - - - {file.newPath} - - - - {file.hunks.map((hunk) => ( - - ))} - - - +
+ {count} unchanged {count === 1 ? 'paragraph' : 'paragraphs'} +
); } From 96e115ed86a3a7c9bb34c9ba24cfbc46e9190445 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Nov 2025 09:00:28 -0500 Subject: [PATCH 2/7] feat(SOA): implement SOA logic and do refactor (#1836) Co-authored-by: Tofik Hasanov Co-authored-by: Mariano Fuentes --- .../app/(app)/[orgId]/knowledge-base/page.tsx | 12 +- .../components/QuestionnaireBreadcrumb.tsx | 2 +- .../components/QuestionnaireDetailClient.tsx | 0 .../[questionnaireId]/data/queries.ts | 0 .../[questionnaireId]/page.tsx | 2 +- .../actions/answer-single-question.ts | 0 .../actions/create-trigger-token.ts | 0 .../actions/delete-questionnaire-answer.ts | 0 .../actions/export-questionnaire.ts | 2 +- .../actions/parse-questionnaire-ai.ts | 0 .../actions/save-answer.ts | 0 .../actions/save-answers-batch.ts | 0 .../actions/update-questionnaire-answer.ts | 0 .../actions/upload-questionnaire-file.ts | 0 .../vendor-questionnaire-orchestrator.ts | 0 .../components/KnowledgeBaseDocumentLink.tsx | 4 +- .../components/ManualAnswerLink.tsx | 2 +- .../components/QuestionnaireParser.tsx | 0 .../components/QuestionnaireResults.tsx | 0 .../components/QuestionnaireResultsCards.tsx | 0 .../components/QuestionnaireResultsHeader.tsx | 0 .../components/QuestionnaireResultsTable.tsx | 0 .../components/QuestionnaireSidebar.tsx | 0 .../components/QuestionnaireUpload.tsx | 0 .../components/QuestionnaireView.tsx | 0 .../components/types.ts | 0 .../hooks/usePersistGeneratedAnswers.ts | 0 .../hooks/useQuestionnaireActions.ts | 0 .../hooks/useQuestionnaireAutoAnswer.ts | 2 +- .../hooks/useQuestionnaireDetail/index.ts | 0 .../hooks/useQuestionnaireDetail/types.ts | 0 .../useQuestionnaireDetail.ts | 0 .../useQuestionnaireDetailHandlers.ts | 0 .../useQuestionnaireDetailState.ts | 0 .../hooks/useQuestionnaireParse.ts | 2 +- .../hooks/useQuestionnaireParser.ts | 0 .../hooks/useQuestionnaireSingleAnswer.ts | 2 +- .../hooks/useQuestionnaireState.ts | 0 .../actions/delete-document.ts | 2 +- .../actions/download-document.ts | 0 .../actions/get-document-view-url.ts | 0 .../actions/process-documents.ts | 0 .../actions/upload-document.ts | 2 +- .../components/AdditionalDocumentsSection.tsx | 0 .../additional-documents/components/index.ts | 0 .../hooks/useDocumentProcessing.ts | 0 .../knowledge-base/components/BackButton.tsx | 0 .../components/KnowledgeBaseBreadcrumb.tsx | 0 .../components/KnowledgeBaseHeader.tsx | 0 .../knowledge-base/components/index.ts | 0 .../context/components/ContextSection.tsx | 0 .../context/components/index.ts | 0 .../knowledge-base/data/queries.ts | 0 .../knowledge-base/hooks/usePagination.ts | 0 .../actions/delete-all-manual-answers.ts | 2 +- .../actions/delete-manual-answer.ts | 2 +- .../actions/save-manual-answer.ts | 2 +- .../components/ManualAnswersSection.tsx | 2 +- .../manual-answers/components/index.ts | 0 .../knowledge-base/page.tsx | 0 .../components/PublishedPoliciesSection.tsx | 0 .../published-policies/components/index.ts | 0 .../(app)/[orgId]/questionnaire/layout.tsx | 71 ++ .../loading.tsx | 0 .../new_questionnaire/page.tsx | 4 +- .../page.tsx | 0 .../soa/actions/approve-soa-document.ts | 88 +++ .../soa/actions/auto-fill-soa.ts | 328 +++++++++ .../soa/actions/create-soa-document.ts | 89 +++ .../soa/actions/decline-soa-document.ts | 88 +++ .../soa/actions/ensure-soa-setup.ts | 142 ++++ .../soa/actions/save-soa-answer.ts | 183 +++++ .../soa/actions/seed-soa-config.ts | 55 ++ .../soa/actions/submit-soa-for-approval.ts | 81 +++ .../soa/components/CreateSOADocument.tsx | 78 ++ .../soa/components/EditableIsApplicable.tsx | 150 ++++ .../soa/components/EditableJustification.tsx | 158 +++++ .../soa/components/EditableSOAFields.tsx | 227 ++++++ .../soa/components/SOADocumentInfo.tsx | 133 ++++ .../soa/components/SOAFrameworkTable.tsx | 301 ++++++++ .../soa/components/SOAFrameworkTabs.tsx | 176 +++++ .../components/SOAPendingApprovalAlert.tsx | 88 +++ .../questionnaire/soa/components/SOATable.tsx | 150 ++++ .../soa/components/SOATableRow.tsx | 168 +++++ .../soa/components/SubmitApprovalDialog.tsx | 70 ++ .../[orgId]/questionnaire/soa/data/queries.ts | 344 +++++++++ .../questionnaire/soa/hooks/useSOAAutoFill.ts | 138 ++++ .../(app)/[orgId]/questionnaire/soa/page.tsx | 271 +++++++ .../soa/seedJson/ISO/config.json | 671 ++++++++++++++++++ .../soa/utils/generate-soa-answer.ts | 205 ++++++ .../soa/utils/transform-iso-config.ts | 129 ++++ .../start_page/README.md | 8 +- .../actions/delete-questionnaire.ts | 2 +- .../components/QuestionnaireHistory.tsx | 2 +- .../components/QuestionnaireOverview.tsx | 2 +- .../start_page/components/index.ts | 0 .../start_page/data/queries.ts | 0 .../hooks/useQuestionnaireHistory.ts | 0 .../utils/deduplicate-sources.ts | 0 .../[orgId]/security-questionnaire/layout.tsx | 29 - .../answer-single/route.ts | 0 .../auto-answer/route.ts | 0 .../auto-fill/helpers/check-fully-remote.ts | 51 ++ .../soa/auto-fill/helpers/process-question.ts | 254 +++++++ .../soa/auto-fill/helpers/save-answers.ts | 131 ++++ .../api/questionnaire/soa/auto-fill/route.ts | 228 ++++++ apps/app/src/components/main-menu.tsx | 2 +- .../tasks/vendors/answer-question-helpers.ts | 2 +- .../migration.sql | 117 +++ .../migration.sql | 5 + .../migration.sql | 9 + packages/db/prisma/schema/auth.prisma | 1 + .../db/prisma/schema/framework-editor.prisma | 2 + .../schema/knowledge-base-document.prisma | 25 +- packages/db/prisma/schema/organization.prisma | 43 +- .../db/prisma/schema/questionnaire.prisma | 49 +- ...ecurity-questionnaire-manual-answer.prisma | 25 +- packages/db/prisma/schema/soa.prisma | 134 ++++ packages/ui/src/components/secondary-menu.tsx | 2 +- 119 files changed, 5617 insertions(+), 134 deletions(-) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/[questionnaireId]/components/QuestionnaireBreadcrumb.tsx (96%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/[questionnaireId]/components/QuestionnaireDetailClient.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/[questionnaireId]/data/queries.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/[questionnaireId]/page.tsx (94%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/actions/answer-single-question.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/actions/create-trigger-token.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/actions/delete-questionnaire-answer.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/actions/export-questionnaire.ts (99%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/actions/parse-questionnaire-ai.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/actions/save-answer.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/actions/save-answers-batch.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/actions/update-questionnaire-answer.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/actions/upload-questionnaire-file.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/actions/vendor-questionnaire-orchestrator.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/KnowledgeBaseDocumentLink.tsx (92%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/ManualAnswerLink.tsx (86%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/QuestionnaireParser.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/QuestionnaireResults.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/QuestionnaireResultsCards.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/QuestionnaireResultsHeader.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/QuestionnaireResultsTable.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/QuestionnaireSidebar.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/QuestionnaireUpload.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/QuestionnaireView.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/components/types.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/usePersistGeneratedAnswers.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireActions.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireAutoAnswer.ts (99%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireDetail/index.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireDetail/types.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireDetail/useQuestionnaireDetail.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireDetail/useQuestionnaireDetailHandlers.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireDetail/useQuestionnaireDetailState.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireParse.ts (98%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireParser.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireSingleAnswer.ts (98%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/hooks/useQuestionnaireState.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/additional-documents/actions/delete-document.ts (97%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/additional-documents/actions/download-document.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/additional-documents/actions/get-document-view-url.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/additional-documents/actions/process-documents.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/additional-documents/actions/upload-document.ts (98%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/additional-documents/components/AdditionalDocumentsSection.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/additional-documents/components/index.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/additional-documents/hooks/useDocumentProcessing.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/components/BackButton.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/components/KnowledgeBaseBreadcrumb.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/components/KnowledgeBaseHeader.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/components/index.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/context/components/ContextSection.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/context/components/index.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/data/queries.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/hooks/usePagination.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/manual-answers/actions/delete-all-manual-answers.ts (97%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/manual-answers/actions/delete-manual-answer.ts (96%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/manual-answers/actions/save-manual-answer.ts (98%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/manual-answers/components/ManualAnswersSection.tsx (99%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/manual-answers/components/index.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/page.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/published-policies/components/PublishedPoliciesSection.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/knowledge-base/published-policies/components/index.ts (100%) create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/layout.tsx rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/loading.tsx (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/new_questionnaire/page.tsx (96%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/page.tsx (100%) create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/approve-soa-document.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/auto-fill-soa.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/create-soa-document.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/decline-soa-document.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/ensure-soa-setup.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/save-soa-answer.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/seed-soa-config.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/submit-soa-for-approval.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableIsApplicable.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableJustification.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableSOAFields.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOADocumentInfo.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTable.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAFrameworkTabs.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOAPendingApprovalAlert.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOATable.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SOATableRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/SubmitApprovalDialog.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/data/queries.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/hooks/useSOAAutoFill.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/seedJson/ISO/config.json create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/utils/generate-soa-answer.ts create mode 100644 apps/app/src/app/(app)/[orgId]/questionnaire/soa/utils/transform-iso-config.ts rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/start_page/README.md (66%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/start_page/actions/delete-questionnaire.ts (95%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/start_page/components/QuestionnaireHistory.tsx (99%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/start_page/components/QuestionnaireOverview.tsx (96%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/start_page/components/index.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/start_page/data/queries.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/start_page/hooks/useQuestionnaireHistory.ts (100%) rename apps/app/src/app/(app)/[orgId]/{security-questionnaire => questionnaire}/utils/deduplicate-sources.ts (100%) delete mode 100644 apps/app/src/app/(app)/[orgId]/security-questionnaire/layout.tsx rename apps/app/src/app/api/{security-questionnaire => questionnaire}/answer-single/route.ts (100%) rename apps/app/src/app/api/{security-questionnaire => questionnaire}/auto-answer/route.ts (100%) create mode 100644 apps/app/src/app/api/questionnaire/soa/auto-fill/helpers/check-fully-remote.ts create mode 100644 apps/app/src/app/api/questionnaire/soa/auto-fill/helpers/process-question.ts create mode 100644 apps/app/src/app/api/questionnaire/soa/auto-fill/helpers/save-answers.ts create mode 100644 apps/app/src/app/api/questionnaire/soa/auto-fill/route.ts create mode 100644 packages/db/prisma/migrations/20251125130730_add_soa_tables_with_versioning/migration.sql create mode 100644 packages/db/prisma/migrations/20251125182239_add_prepared_by_approved_by_to_soa_document/migration.sql create mode 100644 packages/db/prisma/migrations/20251125191143_change_approved_by_to_approver_id/migration.sql create mode 100644 packages/db/prisma/schema/soa.prisma diff --git a/apps/app/src/app/(app)/[orgId]/knowledge-base/page.tsx b/apps/app/src/app/(app)/[orgId]/knowledge-base/page.tsx index 50e87f488..f2aac3af1 100644 --- a/apps/app/src/app/(app)/[orgId]/knowledge-base/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/knowledge-base/page.tsx @@ -2,17 +2,17 @@ import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; import { auth } from '@/utils/auth'; import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; -import { AdditionalDocumentsSection } from '../security-questionnaire/knowledge-base/additional-documents/components'; -import { ContextSection } from '../security-questionnaire/knowledge-base/context/components'; -import { ManualAnswersSection } from '../security-questionnaire/knowledge-base/manual-answers/components'; -import { PublishedPoliciesSection } from '../security-questionnaire/knowledge-base/published-policies/components'; -import { KnowledgeBaseHeader } from '../security-questionnaire/knowledge-base/components/KnowledgeBaseHeader'; +import { AdditionalDocumentsSection } from '../questionnaire/knowledge-base/additional-documents/components'; +import { ContextSection } from '../questionnaire/knowledge-base/context/components'; +import { ManualAnswersSection } from '../questionnaire/knowledge-base/manual-answers/components'; +import { PublishedPoliciesSection } from '../questionnaire/knowledge-base/published-policies/components'; +import { KnowledgeBaseHeader } from '../questionnaire/knowledge-base/components/KnowledgeBaseHeader'; import { getContextEntries, getKnowledgeBaseDocuments, getManualAnswers, getPublishedPolicies, -} from '../security-questionnaire/knowledge-base/data/queries'; +} from '../questionnaire/knowledge-base/data/queries'; export default async function KnowledgeBasePage() { const session = await auth.api.getSession({ diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireBreadcrumb.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/[questionnaireId]/components/QuestionnaireBreadcrumb.tsx similarity index 96% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireBreadcrumb.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/[questionnaireId]/components/QuestionnaireBreadcrumb.tsx index 763ed0251..13ab01bea 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireBreadcrumb.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/[questionnaireId]/components/QuestionnaireBreadcrumb.tsx @@ -19,7 +19,7 @@ export function QuestionnaireBreadcrumb({ filename, organizationId }: Questionna {/* Security Questionnaire Link */}
  • diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/data/queries.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/[questionnaireId]/data/queries.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/data/queries.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/[questionnaireId]/data/queries.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/[questionnaireId]/page.tsx similarity index 94% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/[questionnaireId]/page.tsx index 022f57ec7..0b7f2b083 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/[questionnaireId]/page.tsx @@ -35,7 +35,7 @@ export default async function QuestionnaireDetailPage({ return ( diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/answer-single-question.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/answer-single-question.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/answer-single-question.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/actions/answer-single-question.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/create-trigger-token.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/create-trigger-token.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/create-trigger-token.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/actions/create-trigger-token.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/delete-questionnaire-answer.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/delete-questionnaire-answer.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/delete-questionnaire-answer.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/actions/delete-questionnaire-answer.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/export-questionnaire.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/export-questionnaire.ts similarity index 99% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/export-questionnaire.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/actions/export-questionnaire.ts index e949ee2d8..3a7dba5b6 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/export-questionnaire.ts +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/export-questionnaire.ts @@ -142,7 +142,7 @@ export const exportQuestionnaire = authActionClient const organizationId = session.activeOrganizationId; try { - const vendorName = 'security-questionnaire'; + const vendorName = 'questionnaire'; const sanitizedVendorName = vendorName.toLowerCase().replace(/[^a-z0-9]/g, '-'); const timestamp = new Date().toISOString().split('T')[0]; diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/parse-questionnaire-ai.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/parse-questionnaire-ai.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/parse-questionnaire-ai.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/actions/parse-questionnaire-ai.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answer.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/save-answer.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answer.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/actions/save-answer.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answers-batch.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/save-answers-batch.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answers-batch.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/actions/save-answers-batch.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/update-questionnaire-answer.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/update-questionnaire-answer.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/update-questionnaire-answer.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/actions/update-questionnaire-answer.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/upload-questionnaire-file.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/upload-questionnaire-file.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/upload-questionnaire-file.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/actions/upload-questionnaire-file.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/vendor-questionnaire-orchestrator.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/actions/vendor-questionnaire-orchestrator.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/vendor-questionnaire-orchestrator.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/actions/vendor-questionnaire-orchestrator.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/KnowledgeBaseDocumentLink.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/components/KnowledgeBaseDocumentLink.tsx similarity index 92% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/components/KnowledgeBaseDocumentLink.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/components/KnowledgeBaseDocumentLink.tsx index 1fdf1fcdd..9ce4ecb64 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/KnowledgeBaseDocumentLink.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/components/KnowledgeBaseDocumentLink.tsx @@ -37,14 +37,14 @@ export function KnowledgeBaseDocumentLink({ window.open(signedUrl, '_blank', 'noopener,noreferrer'); } else { // File cannot be viewed in browser - navigate to knowledge base page - const knowledgeBaseUrl = `/${orgId}/security-questionnaire/knowledge-base`; + const knowledgeBaseUrl = `/${orgId}/questionnaire/knowledge-base`; window.open(knowledgeBaseUrl, '_blank', 'noopener,noreferrer'); } } } catch (error) { console.error('Error opening knowledge base document:', error); // Fallback: navigate to knowledge base page - const knowledgeBaseUrl = `/${orgId}/security-questionnaire/knowledge-base`; + const knowledgeBaseUrl = `/${orgId}/questionnaire/knowledge-base`; window.open(knowledgeBaseUrl, '_blank', 'noopener,noreferrer'); } finally { setIsLoading(false); diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/ManualAnswerLink.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/components/ManualAnswerLink.tsx similarity index 86% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/components/ManualAnswerLink.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/components/ManualAnswerLink.tsx index 46727807f..b04b07629 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/ManualAnswerLink.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/components/ManualAnswerLink.tsx @@ -17,7 +17,7 @@ export function ManualAnswerLink({ className = 'font-medium text-primary hover:underline inline-flex items-center gap-1', }: ManualAnswerLinkProps) { // Link to knowledge base page with hash anchor to scroll to specific manual answer - const knowledgeBaseUrl = `/${orgId}/security-questionnaire/knowledge-base#manual-answer-${manualAnswerId}`; + const knowledgeBaseUrl = `/${orgId}/questionnaire/knowledge-base#manual-answer-${manualAnswerId}`; return ( ({ diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParser.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useQuestionnaireParser.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParser.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useQuestionnaireParser.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useQuestionnaireSingleAnswer.ts similarity index 98% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useQuestionnaireSingleAnswer.ts index e1fc35435..237611c75 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useQuestionnaireSingleAnswer.ts @@ -63,7 +63,7 @@ export function useQuestionnaireSingleAnswer({ try { // Call server action directly via fetch for parallel processing - const response = await fetch('/api/security-questionnaire/answer-single', { + const response = await fetch('/api/questionnaire/answer-single', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireState.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useQuestionnaireState.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireState.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/hooks/useQuestionnaireState.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/delete-document.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/delete-document.ts similarity index 97% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/delete-document.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/delete-document.ts index 2fbda7066..63e71e7dd 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/delete-document.ts +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/delete-document.ts @@ -99,7 +99,7 @@ export const deleteKnowledgeBaseDocumentAction = authActionClient }, }); - revalidatePath(`/${activeOrganizationId}/security-questionnaire/knowledge-base`); + revalidatePath(`/${activeOrganizationId}/questionnaire/knowledge-base`); return { success: true, diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/download-document.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/download-document.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/download-document.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/download-document.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/get-document-view-url.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/get-document-view-url.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/get-document-view-url.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/get-document-view-url.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/process-documents.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/process-documents.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/process-documents.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/process-documents.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/upload-document.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/upload-document.ts similarity index 98% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/upload-document.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/upload-document.ts index 66108a77a..ae8da911c 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/actions/upload-document.ts +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/actions/upload-document.ts @@ -111,7 +111,7 @@ export const uploadKnowledgeBaseDocumentAction = authActionClient // Note: Processing is triggered by orchestrator in the component // when multiple files are uploaded, or individually for single files - revalidatePath(`/${organizationId}/security-questionnaire/knowledge-base`); + revalidatePath(`/${organizationId}/questionnaire/knowledge-base`); return { success: true, diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/components/AdditionalDocumentsSection.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/components/AdditionalDocumentsSection.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/components/AdditionalDocumentsSection.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/components/AdditionalDocumentsSection.tsx diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/components/index.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/components/index.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/components/index.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/components/index.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/hooks/useDocumentProcessing.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/hooks/useDocumentProcessing.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/additional-documents/hooks/useDocumentProcessing.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/additional-documents/hooks/useDocumentProcessing.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/components/BackButton.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/components/BackButton.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/components/BackButton.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/components/BackButton.tsx diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/components/KnowledgeBaseBreadcrumb.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/components/KnowledgeBaseBreadcrumb.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/components/KnowledgeBaseBreadcrumb.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/components/KnowledgeBaseBreadcrumb.tsx diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/components/KnowledgeBaseHeader.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/components/KnowledgeBaseHeader.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/components/KnowledgeBaseHeader.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/components/KnowledgeBaseHeader.tsx diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/components/index.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/components/index.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/components/index.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/components/index.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/context/components/ContextSection.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/context/components/ContextSection.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/context/components/ContextSection.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/context/components/ContextSection.tsx diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/context/components/index.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/context/components/index.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/context/components/index.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/context/components/index.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/data/queries.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/data/queries.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/data/queries.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/data/queries.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/hooks/usePagination.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/hooks/usePagination.ts similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/hooks/usePagination.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/hooks/usePagination.ts diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/actions/delete-all-manual-answers.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/actions/delete-all-manual-answers.ts similarity index 97% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/actions/delete-all-manual-answers.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/actions/delete-all-manual-answers.ts index c992b61b5..ae3054ac5 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/actions/delete-all-manual-answers.ts +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/actions/delete-all-manual-answers.ts @@ -89,7 +89,7 @@ export const deleteAllManualAnswers = authActionClient path = path.replace(/\/[a-z]{2}\//, '/'); revalidatePath(path); - revalidatePath(`/${activeOrganizationId}/security-questionnaire/knowledge-base`); + revalidatePath(`/${activeOrganizationId}/questionnaire/knowledge-base`); return { success: true, diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/actions/delete-manual-answer.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/actions/delete-manual-answer.ts similarity index 96% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/actions/delete-manual-answer.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/actions/delete-manual-answer.ts index 87e24c27d..6acac9f07 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/actions/delete-manual-answer.ts +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/actions/delete-manual-answer.ts @@ -82,7 +82,7 @@ export const deleteManualAnswer = authActionClient path = path.replace(/\/[a-z]{2}\//, '/'); revalidatePath(path); - revalidatePath(`/${activeOrganizationId}/security-questionnaire/knowledge-base`); + revalidatePath(`/${activeOrganizationId}/questionnaire/knowledge-base`); return { success: true, diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/actions/save-manual-answer.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/actions/save-manual-answer.ts similarity index 98% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/actions/save-manual-answer.ts rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/actions/save-manual-answer.ts index 6611fd2a7..bddcd570f 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/actions/save-manual-answer.ts +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/actions/save-manual-answer.ts @@ -119,7 +119,7 @@ export const saveManualAnswer = authActionClient revalidatePath(path); // Also revalidate knowledge base page - revalidatePath(`/${activeOrganizationId}/security-questionnaire/knowledge-base`); + revalidatePath(`/${activeOrganizationId}/questionnaire/knowledge-base`); // Return embedding ID for verification // Use embeddingId from syncResult if available, otherwise construct it diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/components/ManualAnswersSection.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/components/ManualAnswersSection.tsx similarity index 99% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/components/ManualAnswersSection.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/components/ManualAnswersSection.tsx index afb59e7b5..35ca233d7 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/knowledge-base/manual-answers/components/ManualAnswersSection.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/knowledge-base/manual-answers/components/ManualAnswersSection.tsx @@ -198,7 +198,7 @@ export function ManualAnswersSection({ manualAnswers }: ManualAnswersSectionProp
    {answer.sourceQuestionnaireId && ( ; +} + +export default async function Layout({ children, params }: LayoutProps) { + const { orgId } = await params; + + // Check if organization has ISO 27001 framework and feature flag + const session = await auth.api.getSession({ + headers: await headers(), + }); + + let hasISO27001 = false; + let isSOAFeatureEnabled = false; + + if (session?.session?.activeOrganizationId === orgId && session?.user?.id) { + // Check feature flag + const flags = await getFeatureFlags(session.user.id); + isSOAFeatureEnabled = + flags['is-statement-of-applicability-enabled'] === true || + flags['is-statement-of-applicability-enabled'] === 'true'; + + // Check if organization has ISO 27001 framework + const isoFrameworkInstance = await db.frameworkInstance.findFirst({ + where: { + organizationId: orgId, + framework: { + name: { + in: ['ISO 27001', 'iso27001', 'ISO27001'], + }, + }, + }, + }); + hasISO27001 = !!isoFrameworkInstance; + } + + const menuItems = [ + { + path: `/${orgId}/questionnaire`, + label: 'Questionnaires', + }, + ]; + + // Only show Statement of Applicability tab if organization has ISO 27001 and feature flag is enabled + if (hasISO27001 && isSOAFeatureEnabled) { + menuItems.push({ + path: `/${orgId}/questionnaire/soa`, + label: 'Statement of Applicability', + }); + } + + menuItems.push({ + path: `/${orgId}/questionnaire/knowledge-base`, + label: 'Knowledge Base', + }); + + return ( +
    + +
    {children}
    +
    + ); +} + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/loading.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/loading.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/loading.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/loading.tsx diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/new_questionnaire/page.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/new_questionnaire/page.tsx similarity index 96% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/new_questionnaire/page.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/new_questionnaire/page.tsx index 0342b7d7f..cca43ad60 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/new_questionnaire/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/new_questionnaire/page.tsx @@ -34,7 +34,7 @@ export default async function NewQuestionnairePage() { return ( @@ -75,7 +75,7 @@ export default async function NewQuestionnairePage() { return ( diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/page.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/page.tsx similarity index 100% rename from apps/app/src/app/(app)/[orgId]/security-questionnaire/page.tsx rename to apps/app/src/app/(app)/[orgId]/questionnaire/page.tsx diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/approve-soa-document.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/approve-soa-document.ts new file mode 100644 index 000000000..471fa410a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/approve-soa-document.ts @@ -0,0 +1,88 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import { z } from 'zod'; +import 'server-only'; + +const approveSOADocumentSchema = z.object({ + documentId: z.string(), +}); + +export const approveSOADocument = authActionClient + .inputSchema(approveSOADocumentSchema) + .metadata({ + name: 'approve-soa-document', + track: { + event: 'approve-soa-document', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { documentId } = parsedInput; + const { session, user } = ctx; + + if (!session?.activeOrganizationId || !user?.id) { + throw new Error('Unauthorized'); + } + + const organizationId = session.activeOrganizationId; + const userId = user.id; + + // Check if user is owner or admin + const member = await db.member.findFirst({ + where: { + organizationId, + userId, + deactivated: false, + }, + }); + + if (!member) { + throw new Error('Member not found'); + } + + // Check if user has owner or admin role + const isOwnerOrAdmin = member.role.includes('owner') || member.role.includes('admin'); + + if (!isOwnerOrAdmin) { + throw new Error('Only owners and admins can approve SOA documents'); + } + + // Get the document + const document = await db.sOADocument.findFirst({ + where: { + id: documentId, + organizationId, + }, + }); + + if (!document) { + throw new Error('SOA document not found'); + } + + // Check if document is pending approval and current member is the approver + if (!(document as any).approverId || (document as any).approverId !== member.id) { + throw new Error('Document is not pending your approval'); + } + + if ((document as any).status !== 'needs_review') { + throw new Error('Document is not in needs_review status'); + } + + // Approve the document - keep approverId to track who approved, set status to completed, set approvedAt + const updatedDocument = await db.sOADocument.update({ + where: { id: documentId }, + data: { + // Keep approverId to track who approved it + status: 'completed', + approvedAt: new Date(), + }, + }); + + return { + success: true, + data: updatedDocument, + }; + }); + diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/auto-fill-soa.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/auto-fill-soa.ts new file mode 100644 index 000000000..c4e7b3cdd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/auto-fill-soa.ts @@ -0,0 +1,328 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { syncOrganizationEmbeddings } from '@/lib/vector'; +import { db } from '@db'; +import { logger } from '@/utils/logger'; +import { headers } from 'next/headers'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { generateSOAAnswerWithRAG } from '../utils/generate-soa-answer'; +import 'server-only'; + +const inputSchema = z.object({ + documentId: z.string(), +}); + +export const autoFillSOA = authActionClient + .inputSchema(inputSchema) + .metadata({ + name: 'auto-fill-soa', + track: { + event: 'auto-fill-soa', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { documentId } = parsedInput; + const { session, user } = ctx; + + if (!session?.activeOrganizationId) { + throw new Error('No active organization'); + } + + if (!user?.id) { + throw new Error('User not authenticated'); + } + + const organizationId = session.activeOrganizationId; + const userId = user.id; + + try { + // Fetch SOA document with configuration + const document = await db.sOADocument.findFirst({ + where: { + id: documentId, + organizationId, + }, + include: { + framework: true, + configuration: true, + answers: { + where: { + isLatestAnswer: true, + }, + }, + }, + }); + + if (!document) { + throw new Error('SOA document not found'); + } + + const configuration = document.configuration; + const questions = configuration.questions as Array<{ + id: string; + text: string; + columnMapping: { + title: string; + control_objective: string | null; + isApplicable: boolean | null; + }; + }>; + + // Process ALL questions - determine applicability for all + // If isApplicable is already set, we can still regenerate if needed + // For now, process all questions to ensure completeness + const questionsToAnswer = questions; + + logger.info('Starting auto-fill SOA', { + organizationId, + documentId, + totalQuestions: questions.length, + questionsToAnswer: questionsToAnswer.length, + }); + + // Sync organization embeddings before generating answers + try { + await syncOrganizationEmbeddings(organizationId); + logger.info('Organization embeddings synced successfully', { + organizationId, + }); + } catch (error) { + logger.warn('Failed to sync organization embeddings', { + organizationId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + // Continue with existing embeddings if sync fails + } + + // Process questions in batches to avoid overwhelming the system + // Process all questions to determine applicability + const batchSize = 10; + const results: Array<{ + questionId: string; + isApplicable: boolean | null; + justification: string | null; + sources: unknown; + success: boolean; + error: string | null; + }> = []; + + for (let i = 0; i < questionsToAnswer.length; i += batchSize) { + const batch = questionsToAnswer.slice(i, i + batchSize); + + const batchResults = await Promise.all( + batch.map((question, batchIndex) => { + const globalIndex = i + batchIndex; + + // First, determine if the control is applicable based on organization's context + const applicabilityQuestion = `Based on our organization's policies, documentation, business context, and operations, is the control "${question.columnMapping.title}" (${question.text}) applicable to our organization? + +Consider: +- Our business type and industry +- Our operational scope and scale +- Our risk profile +- Our regulatory requirements +- Our technical infrastructure + +Respond with ONLY "YES" or "NO" - no additional explanation.`; + + return generateSOAAnswerWithRAG( + applicabilityQuestion, + organizationId, + ).then(async (applicabilityResult) => { + if (!applicabilityResult.answer) { + return { + questionId: question.id, + isApplicable: null, + justification: null, + sources: applicabilityResult.sources, + success: false, + error: 'Failed to determine applicability - no answer generated', + }; + } + + // Parse YES/NO from answer + const answerText = applicabilityResult.answer.trim().toUpperCase(); + const isApplicable = answerText.includes('YES') || answerText.includes('APPLICABLE'); + const isNotApplicable = answerText.includes('NO') || answerText.includes('NOT APPLICABLE') || answerText.includes('NOT APPLICABLE'); + + let finalIsApplicable: boolean | null = null; + if (isApplicable && !isNotApplicable) { + finalIsApplicable = true; + } else if (isNotApplicable && !isApplicable) { + finalIsApplicable = false; + } + + // If not applicable, generate justification + let justification: string | null = null; + if (finalIsApplicable === false) { + const justificationQuestion = `Why is the control "${question.columnMapping.title}" not applicable to our organization? + +Provide a clear, professional justification explaining: +- Why this control does not apply to our business context +- Our operational characteristics that make it irrelevant +- Our risk profile considerations +- Any other relevant factors + +Keep the justification concise (2-3 sentences).`; + + const justificationResult = await generateSOAAnswerWithRAG( + justificationQuestion, + organizationId, + ); + + if (justificationResult.answer) { + justification = justificationResult.answer; + } + } + + return { + questionId: question.id, + isApplicable: finalIsApplicable, + justification, + sources: applicabilityResult.sources, + success: true, + error: null, + }; + }); + }), + ); + + results.push(...batchResults); + } + + // Save answers to database + const answersToSave = results + .filter((r) => r.success && r.isApplicable !== null) + .map((result) => { + const question = questionsToAnswer.find((q) => q.id === result.questionId); + if (!question) return null; + + // Get existing answer to determine version + return db.sOAAnswer.findFirst({ + where: { + documentId, + questionId: question.id, + isLatestAnswer: true, + }, + orderBy: { + answerVersion: 'desc', + }, + }).then(async (existingAnswer: { id: string; answerVersion: number } | null) => { + const nextVersion = existingAnswer ? existingAnswer.answerVersion + 1 : 1; + + // Mark existing answer as not latest if it exists + if (existingAnswer) { + await db.sOAAnswer.update({ + where: { id: existingAnswer.id }, + data: { isLatestAnswer: false }, + }); + } + + // Store justification in answer field if not applicable + // If applicable, answer is null (we don't need justification) + const answerValue = result.isApplicable === false ? result.justification : null; + + // Create new answer with justification (if not applicable) + const newAnswer = await db.sOAAnswer.create({ + data: { + documentId, + questionId: question.id, + answer: answerValue, // Store justification here if not applicable + status: 'generated', + sources: result.sources || undefined, + generatedAt: new Date(), + answerVersion: nextVersion, + isLatestAnswer: true, + createdBy: userId, + }, + }); + + return newAnswer; + }); + }) + .filter((promise) => promise !== null); + + await Promise.all(answersToSave); + + // Update the configuration's question mapping with all generated isApplicable values + const configQuestions = configuration.questions as Array<{ + id: string; + text: string; + columnMapping: { + title: string; + control_objective: string | null; + isApplicable: boolean | null; + justification: string | null; + }; + }>; + + // Create a map of results for easy lookup + const resultsMap = new Map( + results + .filter((r) => r.success && r.isApplicable !== null) + .map((r) => [r.questionId, r]) + ); + + // Update all questions in the configuration + const updatedQuestions = configQuestions.map((q) => { + const result = resultsMap.get(q.id); + if (result) { + return { + ...q, + columnMapping: { + ...q.columnMapping, + isApplicable: result.isApplicable, + justification: result.justification, + }, + }; + } + return q; + }); + + // Update configuration with new isApplicable values + await db.sOAFrameworkConfiguration.update({ + where: { id: configuration.id }, + data: { + questions: updatedQuestions, + }, + }); + + // Update document answered questions count + // Count questions that have isApplicable determined (not null) + const answeredCount = results.filter((r) => r.success && r.isApplicable !== null).length; + + await db.sOADocument.update({ + where: { id: documentId }, + data: { + answeredQuestions: answeredCount, + status: answeredCount === document.totalQuestions ? 'completed' : 'in_progress', + completedAt: answeredCount === document.totalQuestions ? new Date() : null, + }, + }); + + // Revalidate the page + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: true, + data: { + answered: results.filter((r) => r.success).length, + total: questionsToAnswer.length, + }, + }; + } catch (error) { + logger.error('Failed to auto-fill SOA', { + organizationId, + documentId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + throw error instanceof Error ? error : new Error('Failed to auto-fill SOA'); + } + }); + diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/create-soa-document.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/create-soa-document.ts new file mode 100644 index 000000000..a0692f4d5 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/create-soa-document.ts @@ -0,0 +1,89 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import { z } from 'zod'; +import 'server-only'; + +const createSOADocumentSchema = z.object({ + frameworkId: z.string(), + organizationId: z.string(), +}); + +export const createSOADocument = authActionClient + .inputSchema(createSOADocumentSchema) + .metadata({ + name: 'create-soa-document', + track: { + event: 'create-soa-document', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { frameworkId, organizationId } = parsedInput; + const { session } = ctx; + + if (!session?.activeOrganizationId || session.activeOrganizationId !== organizationId) { + throw new Error('Unauthorized'); + } + + // Get the latest SOA configuration for this framework + const configuration = await db.sOAFrameworkConfiguration.findFirst({ + where: { + frameworkId, + isLatest: true, + }, + }); + + if (!configuration) { + throw new Error('No SOA configuration found for this framework'); + } + + // Check if there's already a latest document for this framework and organization + const existingLatestDocument = await db.sOADocument.findFirst({ + where: { + frameworkId, + organizationId, + isLatest: true, + }, + }); + + // Determine the next version number + let nextVersion = 1; + if (existingLatestDocument) { + // Mark existing document as not latest + await db.sOADocument.update({ + where: { id: existingLatestDocument.id }, + data: { isLatest: false }, + }); + nextVersion = existingLatestDocument.version + 1; + } + + // Get questions from configuration to calculate totalQuestions + const questions = configuration.questions as Array<{ id: string }>; + const totalQuestions = Array.isArray(questions) ? questions.length : 0; + + // Create new SOA document + const document = await db.sOADocument.create({ + data: { + frameworkId, + organizationId, + configurationId: configuration.id, + version: nextVersion, + isLatest: true, + status: 'draft', + totalQuestions, + answeredQuestions: 0, + }, + include: { + framework: true, + configuration: true, + }, + }); + + return { + success: true, + data: document, + }; + }); + diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/decline-soa-document.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/decline-soa-document.ts new file mode 100644 index 000000000..1b50d32dd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/decline-soa-document.ts @@ -0,0 +1,88 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import { z } from 'zod'; +import 'server-only'; + +const declineSOADocumentSchema = z.object({ + documentId: z.string(), +}); + +export const declineSOADocument = authActionClient + .inputSchema(declineSOADocumentSchema) + .metadata({ + name: 'decline-soa-document', + track: { + event: 'decline-soa-document', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { documentId } = parsedInput; + const { session, user } = ctx; + + if (!session?.activeOrganizationId || !user?.id) { + throw new Error('Unauthorized'); + } + + const organizationId = session.activeOrganizationId; + const userId = user.id; + + // Check if user is owner or admin + const member = await db.member.findFirst({ + where: { + organizationId, + userId, + deactivated: false, + }, + }); + + if (!member) { + throw new Error('Member not found'); + } + + // Check if user has owner or admin role + const isOwnerOrAdmin = member.role.includes('owner') || member.role.includes('admin'); + + if (!isOwnerOrAdmin) { + throw new Error('Only owners and admins can decline SOA documents'); + } + + // Get the document + const document = await db.sOADocument.findFirst({ + where: { + id: documentId, + organizationId, + }, + }); + + if (!document) { + throw new Error('SOA document not found'); + } + + // Check if document is pending approval and current member is the approver + if (!(document as any).approverId || (document as any).approverId !== member.id) { + throw new Error('Document is not pending your approval'); + } + + if ((document as any).status !== 'needs_review') { + throw new Error('Document is not in needs_review status'); + } + + // Decline the document - clear approverId and set status back to completed (or in_progress) + const updatedDocument = await db.sOADocument.update({ + where: { id: documentId }, + data: { + approverId: null, // Clear approver + approvedAt: null, // Clear approved date + status: 'completed', // Set back to completed so it can be edited and resubmitted + }, + }); + + return { + success: true, + data: updatedDocument, + }; + }); + diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/ensure-soa-setup.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/ensure-soa-setup.ts new file mode 100644 index 000000000..9b8235c85 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/ensure-soa-setup.ts @@ -0,0 +1,142 @@ +'use server'; + +import { db } from '@db'; +import { seedISO27001SOAConfig } from './seed-soa-config'; +import 'server-only'; + +/** + * Direct server function to create SOA document without revalidation + * Used during page render, so cannot use server actions with revalidatePath + */ +async function createSOADocumentDirect(frameworkId: string, organizationId: string, configurationId: string) { + // Check if there's already a latest document for this framework and organization + const existingLatestDocument = await db.sOADocument.findFirst({ + where: { + frameworkId, + organizationId, + isLatest: true, + }, + }); + + // Determine the next version number + let nextVersion = 1; + if (existingLatestDocument) { + // Mark existing document as not latest + await db.sOADocument.update({ + where: { id: existingLatestDocument.id }, + data: { isLatest: false }, + }); + nextVersion = existingLatestDocument.version + 1; + } + + // Get questions from configuration to calculate totalQuestions + const configuration = await db.sOAFrameworkConfiguration.findUnique({ + where: { id: configurationId }, + }); + + if (!configuration) { + throw new Error('Configuration not found'); + } + + const questions = configuration.questions as Array<{ id: string }>; + const totalQuestions = Array.isArray(questions) ? questions.length : 0; + + // Create new SOA document + const document = await db.sOADocument.create({ + data: { + frameworkId, + organizationId, + configurationId: configuration.id, + version: nextVersion, + isLatest: true, + status: 'draft', + totalQuestions, + answeredQuestions: 0, + }, + include: { + answers: { + where: { + isLatestAnswer: true, + }, + }, + }, + }); + + return document; +} + +/** + * Ensures SOA configuration and document exist for a framework + * Currently only supports ISO 27001 + */ +export async function ensureSOASetup(frameworkId: string, organizationId: string) { + // Get framework to check if it's ISO + const framework = await db.frameworkEditorFramework.findUnique({ + where: { id: frameworkId }, + }); + + if (!framework) { + throw new Error('Framework not found'); + } + + // Check if framework is ISO 27001 (currently only supported framework) + const isISO27001 = ['ISO 27001', 'iso27001', 'ISO27001'].includes(framework.name); + + if (!isISO27001) { + return { + success: false, + error: 'Only ISO 27001 framework is currently supported', + configuration: null, + document: null, + }; + } + + // Check if configuration exists + let configuration = await db.sOAFrameworkConfiguration.findFirst({ + where: { + frameworkId, + isLatest: true, + }, + }); + + // Create configuration if it doesn't exist + if (!configuration) { + try { + configuration = await seedISO27001SOAConfig(); + } catch (error) { + throw new Error(`Failed to create SOA configuration: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Check if document exists + let document = await db.sOADocument.findFirst({ + where: { + frameworkId, + organizationId, + isLatest: true, + }, + include: { + answers: { + where: { + isLatestAnswer: true, + }, + }, + }, + }); + + // Create document if it doesn't exist (using direct function to avoid revalidation during render) + if (!document && configuration) { + try { + document = await createSOADocumentDirect(frameworkId, organizationId, configuration.id); + } catch (error) { + throw new Error(`Failed to create SOA document: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return { + success: true, + configuration, + document, + }; +} + diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/save-soa-answer.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/save-soa-answer.ts new file mode 100644 index 000000000..97b2942fe --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/save-soa-answer.ts @@ -0,0 +1,183 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import { headers } from 'next/headers'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import 'server-only'; + +const saveAnswerSchema = z.object({ + documentId: z.string(), + questionId: z.string(), + answer: z.string().nullable(), + isApplicable: z.boolean().nullable().optional(), + justification: z.string().nullable().optional(), +}); + +export const saveSOAAnswer = authActionClient + .inputSchema(saveAnswerSchema) + .metadata({ + name: 'save-soa-answer', + track: { + event: 'save-soa-answer', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { documentId, questionId, answer, isApplicable, justification } = parsedInput; + const { session, user } = ctx; + + if (!session?.activeOrganizationId) { + throw new Error('No active organization'); + } + + if (!user?.id) { + throw new Error('User not authenticated'); + } + + const organizationId = session.activeOrganizationId; + const userId = user.id; + + try { + // Verify document exists and belongs to organization + const document = await db.sOADocument.findFirst({ + where: { + id: documentId, + organizationId, + }, + include: { + configuration: true, + }, + }); + + if (!document) { + throw new Error('SOA document not found'); + } + + // Get existing answer to determine version + const existingAnswer = await db.sOAAnswer.findFirst({ + where: { + documentId, + questionId, + isLatestAnswer: true, + }, + orderBy: { + answerVersion: 'desc', + }, + }); + + const nextVersion = existingAnswer ? existingAnswer.answerVersion + 1 : 1; + + // Mark existing answer as not latest if it exists + if (existingAnswer) { + await db.sOAAnswer.update({ + where: { id: existingAnswer.id }, + data: { isLatestAnswer: false }, + }); + } + + // Determine answer value: if isApplicable is NO, use justification; otherwise use provided answer or null + let finalAnswer: string | null = null; + if (isApplicable !== undefined) { + // If isApplicable is provided, use justification if NO, otherwise null + finalAnswer = isApplicable === false ? (justification || answer || null) : null; + } else { + // Fallback to provided answer + finalAnswer = answer || null; + } + + // Create or update answer + await db.sOAAnswer.create({ + data: { + documentId, + questionId, + answer: finalAnswer, + status: finalAnswer && finalAnswer.trim().length > 0 ? 'manual' : 'untouched', + answerVersion: nextVersion, + isLatestAnswer: true, + createdBy: existingAnswer ? undefined : userId, + updatedBy: userId, + }, + }); + + // Update configuration's question mapping if isApplicable or justification provided + // This needs to happen before counting answered questions + if (isApplicable !== undefined || justification !== undefined) { + const configuration = document.configuration; + const questions = configuration.questions as Array<{ + id: string; + text: string; + columnMapping: { + closure: string; + title: string; + control_objective: string | null; + isApplicable: boolean | null; + justification: string | null; + }; + }>; + + const updatedQuestions = questions.map((q) => { + if (q.id === questionId) { + return { + ...q, + columnMapping: { + ...q.columnMapping, + isApplicable: isApplicable !== undefined ? isApplicable : q.columnMapping.isApplicable, + justification: justification !== undefined ? justification : q.columnMapping.justification, + }, + }; + } + return q; + }); + + await db.sOAFrameworkConfiguration.update({ + where: { id: configuration.id }, + data: { + questions: updatedQuestions, + }, + }); + } + + // Update document answered questions count (count questions with isApplicable set in configuration) + const updatedConfiguration = await db.sOAFrameworkConfiguration.findUnique({ + where: { id: document.configurationId }, + }); + + let answeredCount = 0; + if (updatedConfiguration) { + const configQuestions = updatedConfiguration.questions as Array<{ + id: string; + columnMapping: { + isApplicable: boolean | null; + }; + }>; + answeredCount = configQuestions.filter(q => q.columnMapping.isApplicable !== null).length; + } + + await db.sOADocument.update({ + where: { id: documentId }, + data: { + answeredQuestions: answeredCount, + status: answeredCount === document.totalQuestions ? 'completed' : 'in_progress', + completedAt: answeredCount === document.totalQuestions ? new Date() : null, + // Clear approval when answers are edited + approverId: null, + approvedAt: null, + }, + }); + + // Revalidate the page + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: true, + }; + } catch (error) { + throw error instanceof Error ? error : new Error('Failed to save SOA answer'); + } + }); + diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/seed-soa-config.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/seed-soa-config.ts new file mode 100644 index 000000000..da425e372 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/seed-soa-config.ts @@ -0,0 +1,55 @@ +'use server'; + +import { db } from '@db'; +import { loadISOConfig } from '../utils/transform-iso-config'; +import 'server-only'; + +/** + * Seeds SOA configuration for ISO 27001 framework + * This creates the initial configuration if it doesn't exist + */ +export async function seedISO27001SOAConfig() { + // Find ISO 27001 framework by name + const iso27001Framework = await db.frameworkEditorFramework.findFirst({ + where: { + OR: [ + { name: 'ISO 27001' }, + { name: 'iso27001' }, + { name: 'ISO27001' }, + ], + }, + }); + + if (!iso27001Framework) { + throw new Error('ISO 27001 framework not found'); + } + + // Check if configuration already exists + const existingConfig = await db.sOAFrameworkConfiguration.findFirst({ + where: { + frameworkId: iso27001Framework.id, + isLatest: true, + }, + }); + + if (existingConfig) { + return existingConfig; // Return existing config + } + + // Load and transform ISO config + const soaConfig = await loadISOConfig(); + + // Create new SOA configuration + const newConfig = await db.sOAFrameworkConfiguration.create({ + data: { + frameworkId: iso27001Framework.id, + version: 1, + isLatest: true, + columns: soaConfig.columns, + questions: soaConfig.questions, + }, + }); + + return newConfig; +} + diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/submit-soa-for-approval.ts b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/submit-soa-for-approval.ts new file mode 100644 index 000000000..1f806eb41 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/actions/submit-soa-for-approval.ts @@ -0,0 +1,81 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import { z } from 'zod'; +import 'server-only'; + +const submitSOAForApprovalSchema = z.object({ + documentId: z.string(), + approverId: z.string(), +}); + +export const submitSOAForApproval = authActionClient + .inputSchema(submitSOAForApprovalSchema) + .metadata({ + name: 'submit-soa-for-approval', + track: { + event: 'submit-soa-for-approval', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { documentId, approverId } = parsedInput; + const { session, user } = ctx; + + if (!session?.activeOrganizationId || !user?.id) { + throw new Error('Unauthorized'); + } + + const organizationId = session.activeOrganizationId; + + // Verify approver is a member of the organization + const approverMember = await db.member.findFirst({ + where: { + id: approverId, + organizationId, + deactivated: false, + }, + }); + + if (!approverMember) { + throw new Error('Approver not found in organization'); + } + + // Check if approver is owner or admin + const isOwnerOrAdmin = approverMember.role.includes('owner') || approverMember.role.includes('admin'); + if (!isOwnerOrAdmin) { + throw new Error('Approver must be an owner or admin'); + } + + // Get the document + const document = await db.sOADocument.findFirst({ + where: { + id: documentId, + organizationId, + }, + }); + + if (!document) { + throw new Error('SOA document not found'); + } + + if ((document as any).status === 'needs_review') { + throw new Error('Document is already pending approval'); + } + + // Submit for approval - set approverId and status to needs_review + const updatedDocument = await db.sOADocument.update({ + where: { id: documentId }, + data: { + approverId, + status: 'needs_review', + }, + }); + + return { + success: true, + data: updatedDocument, + }; + }); + diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.tsx new file mode 100644 index 000000000..1b0898286 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/CreateSOADocument.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Button } from '@comp/ui/button'; +import { Card } from '@comp/ui'; +import { Plus, Loader2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { createSOADocument } from '../actions/create-soa-document'; + +interface CreateSOADocumentProps { + frameworkId: string; + frameworkName: string; + organizationId: string; +} + +export function CreateSOADocument({ + frameworkId, + frameworkName, + organizationId, +}: CreateSOADocumentProps) { + const router = useRouter(); + const [isCreating, setIsCreating] = useState(false); + + const handleCreate = async () => { + setIsCreating(true); + + try { + const result = await createSOADocument({ + frameworkId, + organizationId, + }); + + if (result?.data?.success && result?.data?.data) { + toast.success('SOA document created successfully'); + router.push(`/${organizationId}/questionnaire/soa/${result.data.data.id}`); + router.refresh(); + } else { + toast.error(result?.serverError || 'Failed to create SOA document'); + } + } catch (error) { + toast.error('An error occurred while creating the SOA document'); + } finally { + setIsCreating(false); + } + }; + + return ( + +
    +
    +

    {frameworkName}

    +

    + Create a new SOA document for this framework +

    +
    + +
    +
    + ); +} + diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableIsApplicable.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableIsApplicable.tsx new file mode 100644 index 000000000..63f610591 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableIsApplicable.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@comp/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@comp/ui/select'; +import { Edit2, Loader2 } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; +import { saveSOAAnswer } from '../actions/save-soa-answer'; +import { toast } from 'sonner'; + +interface EditableIsApplicableProps { + documentId: string; + questionId: string; + isApplicable: boolean | null; + isPendingApproval: boolean; + isControl7?: boolean; + isFullyRemote?: boolean; + onUpdate?: () => void; +} + +export function EditableIsApplicable({ + documentId, + questionId, + isApplicable: initialIsApplicable, + isPendingApproval, + isControl7 = false, + isFullyRemote = false, + onUpdate, +}: EditableIsApplicableProps) { + const [isEditing, setIsEditing] = useState(false); + const [isApplicable, setIsApplicable] = useState(initialIsApplicable); + + const saveAction = useAction(saveSOAAnswer, { + onSuccess: () => { + setIsEditing(false); + toast.success('Answer saved successfully'); + // Refresh page to update configuration + if (typeof window !== 'undefined') { + window.location.reload(); + } + onUpdate?.(); + }, + onError: ({ error }) => { + toast.error(error.serverError || 'Failed to save answer'); + }, + }); + + // If control 7.* and fully remote, disable editing + const isDisabled = isPendingApproval || (isControl7 && isFullyRemote); + + const handleSave = async () => { + await saveAction.execute({ + documentId, + questionId, + answer: null, // Answer is stored in justification field when NO + isApplicable, + justification: null, // Justification is handled separately in EditableJustification + }); + }; + + const handleCancel = () => { + setIsApplicable(initialIsApplicable); + setIsEditing(false); + }; + + if (isDisabled && !isEditing) { + return ( + + {isApplicable === true ? 'YES' : isApplicable === false ? 'NO' : '—'} + + ); + } + + if (!isEditing) { + return ( +
    + + {isApplicable === true ? 'YES' : isApplicable === false ? 'NO' : '—'} + + +
    + ); + } + + return ( +
    + + + +
    + ); +} + diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableJustification.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableJustification.tsx new file mode 100644 index 000000000..2d8af4833 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/soa/components/EditableJustification.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@comp/ui/button'; +import { Textarea } from '@comp/ui/textarea'; +import { Check, X, Loader2, Edit2 } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; +import { saveSOAAnswer } from '../actions/save-soa-answer'; +import { toast } from 'sonner'; + +interface EditableJustificationProps { + documentId: string; + questionId: string; + isApplicable: boolean | null; + justification: string | null; + isPendingApproval: boolean; + isControl7?: boolean; + isFullyRemote?: boolean; + onUpdate?: () => void; +} + +export function EditableJustification({ + documentId, + questionId, + isApplicable, + justification: initialJustification, + isPendingApproval, + isControl7 = false, + isFullyRemote = false, + onUpdate, +}: EditableJustificationProps) { + const [isEditing, setIsEditing] = useState(false); + const [justification, setJustification] = useState(initialJustification); + const [error, setError] = useState(null); + + const saveAction = useAction(saveSOAAnswer, { + onSuccess: () => { + setIsEditing(false); + setError(null); + toast.success('Answer saved successfully'); + // Refresh page to update configuration + if (typeof window !== 'undefined') { + window.location.reload(); + } + onUpdate?.(); + }, + onError: ({ error }) => { + setError(error.serverError || 'Failed to save answer'); + toast.error(error.serverError || 'Failed to save answer'); + }, + }); + + // If control 7.* and fully remote, disable editing + const isDisabled = isPendingApproval || (isControl7 && isFullyRemote); + + const handleSave = async () => { + // Validate: if NO, justification is required + if (isApplicable === false && (!justification || justification.trim().length === 0)) { + setError('Justification is required when Applicable is NO'); + return; + } + + const answerValue = isApplicable === false ? justification : null; + + await saveAction.execute({ + documentId, + questionId, + answer: answerValue, + isApplicable, + justification: isApplicable === false ? justification : null, + }); + }; + + const handleCancel = () => { + setJustification(initialJustification); + setIsEditing(false); + setError(null); + }; + + // Only show if isApplicable is NO + if (isApplicable !== false) { + return ; + } + + if (isDisabled && !isEditing) { + return ( +

    + {justification || '—'} +

    + ); + } + + if (!isEditing) { + return ( +
    +
    +

    + {justification || '—'} +

    + +
    +
    + ); + } + + return ( +
    +