Skip to content

Commit 807445e

Browse files
committed
chore: merge main into release for new releases
2 parents 71ad117 + d122112 commit 807445e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+2518
-1746
lines changed

apps/app/src/actions/safe-action.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,18 @@ export const authActionClient = actionClientWithMeta
8383
const headersList = await headers();
8484
let remaining: number | undefined;
8585

86-
if (ratelimit) {
86+
// Exclude answer saving actions from rate limiting
87+
// These actions are user-initiated and should not be rate limited
88+
const excludedActions = [
89+
'save-questionnaire-answer',
90+
'update-questionnaire-answer',
91+
'save-manual-answer',
92+
'save-questionnaire-answers-batch',
93+
];
94+
95+
const shouldRateLimit = !excludedActions.includes(metadata.name);
96+
97+
if (ratelimit && shouldRateLimit) {
8798
const { success, remaining: rateLimitRemaining } = await ratelimit.limit(
8899
`${headersList.get('x-forwarded-for')}-${metadata.name}`,
89100
);
@@ -283,7 +294,18 @@ export const authActionClientWithoutOrg = actionClientWithMeta
283294
const headersList = await headers();
284295
let remaining: number | undefined;
285296

286-
if (ratelimit) {
297+
// Exclude answer saving actions from rate limiting
298+
// These actions are user-initiated and should not be rate limited
299+
const excludedActions = [
300+
'save-questionnaire-answer',
301+
'update-questionnaire-answer',
302+
'save-manual-answer',
303+
'save-questionnaire-answers-batch',
304+
];
305+
306+
const shouldRateLimit = !excludedActions.includes(metadata.name);
307+
308+
if (ratelimit && shouldRateLimit) {
287309
const { success, remaining: rateLimitRemaining } = await ratelimit.limit(
288310
`${headersList.get('x-forwarded-for')}-${metadata.name}`,
289311
);
Lines changed: 48 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { QuestionnaireResults } from '../../components/QuestionnaireResults';
3+
import { QuestionnaireView } from '../../components/QuestionnaireView';
44
import { useQuestionnaireDetail } from '../../hooks/useQuestionnaireDetail';
55

66
interface QuestionnaireDetailClientProps {
@@ -33,6 +33,7 @@ export function QuestionnaireDetailClient({
3333
expandedSources,
3434
questionStatuses,
3535
answeringQuestionIndex,
36+
answerQueue,
3637
hasClickedAutoAnswer,
3738
isLoading,
3839
isAutoAnswering,
@@ -57,60 +58,52 @@ export function QuestionnaireDetailClient({
5758
});
5859

5960
return (
60-
<div className="flex flex-col gap-6">
61-
<div className="flex flex-col gap-2">
62-
<h1 className="text-xl lg:text-2xl font-semibold text-foreground">{filename}</h1>
63-
<p className="text-xs lg:text-sm text-muted-foreground leading-relaxed max-w-3xl">
64-
Review and manage answers for this questionnaire
65-
</p>
66-
</div>
67-
<QuestionnaireResults
68-
orgId={organizationId}
69-
results={results.map((r, index) => ({
70-
question: r.question,
71-
answer: r.answer,
72-
sources: r.sources,
73-
failedToGenerate: (r as any).failedToGenerate ?? false, // Preserve failedToGenerate from result
74-
status: (r as any).status ?? 'untouched', // Preserve status field for UI behavior
75-
_originalIndex: (r as any).originalIndex ?? index, // Preserve originalIndex for reference, fallback to map index
76-
}))}
77-
filteredResults={filteredResults?.map((r, index) => ({
78-
question: r.question,
79-
answer: r.answer,
80-
sources: r.sources,
81-
failedToGenerate: (r as any).failedToGenerate ?? false, // Preserve failedToGenerate from result
82-
status: (r as any).status ?? 'untouched', // Preserve status field for UI behavior
83-
_originalIndex: (r as any).originalIndex ?? index, // Preserve originalIndex for reference, fallback to map index
84-
}))}
85-
searchQuery={searchQuery}
86-
onSearchChange={setSearchQuery}
87-
editingIndex={editingIndex}
88-
editingAnswer={editingAnswer}
89-
onEditingAnswerChange={setEditingAnswer}
90-
expandedSources={expandedSources}
91-
questionStatuses={questionStatuses}
92-
answeringQuestionIndex={answeringQuestionIndex}
93-
hasClickedAutoAnswer={hasClickedAutoAnswer}
94-
isLoading={isLoading}
95-
isAutoAnswering={isAutoAnswering}
96-
isExporting={isExporting}
97-
isSaving={isSaving}
98-
savingIndex={savingIndex}
99-
showExitDialog={false}
100-
onShowExitDialogChange={() => {}}
101-
onExit={() => {}}
102-
onAutoAnswer={handleAutoAnswer}
103-
onAnswerSingleQuestion={handleAnswerSingleQuestion}
104-
onEditAnswer={handleEditAnswer}
105-
onSaveAnswer={handleSaveAnswer}
106-
onCancelEdit={handleCancelEdit}
107-
onExport={handleExport}
108-
onToggleSource={handleToggleSource}
109-
totalCount={totalCount}
110-
answeredCount={answeredCount}
111-
progressPercentage={progressPercentage}
112-
/>
113-
</div>
61+
<QuestionnaireView
62+
orgId={organizationId}
63+
results={results.map((r, index) => ({
64+
question: r.question,
65+
answer: r.answer,
66+
sources: r.sources,
67+
failedToGenerate: (r as any).failedToGenerate ?? false,
68+
status: (r as any).status ?? 'untouched',
69+
_originalIndex: (r as any).originalIndex ?? index,
70+
}))}
71+
filteredResults={filteredResults?.map((r, index) => ({
72+
question: r.question,
73+
answer: r.answer,
74+
sources: r.sources,
75+
failedToGenerate: (r as any).failedToGenerate ?? false,
76+
status: (r as any).status ?? 'untouched',
77+
_originalIndex: (r as any).originalIndex ?? index,
78+
}))}
79+
searchQuery={searchQuery}
80+
setSearchQuery={setSearchQuery}
81+
editingIndex={editingIndex}
82+
editingAnswer={editingAnswer}
83+
setEditingAnswer={setEditingAnswer}
84+
expandedSources={expandedSources}
85+
questionStatuses={questionStatuses as Map<number, 'pending' | 'processing' | 'completed'>}
86+
answeringQuestionIndex={answeringQuestionIndex}
87+
answerQueue={answerQueue}
88+
hasClickedAutoAnswer={hasClickedAutoAnswer}
89+
isLoading={isLoading}
90+
isAutoAnswering={isAutoAnswering}
91+
isExporting={isExporting}
92+
isSaving={isSaving}
93+
savingIndex={savingIndex}
94+
totalCount={totalCount}
95+
answeredCount={answeredCount}
96+
progressPercentage={progressPercentage}
97+
onAutoAnswer={handleAutoAnswer}
98+
onAnswerSingleQuestion={handleAnswerSingleQuestion}
99+
onEditAnswer={handleEditAnswer}
100+
onSaveAnswer={handleSaveAnswer}
101+
onCancelEdit={handleCancelEdit}
102+
onExport={handleExport}
103+
onToggleSource={handleToggleSource}
104+
filename={filename}
105+
description="Review and manage answers for this questionnaire"
106+
/>
114107
);
115108
}
116109

apps/app/src/app/(app)/[orgId]/security-questionnaire/[questionnaireId]/page.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb';
22
import { auth } from '@/utils/auth';
33
import { headers } from 'next/headers';
44
import { notFound } from 'next/navigation';
5-
import { QuestionnaireResults } from '../components/QuestionnaireResults';
6-
import { useQuestionnaireDetail } from '../hooks/useQuestionnaireDetail';
75
import { getQuestionnaireById } from './data/queries';
86
import { QuestionnaireDetailClient } from './components/QuestionnaireDetailClient';
97

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use server';
2+
3+
import { authActionClient } from '@/actions/safe-action';
4+
import { answerQuestion } from '@/jobs/tasks/vendors/answer-question';
5+
import { z } from 'zod';
6+
import { headers } from 'next/headers';
7+
import { revalidatePath } from 'next/cache';
8+
9+
const inputSchema = z.object({
10+
question: z.string(),
11+
questionIndex: z.number(),
12+
totalQuestions: z.number(),
13+
});
14+
15+
export const answerSingleQuestionAction = authActionClient
16+
.inputSchema(inputSchema)
17+
.metadata({
18+
name: 'answer-single-question',
19+
track: {
20+
event: 'answer-single-question',
21+
channel: 'server',
22+
},
23+
})
24+
.action(async ({ parsedInput, ctx }) => {
25+
const { question, questionIndex, totalQuestions } = parsedInput;
26+
const { session } = ctx;
27+
28+
if (!session?.activeOrganizationId) {
29+
throw new Error('No active organization');
30+
}
31+
32+
const organizationId = session.activeOrganizationId;
33+
34+
try {
35+
// Call answerQuestion function directly
36+
const result = await answerQuestion(
37+
{
38+
question,
39+
organizationId,
40+
questionIndex,
41+
totalQuestions,
42+
},
43+
{
44+
useMetadata: false,
45+
},
46+
);
47+
48+
// Revalidate the page to show updated answer
49+
const headersList = await headers();
50+
let path = headersList.get('x-pathname') || headersList.get('referer') || '';
51+
path = path.replace(/\/[a-z]{2}\//, '/');
52+
revalidatePath(path);
53+
54+
return {
55+
success: result.success,
56+
data: {
57+
questionIndex: result.questionIndex,
58+
question: result.question,
59+
answer: result.answer,
60+
sources: result.sources,
61+
error: result.error,
62+
},
63+
};
64+
} catch (error) {
65+
return {
66+
success: false,
67+
error: error instanceof Error ? error.message : 'Failed to answer question',
68+
};
69+
}
70+
});
71+

apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/save-answer.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,10 @@ export const saveAnswerAction = authActionClient
9090
},
9191
});
9292

93-
// If status is manual and answer exists, also save to SecurityQuestionnaireManualAnswer
94-
if (status === 'manual' && answer && answer.trim().length > 0 && existingQuestion.question) {
93+
const shouldPersistManualAnswer =
94+
status === 'manual' && answer && answer.trim().length > 0 && existingQuestion.question;
95+
96+
if (shouldPersistManualAnswer) {
9597
try {
9698
const manualAnswer = await db.securityQuestionnaireManualAnswer.upsert({
9799
where: {

apps/app/src/app/(app)/[orgId]/security-questionnaire/actions/update-questionnaire-answer.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ const updateAnswerSchema = z.object({
1212
questionnaireId: z.string(),
1313
questionAnswerId: z.string(),
1414
answer: z.string(),
15+
status: z.enum(['generated', 'manual']).optional().default('manual'),
16+
sources: z
17+
.array(
18+
z.object({
19+
sourceType: z.string(),
20+
sourceName: z.string().optional(),
21+
sourceId: z.string().optional(),
22+
policyName: z.string().optional(),
23+
documentName: z.string().optional(),
24+
score: z.number(),
25+
}),
26+
)
27+
.optional(),
1528
});
1629

1730
export const updateQuestionnaireAnswer = authActionClient
@@ -25,7 +38,7 @@ export const updateQuestionnaireAnswer = authActionClient
2538
},
2639
})
2740
.action(async ({ parsedInput, ctx }) => {
28-
const { questionnaireId, questionAnswerId, answer } = parsedInput;
41+
const { questionnaireId, questionAnswerId, answer, status, sources } = parsedInput;
2942
const { activeOrganizationId } = ctx.session;
3043
const userId = ctx.user.id;
3144

@@ -67,21 +80,28 @@ export const updateQuestionnaireAnswer = authActionClient
6780
};
6881
}
6982

83+
// Store the previous status to determine if this was written from scratch
84+
const previousStatus = questionAnswer.status;
85+
7086
// Update the answer
7187
await db.questionnaireQuestionAnswer.update({
7288
where: {
7389
id: questionAnswerId,
7490
},
7591
data: {
7692
answer: answer.trim() || null,
77-
status: 'manual',
78-
updatedBy: userId || null,
93+
status: status === 'generated' ? 'generated' : 'manual',
94+
sources: sources ? (sources as any) : null,
95+
generatedAt: status === 'generated' ? new Date() : null,
96+
updatedBy: status === 'manual' ? userId || null : null,
7997
updatedAt: new Date(),
8098
},
8199
});
82100

83-
// Also save to SecurityQuestionnaireManualAnswer if answer exists
84-
if (answer && answer.trim().length > 0 && questionAnswer.question) {
101+
const shouldPersistManualAnswer =
102+
status === 'manual' && answer && answer.trim().length > 0 && questionAnswer.question;
103+
104+
if (shouldPersistManualAnswer) {
85105
try {
86106
const manualAnswer = await db.securityQuestionnaireManualAnswer.upsert({
87107
where: {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
3+
import { LinkIcon } from 'lucide-react';
4+
import Link from 'next/link';
5+
6+
interface ManualAnswerLinkProps {
7+
manualAnswerId: string;
8+
sourceName: string;
9+
orgId: string;
10+
className?: string;
11+
}
12+
13+
export function ManualAnswerLink({
14+
manualAnswerId,
15+
sourceName,
16+
orgId,
17+
className = 'font-medium text-primary hover:underline inline-flex items-center gap-1',
18+
}: ManualAnswerLinkProps) {
19+
// Link to knowledge base page with hash anchor to scroll to specific manual answer
20+
const knowledgeBaseUrl = `/${orgId}/security-questionnaire/knowledge-base#manual-answer-${manualAnswerId}`;
21+
22+
return (
23+
<Link
24+
href={knowledgeBaseUrl}
25+
target="_blank"
26+
rel="noopener noreferrer"
27+
className={className}
28+
>
29+
{sourceName}
30+
<LinkIcon className="h-3 w-3" />
31+
</Link>
32+
);
33+
}
34+

0 commit comments

Comments
 (0)