diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts index 822eef671..012e36de1 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts @@ -6,7 +6,11 @@ import { z } from 'zod'; // Adjust safe-action import for colocalized structure import { authActionClient } from '@/actions/safe-action'; import type { ActionResponse } from '@/actions/types'; -import { sendUnassignedItemsNotificationEmail, type UnassignedItem } from '@comp/email'; +import { + isUserUnsubscribed, + sendUnassignedItemsNotificationEmail, + type UnassignedItem, +} from '@comp/email'; const removeMemberSchema = z.object({ memberId: z.string(), @@ -252,15 +256,24 @@ export const removeMember = authActionClient const removedMemberName = targetMember.user.name || targetMember.user.email || 'Member'; if (owner) { - // Send email to the org owner - sendUnassignedItemsNotificationEmail({ - email: owner.user.email, - userName: owner.user.name || owner.user.email || 'Owner', - organizationName: organization.name, - organizationId: ctx.session.activeOrganizationId, - removedMemberName, - unassignedItems, - }); + // Check if owner is unsubscribed from unassigned items notifications + const unsubscribed = await isUserUnsubscribed( + db, + owner.user.email, + 'unassignedItemsNotifications', + ); + + if (!unsubscribed) { + // Send email to the org owner + sendUnassignedItemsNotificationEmail({ + email: owner.user.email, + userName: owner.user.name || owner.user.email || 'Owner', + organizationName: organization.name, + organizationId: ctx.session.activeOrganizationId, + removedMemberName, + unassignedItems, + }); + } } } diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx index 94cf176d8..601a47985 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/components/QuestionnaireDetailClient.tsx @@ -64,17 +64,17 @@ export function QuestionnaireDetailClient({ question: r.question, answer: r.answer, sources: r.sources, - failedToGenerate: (r as any).failedToGenerate ?? false, - status: (r as any).status ?? 'untouched', - _originalIndex: (r as any).originalIndex ?? index, + failedToGenerate: r.failedToGenerate ?? false, + status: r.status ?? 'untouched', + _originalIndex: r.originalIndex ?? index, }))} filteredResults={filteredResults?.map((r, index) => ({ question: r.question, answer: r.answer, sources: r.sources, - failedToGenerate: (r as any).failedToGenerate ?? false, - status: (r as any).status ?? 'untouched', - _originalIndex: (r as any).originalIndex ?? index, + failedToGenerate: r.failedToGenerate ?? false, + status: r.status ?? 'untouched', + _originalIndex: r.originalIndex ?? index, }))} searchQuery={searchQuery} setSearchQuery={setSearchQuery} diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/vendor-questionnaire-orchestrator.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/vendor-questionnaire-orchestrator.ts index 1bf54ed56..9d0c6d474 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/vendor-questionnaire-orchestrator.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/vendor-questionnaire-orchestrator.ts @@ -1,8 +1,11 @@ 'use server'; import { authActionClient } from '@/actions/safe-action'; -import { vendorQuestionnaireOrchestratorTask } from '@/jobs/tasks/vendors/vendor-questionnaire-orchestrator'; -import { tasks } from '@trigger.dev/sdk'; +import { answerQuestion } from '@/jobs/tasks/vendors/answer-question'; +import { syncOrganizationEmbeddings } from '@/lib/vector'; +import { logger } from '@/utils/logger'; +import { headers } from 'next/headers'; +import { revalidatePath } from 'next/cache'; import { z } from 'zod'; const inputSchema = z.object({ @@ -10,6 +13,7 @@ const inputSchema = z.object({ z.object({ question: z.string(), answer: z.string().nullable(), + _originalIndex: z.number().optional(), // Preserves original index from QuestionnaireResult }), ), }); @@ -34,26 +38,99 @@ export const vendorQuestionnaireOrchestrator = authActionClient const organizationId = session.activeOrganizationId; try { - // Trigger the root orchestrator task - it will handle batching internally - const handle = await tasks.trigger( - 'vendor-questionnaire-orchestrator', - { - vendorId: `org_${organizationId}`, + logger.info('Starting auto-answer questionnaire', { + organizationId, + questionCount: questionsAndAnswers.length, + }); + + // Sync organization embeddings before generating answers + // Uses incremental sync: only updates what changed (much faster than full sync) + try { + await syncOrganizationEmbeddings(organizationId); + logger.info('Organization embeddings synced successfully', { organizationId, - questionsAndAnswers, - }, + }); + } 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 + } + + // Filter questions that need answers (skip already answered) + // Preserve original index if provided (for single question answers) + const questionsToAnswer = questionsAndAnswers + .map((qa, index) => ({ + ...qa, + index: qa._originalIndex !== undefined ? qa._originalIndex : index, + })) + .filter((qa) => !qa.answer || qa.answer.trim().length === 0); + + logger.info('Questions to answer', { + total: questionsAndAnswers.length, + toAnswer: questionsToAnswer.length, + }); + + // Process all questions in parallel by calling answerQuestion directly + // Note: metadata updates are disabled since we're not in a Trigger.dev task context + const results = await Promise.all( + questionsToAnswer.map((qa) => + answerQuestion( + { + question: qa.question, + organizationId, + questionIndex: qa.index, + totalQuestions: questionsAndAnswers.length, + }, + { useMetadata: false }, + ), + ), ); + // Process results + const allAnswers: Array<{ + questionIndex: number; + question: string; + answer: string | null; + sources?: Array<{ + sourceType: string; + sourceName?: string; + score: number; + }>; + }> = results.map((result) => ({ + questionIndex: result.questionIndex, + question: result.question, + answer: result.answer, + sources: result.sources, + })); + + logger.info('Auto-answer questionnaire completed', { + organizationId, + totalQuestions: questionsAndAnswers.length, + answered: allAnswers.filter((a) => a.answer).length, + }); + + // Revalidate the page to show updated answers + 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: { - taskId: handle.id, // Return orchestrator task ID for polling + answers: allAnswers, }, }; } catch (error) { + logger.error('Failed to answer questions', { + organizationId, + error: error instanceof Error ? error.message : 'Unknown error', + }); throw error instanceof Error ? error - : new Error('Failed to trigger vendor questionnaire orchestrator'); + : new Error('Failed to answer questions'); } }); diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx index 2d5aa88b9..9c0978faa 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsCards.tsx @@ -56,8 +56,8 @@ export function QuestionnaireResultsCards({
{filteredResults.map((qa, index) => { // Use originalIndex if available (from detail page), otherwise find by question text - const originalIndex = (qa as any)._originalIndex !== undefined - ? (qa as any)._originalIndex + const originalIndex = qa._originalIndex !== undefined + ? qa._originalIndex : results.findIndex((r) => r.question === qa.question); // Fallback to index if not found (shouldn't happen, but safety check) const safeIndex = originalIndex >= 0 ? originalIndex : index; diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx index 0c827dbc4..c5f0daa6e 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/QuestionnaireResultsTable.tsx @@ -66,8 +66,8 @@ export function QuestionnaireResultsTable({ {filteredResults.map((qa, index) => { // Use originalIndex if available (from detail page), otherwise find by question text - const originalIndex = (qa as any)._originalIndex !== undefined - ? (qa as any)._originalIndex + const originalIndex = qa._originalIndex !== undefined + ? qa._originalIndex : results.findIndex((r) => r.question === qa.question); // Fallback to index if not found (shouldn't happen, but safety check) const safeIndex = originalIndex >= 0 ? originalIndex : index; diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/types.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/types.ts index e08acb40b..6300a207b 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/types.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/components/types.ts @@ -11,5 +11,8 @@ export interface QuestionAnswer { }>; failedToGenerate?: boolean; // Track if auto-generation was attempted but failed status?: 'untouched' | 'generated' | 'manual'; // Track answer source: untouched, AI-generated, or manually edited + // Optional field used when converting QuestionnaireResult to QuestionAnswer for orchestrator + // Preserves the original index from QuestionnaireResult.originalIndex + _originalIndex?: number; } diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireAutoAnswer.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireAutoAnswer.ts index 7d5d8d84d..effcbae74 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireAutoAnswer.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireAutoAnswer.ts @@ -1,16 +1,12 @@ 'use client'; -import { useRealtimeTaskTrigger } from '@trigger.dev/react-hooks'; -import type { vendorQuestionnaireOrchestratorTask } from '@/jobs/tasks/vendors/vendor-questionnaire-orchestrator'; -import { useEffect, useMemo, useRef, useTransition } from 'react'; -import { toast } from 'sonner'; -import { useAction } from 'next-safe-action/hooks'; -import { saveAnswerAction } from '../actions/save-answer'; import { saveAnswersBatchAction } from '../actions/save-answers-batch'; +import { useAction } from 'next-safe-action/hooks'; +import { useTransition, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; import type { QuestionAnswer } from '../components/types'; interface UseQuestionnaireAutoAnswerProps { - autoAnswerToken: string | null; results: QuestionAnswer[] | null; answeringQuestionIndex: number | null; isAutoAnswerProcessStarted: boolean; @@ -25,7 +21,6 @@ interface UseQuestionnaireAutoAnswerProps { } export function useQuestionnaireAutoAnswer({ - autoAnswerToken, results, answeringQuestionIndex, isAutoAnswerProcessStarted, @@ -36,16 +31,9 @@ export function useQuestionnaireAutoAnswer({ setAnsweringQuestionIndex, questionnaireId, }: UseQuestionnaireAutoAnswerProps) { - // Use realtime task trigger for auto-answer - const { - submit: triggerAutoAnswer, - run: autoAnswerRun, - error: autoAnswerError, - isLoading: isAutoAnswerTriggering, - } = useRealtimeTaskTrigger('vendor-questionnaire-orchestrator', { - accessToken: autoAnswerToken || undefined, - enabled: !!autoAnswerToken, - }); + const [isAutoAnswerTriggering, setIsAutoAnswerTriggering] = useState(false); + const [autoAnswerError, setAutoAnswerError] = useState(null); + const completedAnswersRef = useRef>(new Set()); // Action for saving answers batch const saveAnswersBatch = useAction(saveAnswersBatchAction, { @@ -56,537 +44,240 @@ export function useQuestionnaireAutoAnswer({ const [isPending, startTransition] = useTransition(); - - // Track which run ID we're currently processing for single questions - const currentRunIdRef = useRef(null); - // Track which run IDs we've already processed completion for (to prevent infinite loops) - const processedCompletionRef = useRef>(new Set()); - // Use ref to access latest results without causing dependency issues - const resultsRef = useRef(results); - useEffect(() => { - resultsRef.current = results; - }, [results]); - - // Track run ID when a new single question operation starts - useEffect(() => { - if (answeringQuestionIndex !== null && autoAnswerRun?.id) { - currentRunIdRef.current = autoAnswerRun.id; - } else if (answeringQuestionIndex === null) { - currentRunIdRef.current = null; // Clear when no single question is active - } - }, [answeringQuestionIndex, autoAnswerRun?.id]); - - // Extract answers and statuses from metadata using useMemo (like OnboardingTracker) - // This ensures React re-renders whenever metadata changes - const metadataAnswers = useMemo(() => { - if (!autoAnswerRun?.metadata || !resultsRef.current) { - return { - answers: [], - statuses: new Map() - }; - } - - // For single question operations, only process metadata from the current run - if (answeringQuestionIndex !== null) { - if (currentRunIdRef.current && autoAnswerRun.id !== currentRunIdRef.current) { - return { - answers: [], - statuses: new Map(), - sources: new Map() - }; - } - } - - const meta = autoAnswerRun.metadata as Record; - - // Get all answer keys and status keys from metadata - // Exclude _sources keys - they are handled separately - const answerKeys = Object.keys(meta).filter((key) => - key.startsWith('answer_') && !key.endsWith('_sources') - ).sort(); - const statusKeys = Object.keys(meta).filter((key) => key.startsWith('question_') && key.endsWith('_status')).sort(); - - // Extract all answers from metadata - const answers = answerKeys - .map((key) => { - const rawValue = meta[key]; - - if (!rawValue || typeof rawValue !== 'object') { - return undefined; - } - - const answerData = rawValue as { - questionIndex?: number; - question?: string; - answer?: string | null; - sources?: Array<{ - sourceType: string; - sourceName?: string; - score: number; - }>; - }; - - if (typeof answerData.questionIndex !== 'number') { - return undefined; - } - - return { - metadataKey: key, - questionIndex: answerData.questionIndex, - question: answerData.question || '', - answer: answerData.answer ?? null, - sources: answerData.sources || [], - }; - }) - .filter((answer): answer is NonNullable => answer !== undefined) - .sort((a, b) => a.questionIndex - b.questionIndex); - - // Extract statuses - const statusMap = new Map(); - statusKeys.forEach((key) => { - const match = key.match(/^question_(\d+)_status$/); - if (match) { - const questionIndex = parseInt(match[1], 10); - - // If this is a single question operation, only process status for that question - if (answeringQuestionIndex !== null) { - if (questionIndex !== answeringQuestionIndex) { - return; - } - } - - const status = meta[key] as 'pending' | 'processing' | 'completed' | undefined; - if (status) { - statusMap.set(questionIndex, status); - } - } - }); - - return { answers, statuses: statusMap }; - }, [autoAnswerRun?.metadata, autoAnswerRun?.id, answeringQuestionIndex]); - - // Apply metadata updates to state whenever metadataAnswers changes - // This pattern matches OnboardingTracker - React automatically re-renders when metadata changes - useEffect(() => { - if (!resultsRef.current) { - // Still update statuses even if no results - if (metadataAnswers.statuses.size > 0) { - setQuestionStatuses((prev) => { - const newStatuses = new Map(prev); - let hasChanges = false; - metadataAnswers.statuses.forEach((status, questionIndex) => { - if (prev.get(questionIndex) !== status) { - newStatuses.set(questionIndex, status); - hasChanges = true; - } - }); - return hasChanges ? newStatuses : prev; - }); - } - return; - } - - const isSingleQuestion = answeringQuestionIndex !== null; - - // Update statuses first - if (metadataAnswers.statuses.size > 0) { + const triggerAutoAnswer = async (payload: { + vendorId: string; + organizationId: string; + questionsAndAnswers: Array<{ + question: string; + answer: string | null; + }>; + }) => { + // Reset state + setIsAutoAnswerTriggering(true); + setAutoAnswerError(null); + completedAnswersRef.current.clear(); + isAutoAnswerProcessStartedRef.current = true; + setIsAutoAnswerProcessStarted(true); + + // Set all unanswered questions to processing + // Use originalIndex/_originalIndex instead of array index to match SSE response questionIndex + if (results) { setQuestionStatuses((prev) => { const newStatuses = new Map(prev); let hasChanges = false; - metadataAnswers.statuses.forEach((status, questionIndex) => { - if (prev.get(questionIndex) !== status) { - newStatuses.set(questionIndex, status); - hasChanges = true; + results.forEach((result, index) => { + if (!result.answer || result.answer.trim().length === 0) { + // Use originalIndex/_originalIndex if available, otherwise fall back to array index + const resultOriginalIndex = (result as QuestionAnswer & { originalIndex?: number; _originalIndex?: number }).originalIndex ?? + (result as QuestionAnswer & { originalIndex?: number; _originalIndex?: number })._originalIndex ?? + index; + + if (prev.get(resultOriginalIndex) !== 'processing') { + newStatuses.set(resultOriginalIndex, 'processing'); + hasChanges = true; + } } }); return hasChanges ? newStatuses : prev; }); } - - // Update answers - process each answer individually - setResults((prevResults) => { - if (!prevResults) { - return prevResults; - } - - const updatedResults = [...prevResults]; - let hasChanges = false; - let updatedCount = 0; - let skippedCount = 0; - metadataAnswers.answers.forEach((answer) => { - // For single question operations, only process answers for that specific question - if (isSingleQuestion && answeringQuestionIndex !== null) { - if (answer.questionIndex !== answeringQuestionIndex) { - return; - } - } - - const targetIndex = answer.questionIndex; - - // Safety check - if (targetIndex < 0 || targetIndex >= updatedResults.length) { - return; - } + try { + // Use fetch with ReadableStream for SSE (EventSource only supports GET) + // credentials: 'include' is required to send cookies for authentication + const response = await fetch('/api/security-questionnaire/auto-answer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // Include cookies for authentication + body: JSON.stringify({ + questionsAndAnswers: payload.questionsAndAnswers, + }), + }); - const currentAnswer = updatedResults[targetIndex]?.answer; - const originalQuestion = updatedResults[targetIndex]?.question; - - // Skip only if we already have the exact same answer (both non-null and equal) - // Always update if: answer changed, or going from null to answer, or answer to null - if (currentAnswer !== null && currentAnswer === answer.answer) { - // Already has this exact non-null answer, skip to avoid unnecessary updates - return; - } - - if (answer.answer) { - // Update successful answer immediately - show it as soon as it's available - // Sources will be updated separately from answer_${questionIndex}_sources metadata key - // Preserve existing sources - don't overwrite them with empty array - const existingSources = updatedResults[targetIndex]?.sources || []; - - updatedResults[targetIndex] = { - ...updatedResults[targetIndex], - question: originalQuestion || answer.question, - answer: answer.answer, - sources: existingSources, // Keep existing sources, they'll be updated separately - failedToGenerate: false, - }; - hasChanges = true; - } else { - // Update failed answer only if no answer exists yet - if (!currentAnswer) { - updatedResults[targetIndex] = { - ...updatedResults[targetIndex], - question: originalQuestion || answer.question, - answer: null, - failedToGenerate: true, - }; - hasChanges = true; - } - } - }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } - return hasChanges ? updatedResults : prevResults; - }); - }, [metadataAnswers, answeringQuestionIndex]); - - // Update sources from final output when available - // This ensures sources are updated even if they weren't in metadata - useEffect(() => { - if ( - autoAnswerRun?.status === 'COMPLETED' && - autoAnswerRun.output && - autoAnswerRun.output.answers - ) { - const answers = autoAnswerRun.output.answers as - | Array<{ - questionIndex: number; - sources?: Array<{ - sourceType: string; - sourceName?: string; - score: number; - }>; - }> - | undefined; - - if (answers && Array.isArray(answers)) { - setResults((prevResults) => { - if (!prevResults) return prevResults; - - const updatedResults = [...prevResults]; - let hasChanges = false; - - answers.forEach((answer) => { - if (answer.sources && answer.sources.length > 0) { - const directIndex = - answer.questionIndex >= 0 && answer.questionIndex < updatedResults.length - ? answer.questionIndex - : -1; - - const fallbackIndex = - directIndex === -1 - ? updatedResults.findIndex((r, idx) => { - const candidate = - (r as { originalIndex?: number; _originalIndex?: number }).originalIndex ?? - (r as { originalIndex?: number; _originalIndex?: number })._originalIndex ?? - idx; - return candidate === answer.questionIndex; - }) - : directIndex; - - if (fallbackIndex >= 0 && fallbackIndex < updatedResults.length) { - const currentSources = updatedResults[fallbackIndex]?.sources || []; - const sourcesChanged = - JSON.stringify(currentSources) !== JSON.stringify(answer.sources); - - if (sourcesChanged) { - updatedResults[fallbackIndex] = { - ...updatedResults[fallbackIndex], - sources: answer.sources, - }; - hasChanges = true; - } - } - } - }); + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); - return hasChanges ? updatedResults : prevResults; - }); + if (!reader) { + throw new Error('Response body is not readable'); } - } - }, [autoAnswerRun?.status, autoAnswerRun?.output, setResults]); - - // Handle final completion - read ALL answers from final output - // This is a fallback to ensure all answers are shown even if metadata updates were missed - // Primary source is incremental metadata updates above, which show answers as they complete - useEffect(() => { - if ( - autoAnswerRun?.status === 'COMPLETED' && - autoAnswerRun.output && - autoAnswerRun.id && - !processedCompletionRef.current.has(autoAnswerRun.id) - ) { - const answers = autoAnswerRun.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)) { - // Mark this run as processed to prevent infinite loops - processedCompletionRef.current.add(autoAnswerRun.id); - - // Update results from final output - merge new answers with existing ones - // The orchestrator only returns answers for questions it processed (unanswered ones) - // So we merge them with existing answers - setResults((prevResults) => { - if (!prevResults) return prevResults; - - const updatedResults = [...prevResults]; - let hasChanges = false; - - // Create a map of new answers by questionIndex for quick lookup - const newAnswersMap = new Map( - answers.map((answer) => [answer.questionIndex, answer]) - ); - - // Update only the questions that were processed (have new answers) - newAnswersMap.forEach((answer, targetIndex) => { - // Safety check: ensure targetIndex is valid - if (targetIndex < 0 || targetIndex >= updatedResults.length) { - return; - } - const originalQuestion = updatedResults[targetIndex]?.question; - const currentAnswer = updatedResults[targetIndex]?.answer; - const currentSources = updatedResults[targetIndex]?.sources || []; - - // Update with new answer from orchestrator - if (answer.answer) { - // Always update sources from final output if they exist, even if answer is the same - // This ensures sources are available even if they weren't in metadata - const sourcesToUse = answer.sources && answer.sources.length > 0 - ? answer.sources - : currentSources; - - // Update if answer changed or sources changed - const answerChanged = currentAnswer !== answer.answer; - const sourcesChanged = JSON.stringify(currentSources) !== JSON.stringify(sourcesToUse); - - if (answerChanged || sourcesChanged) { - updatedResults[targetIndex] = { - ...updatedResults[targetIndex], // Preserve status and other fields - question: originalQuestion || answer.question, - answer: answer.answer, - sources: sourcesToUse, - failedToGenerate: false, - }; - hasChanges = true; - } - } else { - // Mark as failed if no answer was generated (only if it wasn't already answered) - if (!currentAnswer) { - updatedResults[targetIndex] = { - ...updatedResults[targetIndex], - question: originalQuestion || answer.question, - answer: null, - failedToGenerate: true, - }; - hasChanges = true; - } - } - }); + let buffer = ''; - return hasChanges ? updatedResults : prevResults; - }); + while (true) { + const { done, value } = await reader.read(); - // Save all answers in batch after final output (defer to avoid rendering issues) - if (questionnaireId) { - const answersToSave = answers - .map((answer) => { - if (answer.answer) { - return { - questionIndex: answer.questionIndex, - answer: answer.answer, - sources: answer.sources, - status: 'generated' as const, - }; - } - return null; - }) - .filter((a): a is NonNullable => a !== null); - - if (answersToSave.length > 0) { - // Use startTransition to defer the save call to avoid rendering issues - startTransition(() => { - saveAnswersBatch.execute({ - questionnaireId, - answers: answersToSave, - }); - }); - } + if (done) { + break; } - const isSingleQuestion = answeringQuestionIndex !== null; - - // Mark all remaining "processing" questions as "completed" when orchestrator finishes - setQuestionStatuses((prev) => { - const newStatuses = new Map(prev); - if (isSingleQuestion && answeringQuestionIndex !== null) { - // Single question: only mark that question as completed - const currentStatus = prev.get(answeringQuestionIndex); - if (currentStatus === 'processing') { - newStatuses.set(answeringQuestionIndex, 'completed'); - } - } else { - // Batch operation: mark all processing questions as completed - answers.forEach((answer) => { - const currentStatus = prev.get(answer.questionIndex); - if (currentStatus === 'processing') { - newStatuses.set(answer.questionIndex, 'completed'); + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + + switch (data.type) { + case 'progress': + // Update progress if needed + break; + + case 'answer': + // Update individual answer as it completes + if (!completedAnswersRef.current.has(data.questionIndex)) { + completedAnswersRef.current.add(data.questionIndex); + + setResults((prevResults) => { + if (!prevResults) return prevResults; + + const updatedResults = [...prevResults]; + const targetOriginalIndex = data.questionIndex; // This is the original question index + + // Find the result by matching originalIndex (like useQuestionnaireSingleAnswer does) + let resultIndex = -1; + for (let i = 0; i < updatedResults.length; i++) { + const result = updatedResults[i] as QuestionAnswer & { originalIndex?: number; _originalIndex?: number }; + // Check both originalIndex (from QuestionnaireResult) and _originalIndex (from QuestionAnswer) + if (result.originalIndex === targetOriginalIndex || result._originalIndex === targetOriginalIndex) { + resultIndex = i; + break; + } + } + + // Fallback to array index if not found by originalIndex (for backward compatibility) + if (resultIndex === -1 && targetOriginalIndex >= 0 && targetOriginalIndex < updatedResults.length) { + resultIndex = targetOriginalIndex; + } + + if (resultIndex >= 0 && resultIndex < updatedResults.length) { + const existingResult = updatedResults[resultIndex]; + const originalQuestion = existingResult.question; + + if (data.answer) { + updatedResults[resultIndex] = { + ...existingResult, + question: originalQuestion || data.question, + answer: data.answer, + sources: data.sources || [], + failedToGenerate: false, + }; + } else { + const currentAnswer = existingResult.answer; + if (!currentAnswer) { + updatedResults[resultIndex] = { + ...existingResult, + question: originalQuestion || data.question, + answer: null, + failedToGenerate: true, + }; + } + } + } + + return updatedResults; + }); + + // Update status to completed using the original index + setQuestionStatuses((prev) => { + const newStatuses = new Map(prev); + newStatuses.set(data.questionIndex, 'completed'); + return newStatuses; + }); + } + break; + + case 'complete': + // All questions completed + setIsAutoAnswerTriggering(false); + isAutoAnswerProcessStartedRef.current = false; + setIsAutoAnswerProcessStarted(false); + setAnsweringQuestionIndex(null); + + // Save all answers in batch + if (questionnaireId && data.answers) { + const answersToSave = data.answers + .map((answer: any) => { + if (answer.answer) { + return { + questionIndex: answer.questionIndex, + answer: answer.answer, + sources: answer.sources || [], + status: 'generated' as const, + }; + } + return null; + }) + .filter((a: any): a is NonNullable => a !== null); + + if (answersToSave.length > 0) { + startTransition(() => { + saveAnswersBatch.execute({ + questionnaireId, + answers: answersToSave, + }); + }); + } + } + + // Show final toast + const totalQuestions = data.total; + const answeredQuestions = data.answered; + const noAnswerQuestions = totalQuestions - answeredQuestions; + + 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.`, + ); + } + break; + + case 'error': + setIsAutoAnswerTriggering(false); + isAutoAnswerProcessStartedRef.current = false; + setIsAutoAnswerProcessStarted(false); + setAutoAnswerError(new Error(data.error || 'Unknown error')); + toast.error(`Failed to generate answers: ${data.error || 'Unknown error'}`); + + // Mark all processing questions as completed on error + setQuestionStatuses((prev) => { + const newStatuses = new Map(prev); + prev.forEach((status, index) => { + if (status === 'processing') { + newStatuses.set(index, 'completed'); + } + }); + return newStatuses; + }); + setAnsweringQuestionIndex(null); + break; } - }); - } - return newStatuses; - }); - - // Cleanup: mark process as finished - if (!isSingleQuestion) { - isAutoAnswerProcessStartedRef.current = false; - setIsAutoAnswerProcessStarted(false); - } - - // Reset answering index and run ID for single questions - if (isSingleQuestion) { - setAnsweringQuestionIndex(null); - currentRunIdRef.current = null; - } - - // Show final toast notification - 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(' ')}...`, - ); + } catch (error) { + console.error('Error parsing SSE data:', error); + } } } } - } - }, [ - autoAnswerRun?.status, - autoAnswerRun?.output, - autoAnswerRun?.id, - answeringQuestionIndex, - questionnaireId, - saveAnswersBatch, - setAnsweringQuestionIndex, - setQuestionStatuses, - setIsAutoAnswerProcessStarted, - isAutoAnswerProcessStartedRef, - ]); - - // Handle auto-answer errors - useEffect(() => { - if (autoAnswerError) { + } catch (error) { + setIsAutoAnswerTriggering(false); isAutoAnswerProcessStartedRef.current = false; setIsAutoAnswerProcessStarted(false); - toast.error(`Failed to generate answer: ${autoAnswerError.message}`); - setQuestionStatuses((prev) => { - const newStatuses = new Map(prev); - prev.forEach((status, index) => { - if (status === 'processing') { - newStatuses.set(index, 'completed'); - } - }); - return newStatuses; - }); - setAnsweringQuestionIndex(null); - currentRunIdRef.current = null; // Clear run ID on error - } - }, [ - autoAnswerError, - setIsAutoAnswerProcessStarted, - isAutoAnswerProcessStartedRef, - setQuestionStatuses, - setAnsweringQuestionIndex, - ]); - - // Handle auto-answer task status changes - // Only set global process started for batch operations (when answeringQuestionIndex is null) - useEffect(() => { - const isBatchOp = answeringQuestionIndex === null; - - // For single question operations, track the run ID - if (!isBatchOp && autoAnswerRun?.id && answeringQuestionIndex !== null) { - currentRunIdRef.current = autoAnswerRun.id; - } - - if ( - (autoAnswerRun?.status === 'EXECUTING' || autoAnswerRun?.status === 'QUEUED') && - !isAutoAnswerProcessStarted && - isBatchOp - ) { - isAutoAnswerProcessStartedRef.current = true; - setIsAutoAnswerProcessStarted(true); - } - }, [autoAnswerRun?.status, autoAnswerRun?.id, isAutoAnswerProcessStarted, setIsAutoAnswerProcessStarted, isAutoAnswerProcessStartedRef, answeringQuestionIndex]); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setAutoAnswerError(new Error(errorMessage)); + toast.error(`Failed to generate answers: ${errorMessage}`); - // Handle task failures and cancellations - useEffect(() => { - if (autoAnswerRun?.status === 'FAILED' || autoAnswerRun?.status === 'CANCELED') { - isAutoAnswerProcessStartedRef.current = false; - setIsAutoAnswerProcessStarted(false); - const errorMessage = - autoAnswerRun.error instanceof Error - ? autoAnswerRun.error.message - : typeof autoAnswerRun.error === 'string' - ? autoAnswerRun.error - : 'Task failed or was canceled'; - toast.error(`Failed to generate answer: ${errorMessage}`); - - // Mark all processing questions as completed on failure + // Mark all processing questions as completed on error setQuestionStatuses((prev) => { const newStatuses = new Map(prev); prev.forEach((status, index) => { @@ -597,74 +288,18 @@ export function useQuestionnaireAutoAnswer({ return newStatuses; }); setAnsweringQuestionIndex(null); - currentRunIdRef.current = null; // Clear run ID on failure/cancellation } - }, [ - autoAnswerRun?.status, - autoAnswerRun?.error, - setQuestionStatuses, - setIsAutoAnswerProcessStarted, - isAutoAnswerProcessStartedRef, - setAnsweringQuestionIndex, - ]); - - // Check if this is a batch operation (all questions) vs single question - const isBatchOperation = useMemo(() => { - // If answeringQuestionIndex is null, it's a batch operation - // If answeringQuestionIndex is set, it's a single question operation - return answeringQuestionIndex === null; - }, [answeringQuestionIndex]); + }; const isAutoAnswering = useMemo(() => { - // Only consider it "auto answering" if it's a batch operation - // Single question operations are tracked separately via answeringQuestionIndex - if (!isBatchOperation) { - return false; - } - - const processStarted = isAutoAnswerProcessStartedRef.current || isAutoAnswerProcessStarted; - - if (processStarted) { - if ( - autoAnswerRun?.status === 'COMPLETED' || - autoAnswerRun?.status === 'FAILED' || - autoAnswerRun?.status === 'CANCELED' - ) { - return false; - } - return true; - } - - const isRunActive = - autoAnswerRun?.status === 'EXECUTING' || - autoAnswerRun?.status === 'QUEUED' || - autoAnswerRun?.status === 'WAITING'; - - if (isRunActive) { - return true; - } - - if (isAutoAnswerTriggering) { - return true; - } - - if ( - autoAnswerRun?.status === 'COMPLETED' || - autoAnswerRun?.status === 'FAILED' || - autoAnswerRun?.status === 'CANCELED' - ) { - return false; - } - - return false; - }, [isAutoAnswerTriggering, autoAnswerRun?.status, isAutoAnswerProcessStarted, autoAnswerRun, isAutoAnswerProcessStartedRef, isBatchOperation]); + return isAutoAnswerTriggering || isAutoAnswerProcessStarted; + }, [isAutoAnswerTriggering, isAutoAnswerProcessStarted]); return { triggerAutoAnswer, - autoAnswerRun, + autoAnswerRun: null, // No Trigger.dev run object autoAnswerError, isAutoAnswerTriggering, isAutoAnswering, }; } - diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetail.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetail.ts index 0da764d8c..623b0bbae 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetail.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetail.ts @@ -22,7 +22,6 @@ export function useQuestionnaireDetail({ // Auto-answer hook const autoAnswer = useQuestionnaireAutoAnswer({ - autoAnswerToken: state.autoAnswerToken, results: state.results as QuestionAnswer[] | null, answeringQuestionIndex: state.answeringQuestionIndex, isAutoAnswerProcessStarted: state.isAutoAnswerProcessStarted, @@ -68,7 +67,7 @@ export function useQuestionnaireDetail({ return newResults.map((newR, index) => { const originalIndex = - (newR as any)._originalIndex !== undefined ? (newR as any)._originalIndex : index; + newR._originalIndex !== undefined ? newR._originalIndex : index; const existingResult = prevResults.find((r) => r.originalIndex === originalIndex); if (existingResult) { return { @@ -101,12 +100,12 @@ export function useQuestionnaireDetail({ failedToGenerate: (r as any).failedToGenerate ?? false, _originalIndex: r.originalIndex, })) as QuestionAnswer[], - answeringQuestionIndex: state.answeringQuestionIndex, + answeringQuestionIndices: state.answeringQuestionIndices, setResults: setResultsWrapper, setQuestionStatuses: state.setQuestionStatuses as Dispatch< SetStateAction> >, - setAnsweringQuestionIndex: state.setAnsweringQuestionIndex, + setAnsweringQuestionIndices: state.setAnsweringQuestionIndices, questionnaireId, }); @@ -210,14 +209,12 @@ export function useQuestionnaireDetail({ return ( state.isAutoAnswerProcessStarted && state.hasClickedAutoAnswer && - (autoAnswer.autoAnswerRun?.status === 'EXECUTING' || - autoAnswer.autoAnswerRun?.status === 'QUEUED' || - autoAnswer.autoAnswerRun?.status === 'WAITING') + autoAnswer.isAutoAnswerTriggering ); }, [ state.isAutoAnswerProcessStarted, state.hasClickedAutoAnswer, - autoAnswer.autoAnswerRun?.status, + autoAnswer.isAutoAnswerTriggering, ]); const isLoading = useMemo(() => { @@ -226,22 +223,16 @@ export function useQuestionnaireDetail({ ); const isSingleAnswerTriggering = singleAnswer.isSingleAnswerTriggering; const isAutoAnswerTriggering = autoAnswer.isAutoAnswerTriggering; - const isAutoAnswerRunActive = - autoAnswer.autoAnswerRun?.status === 'EXECUTING' || - autoAnswer.autoAnswerRun?.status === 'QUEUED' || - autoAnswer.autoAnswerRun?.status === 'WAITING'; return ( hasProcessingQuestions || isSingleAnswerTriggering || - isAutoAnswerTriggering || - isAutoAnswerRunActive + isAutoAnswerTriggering ); }, [ state.questionStatuses, singleAnswer.isSingleAnswerTriggering, autoAnswer.isAutoAnswerTriggering, - autoAnswer.autoAnswerRun?.status, ]); const isSaving = state.updateAnswerAction.status === 'executing'; diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailHandlers.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailHandlers.ts index 23745d715..52f50a7fc 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailHandlers.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailHandlers.ts @@ -145,54 +145,46 @@ export function useQuestionnaireDetailHandlers({ }; const processNextInQueue = useCallback(() => { - // If there's already a question being processed, don't start a new one - if (answeringQuestionIndex !== null) { - return; - } - // Get the next question from queue const queue = answerQueueRef.current; if (queue.length === 0) { return; } - const nextIndex = queue[0]; - const result = results.find((r) => r.originalIndex === nextIndex); - - if (!result) { - // Remove invalid index from queue - setAnswerQueue((prev) => prev.filter((idx) => idx !== nextIndex)); - // Try next one - setTimeout(() => processNextInQueue(), 0); - return; - } - - // Skip if already answered manually - if (result.status === 'manual' && result.answer && result.answer.trim().length > 0) { - // Remove from queue + // Process all questions in queue in parallel (no blocking) + queue.forEach((nextIndex) => { + const result = results.find((r) => r.originalIndex === nextIndex); + + if (!result) { + // Remove invalid index from queue + setAnswerQueue((prev) => prev.filter((idx) => idx !== nextIndex)); + return; + } + + // Skip if already answered manually + if (result.status === 'manual' && result.answer && result.answer.trim().length > 0) { + // Remove from queue + setAnswerQueue((prev) => prev.filter((idx) => idx !== nextIndex)); + return; + } + + // Remove from queue and start processing immediately (parallel) setAnswerQueue((prev) => prev.filter((idx) => idx !== nextIndex)); - // Try next one - setTimeout(() => processNextInQueue(), 0); - return; - } - // Remove from queue and start processing - setAnswerQueue((prev) => prev.filter((idx) => idx !== nextIndex)); - setAnsweringQuestionIndex(nextIndex); - - setQuestionStatuses((prev) => { - const newStatuses = new Map(prev); - newStatuses.set(nextIndex, 'processing'); - return newStatuses; - }); + setQuestionStatuses((prev) => { + const newStatuses = new Map(prev); + newStatuses.set(nextIndex, 'processing'); + return newStatuses; + }); - triggerSingleAnswer({ - question: result.question, - organizationId, - questionIndex: nextIndex, - totalQuestions: results.length, + triggerSingleAnswer({ + question: result.question, + organizationId, + questionIndex: nextIndex, + totalQuestions: results.length, + }); }); - }, [answeringQuestionIndex, results, organizationId, setAnswerQueue, setAnsweringQuestionIndex, setQuestionStatuses, triggerSingleAnswer]); + }, [results, organizationId, setAnswerQueue, setQuestionStatuses, triggerSingleAnswer]); const handleAnswerSingleQuestion = (index: number) => { // Don't allow adding to queue if batch operation is running @@ -218,29 +210,30 @@ export function useQuestionnaireDetailHandlers({ // Check if currently being processed if (answeringQuestionIndex === index) { - return; // Already processing + return; // Already processing (backward compatibility check) } - // Add to queue - setAnswerQueue((prev) => [...prev, index]); + // Start processing immediately (no queue needed for parallel processing) + setQuestionStatuses((prev) => { + const newStatuses = new Map(prev); + newStatuses.set(index, 'processing'); + return newStatuses; + }); - // If no question is currently being processed, start processing immediately - if (answeringQuestionIndex === null) { - processNextInQueue(); - } + triggerSingleAnswer({ + question: result.question, + organizationId, + questionIndex: index, + totalQuestions: results.length, + }); }; - // Auto-process next question in queue when current question finishes + // Process questions in queue (no longer needed for parallel processing, but kept for backward compatibility) useEffect(() => { - // When answeringQuestionIndex becomes null (question finished), process next in queue - if (answeringQuestionIndex === null && answerQueue.length > 0) { - // Small delay to ensure state updates are complete - const timeoutId = setTimeout(() => { - processNextInQueue(); - }, 100); - return () => clearTimeout(timeoutId); + if (answerQueue.length > 0) { + processNextInQueue(); } - }, [answeringQuestionIndex, answerQueue, processNextInQueue]); + }, [answerQueue.length, processNextInQueue]); const handleDeleteAnswer = async (questionAnswerId: string, questionIndex: number) => { try { diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailState.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailState.ts index 5a4970cf6..3bc996059 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailState.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireDetail/useQuestionnaireDetailState.ts @@ -39,6 +39,10 @@ export function useQuestionnaireDetailState({ const [questionStatuses, setQuestionStatuses] = useState< Map >(new Map()); + // Use Set to track multiple questions being processed in parallel + const [answeringQuestionIndices, setAnsweringQuestionIndices] = useState>(new Set()); + + // Keep answeringQuestionIndex for backward compatibility (will be removed) const [answeringQuestionIndex, setAnsweringQuestionIndex] = useState(null); const [isAutoAnswerProcessStarted, setIsAutoAnswerProcessStarted] = useState(false); const [isParseProcessStarted, setIsParseProcessStarted] = useState(false); @@ -100,18 +104,7 @@ export function useQuestionnaireDetailState({ const deleteAnswerAction = useAction(deleteQuestionnaireAnswer); - // Create trigger token for auto-answer (single question answers now use server action) - useEffect(() => { - const fetchToken = async () => { - const autoTokenResult = await createTriggerToken('vendor-questionnaire-orchestrator'); - - if (autoTokenResult.success && autoTokenResult.token) { - setAutoAnswerToken(autoTokenResult.token); - } - }; - - fetchToken(); - }, []); + // No longer need trigger tokens - using server actions instead of Trigger.dev // Sync queue ref with state useEffect(() => { @@ -131,6 +124,8 @@ export function useQuestionnaireDetailState({ setQuestionStatuses, answeringQuestionIndex, setAnsweringQuestionIndex, + answeringQuestionIndices, + setAnsweringQuestionIndices, isAutoAnswerProcessStarted, setIsAutoAnswerProcessStarted, isParseProcessStarted, diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParser.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParser.ts index 2a6f4cd54..a6ab4186e 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParser.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireParser.ts @@ -27,7 +27,6 @@ export function useQuestionnaireParser() { }); const autoAnswer = useQuestionnaireAutoAnswer({ - autoAnswerToken: state.autoAnswerToken, results: state.results, answeringQuestionIndex: state.answeringQuestionIndex, isAutoAnswerProcessStarted: state.isAutoAnswerProcessStarted, @@ -43,12 +42,12 @@ export function useQuestionnaireParser() { const singleAnswer = useQuestionnaireSingleAnswer({ results: state.results, - answeringQuestionIndex: state.answeringQuestionIndex, + answeringQuestionIndices: state.answeringQuestionIndices, setResults: state.setResults, setQuestionStatuses: state.setQuestionStatuses as Dispatch< SetStateAction> >, - setAnsweringQuestionIndex: state.setAnsweringQuestionIndex, + setAnsweringQuestionIndices: state.setAnsweringQuestionIndices, questionnaireId: state.questionnaireId, }); diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts index c01d4feda..e1fc35435 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireSingleAnswer.ts @@ -1,44 +1,89 @@ 'use client'; -import { useAction } from 'next-safe-action/hooks'; -import { answerSingleQuestionAction } from '../actions/answer-single-question'; import { saveAnswerAction } from '../actions/save-answer'; +import { useAction } from 'next-safe-action/hooks'; import type { QuestionAnswer } from '../components/types'; import { toast } from 'sonner'; -import { useTransition, useEffect } from 'react'; +import { useTransition, useRef } from 'react'; interface UseQuestionnaireSingleAnswerProps { results: QuestionAnswer[] | null; - answeringQuestionIndex: number | null; + answeringQuestionIndices: Set; setResults: React.Dispatch>; setQuestionStatuses: React.Dispatch< React.SetStateAction> >; - setAnsweringQuestionIndex: (index: number | null) => void; + setAnsweringQuestionIndices: React.Dispatch>>; questionnaireId: string | null; } export function useQuestionnaireSingleAnswer({ results, - answeringQuestionIndex, + answeringQuestionIndices, setResults, setQuestionStatuses, - setAnsweringQuestionIndex, + setAnsweringQuestionIndices, questionnaireId, }: UseQuestionnaireSingleAnswerProps) { - // Use server action to answer single question directly - const answerQuestion = useAction(answerSingleQuestionAction, { - onSuccess: ({ data }) => { - if (!data?.data || answeringQuestionIndex === null) return; + // Track active requests to prevent duplicate calls + const activeRequestsRef = useRef>(new Set()); + + // Action for saving answer + const saveAnswer = useAction(saveAnswerAction, { + onError: ({ error }) => { + console.error('Error saving answer:', error); + }, + }); + + const [isPending, startTransition] = useTransition(); + + const triggerSingleAnswer = async (payload: { + question: string; + organizationId: string; + questionIndex: number; + totalQuestions: number; + }) => { + const { questionIndex } = payload; + + // Prevent duplicate requests for the same question + if (activeRequestsRef.current.has(questionIndex)) { + return; + } - const output = data.data; + // Add to active requests and answering indices + activeRequestsRef.current.add(questionIndex); + setAnsweringQuestionIndices((prev) => new Set(prev).add(questionIndex)); + + // Set status to processing + setQuestionStatuses((prev) => { + const newStatuses = new Map(prev); + newStatuses.set(questionIndex, 'processing'); + return newStatuses; + }); + + try { + // Call server action directly via fetch for parallel processing + const response = await fetch('/api/security-questionnaire/answer-single', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + question: payload.question, + questionIndex: payload.questionIndex, + totalQuestions: payload.totalQuestions, + }), + }); - // Verify we're processing the correct question - if (output.questionIndex !== answeringQuestionIndex) { - return; + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); } - if (data.success && output.answer) { + const result = await response.json(); + + if (result.success && result.data?.answer) { + const output = result.data; const targetIndex = output.questionIndex; // Update the results with the answer @@ -84,7 +129,7 @@ export function useQuestionnaireSingleAnswer({ saveAnswer.execute({ questionnaireId, questionIndex: targetIndex, - answer: output.answer!, + answer: output.answer, sources: output.sources, status: 'generated', }); @@ -94,9 +139,7 @@ export function useQuestionnaireSingleAnswer({ // Mark question as completed setQuestionStatuses((prev) => { const newStatuses = new Map(prev); - if (output.questionIndex === answeringQuestionIndex) { - newStatuses.set(output.questionIndex, 'completed'); - } + newStatuses.set(output.questionIndex, 'completed'); return newStatuses; }); @@ -107,9 +150,9 @@ export function useQuestionnaireSingleAnswer({ if (!prevResults) return prevResults; const updatedResults = [...prevResults]; - const targetIndex = output.questionIndex; + const targetIndex = result.data?.questionIndex ?? questionIndex; - if (targetIndex === answeringQuestionIndex && targetIndex >= 0 && targetIndex < updatedResults.length) { + if (targetIndex >= 0 && targetIndex < updatedResults.length) { updatedResults[targetIndex] = { ...updatedResults[targetIndex], failedToGenerate: true, @@ -122,71 +165,35 @@ export function useQuestionnaireSingleAnswer({ setQuestionStatuses((prev) => { const newStatuses = new Map(prev); - if (output.questionIndex === answeringQuestionIndex) { - newStatuses.set(output.questionIndex, 'completed'); - } + newStatuses.set(questionIndex, 'completed'); return newStatuses; }); toast.warning('Could not find relevant information in your policies for this question.'); } - - // Reset answering index - setAnsweringQuestionIndex(null); - }, - onError: ({ error }) => { - if (answeringQuestionIndex !== null) { - setQuestionStatuses((prev) => { - const newStatuses = new Map(prev); - newStatuses.set(answeringQuestionIndex, 'completed'); - return newStatuses; - }); - setAnsweringQuestionIndex(null); - toast.error(`Failed to generate answer: ${error.serverError || 'Unknown error'}`); - } - }, - }); - - // Action for saving answer - const saveAnswer = useAction(saveAnswerAction, { - onError: ({ error }) => { - console.error('Error saving answer:', error); - }, - }); - - const [isPending, startTransition] = useTransition(); - - // Set status to processing when action is executing - useEffect(() => { - if (answeringQuestionIndex !== null && answerQuestion.status === 'executing') { + } catch (error) { setQuestionStatuses((prev) => { const newStatuses = new Map(prev); - const currentStatus = prev.get(answeringQuestionIndex); - if (currentStatus !== 'processing') { - newStatuses.set(answeringQuestionIndex, 'processing'); - return newStatuses; - } - return prev; + newStatuses.set(questionIndex, 'completed'); + return newStatuses; }); - } - }, [answeringQuestionIndex, answerQuestion.status, setQuestionStatuses]); - const triggerSingleAnswer = (payload: { - question: string; - organizationId: string; - questionIndex: number; - totalQuestions: number; - }) => { - answerQuestion.execute({ - question: payload.question, - questionIndex: payload.questionIndex, - totalQuestions: payload.totalQuestions, - }); + toast.error(`Failed to generate answer: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + // Remove from active requests and answering indices + activeRequestsRef.current.delete(questionIndex); + setAnsweringQuestionIndices((prev) => { + const newSet = new Set(prev); + newSet.delete(questionIndex); + return newSet; + }); + } }; + const isSingleAnswerTriggering = answeringQuestionIndices.size > 0; + return { triggerSingleAnswer, - isSingleAnswerTriggering: answerQuestion.status === 'executing', + isSingleAnswerTriggering, }; } - diff --git a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireState.ts b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireState.ts index 327049809..621f46c45 100644 --- a/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireState.ts +++ b/apps/app/src/app/(app)/[orgId]/security-questionnaire/hooks/useQuestionnaireState.ts @@ -21,6 +21,8 @@ export function useQuestionnaireState() { >(new Map()); const [hasClickedAutoAnswer, setHasClickedAutoAnswer] = useState(false); const [answeringQuestionIndex, setAnsweringQuestionIndex] = useState(null); + // Use Set to track multiple questions being processed in parallel + const [answeringQuestionIndices, setAnsweringQuestionIndices] = useState>(new Set()); const [parseTaskId, setParseTaskId] = useState(null); const [parseToken, setParseToken] = useState(null); const [autoAnswerToken, setAutoAnswerToken] = useState(null); @@ -70,6 +72,8 @@ export function useQuestionnaireState() { setHasClickedAutoAnswer, answeringQuestionIndex, setAnsweringQuestionIndex, + answeringQuestionIndices, + setAnsweringQuestionIndices, parseTaskId, setParseTaskId, parseToken, diff --git a/apps/app/src/app/(app)/[orgId]/settings/layout.tsx b/apps/app/src/app/(app)/[orgId]/settings/layout.tsx index 835945cbe..4f6f5e7d0 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/layout.tsx @@ -40,6 +40,10 @@ export default async function Layout({ children }: { children: React.ReactNode } path: `/${orgId}/settings/secrets`, label: 'Secrets', }, + { + path: `/${orgId}/settings/user`, + label: 'User Settings', + }, ]} /> diff --git a/apps/app/src/app/(app)/[orgId]/settings/user/actions/update-email-preferences.ts b/apps/app/src/app/(app)/[orgId]/settings/user/actions/update-email-preferences.ts new file mode 100644 index 000000000..6d4edd612 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/user/actions/update-email-preferences.ts @@ -0,0 +1,66 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +const emailPreferencesSchema = z.object({ + preferences: z.object({ + policyNotifications: z.boolean(), + taskReminders: z.boolean(), + weeklyTaskDigest: z.boolean(), + unassignedItemsNotifications: z.boolean(), + }), +}); + +export const updateEmailPreferencesAction = authActionClient + .inputSchema(emailPreferencesSchema) + .metadata({ + name: 'update-email-preferences', + track: { + event: 'update-email-preferences', + description: 'Update Email Preferences', + channel: 'server', + }, + }) + .action(async ({ ctx, parsedInput }) => { + const { user } = ctx; + + if (!user?.email) { + return { + success: false, + error: 'Not authorized', + }; + } + + try { + const { preferences } = parsedInput; + + // Check if all preferences are disabled + const allUnsubscribed = Object.values(preferences).every((v) => v === false); + + await db.user.update({ + where: { email: user.email }, + data: { + emailPreferences: preferences, + emailNotificationsUnsubscribed: allUnsubscribed, + }, + }); + + // Revalidate the settings page + if (ctx.session.activeOrganizationId) { + revalidatePath(`/${ctx.session.activeOrganizationId}/settings/user`); + } + + return { + success: true, + }; + } catch (error) { + console.error('Error updating email preferences:', error); + return { + success: false, + error: 'Failed to update email preferences', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/settings/user/components/EmailNotificationPreferences.tsx b/apps/app/src/app/(app)/[orgId]/settings/user/components/EmailNotificationPreferences.tsx new file mode 100644 index 000000000..bdb0d0d5c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/user/components/EmailNotificationPreferences.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { Button } from '@comp/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@comp/ui/card'; +import { Checkbox } from '@comp/ui/checkbox'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { updateEmailPreferencesAction } from '../actions/update-email-preferences'; + +interface EmailPreferences { + policyNotifications: boolean; + taskReminders: boolean; + weeklyTaskDigest: boolean; + unassignedItemsNotifications: boolean; +} + +interface Props { + initialPreferences: EmailPreferences; + email: string; +} + +export function EmailNotificationPreferences({ initialPreferences, email }: Props) { + // Normal logic: true = subscribed (checked), false = unsubscribed (unchecked) + const [preferences, setPreferences] = useState(initialPreferences); + const [saving, setSaving] = useState(false); + + const { execute } = useAction(updateEmailPreferencesAction, { + onSuccess: () => { + toast.success('Email preferences updated successfully'); + setSaving(false); + }, + onError: ({ error }) => { + toast.error(error.serverError || 'Failed to update preferences'); + setSaving(false); + }, + }); + + const handleToggle = (key: keyof EmailPreferences, checked: boolean) => { + setPreferences((prev) => ({ + ...prev, + [key]: checked, + })); + }; + + const handleSelectAll = () => { + // If all are enabled (all true), disable all (set all to false) + // If any are disabled (some false), enable all (set all to true) + const allEnabled = Object.values(preferences).every((v) => v === true); + setPreferences({ + policyNotifications: !allEnabled, + taskReminders: !allEnabled, + weeklyTaskDigest: !allEnabled, + unassignedItemsNotifications: !allEnabled, + }); + }; + + const handleSave = async () => { + setSaving(true); + execute({ preferences }); + }; + + // Check if all are disabled (all false) + const allDisabled = Object.values(preferences).every((v) => v === false); + + return ( + + + Email Notifications + + Manage which email notifications you receive at{' '} + {email}. These preferences apply to all organizations + you're a member of. + + + +
+
+ +

Toggle all notifications

+
+ +
+ +
+ + + + + + + +
+
+ +
+ You can also manage these preferences by clicking the unsubscribe link in any email + notification. +
+ +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/settings/user/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/user/page.tsx new file mode 100644 index 000000000..6efe99532 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/user/page.tsx @@ -0,0 +1,62 @@ +import { auth } from '@/utils/auth'; +import { db } from '@db'; +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { EmailNotificationPreferences } from './components/EmailNotificationPreferences'; + +export default async function UserSettings() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.email) { + return null; + } + + const user = await db.user.findUnique({ + where: { email: session.user.email }, + select: { + emailPreferences: true, + emailNotificationsUnsubscribed: true, + }, + }); + + const DEFAULT_PREFERENCES = { + policyNotifications: true, + taskReminders: true, + weeklyTaskDigest: true, + unassignedItemsNotifications: true, + }; + + // If user has the old all-or-nothing unsubscribe flag, convert to preferences + if (user?.emailNotificationsUnsubscribed) { + const preferences = { + policyNotifications: false, + taskReminders: false, + weeklyTaskDigest: false, + unassignedItemsNotifications: false, + }; + return ( +
+ +
+ ); + } + + const preferences = + user?.emailPreferences && typeof user.emailPreferences === 'object' + ? { ...DEFAULT_PREFERENCES, ...(user.emailPreferences as Record) } + : DEFAULT_PREFERENCES; + + return ( +
+ +
+ ); +} + +export async function generateMetadata(): Promise { + return { + title: 'User Settings', + }; +} diff --git a/apps/app/src/app/api/security-questionnaire/answer-single/route.ts b/apps/app/src/app/api/security-questionnaire/answer-single/route.ts new file mode 100644 index 000000000..07944f29e --- /dev/null +++ b/apps/app/src/app/api/security-questionnaire/answer-single/route.ts @@ -0,0 +1,82 @@ +import { auth } from '@/utils/auth'; +import { answerQuestion } from '@/jobs/tasks/vendors/answer-question'; +import { logger } from '@/utils/logger'; +import { NextRequest, NextResponse } from 'next/server'; +import { headers } from 'next/headers'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +const inputSchema = z.object({ + question: z.string(), + questionIndex: z.number(), + totalQuestions: z.number(), +}); + +export async function POST(req: NextRequest) { + const sessionResponse = await auth.api.getSession({ + headers: await headers(), + }); + + if (!sessionResponse?.session?.activeOrganizationId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const organizationId = sessionResponse.session.activeOrganizationId; + + try { + const body = await req.json(); + const parsedInput = inputSchema.parse(body); + const { question, questionIndex, totalQuestions } = parsedInput; + + // Call answerQuestion function directly + const result = await answerQuestion( + { + question, + organizationId, + questionIndex, + totalQuestions, + }, + { + useMetadata: false, + }, + ); + + // Revalidate the page to show updated answer + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return NextResponse.json({ + success: result.success, + data: { + questionIndex: result.questionIndex, + question: result.question, + answer: result.answer, + sources: result.sources, + error: result.error, + }, + }); + } catch (error) { + logger.error('Failed to answer single question', { + organizationId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, error: 'Invalid input', details: error.errors }, + { status: 400 }, + ); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to answer question', + }, + { status: 500 }, + ); + } +} + diff --git a/apps/app/src/app/api/security-questionnaire/auto-answer/route.ts b/apps/app/src/app/api/security-questionnaire/auto-answer/route.ts new file mode 100644 index 000000000..1fca3f87e --- /dev/null +++ b/apps/app/src/app/api/security-questionnaire/auto-answer/route.ts @@ -0,0 +1,191 @@ +import { auth } from '@/utils/auth'; +import { answerQuestion } from '@/jobs/tasks/vendors/answer-question'; +import { syncOrganizationEmbeddings } from '@/lib/vector'; +import { logger } from '@/utils/logger'; +import { NextRequest } from 'next/server'; +import { headers } from 'next/headers'; + +export async function POST(req: NextRequest) { + const sessionResponse = await auth.api.getSession({ + headers: await headers(), + }); + + if (!sessionResponse?.session?.activeOrganizationId) { + return new Response('Unauthorized', { status: 401 }); + } + + const organizationId = sessionResponse.session.activeOrganizationId; + + // Set up SSE headers + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const send = (data: object) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + + try { + const body = await req.json(); + const { questionsAndAnswers } = body; + + logger.info('Starting auto-answer questionnaire via SSE', { + organizationId, + questionCount: questionsAndAnswers.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', + }); + } + + // Filter questions that need answers + // Preserve original index if provided (for QuestionnaireResult with originalIndex) + type QuestionWithIndex = { + question: string; + answer: string | null; + _originalIndex?: number; + index: number; + }; + + const questionsToAnswer = questionsAndAnswers + .map((qa: { question: string; answer: string | null; _originalIndex?: number }, index: number): QuestionWithIndex => ({ + ...qa, + index: qa._originalIndex !== undefined ? qa._originalIndex : index, + })) + .filter((qa: QuestionWithIndex) => !qa.answer || qa.answer.trim().length === 0); + + // Send initial progress + send({ + type: 'progress', + total: questionsToAnswer.length, + completed: 0, + remaining: questionsToAnswer.length, + }); + + // Process questions in parallel but send updates as they complete + const results: Array<{ + questionIndex: number; + question: string; + answer: string | null; + sources?: Array<{ + sourceType: string; + sourceName?: string; + score: number; + }>; + }> = []; + + // Use Promise.allSettled to handle all questions and send updates incrementally + const promises = questionsToAnswer.map(async (qa: any) => { + try { + const result = await answerQuestion( + { + question: qa.question, + organizationId, + questionIndex: qa.index, + totalQuestions: questionsAndAnswers.length, + }, + { useMetadata: false }, + ); + + // Send update for this completed question + send({ + type: 'answer', + questionIndex: result.questionIndex, + question: result.question, + answer: result.answer, + sources: result.sources, + success: result.success, + }); + + return result; + } catch (error) { + logger.error('Failed to answer question', { + questionIndex: qa.index, + error: error instanceof Error ? error.message : 'Unknown error', + }); + + // Send error update + send({ + type: 'answer', + questionIndex: qa.index, + question: qa.question, + answer: null, + sources: [], + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + + return { + success: false, + questionIndex: qa.index, + question: qa.question, + answer: null, + sources: [], + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); + + // Wait for all questions to complete + const settledResults = await Promise.allSettled(promises); + + // Collect all results + settledResults.forEach((result) => { + if (result.status === 'fulfilled') { + results.push({ + questionIndex: result.value.questionIndex, + question: result.value.question, + answer: result.value.answer, + sources: result.value.sources, + }); + } + }); + + // Send completion + send({ + type: 'complete', + total: questionsToAnswer.length, + answered: results.filter((r) => r.answer).length, + answers: results, + }); + + logger.info('Auto-answer questionnaire completed via SSE', { + organizationId, + totalQuestions: questionsAndAnswers.length, + answered: results.filter((r) => r.answer).length, + }); + + controller.close(); + } catch (error) { + logger.error('Error in auto-answer SSE stream', { + organizationId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + + send({ + type: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }); + + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} + diff --git a/apps/app/src/app/unsubscribe/page.tsx b/apps/app/src/app/unsubscribe/page.tsx new file mode 100644 index 000000000..60a016eec --- /dev/null +++ b/apps/app/src/app/unsubscribe/page.tsx @@ -0,0 +1,82 @@ +import { getUnsubscribeUrl } from '@/lib/unsubscribe'; +import { db } from '@db'; +import { redirect } from 'next/navigation'; + +interface PageProps { + searchParams: Promise<{ success?: string; email?: string }>; +} + +export default async function UnsubscribePage({ searchParams }: PageProps) { + const params = await searchParams; + const { success, email } = params; + + if (success === 'true' && email) { + return ( +
+
+
+
+

Successfully Unsubscribed

+

+ You have been unsubscribed from email notifications and reminders. You will no longer receive these emails + at {email}. +

+

+ If you change your mind, you can contact your organization administrator to re-enable notifications. +

+
+
+
+ ); + } + + if (email) { + const user = await db.user.findUnique({ + where: { email }, + select: { emailNotificationsUnsubscribed: true }, + }); + + if (user?.emailNotificationsUnsubscribed) { + return ( +
+
+
+

Already Unsubscribed

+

+ You are already unsubscribed from email notifications and reminders. +

+
+
+
+ ); + } + + const unsubscribeUrl = getUnsubscribeUrl(email); + + return ( +
+
+
+

Unsubscribe from Email Notifications

+

+ Are you sure you want to unsubscribe from email notifications and reminders? You will no longer receive + policy notifications, task reminders, or other automated emails. +

+ + Unsubscribe + +

+ If you change your mind, you can contact your organization administrator to re-enable notifications. +

+
+
+
+ ); + } + + redirect('/'); +} + diff --git a/apps/app/src/app/unsubscribe/preferences/actions/update-preferences.ts b/apps/app/src/app/unsubscribe/preferences/actions/update-preferences.ts new file mode 100644 index 000000000..7a71209f4 --- /dev/null +++ b/apps/app/src/app/unsubscribe/preferences/actions/update-preferences.ts @@ -0,0 +1,67 @@ +'use server'; + +import { db } from '@db'; +import { verifyUnsubscribeToken } from '@/lib/unsubscribe'; +import { createSafeActionClient } from 'next-safe-action'; +import { z } from 'zod'; +import type { EmailPreferences } from '../client'; + +const updatePreferencesSchema = z.object({ + email: z.string().email(), + token: z.string(), + preferences: z.object({ + policyNotifications: z.boolean(), + taskReminders: z.boolean(), + weeklyTaskDigest: z.boolean(), + unassignedItemsNotifications: z.boolean(), + }), +}); + +export const updateUnsubscribePreferencesAction = createSafeActionClient() + .inputSchema(updatePreferencesSchema) + .action(async ({ parsedInput }) => { + const { email, token, preferences } = parsedInput; + + if (!verifyUnsubscribeToken(email, token)) { + return { + success: false as const, + error: 'Invalid token', + }; + } + + const user = await db.user.findUnique({ + where: { email }, + }); + + if (!user) { + return { + success: false as const, + error: 'User not found', + }; + } + + try { + // Check if all preferences are disabled + const allUnsubscribed = Object.values(preferences).every((v) => v === false); + + await db.user.update({ + where: { email }, + data: { + emailPreferences: preferences, + emailNotificationsUnsubscribed: allUnsubscribed, + }, + }); + + return { + success: true as const, + data: preferences, + }; + } catch (error) { + console.error('Error updating unsubscribe preferences:', error); + return { + success: false as const, + error: 'Failed to update preferences', + }; + } + }); + diff --git a/apps/app/src/app/unsubscribe/preferences/client.tsx b/apps/app/src/app/unsubscribe/preferences/client.tsx new file mode 100644 index 000000000..0d9d4f76f --- /dev/null +++ b/apps/app/src/app/unsubscribe/preferences/client.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { Button } from '@comp/ui/button'; +import { Checkbox } from '@comp/ui/checkbox'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { updateUnsubscribePreferencesAction } from './actions/update-preferences'; + +export interface EmailPreferences { + policyNotifications: boolean; + taskReminders: boolean; + weeklyTaskDigest: boolean; + unassignedItemsNotifications: boolean; +} + +interface Props { + email: string; + token: string; + initialPreferences: EmailPreferences; +} + +export function UnsubscribePreferencesClient({ email, token, initialPreferences }: Props) { + // Invert preferences for display: true (receiving) becomes false (unchecked), false (unsubscribed) becomes true (checked) + const [preferences, setPreferences] = useState({ + policyNotifications: !initialPreferences.policyNotifications, + taskReminders: !initialPreferences.taskReminders, + weeklyTaskDigest: !initialPreferences.weeklyTaskDigest, + unassignedItemsNotifications: !initialPreferences.unassignedItemsNotifications, + }); + const [error, setError] = useState(''); + + const { execute, status } = useAction(updateUnsubscribePreferencesAction, { + onSuccess: () => { + toast.success('Preferences saved successfully'); + setError(''); + }, + onError: ({ error }) => { + setError(error.serverError || 'Failed to save preferences'); + }, + }); + + const handleToggle = (key: keyof EmailPreferences, checked: boolean) => { + // checked = true means unsubscribe (store false in DB), unchecked = false means subscribe (store true in DB) + setPreferences((prev) => ({ + ...prev, + [key]: checked, + })); + }; + + const handleSelectAll = () => { + // If all are unchecked (all receiving), check all (unsubscribe all) + // If any are checked (some unsubscribed), uncheck all (subscribe all) + const allUnsubscribed = Object.values(preferences).every((v) => v === true); + setPreferences({ + policyNotifications: !allUnsubscribed, + taskReminders: !allUnsubscribed, + weeklyTaskDigest: !allUnsubscribed, + unassignedItemsNotifications: !allUnsubscribed, + }); + }; + + const handleSave = () => { + setError(''); + // Invert preferences before saving: checked (true) = unsubscribed (false in DB), unchecked (false) = subscribed (true in DB) + const invertedPreferences = { + policyNotifications: !preferences.policyNotifications, + taskReminders: !preferences.taskReminders, + weeklyTaskDigest: !preferences.weeklyTaskDigest, + unassignedItemsNotifications: !preferences.unassignedItemsNotifications, + }; + + execute({ + email, + token, + preferences: invertedPreferences, + }); + }; + + // Check if all are checked (all unsubscribed) - preferences are inverted for display + const allUnsubscribed = Object.values(preferences).every((v) => v === true); + + return ( +
+
+

+ Unsubscribe from Email Notifications +

+

+ Check the boxes below to unsubscribe from specific email notifications at{' '} + {email}. +

+ +
+
+
+ +

Toggle all notifications at once

+
+ +
+ +
+ + + + + + + +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ +

+ You can change these preferences at any time by clicking the unsubscribe link in any + email. +

+
+
+ ); +} diff --git a/apps/app/src/app/unsubscribe/preferences/page.tsx b/apps/app/src/app/unsubscribe/preferences/page.tsx new file mode 100644 index 000000000..a74cc8242 --- /dev/null +++ b/apps/app/src/app/unsubscribe/preferences/page.tsx @@ -0,0 +1,87 @@ +import { verifyUnsubscribeToken } from '@/lib/unsubscribe'; +import { db } from '@db'; +import { UnsubscribePreferencesClient, type EmailPreferences } from './client'; + +interface PageProps { + searchParams: Promise<{ email?: string; token?: string }>; +} + +const DEFAULT_PREFERENCES: EmailPreferences = { + policyNotifications: true, + taskReminders: true, + weeklyTaskDigest: true, + unassignedItemsNotifications: true, +}; + +async function fetchUserPreferences( + email: string, + token: string, +): Promise<{ error: string } | { preferences: EmailPreferences }> { + if (!verifyUnsubscribeToken(email, token)) { + return { error: 'Invalid token' }; + } + + const user = await db.user.findUnique({ + where: { email }, + select: { emailPreferences: true, emailNotificationsUnsubscribed: true }, + }); + + if (!user) { + return { error: 'User not found' }; + } + + // If user has the old all-or-nothing unsubscribe flag, convert to preferences + if (user.emailNotificationsUnsubscribed) { + return { + preferences: { + policyNotifications: false, + taskReminders: false, + weeklyTaskDigest: false, + unassignedItemsNotifications: false, + }, + }; + } + + // Return preferences or defaults + const preferences = + user.emailPreferences && typeof user.emailPreferences === 'object' + ? { ...DEFAULT_PREFERENCES, ...(user.emailPreferences as Record) } + : DEFAULT_PREFERENCES; + + return { preferences }; +} + +export default async function UnsubscribePreferencesPage({ searchParams }: PageProps) { + const params = await searchParams; + const { email, token } = params; + + if (!email || !token) { + return ( +
+
+
Email and token are required
+
+
+ ); + } + + const result = await fetchUserPreferences(email, token); + + if ('error' in result) { + return ( +
+
+
{result.error}
+
+
+ ); + } + + return ( + + ); +} diff --git a/apps/app/src/components/header.tsx b/apps/app/src/components/header.tsx index c96fa8752..0c12a4185 100644 --- a/apps/app/src/components/header.tsx +++ b/apps/app/src/components/header.tsx @@ -47,7 +47,7 @@ export async function Header({
}> - +
diff --git a/apps/app/src/components/user-menu.tsx b/apps/app/src/components/user-menu.tsx index 9e116df13..baff2bec6 100644 --- a/apps/app/src/components/user-menu.tsx +++ b/apps/app/src/components/user-menu.tsx @@ -3,14 +3,16 @@ import { Avatar, AvatarFallback, AvatarImageNext } from '@comp/ui/avatar'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@comp/ui/dropdown-menu'; import { headers } from 'next/headers'; +import Link from 'next/link'; import { SignOut } from './sign-out'; -export async function UserMenu({ onlySignOut }: { onlySignOut?: boolean }) { +export async function UserMenu({ onlySignOut, orgId }: { onlySignOut?: boolean; orgId?: string }) { const session = await auth.api.getSession({ headers: await headers(), }); @@ -50,6 +52,12 @@ export async function UserMenu({ onlySignOut }: { onlySignOut?: boolean }) { + {orgId && ( + + User Settings + + )} + )} diff --git a/apps/app/src/jobs/tasks/email/new-policy-email.ts b/apps/app/src/jobs/tasks/email/new-policy-email.ts index b9ee7f4da..301657da3 100644 --- a/apps/app/src/jobs/tasks/email/new-policy-email.ts +++ b/apps/app/src/jobs/tasks/email/new-policy-email.ts @@ -1,4 +1,6 @@ +import { db } from '@db'; import { sendPolicyNotificationEmail } from '@comp/email'; +import { isUserUnsubscribed } from '@comp/email/lib/check-unsubscribe'; import { logger, queue, task } from '@trigger.dev/sdk'; // Queue with concurrency limit of 1 to ensure rate limiting (1 email per second max) @@ -26,6 +28,19 @@ export const sendNewPolicyEmail = task({ }); try { + const unsubscribed = await isUserUnsubscribed(db, payload.email, 'policyNotifications'); + if (unsubscribed) { + logger.info('User is unsubscribed from email notifications, skipping', { + email: payload.email, + }); + return { + success: true, + email: payload.email, + skipped: true, + reason: 'unsubscribed', + }; + } + await sendPolicyNotificationEmail(payload); logger.info('Successfully sent policy email', { diff --git a/apps/app/src/jobs/tasks/email/publish-all-policies-email.ts b/apps/app/src/jobs/tasks/email/publish-all-policies-email.ts index ae03d786c..215f97f77 100644 --- a/apps/app/src/jobs/tasks/email/publish-all-policies-email.ts +++ b/apps/app/src/jobs/tasks/email/publish-all-policies-email.ts @@ -1,4 +1,6 @@ +import { db } from '@db'; import { sendAllPolicyNotificationEmail } from '@comp/email'; +import { isUserUnsubscribed } from '@comp/email/lib/check-unsubscribe'; import { logger, queue, task } from '@trigger.dev/sdk'; // Queue with concurrency limit to ensure rate limiting @@ -24,6 +26,19 @@ export const sendPublishAllPoliciesEmail = task({ }); try { + const unsubscribed = await isUserUnsubscribed(db, payload.email, 'policyNotifications'); + if (unsubscribed) { + logger.info('User is unsubscribed from email notifications, skipping', { + email: payload.email, + }); + return { + success: true, + email: payload.email, + skipped: true, + reason: 'unsubscribed', + }; + } + await sendAllPolicyNotificationEmail(payload); logger.info('Successfully sent all policies email', { diff --git a/apps/app/src/jobs/tasks/email/weekly-task-digest-email.ts b/apps/app/src/jobs/tasks/email/weekly-task-digest-email.ts index d9e15429a..fb383b0aa 100644 --- a/apps/app/src/jobs/tasks/email/weekly-task-digest-email.ts +++ b/apps/app/src/jobs/tasks/email/weekly-task-digest-email.ts @@ -1,5 +1,7 @@ +import { db } from '@db'; import { logger, queue, task } from '@trigger.dev/sdk'; import { sendWeeklyTaskDigestEmail } from '@trycompai/email/lib/weekly-task-digest'; +import { isUserUnsubscribed } from '@comp/email/lib/check-unsubscribe'; // Queue with concurrency limit to prevent rate limiting const weeklyTaskDigestQueue = queue({ @@ -29,6 +31,19 @@ export const sendWeeklyTaskDigestEmailTask = task({ }); try { + const unsubscribed = await isUserUnsubscribed(db, payload.email, 'weeklyTaskDigest'); + if (unsubscribed) { + logger.info('User is unsubscribed from email notifications, skipping', { + email: payload.email, + }); + return { + success: true, + email: payload.email, + skipped: true, + reason: 'unsubscribed', + }; + } + await sendWeeklyTaskDigestEmail(payload); logger.info('Successfully sent weekly task digest email', { diff --git a/apps/app/src/jobs/tasks/vendors/vendor-questionnaire-orchestrator.ts b/apps/app/src/jobs/tasks/vendors/vendor-questionnaire-orchestrator.ts deleted file mode 100644 index 7d0695b9d..000000000 --- a/apps/app/src/jobs/tasks/vendors/vendor-questionnaire-orchestrator.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { syncOrganizationEmbeddings } from '@/lib/vector'; -import { logger, metadata, task } from '@trigger.dev/sdk'; -import { answerQuestion } from './answer-question'; - -// Process all questions in parallel by calling answerQuestion directly as a function -// This allows metadata updates to happen incrementally as questions complete - -export const vendorQuestionnaireOrchestratorTask = task({ - id: 'vendor-questionnaire-orchestrator', - retry: { - maxAttempts: 3, - }, - run: async (payload: { - vendorId: string; - organizationId: string; - questionsAndAnswers: Array<{ - question: string; - answer: string | null; - }>; - }) => { - logger.info('Starting auto-answer questionnaire task', { - vendorId: payload.vendorId, - organizationId: payload.organizationId, - questionCount: payload.questionsAndAnswers.length, - }); - - // Sync organization embeddings before generating answers - // Uses incremental sync: only updates what changed (much faster than full sync) - try { - await syncOrganizationEmbeddings(payload.organizationId); - logger.info('Organization embeddings synced successfully', { - organizationId: payload.organizationId, - }); - } catch (error) { - logger.warn('Failed to sync organization embeddings', { - organizationId: payload.organizationId, - error: error instanceof Error ? error.message : 'Unknown error', - }); - // Continue with existing embeddings if sync fails - } - - // Filter questions that need answers (skip already answered) - // Preserve original index if provided (for single question answers) - const questionsToAnswer = payload.questionsAndAnswers - .map((qa, index) => ({ - ...qa, - index: (qa as any)._originalIndex !== undefined ? (qa as any)._originalIndex : index, - })) - .filter((qa) => !qa.answer || qa.answer.trim().length === 0); - - logger.info('Questions to answer', { - total: payload.questionsAndAnswers.length, - toAnswer: questionsToAnswer.length, - }); - - // Initialize metadata for tracking progress - metadata.set('questionsTotal', questionsToAnswer.length); - metadata.set('questionsCompleted', 0); - metadata.set('questionsRemaining', questionsToAnswer.length); - - // Initialize individual question statuses - all start as 'pending' - // Each question will update its own status to 'processing' when it starts - // and 'completed' when it finishes - questionsToAnswer.forEach((qa) => { - metadata.set(`question_${qa.index}_status`, 'pending'); - }); - - // Process all questions in parallel by calling answerQuestion directly - // This allows metadata updates to happen incrementally as questions complete - const results = await Promise.all( - questionsToAnswer.map((qa) => - answerQuestion({ - question: qa.question, - organizationId: payload.organizationId, - questionIndex: qa.index, - totalQuestions: payload.questionsAndAnswers.length, - }), - ), - ); - - // Process results - const allAnswers: Array<{ - questionIndex: number; - question: string; - answer: string | null; - sources?: Array<{ - sourceType: string; - sourceName?: string; - score: number; - }>; - }> = results.map((result) => ({ - questionIndex: result.questionIndex, - question: result.question, - answer: result.answer, - sources: result.sources, - })); - - logger.info('Auto-answer questionnaire completed', { - vendorId: payload.vendorId, - totalQuestions: payload.questionsAndAnswers.length, - answered: allAnswers.filter((a) => a.answer).length, - }); - - // Mark as completed - metadata.set('completed', true); - - return { - success: true, - answers: allAnswers, - }; - }, -}); diff --git a/apps/app/src/lib/unsubscribe.ts b/apps/app/src/lib/unsubscribe.ts new file mode 100644 index 000000000..49f6ec457 --- /dev/null +++ b/apps/app/src/lib/unsubscribe.ts @@ -0,0 +1,50 @@ +import { createHmac } from 'node:crypto'; + +const UNSUBSCRIBE_SECRET = process.env.UNSUBSCRIBE_SECRET || process.env.AUTH_SECRET || 'fallback-secret'; + +/** + * Get the base URL for unsubscribe links based on environment + * Uses NEXT_PUBLIC_BETTER_AUTH_URL for staging/prod, falls back to NEXT_PUBLIC_APP_URL, + * and handles localhost for local development + */ +function getBaseUrl(): string { + // Prefer NEXT_PUBLIC_BETTER_AUTH_URL (used for staging/prod) + if (process.env.NEXT_PUBLIC_BETTER_AUTH_URL) { + return process.env.NEXT_PUBLIC_BETTER_AUTH_URL; + } + + // Fallback to NEXT_PUBLIC_APP_URL + if (process.env.NEXT_PUBLIC_APP_URL) { + return process.env.NEXT_PUBLIC_APP_URL; + } + + // Default fallback + return 'https://app.trycomp.ai'; +} + +/** + * Generate a secure unsubscribe token for an email address + */ +export function generateUnsubscribeToken(email: string): string { + const hmac = createHmac('sha256', UNSUBSCRIBE_SECRET); + hmac.update(email); + return hmac.digest('base64url'); +} + +/** + * Verify an unsubscribe token matches an email address + */ +export function verifyUnsubscribeToken(email: string, token: string): boolean { + const expectedToken = generateUnsubscribeToken(email); + return expectedToken === token; +} + +/** + * Generate an unsubscribe URL for an email address (preferences page) + */ +export function getUnsubscribeUrl(email: string): string { + const token = generateUnsubscribeToken(email); + const baseUrl = getBaseUrl(); + return `${baseUrl}/unsubscribe/preferences?email=${encodeURIComponent(email)}&token=${token}`; +} + diff --git a/apps/app/src/middleware.ts b/apps/app/src/middleware.ts index 56e12e80a..612c2f542 100644 --- a/apps/app/src/middleware.ts +++ b/apps/app/src/middleware.ts @@ -60,6 +60,11 @@ export async function middleware(request: NextRequest) { return response; } + // Allow unauthenticated access to unsubscribe routes + if (nextUrl.pathname === '/unsubscribe' || nextUrl.pathname.startsWith('/unsubscribe/')) { + return response; + } + // 1. Not authenticated if (!hasToken && nextUrl.pathname !== '/auth') { const url = new URL('/auth', request.url); diff --git a/apps/app/src/test-utils/mocks/auth.ts b/apps/app/src/test-utils/mocks/auth.ts index c54e5512e..4d3157ec0 100644 --- a/apps/app/src/test-utils/mocks/auth.ts +++ b/apps/app/src/test-utils/mocks/auth.ts @@ -1,6 +1,13 @@ import { Departments, type Member, type Session, type User } from '@db'; import { vi } from 'vitest'; +const DEFAULT_EMAIL_PREFERENCES: User['emailPreferences'] = { + policyNotifications: true, + taskReminders: true, + weeklyTaskDigest: true, + unassignedItemsNotifications: true, +}; + // Mock auth API structure export const mockAuthApi = { getSession: vi.fn(), @@ -49,6 +56,8 @@ export const createMockUser = (overrides?: Partial): User => ({ lastLogin: null, createdAt: new Date(), updatedAt: new Date(), + emailNotificationsUnsubscribed: false, + emailPreferences: DEFAULT_EMAIL_PREFERENCES, ...overrides, }); diff --git a/packages/db/prisma/migrations/20251125160539_add_unsubscribe_emails/migration.sql b/packages/db/prisma/migrations/20251125160539_add_unsubscribe_emails/migration.sql new file mode 100644 index 000000000..27f3e16d6 --- /dev/null +++ b/packages/db/prisma/migrations/20251125160539_add_unsubscribe_emails/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."User" ADD COLUMN "emailNotificationsUnsubscribed" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "emailPreferences" JSONB DEFAULT '{"policyNotifications":true,"taskReminders":true,"weeklyTaskDigest":true,"unassignedItemsNotifications":true}'; diff --git a/packages/db/prisma/migrations/20251125180926_update_existing_users_email_preferences/migration.sql b/packages/db/prisma/migrations/20251125180926_update_existing_users_email_preferences/migration.sql new file mode 100644 index 000000000..ed96bfc28 --- /dev/null +++ b/packages/db/prisma/migrations/20251125180926_update_existing_users_email_preferences/migration.sql @@ -0,0 +1,4 @@ +-- Update existing users who have NULL emailPreferences to the default value +UPDATE "public"."User" +SET "emailPreferences" = '{"policyNotifications":true,"taskReminders":true,"weeklyTaskDigest":true,"unassignedItemsNotifications":true}'::jsonb +WHERE "emailPreferences" IS NULL; \ No newline at end of file diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 4efa585d0..f2e78befb 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -1,12 +1,14 @@ model User { - id String @id @default(dbgenerated("generate_prefixed_cuid('usr'::text)")) - name String - email String - emailVerified Boolean - image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastLogin DateTime? + id String @id @default(dbgenerated("generate_prefixed_cuid('usr'::text)")) + name String + email String + emailVerified Boolean + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + emailNotificationsUnsubscribed Boolean @default(false) + emailPreferences Json? @default("{\"policyNotifications\":true,\"taskReminders\":true,\"weeklyTaskDigest\":true,\"unassignedItemsNotifications\":true}") accounts Account[] auditLog AuditLog[] @@ -93,7 +95,7 @@ model Member { department Departments @default(none) isActive Boolean @default(true) - deactivated Boolean @default(false) + deactivated Boolean @default(false) employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[] fleetDmLabelId Int? @@ -124,11 +126,11 @@ model Invitation { // This is only for the app to consume, shouldn't be enforced by DB // Otherwise it won't work with Better Auth, as per https://www.better-auth.com/docs/plugins/organization#access-control enum Role { - owner - admin - auditor - employee - contractor + owner + admin + auditor + employee + contractor } enum PolicyStatus { diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 9c080c692..338d2ece6 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -15,7 +15,13 @@ "groups": [ { "group": "Get Started", - "pages": ["introduction", "automated-evidence", "device-agent", "security-questionnaire"] + "pages": [ + "introduction", + "automated-evidence", + "device-agent", + "security-questionnaire", + "trust-access" + ] } ] }, diff --git a/packages/docs/images/trust-access-admin-approve-duration.png b/packages/docs/images/trust-access-admin-approve-duration.png new file mode 100644 index 000000000..7280cdc3f Binary files /dev/null and b/packages/docs/images/trust-access-admin-approve-duration.png differ diff --git a/packages/docs/images/trust-access-admin-deny-dialog.png b/packages/docs/images/trust-access-admin-deny-dialog.png new file mode 100644 index 000000000..dc46eff26 Binary files /dev/null and b/packages/docs/images/trust-access-admin-deny-dialog.png differ diff --git a/packages/docs/images/trust-access-admin-nda-signed.png b/packages/docs/images/trust-access-admin-nda-signed.png new file mode 100644 index 000000000..7e1ed718f Binary files /dev/null and b/packages/docs/images/trust-access-admin-nda-signed.png differ diff --git a/packages/docs/images/trust-access-admin-request-details.png b/packages/docs/images/trust-access-admin-request-details.png new file mode 100644 index 000000000..c244ff328 Binary files /dev/null and b/packages/docs/images/trust-access-admin-request-details.png differ diff --git a/packages/docs/images/trust-access-email-nda-required.png b/packages/docs/images/trust-access-email-nda-required.png new file mode 100644 index 000000000..141b63a2e Binary files /dev/null and b/packages/docs/images/trust-access-email-nda-required.png differ diff --git a/packages/docs/images/trust-access-nda-signing-page.png b/packages/docs/images/trust-access-nda-signing-page.png new file mode 100644 index 000000000..01334217d Binary files /dev/null and b/packages/docs/images/trust-access-nda-signing-page.png differ diff --git a/packages/docs/images/trust-access-portal-request-button.png b/packages/docs/images/trust-access-portal-request-button.png new file mode 100644 index 000000000..22f00f1ed Binary files /dev/null and b/packages/docs/images/trust-access-portal-request-button.png differ diff --git a/packages/docs/images/trust-access-portal-view.png b/packages/docs/images/trust-access-portal-view.png new file mode 100644 index 000000000..8cd8fea6d Binary files /dev/null and b/packages/docs/images/trust-access-portal-view.png differ diff --git a/packages/docs/images/trust-access-reclaim-access.png b/packages/docs/images/trust-access-reclaim-access.png new file mode 100644 index 000000000..241480ee2 Binary files /dev/null and b/packages/docs/images/trust-access-reclaim-access.png differ diff --git a/packages/docs/images/trust-access-request-form.png b/packages/docs/images/trust-access-request-form.png new file mode 100644 index 000000000..78b62a88d Binary files /dev/null and b/packages/docs/images/trust-access-request-form.png differ diff --git a/packages/docs/trust-access.mdx b/packages/docs/trust-access.mdx new file mode 100644 index 000000000..4b8a3e326 --- /dev/null +++ b/packages/docs/trust-access.mdx @@ -0,0 +1,176 @@ +--- +title: 'Trust Access' +description: 'A comprehensive guide to managing external access requests, NDAs, and approvals.' +--- + +## Overview + +Trust Access enables secure, controlled access to your compliance documentation for external users. This system manages the complete access lifecycle—from initial requests through NDA signing, access grants, and ongoing management—while maintaining full audit trails for compliance purposes. + +## 1. Key Concepts + +Trust Access consists of four core components: + +- **Access Request:** An external user's initial request to access your compliance documentation. +- **NDA Agreement:** A legally binding document that must be digitally signed before access is granted. +- **Access Grant:** A time-limited authorization window (configurable, default 30 days) during which the user has access. +- **Access Link:** A secure, time-limited email link that authenticates the user and grants portal access. + +### Time Limits and Expiration + +Each component has specific time constraints: + +| Item | Duration | Notes | +| :------------------- | :------------- | :----------------------------------------------------------------------- | +| **NDA Signing Link** | **7 Days** | Expires if not signed within 7 days. Administrators can resend the link. | +| **Access Grant** | **7–365 Days** | Configurable access window. Default duration is 30 days. | +| **Access Link** | **24 Hours** | Email authentication links expire after 24 hours for security. | + +--- + +## 2. Prerequisites + +Before using Trust Access, ensure the following is configured: + +1. **Published Trust Portal:** The portal must be published and publicly accessible for users to submit access requests. + +--- + +## 3. Workflow: Step-by-Step + +### Step 1: Access Request Submission + +When external visitors access your public Trust Portal, they see a **Request Access** button. Clicking this button opens a form where they provide: + +- Full name and email address +- Company name and job title +- Reason for requesting access + +Trust Portal with Request Access button + +Access Request Form + +New access requests appear in the **Trust Access Management** dashboard with `Pending` status. If the user already has an active access grant, they see: _"You already have active access."_ If a pending request exists, duplicate submissions are blocked. + +### Step 2: Administrative Review and Decision + +Access the **Trust Access Management** dashboard to view all pending access requests. Each request displays the requester's information, purpose, submission timestamp, and current status. + +Access Request Detail View + +#### Option A: Approve Access Request + +1. Click on the request to view complete details +2. Configure the access grant period (7-365 days, default 30 days) +3. Click **Approve & Send NDA** to proceed + +Approve Access Request with Duration Configuration + +Request status changes to `Approved`, a pending NDA agreement is generated, and an email is sent to the requester with an NDA signing link (valid for 7 days). The requester receives an email notification: _"NDA Signature Required"_ with a secure link to review and sign the NDA. + +NDA Signature Required Email + +#### Option B: Deny Access Request + +1. Provide a reason for denial +2. Click **Deny** to reject the request + +Deny Access Request Dialog + +Request status changes to `Denied` and the denial reason is logged in the audit trail. No email notification is sent to the requester. + +### Step 3: NDA Signing Process + +1. The requester receives an email with subject _"NDA Signature Required"_ containing a secure signing link +2. Clicking the link opens a secure page displaying the complete NDA document +3. They provide their digital signature to accept the agreement +4. After signing, they receive confirmation that the NDA has been completed + +NDA Signing Page + +NDA status updates to `Signed` in the dashboard, the signed NDA PDF is available for download, and the access grant is automatically activated. The audit log captures the signing timestamp, signer's IP address, User Agent information, and the final signed PDF document. + +Dashboard showing Signed NDA status + +If the 7-day signing window expires, administrators see `NDA Link Expired` status and can use **Resend NDA** to generate a new link. If users attempt to access an already-signed NDA link, they are redirected to the portal. + +### Step 4: Portal Access Granted + +After successfully signing the NDA, users receive an email notification: _"Access Granted"_. This email contains their first **Access Link** (valid for 24 hours). + +Once authenticated via the access link, users can: + +- Browse and read all published, non-archived compliance policies +- Generate a single PDF bundle containing all accessible policies + - The downloaded PDF bundle is watermarked with the user's full name, email address, and a unique document identifier + +Trust Portal with Access Granted - Document View + +Access grant status shows as `Active` in the dashboard, the grant expiration date is visible, and download activity is logged when users generate PDF bundles. + +--- + +## 4. Managing Active Access + +### Reclaiming Access (Expired Access Links) + +Access links expire after 24 hours. If a user attempts to use an expired link, they can reclaim access without administrator intervention: + +1. Navigate to the Trust Portal +2. Click **Reclaim Access** +3. Enter their email address + +Reclaim Access button and form + +If their access grant is still within the valid period, the system automatically sends a new 24-hour access link via email. If their access grant has expired, they see: _"No active access found"_ and must submit a new access request. Reclaim attempts are logged in the audit trail. + +### Revoking Access + +Access can be revoked at any time through the **Grants** section of the dashboard: + +1. Navigate to the **Grants** list in the dashboard +2. Locate the active grant for the user +3. Click **Revoke** +4. Enter a reason for revocation + +Grant status immediately changes to `Revoked`, the signed NDA is marked as `Void`, all active access links are immediately invalidated, and the revocation action is logged in the audit trail. Any active access links stop working immediately, and users must submit a new access request if access is needed again. diff --git a/packages/email/components/unsubscribe-link.tsx b/packages/email/components/unsubscribe-link.tsx new file mode 100644 index 000000000..089a17707 --- /dev/null +++ b/packages/email/components/unsubscribe-link.tsx @@ -0,0 +1,21 @@ +import { Link, Section, Text } from '@react-email/components'; + +interface UnsubscribeLinkProps { + email: string; + unsubscribeUrl: string; +} + +export function UnsubscribeLink({ email, unsubscribeUrl }: UnsubscribeLinkProps) { + return ( +
+ + If you no longer wish to receive these notifications, you can{' '} + + unsubscribe here + + . + +
+ ); +} + diff --git a/packages/email/emails/all-policy-notification.tsx b/packages/email/emails/all-policy-notification.tsx index 89275b835..d2273acac 100644 --- a/packages/email/emails/all-policy-notification.tsx +++ b/packages/email/emails/all-policy-notification.tsx @@ -13,6 +13,8 @@ import { } from '@react-email/components'; import { Footer } from '../components/footer'; import { Logo } from '../components/logo'; +import { UnsubscribeLink } from '../components/unsubscribe-link'; +import { getUnsubscribeUrl } from '../lib/unsubscribe'; interface Props { email: string; @@ -104,6 +106,8 @@ export const AllPolicyNotificationEmail = ({ + +