Skip to content

Commit 576aec5

Browse files
committed
feat(security-questionnaire): implement manual answer linking and update questionnaire components
1 parent c7f8df4 commit 576aec5

31 files changed

+1497
-1036
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
);
@@ -282,7 +293,18 @@ export const authActionClientWithoutOrg = actionClientWithMeta
282293
const headersList = await headers();
283294
let remaining: number | undefined;
284295

285-
if (ratelimit) {
296+
// Exclude answer saving actions from rate limiting
297+
// These actions are user-initiated and should not be rate limited
298+
const excludedActions = [
299+
'save-questionnaire-answer',
300+
'update-questionnaire-answer',
301+
'save-manual-answer',
302+
'save-questionnaire-answers-batch',
303+
];
304+
305+
const shouldRateLimit = !excludedActions.includes(metadata.name);
306+
307+
if (ratelimit && shouldRateLimit) {
286308
const { success, remaining: rateLimitRemaining } = await ratelimit.limit(
287309
`${headersList.get('x-forwarded-for')}-${metadata.name}`,
288310
);
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

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)