From e06bb1522a0251b10bfdb00f6b7580c8dc46c6a0 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 14 Nov 2025 16:57:28 -0500 Subject: [PATCH 01/12] =?UTF-8?q?feat(security-questionnaire):=20add=20AI-?= =?UTF-8?q?powered=20questionnaire=20parsing=20an=E2=80=A6=20(#1751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(security-questionnaire): add AI-powered questionnaire parsing and auto-answering functionality * feat(frameworks): enhance FrameworksOverview with badge display and improve Security Questionnaire layout - Update FrameworksOverview to conditionally render badges or initials based on availability. - Refactor Security Questionnaire page to include a breadcrumb navigation and improve layout for better user experience. - Enhance QuestionnaireParser with new alert dialog for exit confirmation and streamline question answering process. - Improve UI components for better accessibility and responsiveness. * refactor(security-questionnaire): improve QuestionnaireParser layout and styling - Update search input styling for better visibility and responsiveness. - Adjust layout of command bar components for improved user experience. - Streamline button functionalities and ensure consistent styling across export options. * feat(security-questionnaire): enhance UI and functionality of Security Questionnaire page - Adjust padding and layout for improved responsiveness on the Security Questionnaire page. - Update header styles for better visibility and consistency. - Implement download functionality for questionnaire responses with enhanced user feedback. - Refactor question and answer display for better organization and accessibility across devices. - Improve button and input styling for a more cohesive user experience. * feat(security-questionnaire): enhance QuestionnaireParser UI and functionality - Update tab trigger styles for improved visibility and consistency. - Refactor file upload and URL input sections for better user experience. - Enhance dropzone component with clearer instructions and improved styling. - Streamline action button layout and functionality for better accessibility. * refactor(security-questionnaire): streamline action button layout in QuestionnaireParser - Reorganize action button section for improved clarity and consistency. - Maintain existing functionality while enhancing the overall UI structure. * feat(security-questionnaire): implement feature flag checks for questionnaire access - Add feature flag checks to control access to the AI vendor questionnaire. - Remove FeatureFlagWrapper component and directly use QuestionnaireParser. - Update header, sidebar, and mobile menu to conditionally render questionnaire options based on feature flag status. * refactor(api): simplify run status retrieval logic in task status route --------- Co-authored-by: Tofik Hasanov Co-authored-by: Claudio Fuentes --- .gitignore | 1 + apps/.cursor/rules/trigger.basic.mdc | 190 +++ apps/app/package.json | 1 + .../components/FrameworksOverview.tsx | 23 +- .../actions/auto-answer-questionnaire.ts | 59 + .../actions/export-questionnaire.ts | 202 +++ .../actions/parse-questionnaire-ai.ts | 601 +++++++++ .../components/QuestionnaireParser.tsx | 1125 +++++++++++++++++ .../[orgId]/security-questionnaire/page.tsx | 52 + .../components/OnboardingFormActions.tsx | 21 +- .../app/api/tasks/[taskId]/status/route.ts | 60 + apps/app/src/components/header.tsx | 19 +- apps/app/src/components/main-menu.tsx | 12 + apps/app/src/components/mobile-menu.tsx | 8 +- apps/app/src/components/sidebar.tsx | 15 +- apps/app/src/env.mjs | 4 + .../tasks/vendors/answer-question-helpers.ts | 130 ++ .../src/jobs/tasks/vendors/answer-question.ts | 63 + .../vendors/auto-answer-questionnaire.ts | 145 +++ apps/app/src/lib/vector/README.md | 111 ++ apps/app/src/lib/vector/core/client.ts | 18 + apps/app/src/lib/vector/core/find-similar.ts | 101 ++ .../src/lib/vector/core/generate-embedding.ts | 30 + .../src/lib/vector/core/upsert-embedding.ts | 76 ++ apps/app/src/lib/vector/index.ts | 9 + apps/app/src/lib/vector/utils/chunk-text.ts | 81 ++ .../lib/vector/utils/extract-policy-text.ts | 106 ++ bun.lock | 25 + package.json | 3 + packages/db/package.json | 3 +- 30 files changed, 3276 insertions(+), 18 deletions(-) create mode 100644 apps/.cursor/rules/trigger.basic.mdc create mode 100644 apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/auto-answer-questionnaire.ts create mode 100644 apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/export-questionnaire.ts create mode 100644 apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/parse-questionnaire-ai.ts create mode 100644 apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/security-questionnaire/page.tsx create mode 100644 apps/app/src/app/api/tasks/[taskId]/status/route.ts create mode 100644 apps/app/src/jobs/tasks/vendors/answer-question-helpers.ts create mode 100644 apps/app/src/jobs/tasks/vendors/answer-question.ts create mode 100644 apps/app/src/jobs/tasks/vendors/auto-answer-questionnaire.ts create mode 100644 apps/app/src/lib/vector/README.md create mode 100644 apps/app/src/lib/vector/core/client.ts create mode 100644 apps/app/src/lib/vector/core/find-similar.ts create mode 100644 apps/app/src/lib/vector/core/generate-embedding.ts create mode 100644 apps/app/src/lib/vector/core/upsert-embedding.ts create mode 100644 apps/app/src/lib/vector/index.ts create mode 100644 apps/app/src/lib/vector/utils/chunk-text.ts create mode 100644 apps/app/src/lib/vector/utils/extract-policy-text.ts diff --git a/.gitignore b/.gitignore index 42824128e..8aecd6d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules .pnp .pnp.js +.idea/ # testing coverage diff --git a/apps/.cursor/rules/trigger.basic.mdc b/apps/.cursor/rules/trigger.basic.mdc new file mode 100644 index 000000000..3d96e6657 --- /dev/null +++ b/apps/.cursor/rules/trigger.basic.mdc @@ -0,0 +1,190 @@ +--- +description: Only the most important rules for writing basic Trigger.dev tasks +globs: **/trigger/**/*.ts +alwaysApply: false +--- +# Trigger.dev Basic Tasks (v4) + +**MUST use `@trigger.dev/sdk` (v4), NEVER `client.defineJob`** + +## Basic Task + +```ts +import { task } from "@trigger.dev/sdk"; + +export const processData = task({ + id: "process-data", + retry: { + maxAttempts: 10, + factor: 1.8, + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + randomize: false, + }, + run: async (payload: { userId: string; data: any[] }) => { + // Task logic - runs for long time, no timeouts + console.log(`Processing ${payload.data.length} items for user ${payload.userId}`); + return { processed: payload.data.length }; + }, +}); +``` + +## Schema Task (with validation) + +```ts +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; + +export const validatedTask = schemaTask({ + id: "validated-task", + schema: z.object({ + name: z.string(), + age: z.number(), + email: z.string().email(), + }), + run: async (payload) => { + // Payload is automatically validated and typed + return { message: `Hello ${payload.name}, age ${payload.age}` }; + }, +}); +``` + +## Scheduled Task + +```ts +import { schedules } from "@trigger.dev/sdk"; + +const dailyReport = schedules.task({ + id: "daily-report", + cron: "0 9 * * *", // Daily at 9:00 AM UTC + // or with timezone: cron: { pattern: "0 9 * * *", timezone: "America/New_York" }, + run: async (payload) => { + console.log("Scheduled run at:", payload.timestamp); + console.log("Last run was:", payload.lastTimestamp); + console.log("Next 5 runs:", payload.upcoming); + + // Generate daily report logic + return { reportGenerated: true, date: payload.timestamp }; + }, +}); +``` + +## Triggering Tasks + +### From Backend Code + +```ts +import { tasks } from "@trigger.dev/sdk"; +import type { processData } from "./trigger/tasks"; + +// Single trigger +const handle = await tasks.trigger("process-data", { + userId: "123", + data: [{ id: 1 }, { id: 2 }], +}); + +// Batch trigger +const batchHandle = await tasks.batchTrigger("process-data", [ + { payload: { userId: "123", data: [{ id: 1 }] } }, + { payload: { userId: "456", data: [{ id: 2 }] } }, +]); +``` + +### From Inside Tasks (with Result handling) + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload) => { + // Trigger and continue + const handle = await childTask.trigger({ data: "value" }); + + // Trigger and wait - returns Result object, NOT task output + const result = await childTask.triggerAndWait({ data: "value" }); + if (result.ok) { + console.log("Task output:", result.output); // Actual task return value + } else { + console.error("Task failed:", result.error); + } + + // Quick unwrap (throws on error) + const output = await childTask.triggerAndWait({ data: "value" }).unwrap(); + + // Batch trigger and wait + const results = await childTask.batchTriggerAndWait([ + { payload: { data: "item1" } }, + { payload: { data: "item2" } }, + ]); + + for (const run of results) { + if (run.ok) { + console.log("Success:", run.output); + } else { + console.log("Failed:", run.error); + } + } + }, +}); + +export const childTask = task({ + id: "child-task", + run: async (payload: { data: string }) => { + return { processed: payload.data }; + }, +}); +``` + +> Never wrap triggerAndWait or batchTriggerAndWait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. + +## Waits + +```ts +import { task, wait } from "@trigger.dev/sdk"; + +export const taskWithWaits = task({ + id: "task-with-waits", + run: async (payload) => { + console.log("Starting task"); + + // Wait for specific duration + await wait.for({ seconds: 30 }); + await wait.for({ minutes: 5 }); + await wait.for({ hours: 1 }); + await wait.for({ days: 1 }); + + // Wait until specific date + await wait.until({ date: new Date("2024-12-25") }); + + // Wait for token (from external system) + await wait.forToken({ + token: "user-approval-token", + timeoutInSeconds: 3600, // 1 hour timeout + }); + + console.log("All waits completed"); + return { status: "completed" }; + }, +}); +``` + +> Never wrap wait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. + +## Key Points + +- **Result vs Output**: `triggerAndWait()` returns a `Result` object with `ok`, `output`, `error` properties - NOT the direct task output +- **Type safety**: Use `import type` for task references when triggering from backend +- **Waits > 5 seconds**: Automatically checkpointed, don't count toward compute usage + +## NEVER Use (v2 deprecated) + +```ts +// BREAKS APPLICATION +client.defineJob({ + id: "job-id", + run: async (payload, io) => { + /* ... */ + }, +}); +``` + +Use v4 SDK (`@trigger.dev/sdk`), check `result.ok` before accessing `result.output` diff --git a/apps/app/package.json b/apps/app/package.json index e2476b603..504c0b5ee 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -106,6 +106,7 @@ "use-debounce": "^10.0.4", "use-long-press": "^3.3.0", "use-stick-to-bottom": "^1.1.1", + "xlsx": "^0.18.5", "xml2js": "^0.6.2", "zaraz-ts": "^1.2.0", "zod": "^3.25.76", diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx index d792e8b51..db848d64e 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx @@ -98,19 +98,28 @@ export function FrameworksOverview({
{frameworksWithControls.map((framework, index) => { const complianceScore = complianceMap.get(framework.id) ?? 0; + const badgeSrc = mapFrameworkToBadge(framework); return (
- {framework.framework.name} + {badgeSrc ? ( + {framework.framework.name} + ) : ( +
+ + {framework.framework.name.charAt(0)} + +
+ )}
diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/auto-answer-questionnaire.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/auto-answer-questionnaire.ts new file mode 100644 index 000000000..7eb82741d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/auto-answer-questionnaire.ts @@ -0,0 +1,59 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { autoAnswerQuestionnaireTask } from '@/jobs/tasks/vendors/auto-answer-questionnaire'; +import { tasks } from '@trigger.dev/sdk'; +import { z } from 'zod'; + +const inputSchema = z.object({ + questionsAndAnswers: z.array( + z.object({ + question: z.string(), + answer: z.string().nullable(), + }), + ), +}); + +export const autoAnswerQuestionnaire = authActionClient + .inputSchema(inputSchema) + .metadata({ + name: 'auto-answer-questionnaire', + track: { + event: 'auto-answer-questionnaire', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { questionsAndAnswers } = parsedInput; + const { session } = ctx; + + if (!session?.activeOrganizationId) { + throw new Error('No active organization'); + } + + const organizationId = session.activeOrganizationId; + + try { + // Trigger the root orchestrator task - it will handle batching internally + const handle = await tasks.trigger( + 'auto-answer-questionnaire', + { + vendorId: `org_${organizationId}`, + organizationId, + questionsAndAnswers, + }, + ); + + return { + success: true, + data: { + taskId: handle.id, // Return orchestrator task ID for polling + }, + }; + } catch (error) { + throw error instanceof Error + ? error + : new Error('Failed to trigger auto-answer questionnaire'); + } + }); + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/export-questionnaire.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/export-questionnaire.ts new file mode 100644 index 000000000..e949ee2d8 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/export-questionnaire.ts @@ -0,0 +1,202 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import * as XLSX from 'xlsx'; +import { jsPDF } from 'jspdf'; +import { z } from 'zod'; + +const inputSchema = z.object({ + questionsAndAnswers: z.array( + z.object({ + question: z.string(), + answer: z.string().nullable(), + }), + ), + format: z.enum(['xlsx', 'csv', 'pdf']), +}); + +interface QuestionAnswer { + question: string; + answer: string | null; +} + +/** + * Generates XLSX file from questions and answers + */ +function generateXLSX(questionsAndAnswers: QuestionAnswer[]): Buffer { + const workbook = XLSX.utils.book_new(); + + // Create worksheet data + const worksheetData = [ + ['#', 'Question', 'Answer'], // Header row + ...questionsAndAnswers.map((qa, index) => [ + index + 1, + qa.question, + qa.answer || '', + ]), + ]; + + const worksheet = XLSX.utils.aoa_to_sheet(worksheetData); + + // Set column widths + worksheet['!cols'] = [ + { wch: 5 }, // # + { wch: 60 }, // Question + { wch: 60 }, // Answer + ]; + + XLSX.utils.book_append_sheet(workbook, worksheet, 'Questionnaire'); + + // Convert to buffer + return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })); +} + +/** + * Generates CSV file from questions and answers + */ +function generateCSV(questionsAndAnswers: QuestionAnswer[]): string { + const rows = [ + ['#', 'Question', 'Answer'], // Header row + ...questionsAndAnswers.map((qa, index) => [ + String(index + 1), + qa.question.replace(/"/g, '""'), // Escape quotes + (qa.answer || '').replace(/"/g, '""'), // Escape quotes + ]), + ]; + + return rows.map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n'); +} + +/** + * Generates PDF file from questions and answers + */ +function generatePDF(questionsAndAnswers: QuestionAnswer[], vendorName?: string): Buffer { + const doc = new jsPDF(); + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const margin = 20; + const contentWidth = pageWidth - 2 * margin; + let yPosition = margin; + const lineHeight = 7; + + // Add title + doc.setFontSize(16); + doc.setFont('helvetica', 'bold'); + const title = vendorName ? `Questionnaire: ${vendorName}` : 'Questionnaire'; + doc.text(title, margin, yPosition); + yPosition += lineHeight * 2; + + // Add date + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.text(`Generated: ${new Date().toLocaleDateString()}`, margin, yPosition); + yPosition += lineHeight * 2; + + // Process each question-answer pair + doc.setFontSize(11); + + questionsAndAnswers.forEach((qa, index) => { + // Check if we need a new page + if (yPosition > pageHeight - 40) { + doc.addPage(); + yPosition = margin; + } + + // Question number and question + doc.setFont('helvetica', 'bold'); + const questionText = `Q${index + 1}: ${qa.question}`; + const questionLines = doc.splitTextToSize(questionText, contentWidth); + doc.text(questionLines, margin, yPosition); + yPosition += questionLines.length * lineHeight + 2; + + // Answer + doc.setFont('helvetica', 'normal'); + const answerText = qa.answer || 'No answer provided'; + const answerLines = doc.splitTextToSize(`A${index + 1}: ${answerText}`, contentWidth); + doc.text(answerLines, margin, yPosition); + yPosition += answerLines.length * lineHeight + 4; + }); + + // Convert to buffer + return Buffer.from(doc.output('arraybuffer')); +} + +export const exportQuestionnaire = authActionClient + .inputSchema(inputSchema) + .metadata({ + name: 'export-questionnaire', + track: { + event: 'export-questionnaire', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { questionsAndAnswers, format } = parsedInput; + const { session } = ctx; + + if (!session?.activeOrganizationId) { + throw new Error('No active organization'); + } + + const organizationId = session.activeOrganizationId; + + try { + const vendorName = 'security-questionnaire'; + const sanitizedVendorName = vendorName.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const timestamp = new Date().toISOString().split('T')[0]; + + let fileBuffer: Buffer; + let mimeType: string; + let fileExtension: string; + let filename: string; + + // Generate file based on format + switch (format) { + case 'xlsx': { + fileBuffer = generateXLSX(questionsAndAnswers); + mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + fileExtension = 'xlsx'; + filename = `questionnaire-${sanitizedVendorName}-${timestamp}.xlsx`; + break; + } + + case 'csv': { + const csvContent = generateCSV(questionsAndAnswers); + fileBuffer = Buffer.from(csvContent, 'utf-8'); + mimeType = 'text/csv'; + fileExtension = 'csv'; + filename = `questionnaire-${sanitizedVendorName}-${timestamp}.csv`; + break; + } + + case 'pdf': { + fileBuffer = generatePDF(questionsAndAnswers, vendorName); + mimeType = 'application/pdf'; + fileExtension = 'pdf'; + filename = `questionnaire-${sanitizedVendorName}-${timestamp}.pdf`; + break; + } + + default: + throw new Error(`Unsupported format: ${format}`); + } + + // Convert buffer to base64 data URL for direct download + const base64Data = fileBuffer.toString('base64'); + const dataUrl = `data:${mimeType};base64,${base64Data}`; + + return { + success: true, + data: { + downloadUrl: dataUrl, + filename, + }, + }; + } catch (error) { + throw error instanceof Error + ? error + : new Error('Failed to export questionnaire'); + } + }); + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/parse-questionnaire-ai.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/parse-questionnaire-ai.ts new file mode 100644 index 000000000..df69e56cc --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/parse-questionnaire-ai.ts @@ -0,0 +1,601 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { BUCKET_NAME, extractS3KeyFromUrl, s3Client } from '@/app/s3'; +import { env } from '@/env.mjs'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { openai } from '@ai-sdk/openai'; +import { db } from '@db'; +import { generateObject, generateText, jsonSchema } from 'ai'; +import * as XLSX from 'xlsx'; +import { z } from 'zod'; +import { findSimilarContent, upsertEmbedding, chunkText, extractTextFromPolicy } from '@/lib/vector'; + +const inputSchema = z.object({ + inputType: z.enum(['file', 'url', 'attachment']), + // For file uploads + fileData: z.string().optional(), // base64 encoded + fileName: z.string().optional(), + fileType: z.string().optional(), // MIME type + // For URLs + url: z.string().url().optional(), + // For attachments + attachmentId: z.string().optional(), +}); + +interface QuestionAnswer { + question: string; + answer: string | null; +} + +/** + * Extracts content from a file using various methods based on file type + */ +async function extractContentFromFile( + fileData: string, + fileType: string, +): Promise { + const fileBuffer = Buffer.from(fileData, 'base64'); + + // Handle Excel files (.xlsx, .xls) + if ( + fileType === 'application/vnd.ms-excel' || + fileType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || + fileType === 'application/vnd.ms-excel.sheet.macroEnabled.12' + ) { + try { + const workbook = XLSX.read(fileBuffer, { type: 'buffer' }); + const sheets: string[] = []; + + // Extract content from all sheets + workbook.SheetNames.forEach((sheetName) => { + const worksheet = workbook.Sheets[sheetName]; + const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }); + + // Convert to readable text format + const sheetText = jsonData + .map((row: any) => { + if (Array.isArray(row)) { + return row.filter((cell) => cell !== null && cell !== undefined && cell !== '').join(' | '); + } + return String(row); + }) + .filter((line: string) => line.trim() !== '') + .join('\n'); + + if (sheetText.trim()) { + sheets.push(`Sheet: ${sheetName}\n${sheetText}`); + } + }); + + return sheets.join('\n\n'); + } catch (error) { + throw new Error(`Failed to parse Excel file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Handle CSV files + if (fileType === 'text/csv' || fileType === 'text/comma-separated-values') { + try { + const text = fileBuffer.toString('utf-8'); + // Convert CSV to readable format + const lines = text.split('\n').filter((line) => line.trim() !== ''); + return lines.join('\n'); + } catch (error) { + throw new Error(`Failed to parse CSV file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Handle plain text files + if (fileType === 'text/plain' || fileType.startsWith('text/')) { + try { + return fileBuffer.toString('utf-8'); + } catch (error) { + throw new Error(`Failed to read text file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // Handle Word documents - try to use OpenAI vision API + if ( + fileType === 'application/msword' || + fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) { + // For Word docs, we'll try to use OpenAI vision API if possible + // Note: OpenAI vision API doesn't directly support .docx, so we'll need to convert or use a library + // For now, let's try to extract text using a fallback approach + try { + // Try to extract text using OpenAI vision by converting to image representation + // Or we could use a library like mammoth, but for now let's use vision API as fallback + throw new Error( + 'Word documents (.docx) are best converted to PDF or image format for parsing. Alternatively, use a URL to view the document.', + ); + } catch (error) { + throw error instanceof Error ? error : new Error('Failed to process Word document'); + } + } + + // For images and PDFs, use OpenAI vision API + const isImage = fileType.startsWith('image/'); + const isPdf = fileType === 'application/pdf'; + + if (isImage || isPdf) { + const base64Data = fileData; + const mimeType = fileType; + + try { + const { text } = await generateText({ + model: openai('gpt-4o'), + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: `Extract all text content from this document and identify question–answer pairs. + • If the document includes columns or sections labeled "Question", "Questions", "Q", "Prompt", treat those as questions. + • If it includes "Answer", "Answers", "A", "Response", treat those as answers. + • If explicit headers are missing, infer which text segments are questions based on phrasing (e.g., sentences ending with "?", or starting with words like What, How, Why, When, Is, Can, Do, etc.), and match them to their most likely corresponding answers nearby. + • Preserve the original order and structure. + • Format the result as a clean list or structured text of Question → Answer pairs. + • Exclude any irrelevant or unrelated text. + • Return only the extracted question–answer pairs, without extra commentary.`, + }, + { + type: 'image', + image: `data:${mimeType};base64,${base64Data}`, + }, + ], + }, + ], + }); + + return text; + } catch (error) { + throw new Error(`Failed to extract content: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + // For other file types that might be binary formats, provide helpful error message + throw new Error( + `Unsupported file type: ${fileType}. Supported formats: PDF, images (PNG, JPG, etc.), Excel (.xlsx, .xls), CSV, text files (.txt), and Word documents (.docx - convert to PDF for best results).`, + ); +} + +/** + * Converts Google Sheets URL to export format URLs + */ +function convertGoogleSheetsUrlToExport(url: string): { csvUrl: string; xlsxUrl: string } | null { + // Match Google Sheets URL pattern: https://docs.google.com/spreadsheets/d/{ID}/edit... + const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); + if (match) { + const sheetId = match[1]; + // Try to extract gid from URL if present + const gidMatch = url.match(/[#&]gid=(\d+)/); + const gid = gidMatch ? gidMatch[1] : '0'; + + return { + csvUrl: `https://docs.google.com/spreadsheets/d/${sheetId}/export?format=csv&gid=${gid}`, + xlsxUrl: `https://docs.google.com/spreadsheets/d/${sheetId}/export?format=xlsx&gid=${gid}`, + }; + } + return null; +} + +/** + * Extracts content from a URL using Firecrawl + */ +async function extractContentFromUrl(url: string): Promise { + // Check if it's a Google Sheets URL + const isGoogleSheets = url.includes('docs.google.com/spreadsheets'); + + // For Google Sheets, use Firecrawl as direct export often fails due to authentication/permissions + // Firecrawl can handle Google Sheets better + if (isGoogleSheets) { + // Note: User should ensure the document is publicly viewable for best results + } + + if (!env.FIRECRAWL_API_KEY) { + throw new Error('Firecrawl API key is not configured'); + } + + try { + const initialResponse = await fetch('https://api.firecrawl.dev/v1/extract', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.FIRECRAWL_API_KEY}`, + }, + body: JSON.stringify({ + urls: [url], + prompt: 'Extract all text content from this page, including any questions and answers, forms, or questionnaire data.', + scrapeOptions: { + onlyMainContent: true, + removeBase64Images: true, + }, + }), + }); + + const initialData = await initialResponse.json(); + + if (!initialData.success || !initialData.id) { + throw new Error('Failed to start Firecrawl extraction'); + } + + const jobId = initialData.id; + const maxWaitTime = 1000 * 60 * 5; // 5 minutes + const pollInterval = 5000; // 5 seconds + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitTime) { + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + + const statusResponse = await fetch(`https://api.firecrawl.dev/v1/extract/${jobId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.FIRECRAWL_API_KEY}`, + }, + }); + + const statusData = await statusResponse.json(); + + if (statusData.status === 'completed' && statusData.data) { + // Extract text from the response + const extractedData = statusData.data; + if (typeof extractedData === 'string') { + return extractedData; + } + if (typeof extractedData === 'object' && extractedData.content) { + return typeof extractedData.content === 'string' + ? extractedData.content + : JSON.stringify(extractedData.content); + } + return JSON.stringify(extractedData); + } + + if (statusData.status === 'failed') { + throw new Error('Firecrawl extraction failed'); + } + + if (statusData.status === 'cancelled') { + throw new Error('Firecrawl extraction was cancelled'); + } + } + + throw new Error('Firecrawl extraction timed out'); + } catch (error) { + throw error instanceof Error ? error : new Error('Failed to extract content from URL'); + } +} + +/** + * Extracts content from an attachment stored in S3 + */ +async function extractContentFromAttachment( + attachmentId: string, + organizationId: string, +): Promise<{ content: string; fileType: string }> { + const attachment = await db.attachment.findUnique({ + where: { + id: attachmentId, + organizationId, + }, + }); + + if (!attachment) { + throw new Error('Attachment not found'); + } + + const key = extractS3KeyFromUrl(attachment.url); + const getCommand = new GetObjectCommand({ + Bucket: BUCKET_NAME!, + Key: key, + }); + + const response = await s3Client.send(getCommand); + + if (!response.Body) { + throw new Error('Failed to retrieve attachment from S3'); + } + + // Convert stream to buffer + const chunks: Uint8Array[] = []; + for await (const chunk of response.Body as any) { + chunks.push(chunk); + } + const buffer = Buffer.concat(chunks); + const base64Data = buffer.toString('base64'); + + // Determine file type from attachment or content type + const fileType = + response.ContentType || + (attachment.type === 'image' ? 'image/png' : 'application/pdf'); + + const content = await extractContentFromFile(base64Data, fileType); + + return { content, fileType }; +} + +/** + * Parses questions and answers from extracted content using LLM + */ +async function parseQuestionsAndAnswers(content: string): Promise { + const { object } = await generateObject({ + model: openai('gpt-4o'), + mode: 'json', + schema: jsonSchema({ + type: 'object', + properties: { + questionsAndAnswers: { + type: 'array', + items: { + type: 'object', + properties: { + question: { + type: 'string', + description: 'The question text', + }, + answer: { + anyOf: [ + { type: 'string' }, + { type: 'null' }, + ], + description: 'The answer to the question. Use null if no answer is provided.', + }, + }, + required: ['question'], + }, + }, + }, + required: ['questionsAndAnswers'], + }), + system: `You are an expert at extracting structured question-answer pairs from vendor questionnaires and security assessment documents. + +Your task is to: +1. Identify all questions in the document +2. Match each question with its corresponding answer +3. Extract the question-answer pairs in a structured format +4. If a question doesn't have a clear answer or the answer is empty, use null (not an empty string or placeholder text) +5. Preserve the exact wording of questions and answers when possible +6. Handle various formats: forms, tables, lists, paragraphs, etc. + +Return all question-answer pairs you find in the document. Use null for missing or empty answers.`, + prompt: `Extract all question-answer pairs from the following content: + +${content} + +Return a structured list of questions and their corresponding answers.`, + }); + + const parsed = (object as { questionsAndAnswers: QuestionAnswer[] }).questionsAndAnswers; + + // Post-process to ensure empty strings are converted to null + return parsed.map((qa) => ({ + question: qa.question, + answer: qa.answer && qa.answer.trim() !== '' ? qa.answer : null, + })); +} + +export const parseQuestionnaireAI = authActionClient + .inputSchema(inputSchema) + .metadata({ + name: 'parse-questionnaire-ai', + track: { + event: 'parse-questionnaire-ai', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { inputType } = parsedInput; + const { session } = ctx; + + if (!session?.activeOrganizationId) { + throw new Error('No active organization'); + } + + const organizationId = session.activeOrganizationId; + + try { + // Ensure embeddings exist for this organization before parsing + // Check if policy embeddings exist specifically, and create them if needed + try { + // Check specifically for policy embeddings by searching and filtering by sourceType + const testResults = await findSimilarContent('policy security compliance', organizationId, 10); + const hasPolicyEmbeddings = testResults.some((result) => result.sourceType === 'policy'); + + // Always ensure policies are synced (they might be missing even if context exists) + if (!hasPolicyEmbeddings) { + // Create embeddings for policies (limit to 10 to avoid timeout) + const policies = await db.policy.findMany({ + where: { + organizationId, + status: 'published', + }, + select: { + id: true, + name: true, + description: true, + content: true, + organizationId: true, + }, + take: 10, + }); + + if (policies.length > 0) { + for (const policy of policies) { + try { + const policyText = extractTextFromPolicy(policy as any); + if (!policyText || policyText.trim().length === 0) { + continue; + } + + const chunks = chunkText(policyText, 500, 50); + if (chunks.length === 0) { + continue; + } + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + if (!chunk || chunk.trim().length === 0) { + continue; + } + const embeddingId = `policy_${policy.id}_chunk${i}`; + await upsertEmbedding(embeddingId, chunk, { + organizationId: policy.organizationId, + sourceType: 'policy', + sourceId: policy.id, + content: chunk, + policyName: policy.name, + }); + } + } catch (error) { + // Continue on error + } + } + } + } + + // Check for context embeddings separately + const contextTestResults = await findSimilarContent('context question answer', organizationId, 1); + const hasContextEmbeddings = contextTestResults.some((result) => result.sourceType === 'context'); + + if (!hasContextEmbeddings) { + // Create embeddings for context entries (limit to 10 to avoid timeout) + const contextEntries = await db.context.findMany({ + where: { organizationId }, + select: { + id: true, + question: true, + answer: true, + organizationId: true, + }, + take: 10, + }); + + if (contextEntries.length > 0) { + for (const context of contextEntries) { + try { + const contextText = `Question: ${context.question}\n\nAnswer: ${context.answer}`; + if (!contextText || contextText.trim().length === 0) { + continue; + } + + const chunks = chunkText(contextText, 500, 50); + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const embeddingId = `context_${context.id}_chunk${i}`; + await upsertEmbedding(embeddingId, chunk, { + organizationId: context.organizationId, + sourceType: 'context', + sourceId: context.id, + content: chunk, + contextQuestion: context.question, + }); + } + } catch (error) { + // Continue on error + } + } + } + } + } catch (error) { + // Don't fail parsing if embeddings check/creation fails + } + + let extractedContent: string; + + // Extract content based on input type + switch (inputType) { + case 'file': { + if (!parsedInput.fileData || !parsedInput.fileType) { + throw new Error('File data and file type are required for file input'); + } + extractedContent = await extractContentFromFile( + parsedInput.fileData, + parsedInput.fileType, + ); + break; + } + + case 'url': { + if (!parsedInput.url) { + throw new Error('URL is required for URL input'); + } + extractedContent = await extractContentFromUrl(parsedInput.url); + break; + } + + case 'attachment': { + if (!parsedInput.attachmentId) { + throw new Error('Attachment ID is required for attachment input'); + } + const result = await extractContentFromAttachment( + parsedInput.attachmentId, + organizationId, + ); + extractedContent = result.content; + break; + } + + default: + throw new Error(`Unsupported input type: ${inputType}`); + } + + // Parse questions and answers from extracted content + const questionsAndAnswers = await parseQuestionsAndAnswers(extractedContent); + + const vendorName = 'Security Questionnaire'; + const sourcePrefix = `org_${organizationId}`; + + // Add parsed questionnaire Q&A pairs to vector database + try { + for (let i = 0; i < questionsAndAnswers.length; i++) { + const qa = questionsAndAnswers[i]; + + // Skip if no answer (we can't use empty answers as context) + if (!qa.answer || qa.answer.trim().length === 0) { + continue; + } + + try { + // Create text representation: "Question: X\n\nAnswer: Y" + const qaText = `Question: ${qa.question}\n\nAnswer: ${qa.answer}`; + + // Chunk if needed (though Q&A pairs are usually short) + const chunks = chunkText(qaText, 500, 50); + + for (let j = 0; j < chunks.length; j++) { + const chunk = chunks[j]; + const embeddingId = `questionnaire_${sourcePrefix}_qa${i}_chunk${j}`; + + await upsertEmbedding(embeddingId, chunk, { + organizationId, + sourceType: 'questionnaire', + sourceId: `${sourcePrefix}_qa${i}`, + content: chunk, + vendorName, + questionnaireQuestion: qa.question, + }); + } + } catch (error) { + // Continue with other Q&A pairs even if one fails + } + } + } catch (error) { + // Don't fail parsing if vector DB addition fails + } + + return { + success: true, + data: { + questionsAndAnswers, + extractedContent: extractedContent.substring(0, 1000), // Return first 1000 chars for preview + }, + }; + } catch (error) { + throw error instanceof Error + ? error + : new Error('Failed to parse questionnaire'); + } + }); + diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx new file mode 100644 index 000000000..598a15bab --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireParser.tsx @@ -0,0 +1,1125 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@comp/ui/alert-dialog'; +import { Button } from '@comp/ui/button'; +import { cn } from '@comp/ui/cn'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@comp/ui/dropdown-menu'; +import { Input } from '@comp/ui/input'; +import { ScrollArea } from '@comp/ui/scroll-area'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@comp/ui/table'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs'; +import { Textarea } from '@comp/ui/textarea'; +import { + BookOpen, + ChevronDown, + ChevronUp, + Download, + File, + FileSpreadsheet, + FileText, + FileText as FileTextIcon, + Link as LinkIcon, + Loader2, + Search, + Sparkles, + Upload, + X, + Zap, +} from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { useCallback, useMemo, useState } from 'react'; +import Dropzone, { type FileRejection } from 'react-dropzone'; +import { toast } from 'sonner'; +import { autoAnswerQuestionnaire } from '../actions/auto-answer-questionnaire'; +import { exportQuestionnaire } from '../actions/export-questionnaire'; +import { parseQuestionnaireAI } from '../actions/parse-questionnaire-ai'; + +interface QuestionAnswer { + question: string; + answer: string | null; + sources?: Array<{ + sourceType: string; + sourceName?: string; + sourceId?: string; + policyName?: string; + score: number; + }>; +} + +export function QuestionnaireParser() { + const params = useParams(); + const orgId = params?.orgId as string; + const [activeTab, setActiveTab] = useState<'file' | 'url'>('file'); + const [selectedFile, setSelectedFile] = useState(null); + const [url, setUrl] = useState(''); + const [showExitDialog, setShowExitDialog] = useState(false); + const [results, setResults] = useState(null); + const [extractedContent, setExtractedContent] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [editingIndex, setEditingIndex] = useState(null); + const [editingAnswer, setEditingAnswer] = useState(''); + const [expandedSources, setExpandedSources] = useState>(new Set()); + const [questionStatuses, setQuestionStatuses] = useState< + Map + >(new Map()); + const [hasClickedAutoAnswer, setHasClickedAutoAnswer] = useState(false); + const [answeringQuestionIndex, setAnsweringQuestionIndex] = useState(null); + + const parseAction = useAction(parseQuestionnaireAI, { + onSuccess: ({ data }: { data: any }) => { + console.log('Parse action success:', data); + const responseData = data?.data || data; + const questionsAndAnswers = responseData?.questionsAndAnswers; + const extractedContent = responseData?.extractedContent; + + if (questionsAndAnswers && Array.isArray(questionsAndAnswers)) { + console.log('Setting results:', questionsAndAnswers); + setResults(questionsAndAnswers); + setExtractedContent(extractedContent || null); + setQuestionStatuses(new Map()); + setHasClickedAutoAnswer(false); + toast.success(`Successfully parsed ${questionsAndAnswers.length} question-answer pairs`); + } else { + console.warn('No questionsAndAnswers in data:', { data, responseData }); + toast.error('Parsed data is missing questions'); + } + }, + onError: ({ error }) => { + console.error('Parse action error:', error); + toast.error(error.serverError || 'Failed to parse questionnaire'); + }, + }); + + const autoAnswerAction = useAction(autoAnswerQuestionnaire, { + onSuccess: async ({ data }: { data: any }) => { + const responseData = data?.data || data; + const orchestratorTaskId = responseData?.taskId as string | undefined; + + if (!orchestratorTaskId) { + toast.error('Failed to start auto-answer task'); + return; + } + + const isSingleQuestion = answeringQuestionIndex !== null; + + if (results && !isSingleQuestion) { + const statuses = new Map(); + results.forEach((qa, index) => { + if (!qa.answer || qa.answer.trim().length === 0) { + statuses.set(index, 'processing'); + } else { + statuses.set(index, 'completed'); + } + }); + setQuestionStatuses(statuses); + } + + const pollInterval = setInterval(async () => { + try { + const response = await fetch(`/api/tasks/${orchestratorTaskId}/status`); + if (!response.ok) { + throw new Error('Failed to fetch orchestrator task status'); + } + + const data = await response.json(); + + if (data.status === 'COMPLETED' && data.output) { + clearInterval(pollInterval); + + const answers = data.output.answers as + | Array<{ + questionIndex: number; + question: string; + answer: string | null; + sources?: Array<{ + sourceType: string; + sourceName?: string; + score: number; + }>; + }> + | undefined; + + if (answers && Array.isArray(answers)) { + setResults((prevResults) => { + if (!prevResults) return prevResults; + + const updatedResults = [...prevResults]; + let answeredCount = 0; + + answers.forEach((answer) => { + // For single question, use the answeringQuestionIndex + const targetIndex = + isSingleQuestion && answeringQuestionIndex !== null + ? answeringQuestionIndex + : answer.questionIndex; + + if (answer.answer) { + answeredCount++; + updatedResults[targetIndex] = { + question: answer.question, + answer: answer.answer, + sources: answer.sources, + }; + + setQuestionStatuses((prev) => { + const newStatuses = new Map(prev); + newStatuses.set(targetIndex, 'completed'); + return newStatuses; + }); + } else { + setQuestionStatuses((prev) => { + const newStatuses = new Map(prev); + newStatuses.set(targetIndex, 'completed'); + return newStatuses; + }); + } + }); + + return updatedResults; + }); + + setAnsweringQuestionIndex(null); + + const totalQuestions = answers.length; + const answeredQuestions = answers.filter((a) => a.answer).length; + const noAnswerQuestions = totalQuestions - answeredQuestions; + + if (isSingleQuestion) { + if (answeredQuestions > 0) { + toast.success('Answer generated successfully'); + } else { + toast.warning( + 'Could not find relevant information in your policies for this question.', + ); + } + } else { + if (answeredQuestions > 0) { + toast.success( + `Answered ${answeredQuestions} of ${totalQuestions} question${totalQuestions > 1 ? 's' : ''}${noAnswerQuestions > 0 ? `. ${noAnswerQuestions} had insufficient information.` : '.'}`, + ); + } else { + toast.warning( + `Could not find relevant information in your policies. Try adding more detail about ${answers[0]?.question.split(' ').slice(0, 5).join(' ')}...`, + ); + } + } + } + } else if (data.status === 'FAILED' || data.status === 'CANCELED') { + clearInterval(pollInterval); + const errorMessage = data.error || 'Task failed or was canceled'; + toast.error(`Failed to generate answer: ${errorMessage}`); + + setQuestionStatuses((prev) => { + const newStatuses = new Map(prev); + prev.forEach((status, index) => { + if (status === 'processing') { + newStatuses.set(index, 'completed'); + } + }); + return newStatuses; + }); + setAnsweringQuestionIndex(null); + } + } catch (error) { + console.error('Error polling orchestrator task:', error); + clearInterval(pollInterval); + toast.error('Failed to poll auto-answer task status'); + } + }, 2000); + + setTimeout( + () => { + clearInterval(pollInterval); + }, + 10 * 60 * 1000, + ); + }, + onError: ({ error }) => { + console.error('Auto-answer action error:', error); + toast.error(error.serverError || 'Failed to start auto-answer process'); + }, + }); + + const exportAction = useAction(exportQuestionnaire, { + onSuccess: ({ data }: { data: any }) => { + const responseData = data?.data || data; + const filename = responseData?.filename; + const downloadUrl = responseData?.downloadUrl; + + if (downloadUrl && filename) { + // Trigger download + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success(`Exported as ${filename}`); + } + }, + onError: ({ error }) => { + console.error('Export action error:', error); + toast.error(error.serverError || 'Failed to export questionnaire'); + }, + }); + + const handleFileSelect = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => { + if (rejectedFiles.length > 0) { + toast.error(`File rejected: ${rejectedFiles[0].errors[0].message}`); + return; + } + + if (acceptedFiles.length > 0) { + setSelectedFile(acceptedFiles[0]); + } + }, []); + + const handleParse = async () => { + if (activeTab === 'file' && selectedFile) { + const reader = new FileReader(); + reader.onloadend = async () => { + const dataUrl = reader.result as string; + const base64 = dataUrl.split(',')[1]; + const fileType = selectedFile.type || 'application/octet-stream'; + + await parseAction.execute({ + inputType: 'file', + fileData: base64, + fileName: selectedFile.name, + fileType, + }); + }; + reader.readAsDataURL(selectedFile); + } else if (activeTab === 'url' && url.trim()) { + await parseAction.execute({ + inputType: 'url', + url: url.trim(), + }); + } + }; + + const confirmReset = () => { + handleReset(); + setShowExitDialog(false); + }; + + const handleReset = () => { + setSelectedFile(null); + setUrl(''); + setResults(null); + setExtractedContent(null); + setSearchQuery(''); + setEditingIndex(null); + setEditingAnswer(''); + setQuestionStatuses(new Map()); + setExpandedSources(new Set()); + setAnsweringQuestionIndex(null); + }; + + const isLoading = parseAction.status === 'executing'; + const isAutoAnswering = autoAnswerAction.status === 'executing'; + const isExporting = exportAction.status === 'executing'; + + const handleAutoAnswer = async () => { + setHasClickedAutoAnswer(true); + if (!results || results.length === 0) { + toast.error('Please analyze a questionnaire first'); + return; + } + + await autoAnswerAction.execute({ + questionsAndAnswers: results, + }); + }; + + const handleAnswerSingleQuestion = async (index: number) => { + if (!results || !results[index]) { + toast.error('Question not found'); + return; + } + + setAnsweringQuestionIndex(index); + setQuestionStatuses((prev) => { + const newStatuses = new Map(prev); + newStatuses.set(index, 'processing'); + return newStatuses; + }); + + try { + // Call the auto-answer action with just this one question + await autoAnswerAction.execute({ + questionsAndAnswers: [results[index]], + }); + } catch (error) { + console.error('Error answering single question:', error); + setQuestionStatuses((prev) => { + const newStatuses = new Map(prev); + newStatuses.set(index, 'completed'); + return newStatuses; + }); + setAnsweringQuestionIndex(null); + } + }; + + const handleEditAnswer = (index: number) => { + setEditingIndex(index); + setEditingAnswer(results![index].answer || ''); + }; + + const handleSaveAnswer = (index: number) => { + if (!results) return; + const updated = [...results]; + updated[index] = { + ...updated[index], + answer: editingAnswer.trim() || null, + }; + setResults(updated); + setEditingIndex(null); + setEditingAnswer(''); + toast.success('Answer updated'); + }; + + const handleCancelEdit = () => { + setEditingIndex(null); + setEditingAnswer(''); + }; + + const handleExport = async (format: 'xlsx' | 'csv' | 'pdf') => { + if (!results || results.length === 0) { + toast.error('No data to export'); + return; + } + + await exportAction.execute({ + questionsAndAnswers: results, + format, + }); + }; + + const filteredResults = useMemo(() => { + if (!results) return null; + if (!searchQuery.trim()) return results; + + const query = searchQuery.toLowerCase(); + return results.filter( + (qa) => + qa.question.toLowerCase().includes(query) || + (qa.answer && qa.answer.toLowerCase().includes(query)), + ); + }, [results, searchQuery]); + + const answeredCount = useMemo(() => { + return results?.filter((qa) => qa.answer).length || 0; + }, [results]); + + const totalCount = results?.length || 0; + const progressPercentage = totalCount > 0 ? Math.round((answeredCount / totalCount) * 100) : 0; + + return ( + <> + {results && results.length > 0 ? ( + // Full-width layout when we have results +
+ {/* Header with title and command bar inline */} +
+
+ + + + + + Exit questionnaire session? + + This will discard all questions and answers. Make sure to export your work + before exiting if you want to keep it. + + + + Cancel + + Exit and Discard + + + + +
+ +
+

+ Questions & Answers +

+
+

+ {searchQuery && filteredResults ? `${filteredResults.length} of ` : ''} + {totalCount} questions • {answeredCount} answered +

+
+
+
+
+
+
+ + {/* Command bar - inline on desktop, stacked on mobile */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 h-9 text-sm" + /> +
+
+
+ {!hasClickedAutoAnswer && results.some((qa) => !qa.answer) && ( + <> +