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/SELF_HOSTING.md b/SELF_HOSTING.md index c33f1a28e..858cd7f43 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -46,6 +46,7 @@ App (`apps/app`): - **APP_AWS_REGION**, **APP_AWS_ACCESS_KEY_ID**, **APP_AWS_SECRET_ACCESS_KEY**, **APP_AWS_BUCKET_NAME**: AWS S3 credentials for file storage (attachments, general uploads). - **APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET**: AWS S3 bucket name specifically for questionnaire file uploads. Required for the Security Questionnaire feature. If not set, users will see an error when trying to parse questionnaires. - **APP_AWS_KNOWLEDGE_BASE_BUCKET**: AWS S3 bucket name specifically for knowledge base documents. Required for the Knowledge Base feature in Security Questionnaire. If not set, users will see an error when trying to upload knowledge base documents. +- **APP_AWS_ORG_ASSETS_BUCKET**: AWS S3 bucket name for organization static assets (e.g., company logos). Required for logo uploads in organization settings. If not set, logo upload will fail. - **OPENAI_API_KEY**: Enables AI features that call OpenAI models. - **UPSTASH_REDIS_REST_URL**, **UPSTASH_REDIS_REST_TOKEN**: Optional Redis (Upstash) used for rate limiting/queues/caching. - **NEXT_PUBLIC_POSTHOG_KEY**, **NEXT_PUBLIC_POSTHOG_HOST**: Client analytics via PostHog; leave unset to disable. @@ -153,6 +154,7 @@ NEXT_PUBLIC_BETTER_AUTH_URL_PORTAL=http://localhost:3002 # APP_AWS_BUCKET_NAME= # APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET= # APP_AWS_KNOWLEDGE_BASE_BUCKET= +# APP_AWS_ORG_ASSETS_BUCKET= # OPENAI_API_KEY= # UPSTASH_REDIS_REST_URL= # UPSTASH_REDIS_REST_TOKEN= diff --git a/apps/app/src/actions/organization/update-organization-logo-action.ts b/apps/app/src/actions/organization/update-organization-logo-action.ts new file mode 100644 index 000000000..dba929af9 --- /dev/null +++ b/apps/app/src/actions/organization/update-organization-logo-action.ts @@ -0,0 +1,112 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +const updateLogoSchema = z.object({ + fileName: z.string(), + fileType: z.string(), + fileData: z.string(), // base64 encoded +}); + +export const updateOrganizationLogoAction = authActionClient + .inputSchema(updateLogoSchema) + .metadata({ + name: 'update-organization-logo', + track: { + event: 'update-organization-logo', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { fileName, fileType, fileData } = parsedInput; + const organizationId = ctx.session.activeOrganizationId; + + if (!organizationId) { + throw new Error('No active organization'); + } + + // Validate file type + if (!fileType.startsWith('image/')) { + throw new Error('Only image files are allowed'); + } + + // Check S3 client + if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) { + throw new Error('File upload service is not available'); + } + + // Convert base64 to buffer + const fileBuffer = Buffer.from(fileData, 'base64'); + + // Validate file size (2MB limit for logos) + const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; + if (fileBuffer.length > MAX_FILE_SIZE_BYTES) { + throw new Error('Logo must be less than 2MB'); + } + + // Generate S3 key + const timestamp = Date.now(); + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + const key = `${organizationId}/logo/${timestamp}-${sanitizedFileName}`; + + // Upload to S3 + const putCommand = new PutObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: key, + Body: fileBuffer, + ContentType: fileType, + }); + await s3Client.send(putCommand); + + // Update organization with new logo key + await db.organization.update({ + where: { id: organizationId }, + data: { logo: key }, + }); + + // Generate signed URL for immediate display + const getCommand = new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: key, + }); + const signedUrl = await getSignedUrl(s3Client, getCommand, { + expiresIn: 3600, + }); + + revalidatePath(`/${organizationId}/settings`); + + return { success: true, logoUrl: signedUrl }; + }); + +export const removeOrganizationLogoAction = authActionClient + .inputSchema(z.object({})) + .metadata({ + name: 'remove-organization-logo', + track: { + event: 'remove-organization-logo', + channel: 'server', + }, + }) + .action(async ({ ctx }) => { + const organizationId = ctx.session.activeOrganizationId; + + if (!organizationId) { + throw new Error('No active organization'); + } + + // Remove logo from organization + await db.organization.update({ + where: { id: organizationId }, + data: { logo: null }, + }); + + revalidatePath(`/${organizationId}/settings`); + + return { success: true }; + }); diff --git a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/components/AuditorView.tsx b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/components/AuditorView.tsx new file mode 100644 index 000000000..f990ac7cf --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/components/AuditorView.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { Download } from 'lucide-react'; +import Image from 'next/image'; + +interface AuditorViewProps { + initialContent: Record; + organizationName: string; + logoUrl: string | null; + employeeCount: string | null; + cSuite: { name: string; title: string }[]; + reportSignatory: { fullName: string; jobTitle: string; email: string } | null; +} + +export function AuditorView({ + initialContent, + organizationName, + logoUrl, + employeeCount, + cSuite, + reportSignatory, +}: AuditorViewProps) { + return ( +
+ {/* Header */} +
+ {logoUrl && ( + + {`${organizationName} +
+ +
+
+ )} +
+

+ {organizationName} +

+

Company Overview

+
+
+ + {/* Company Information */} +
+
+ + +
+ {reportSignatory.fullName} + + {reportSignatory.jobTitle} + +
+
+ {reportSignatory.email} +
+
+ ) : ( + '—' + ) + } + /> + 0 ? ( +
+ {cSuite.map((exec, i) => ( +
+ {exec.name} + {exec.title} +
+ ))} +
+ ) : ( + '—' + ) + } + /> +
+ + + {/* Business Overview */} +
+
+ + + +
+
+ + {/* System Architecture */} +
+ +
+ + {/* Third Party Dependencies */} +
+
+ + +
+
+ + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

+ {title} +

+
+ {children} +
+ ); +} + +function InfoCell({ + label, + value, + className, +}: { + label: string; + value: React.ReactNode; + className?: string; +}) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} + +function ContentRow({ title, content }: { title: string; content?: string }) { + const hasContent = content?.trim().length; + + return ( +
+

{title}

+ {hasContent ? ( +

+ {content} +

+ ) : ( +

Not yet available

+ )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/layout.tsx b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/layout.tsx new file mode 100644 index 000000000..b63d72119 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/layout.tsx @@ -0,0 +1,4 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return
{children}
; +} + diff --git a/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx new file mode 100644 index 000000000..73ea7608b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/auditor/(overview)/page.tsx @@ -0,0 +1,140 @@ +import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; +import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; +import { auth } from '@/utils/auth'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { db, Role } from '@db'; +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { notFound, redirect } from 'next/navigation'; +import { AuditorView } from './components/AuditorView'; + +// Helper to safely parse comma-separated roles string +function parseRolesString(rolesStr: string | null | undefined): Role[] { + if (!rolesStr) return []; + return rolesStr + .split(',') + .map((r) => r.trim()) + .filter((r) => r in Role) as Role[]; +} + +export async function generateMetadata(): Promise { + return { + title: 'Auditor View', + }; +} + +export default async function AuditorPage({ params }: { params: Promise<{ orgId: string }> }) { + const { orgId: organizationId } = await params; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + redirect('/auth'); + } + + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId, + deactivated: false, + }, + }); + + if (!member) { + redirect('/auth/unauthorized'); + } + + const roles = parseRolesString(member.role); + if (!roles.includes(Role.auditor)) { + notFound(); + } + + // Fetch organization for name and logo + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true, logo: true }, + }); + + // Get signed URL for logo if it exists + let logoUrl: string | null = null; + if (organization?.logo && s3Client && APP_AWS_ORG_ASSETS_BUCKET) { + try { + const command = new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: organization.logo, + }); + logoUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 }); + } catch { + // Logo not available + } + } + + // All context questions we need + const CONTEXT_QUESTIONS = [ + // AI-generated sections + 'Company Background & Overview of Operations', + 'Types of Services Provided', + 'Mission & Vision', + 'System Description', + 'Critical Vendors', + 'Subservice Organizations', + // Onboarding data + 'How many employees do you have?', + 'Who are your C-Suite executives?', + 'Who will sign off on the final report?', + ]; + + // Load existing content from Context + const existingContext = await db.context.findMany({ + where: { + organizationId, + question: { in: CONTEXT_QUESTIONS }, + }, + }); + + // Map question -> answer for the frontend + const initialContent: Record = {}; + for (const item of existingContext) { + initialContent[item.question] = item.answer; + } + + // Parse structured data + let cSuiteData: { name: string; title: string }[] = []; + let signatoryData: { fullName: string; jobTitle: string; email: string } | null = null; + + try { + const cSuiteRaw = initialContent['Who are your C-Suite executives?']; + if (cSuiteRaw) { + cSuiteData = JSON.parse(cSuiteRaw); + } + } catch { + // Invalid JSON + } + + try { + const signatoryRaw = initialContent['Who will sign off on the final report?']; + if (signatoryRaw) { + signatoryData = JSON.parse(signatoryRaw); + } + } catch { + // Invalid JSON + } + + return ( + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceOverview.tsx index f4c690b3f..c32dd7a78 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceOverview.tsx @@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; import { Progress } from '@comp/ui/progress'; import { FrameworkInstance } from '@db'; import { ComplianceProgressChart } from './ComplianceProgressChart'; +import { PeopleChart } from './PeopleChart'; import { PoliciesChart } from './PoliciesChart'; import { TasksChart } from './TasksChart'; @@ -13,22 +14,29 @@ export function ComplianceOverview({ publishedPolicies, totalTasks, doneTasks, + totalMembers, + completedMembers, }: { frameworks: FrameworkInstance[]; totalPolicies: number; publishedPolicies: number; totalTasks: number; doneTasks: number; + totalMembers: number; + completedMembers: number; }) { const compliancePercentage = complianceProgress( publishedPolicies, doneTasks, totalPolicies, totalTasks, + totalMembers, + completedMembers, ); const policiesPercentage = Math.round((publishedPolicies / Math.max(totalPolicies, 1)) * 100); const tasksPercentage = Math.round((doneTasks / Math.max(totalTasks, 1)) * 100); + const peoplePercentage = Math.round((completedMembers / Math.max(totalMembers, 1)) * 100); return ( @@ -46,7 +54,7 @@ export function ComplianceOverview({ /> - + {/* Progress bars for smaller screens */}
{/* Overall Compliance Progress Bar */} @@ -84,23 +92,39 @@ export function ComplianceOverview({
+ + {/* People Progress Bar */} +
+
+
+
+ People Score +
+ {peoplePercentage}% +
+ +
{/* Charts for larger screens */} -
+
-
- -
-
- -
-
- +
+
+ +
+
+ +
+
+ +
@@ -113,13 +137,24 @@ function complianceProgress( doneTasks: number, totalPolicies: number, totalTasks: number, + totalMembers: number, + completedMembers: number, ) { - const totalItems = totalPolicies + totalTasks; + // Calculate individual percentages + const policiesPercentage = totalPolicies > 0 ? publishedPolicies / totalPolicies : 0; + const tasksPercentage = totalTasks > 0 ? doneTasks / totalTasks : 0; + const peoplePercentage = totalMembers > 0 ? completedMembers / totalMembers : 0; + + // Calculate average of the three percentages + const totalCategories = [totalPolicies, totalTasks, totalMembers].filter( + (count) => count > 0, + ).length; - if (totalItems === 0) return 0; + if (totalCategories === 0) return 0; - const completedItems = publishedPolicies + doneTasks; - const complianceScore = Math.round((completedItems / totalItems) * 100); + const averagePercentage = + (policiesPercentage + tasksPercentage + peoplePercentage) / totalCategories; + const complianceScore = Math.round(averagePercentage * 100); return complianceScore; } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx index 8a961b384..03acf2135 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx @@ -73,10 +73,10 @@ export function ComplianceProgressChart({ data }: ComplianceProgressChartProps) } satisfies ChartConfig; return ( - + {data.score}% Overall @@ -126,7 +126,7 @@ export function ComplianceProgressChart({ data }: ComplianceProgressChartProps) +
{'Frameworks'} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx index be88095ca..c8b57da44 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/Overview.tsx @@ -22,6 +22,11 @@ export interface DoneTasksScore { incompleteTasks: Task[]; } +export interface PeopleScore { + totalMembers: number; + completedMembers: number; +} + export interface OverviewProps { frameworksWithControls: FrameworkInstanceWithControls[]; frameworksWithCompliance: FrameworkInstanceWithComplianceScore[]; @@ -29,6 +34,7 @@ export interface OverviewProps { organizationId: string; publishedPoliciesScore: PublishedPoliciesScore; doneTasksScore: DoneTasksScore; + peopleScore: PeopleScore; currentMember: { id: string; role: string } | null; } @@ -39,6 +45,7 @@ export const Overview = ({ organizationId, publishedPoliciesScore, doneTasksScore, + peopleScore, currentMember, }: OverviewProps) => { return ( @@ -49,6 +56,8 @@ export const Overview = ({ publishedPolicies={publishedPoliciesScore.publishedPolicies} totalTasks={doneTasksScore.totalTasks} doneTasks={doneTasksScore.doneTasks} + totalMembers={peopleScore.totalMembers} + completedMembers={peopleScore.completedMembers} /> { + if (!data) return []; + const items = [ + { + name: 'Compliant', + value: data.completed, + fill: CHART_COLORS.completed, + }, + { + name: 'Remaining', + value: data.remaining, + fill: CHART_COLORS.remaining, + }, + ]; + return items.filter((item) => item.value > 0); + }, [data]); + + if (!data) { + return ( + + +
+ People +
+
+ +
+
+ +
+

No data available

+
+
+
+ ); + } + + const chartConfig = { + value: { + label: 'People Status', + }, + } satisfies ChartConfig; + + return ( + + + } /> + + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx index 847a1c514..2272645ac 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx @@ -87,10 +87,10 @@ export function PoliciesChart({ data }: PoliciesChartProps) { } satisfies ChartConfig; return ( - + {data.published}% Policies @@ -140,7 +140,7 @@ export function PoliciesChart({ data }: PoliciesChartProps) { + {data.done}% Tasks @@ -124,7 +124,7 @@ export function TasksChart({ data }: TasksChartProps) { { + const roles = member.role.includes(',') ? member.role.split(',') : [member.role]; + return roles.includes('employee') || roles.includes('contractor'); + }); + + if (employees.length === 0) { + return { + totalMembers: 0, + completedMembers: 0, + }; + } + + // Get all required policies (published, required to sign, not archived) + const requiredPolicies = await db.policy.findMany({ + where: { + organizationId, + isRequiredToSign: true, + status: 'published', + isArchived: false, + }, + }); + + // Get all training video completions for these employees + const trainingVideoCompletions = await db.employeeTrainingVideoCompletion.findMany({ + where: { + memberId: { + in: employees.map((e) => e.id), + }, + }, + }); + + // Get required training video IDs (sat-1 through sat-5) + const requiredTrainingVideoIds = trainingVideos.map((video) => video.id); + + // Get fleet instance for device checks + const fleet = await getFleetInstance(); + + // Check each employee's completion status + let completedMembers = 0; + + for (const employee of employees) { + // 1. Check if all policies are accepted + const hasAcceptedAllPolicies = + requiredPolicies.length === 0 || + requiredPolicies.every((policy) => policy.signedBy.includes(employee.id)); + + // 2. Check if all training videos are completed + const employeeVideoCompletions = trainingVideoCompletions.filter( + (completion) => completion.memberId === employee.id, + ); + const completedVideoIds = employeeVideoCompletions + .filter((completion) => completion.completedAt !== null) + .map((completion) => completion.videoId); + const hasCompletedAllTraining = requiredTrainingVideoIds.every((videoId) => + completedVideoIds.includes(videoId), + ); + + // 3. Check if device is secure + let hasSecureDevice = true; + + if (employee.fleetDmLabelId) { + try { + const deviceResponse = await fleet.get(`/labels/${employee.fleetDmLabelId}/hosts`); + const device = deviceResponse.data.hosts?.[0]; + + if (device) { + const deviceWithPolicies = await fleet.get(`/hosts/${device.id}`); + const fleetPolicies = deviceWithPolicies.data.host.policies || []; + hasSecureDevice = fleetPolicies.every( + (policy: { response: string }) => policy.response === 'pass', + ); + } + } catch (error) { + // If there's an error fetching device, consider it not secure + hasSecureDevice = false; + } + } + + // Employee is "done" if all three conditions are met + if (hasAcceptedAllPolicies && hasCompletedAllTraining && hasSecureDevice) { + completedMembers++; + } + } + + return { + totalMembers: employees.length, + completedMembers, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx index 7c87b44eb..02f00747e 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx @@ -6,6 +6,7 @@ import { cache } from 'react'; import { Overview } from './components/Overview'; import { getAllFrameworkInstancesWithControls } from './data/getAllFrameworkInstancesWithControls'; import { getFrameworkWithComplianceScores } from './data/getFrameworkWithComplianceScores'; +import { getPeopleScore } from './lib/getPeople'; import { getPublishedPoliciesScore } from './lib/getPolicies'; import { getDoneTasks } from './lib/getTasks'; @@ -61,6 +62,7 @@ export default async function DashboardPage({ params }: { params: Promise<{ orgI const publishedPoliciesScore = await getPublishedPoliciesScore(organizationId); const doneTasksScore = await getDoneTasks(organizationId); + const peopleScore = await getPeopleScore(organizationId); return ( ); 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]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index 50cc6268c..99d1b69ea 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -6,7 +6,7 @@ import { Sidebar } from '@/components/sidebar'; import { TriggerTokenProvider } from '@/components/trigger-token-provider'; import { SidebarProvider } from '@/context/sidebar-context'; import { auth } from '@/utils/auth'; -import { db } from '@db'; +import { db, Role } from '@db'; import dynamic from 'next/dynamic'; import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -15,6 +15,15 @@ import { ConditionalOnboardingTracker } from './components/ConditionalOnboarding import { ConditionalPaddingWrapper } from './components/ConditionalPaddingWrapper'; import { DynamicMinHeight } from './components/DynamicMinHeight'; +// Helper to safely parse comma-separated roles string +function parseRolesString(rolesStr: string | null | undefined): Role[] { + if (!rolesStr) return []; + return rolesStr + .split(',') + .map((r) => r.trim()) + .filter((r) => r in Role) as Role[]; +} + const HotKeys = dynamic(() => import('@/components/hot-keys').then((mod) => mod.HotKeys), { ssr: true, }); @@ -65,7 +74,11 @@ export default async function Layout({ return redirect('/auth/unauthorized'); } - if (member.role === 'employee' || member.role === 'contractor') { + const roles = parseRolesString(member.role); + const hasAccess = + roles.includes(Role.owner) || roles.includes(Role.admin) || roles.includes(Role.auditor); + + if (!hasAccess) { return redirect('/no-access'); } diff --git a/apps/app/src/app/(app)/[orgId]/page.tsx b/apps/app/src/app/(app)/[orgId]/page.tsx index 19e28e12d..0d4d803ab 100644 --- a/apps/app/src/app/(app)/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/page.tsx @@ -1,7 +1,46 @@ +import { auth } from '@/utils/auth'; +import { db, Role } from '@db'; +import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; +// Helper to safely parse comma-separated roles string +function parseRolesString(rolesStr: string | null | undefined): Role[] { + if (!rolesStr) return []; + return rolesStr + .split(',') + .map((r) => r.trim()) + .filter((r) => r in Role) as Role[]; +} + export default async function DashboardPage({ params }: { params: Promise<{ orgId: string }> }) { - const organizationId = (await params).orgId; + const { orgId: organizationId } = await params; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (session?.user?.id) { + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId, + deactivated: false, + }, + select: { role: true }, + }); + + if (member?.role) { + const roles = parseRolesString(member.role); + // Redirect to auditor view if user has auditor role but not admin or owner + if ( + roles.includes(Role.auditor) && + !roles.includes(Role.admin) && + !roles.includes(Role.owner) + ) { + return redirect(`/${organizationId}/auditor`); + } + } + } return redirect(`/${organizationId}/frameworks`); } 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/(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 ( +
    +