From 109132a64bdee931ec7539c9fdc97443e793c99d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:24:27 -0500 Subject: [PATCH 1/3] [dev] [tofikwest] tofik/transfer-logic-to-server-side-security-questionnaire (#1828) * refactor(security-questionnaire): transfer auto-answer functionality to SSE part * refactor(security-questionnaire): simplify handling of originalIndex in components * refactor(security-questionnaire): enhance type safety for questions in auto-answer --------- Co-authored-by: Tofik Hasanov --- .../components/QuestionnaireDetailClient.tsx | 12 +- .../vendor-questionnaire-orchestrator.ts | 99 ++- .../components/QuestionnaireResultsCards.tsx | 4 +- .../components/QuestionnaireResultsTable.tsx | 4 +- .../components/types.ts | 3 + .../hooks/useQuestionnaireAutoAnswer.ts | 803 +++++------------- .../useQuestionnaireDetail.ts | 21 +- .../useQuestionnaireDetailHandlers.ts | 101 +-- .../useQuestionnaireDetailState.ts | 19 +- .../hooks/useQuestionnaireParser.ts | 5 +- .../hooks/useQuestionnaireSingleAnswer.ts | 157 ++-- .../hooks/useQuestionnaireState.ts | 4 + .../answer-single/route.ts | 82 ++ .../auto-answer/route.ts | 191 +++++ .../vendor-questionnaire-orchestrator.ts | 112 --- 15 files changed, 741 insertions(+), 876 deletions(-) create mode 100644 apps/app/src/app/api/security-questionnaire/answer-single/route.ts create mode 100644 apps/app/src/app/api/security-questionnaire/auto-answer/route.ts delete mode 100644 apps/app/src/jobs/tasks/vendors/vendor-questionnaire-orchestrator.ts 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/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/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, - }; - }, -}); From 31086775855b358f2abc6a9fdad2a6b3f49c004f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 14:00:59 -0500 Subject: [PATCH 2/3] [dev] [Marfuen] mariano/email-unsubscribe (#1830) * feat(email): add granular email unsubscribe preferences - Add emailPreferences JSON field to User model for granular control - Create unsubscribe preferences page with checkboxes for each email type - Add unsubscribe API routes (GET/POST) with secure token verification - Update all notification email templates to include unsubscribe links - Add unsubscribe checks to email sending functions - Create user settings page to re-subscribe from within app - Support per-email-type unsubscribe (policy, task reminders, weekly digest, unassigned items) - Use NEXT_PUBLIC_BETTER_AUTH_URL for unsubscribe links to support localhost/staging * refactor(unsubscribe): remove legacy unsubscribe API and integrate preferences handling * feat(user-settings): add user settings page for email notification preferences * chore(auth): add default email preferences to mock user --------- Co-authored-by: Mariano Fuentes --- .../people/all/actions/removeMember.ts | 33 ++- .../src/app/(app)/[orgId]/settings/layout.tsx | 4 + .../user/actions/update-email-preferences.ts | 66 ++++++ .../EmailNotificationPreferences.tsx | 165 +++++++++++++++ .../app/(app)/[orgId]/settings/user/page.tsx | 62 ++++++ apps/app/src/app/unsubscribe/page.tsx | 82 ++++++++ .../preferences/actions/update-preferences.ts | 67 ++++++ .../app/unsubscribe/preferences/client.tsx | 195 ++++++++++++++++++ .../src/app/unsubscribe/preferences/page.tsx | 87 ++++++++ apps/app/src/components/header.tsx | 2 +- apps/app/src/components/user-menu.tsx | 10 +- .../src/jobs/tasks/email/new-policy-email.ts | 15 ++ .../tasks/email/publish-all-policies-email.ts | 15 ++ .../tasks/email/weekly-task-digest-email.ts | 15 ++ apps/app/src/lib/unsubscribe.ts | 50 +++++ apps/app/src/middleware.ts | 5 + apps/app/src/test-utils/mocks/auth.ts | 9 + .../migration.sql | 3 + .../migration.sql | 4 + packages/db/prisma/schema/auth.prisma | 30 +-- .../email/components/unsubscribe-link.tsx | 21 ++ .../email/emails/all-policy-notification.tsx | 4 + packages/email/emails/policy-notification.tsx | 4 + .../email/emails/reminders/task-reminder.tsx | 4 + .../emails/reminders/weekly-task-digest.tsx | 4 + .../emails/unassigned-items-notification.tsx | 6 + packages/email/index.ts | 2 + packages/email/lib/check-unsubscribe.ts | 65 ++++++ .../lib/unassigned-items-notification.ts | 1 + packages/email/lib/unsubscribe.ts | 42 ++++ 30 files changed, 1046 insertions(+), 26 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/settings/user/actions/update-email-preferences.ts create mode 100644 apps/app/src/app/(app)/[orgId]/settings/user/components/EmailNotificationPreferences.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/settings/user/page.tsx create mode 100644 apps/app/src/app/unsubscribe/page.tsx create mode 100644 apps/app/src/app/unsubscribe/preferences/actions/update-preferences.ts create mode 100644 apps/app/src/app/unsubscribe/preferences/client.tsx create mode 100644 apps/app/src/app/unsubscribe/preferences/page.tsx create mode 100644 apps/app/src/lib/unsubscribe.ts create mode 100644 packages/db/prisma/migrations/20251125160539_add_unsubscribe_emails/migration.sql create mode 100644 packages/db/prisma/migrations/20251125180926_update_existing_users_email_preferences/migration.sql create mode 100644 packages/email/components/unsubscribe-link.tsx create mode 100644 packages/email/lib/check-unsubscribe.ts create mode 100644 packages/email/lib/unsubscribe.ts 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]/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/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/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/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 = ({ + +