(SP: 10) [Frontend] Quiz flow: Security encryption + session persistence + UX fixes#131
Conversation
…z_attempts - Change "Start a Quiz" link to /quizzes (for users without attempts) - Change "Continue Learning" link to /q&a (for users with attempts) - Add CASCADE delete constraint on quiz_attempts.userId foreign key
Issue #130 - Security: Encrypted Answers - Add AES-256-GCM encryption for quiz answers (quiz-crypto.ts) - Create /api/quiz/verify-answer endpoint for server-side validation - Add client-safe types (QuizQuestionClient, QuizAnswerClient) - Remove isCorrect from client response to prevent DevTools cheating Issue #124 - Session Persistence + Quit Confirmation - Add localStorage session persistence (useQuizSession hook) - Add quit confirmation with custom ConfirmModal - Add browser navigation guards (useQuizGuards hook) - Support language switch without losing progress Issue #122 - Language Switch Category Fix - Sync category selection to URL params in QuizzesSection - Preserve query params on language switch Additional fixes: - Add isIncomplete state for timeout scenarios - Reorganize quiz utils into lib/quiz/ directory
✅ Deploy Preview for develop-devlovers ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📝 WalkthroughWalkthroughThis PR implements quiz session persistence, server-side answer verification, and quiz exit functionality. It introduces AES-256-GCM encryption for answer protection, localStorage-based session state recovery, an exit confirmation modal, client-safe question rendering by removing Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Client as QuizContainer
participant API as /api/quiz/verify-answer
participant Crypto as Decryption
participant DB as Database/Query
User->>Client: Selects answer
Client->>API: POST {questionId, answerId, encryptedAnswers}
API->>Crypto: decryptAnswers(encryptedBlob)
Crypto-->>API: CorrectAnswersMap
API->>DB: Lookup correctAnswerId for questionId
DB-->>API: correctAnswerId
API->>API: Compare answerId === correctAnswerId
API-->>Client: {isCorrect: boolean}
Client->>Client: Dispatch ANSWER_SELECTED with isCorrect
Client->>User: Show feedback (correct/incorrect)
sequenceDiagram
participant User
participant Component as QuizContainer
participant Hook as useQuizSession
participant Storage as localStorage
participant Guard as useQuizGuards
Component->>Hook: Mount with quizId
Hook->>Storage: loadQuizSession(quizId)
Storage-->>Hook: QuizSessionData (if exists & valid)
Hook->>Component: onRestore callback
Component->>Component: Dispatch RESTORE_SESSION
Component->>Guard: Register guards (beforeunload, popstate)
User->>Component: Answers questions
Component->>Hook: State updated
Hook->>Storage: saveQuizSession(quizId, state)
Storage-->>Hook: Saved ✓
User->>Guard: Navigates away / language switch
Guard->>Guard: Detect in_progress + exit
Guard->>Component: Trigger onExit (markQuitting)
Component->>Component: Show ConfirmModal
User->>Component: Confirm exit
Component->>Storage: clearQuizSession(quizId)
Component->>User: Redirect to /quizzes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 14
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
frontend/components/quiz/QuizResult.tsx (1)
90-119: Incomplete state says “result not counted” but still shows score/percentage bar.
IfisIncompleteis meant to invalidate scoring, consider rendering “answeredCount / total” (and hiding the % bar) to avoid contradictory UI.frontend/components/quiz/QuizQuestion.tsx (1)
10-18: Consider tri-state correctness (unknown vs false) and null-safe question text.
QuizQuestionClient.questionTextcan be null, andisCorrectis required even when answering—making “verification failed/unknown” indistinguishable from “incorrect”.Proposed adjustment (optional)
interface QuizQuestionProps { question: QuizQuestionClient; status: 'answering' | 'revealed'; selectedAnswerId: string | null; - isCorrect: boolean; + isCorrect: boolean | null; // null = unknown (e.g., verify call failed) onAnswer: (answerId: string) => void; onNext: () => void; isLoading?: boolean; } export function QuizQuestion({ question, status, selectedAnswerId, isCorrect, onAnswer, onNext, isLoading = false, }: QuizQuestionProps) { const t = useTranslations('quiz.question'); const isAnswering = status === 'answering'; const isRevealed = status === 'revealed'; - const isCorrectAnswer = isRevealed && isCorrect; + const isCorrectAnswer = isRevealed && isCorrect === true; return ( <div className="flex flex-col gap-6"> <div className="text-xl font-medium text-gray-900 dark:text-gray-100"> - {question.questionText} + {question.questionText ?? t('missingQuestionText')} </div>frontend/components/quiz/QuizContainer.tsx (1)
60-75: Fix duplicate/out-of-order answer submissions (async verify can corrupt quiz state).Minimal fix: disable during verify + make reducer idempotent per question
export function QuizContainer({ quizSlug, quizId, questions, encryptedAnswers, userId, timeLimitSeconds, seed, categorySlug, onBackToTopics, }: QuizContainerProps) { @@ const [showExitModal, setShowExitModal] = useState(false); + const [isVerifyingAnswer, setIsVerifyingAnswer] = useState(false); @@ function quizReducer(state: QuizState, action: QuizAction): QuizState { switch (action.type) { @@ case 'ANSWER_SELECTED': + // Ensure 1 answer per question (async verify can otherwise duplicate) + const answersWithoutThisQuestion = state.answers.filter( + a => a.questionId !== action.payload.questionId + ); return { ...state, selectedAnswerId: action.payload.answerId, questionStatus: 'revealed', answers: [ - ...state.answers, + ...answersWithoutThisQuestion, { questionId: action.payload.questionId, selectedAnswerId: action.payload.answerId, isCorrect: action.payload.isCorrect, answeredAt: new Date(), }, ], }; @@ - const handleAnswer = async (answerId: string) => { - const response = await fetch('/api/quiz/verify-answer', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - questionId: currentQuestion.id, - answerId, - encryptedAnswers, - }), - }); - - const { isCorrect } = await response.json(); - - dispatch({ - type: 'ANSWER_SELECTED', - payload: { - answerId, - isCorrect, - questionId: currentQuestion.id, - }, - }); -}; + const handleAnswer = async (answerId: string) => { + if (state.questionStatus !== 'answering') return; + if (isVerifyingAnswer) return; + + setIsVerifyingAnswer(true); + try { + const response = await fetch('/api/quiz/verify-answer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + questionId: currentQuestion.id, + answerId, + encryptedAnswers, + }), + }); + + if (!response.ok) { + throw new Error(`verify-answer failed: ${response.status}`); + } + + const data: unknown = await response.json(); + const isCorrect = Boolean((data as any)?.isCorrect); + + dispatch({ + type: 'ANSWER_SELECTED', + payload: { + answerId, + isCorrect, + questionId: currentQuestion.id, + }, + }); + } catch (e) { + console.error(e); + // Option: surface a toast and keep questionStatus='answering' so user can retry + } finally { + setIsVerifyingAnswer(false); + } + }; @@ <QuizQuestion question={currentQuestion} status={state.questionStatus} selectedAnswerId={state.selectedAnswerId} isCorrect={state.answers.find(a => a.questionId === currentQuestion.id)?.isCorrect ?? false} onAnswer={handleAnswer} onNext={handleNext} - isLoading={isPending} + isLoading={isPending || isVerifyingAnswer} />Also applies to: 203-225, 436-444
frontend/app/[locale]/quiz/[slug]/page.tsx (1)
11-34: Guard against NaN seeds from invalid query parameters.The
seedparsing needs validation. IfseedParamis non-numeric (e.g.,"abc"),parseInt(seedParam, 10)returnsNaN, which breaks the seeded randomization. InrandomizeQuizQuestions, when seed isNaN, theseededRandomfunction returnsNaNfor all indices, making the sort comparisons undefined and shuffling unpredictable.Safer seed parsing
- const seed = seedParam ? parseInt(seedParam, 10) : Date.now(); + const parsedSeed = seedParam ? Number(seedParam) : NaN; + const seed = Number.isFinite(parsedSeed) ? Math.trunc(parsedSeed) : Date.now();
🤖 Fix all issues with AI agents
In @frontend/app/api/quiz/verify-answer/route.ts:
- Around line 22-42: This endpoint leaks answer correctness and must enforce
authentication and rate limiting: call getCurrentUser() at the start of the
handler and return a 401/403 when it returns null, then apply a per-user
(fallback to IP) rate limit check before using decryptAnswers/enforcing logic
(use or add a rate limiter that returns 429 when exceeded); ensure the
rate-limit check occurs before returning the isCorrect response and include the
user id (or IP) when recording attempts so brute-force attempts are blocked for
that identity.
- Around line 10-48: In POST, treat JSON parse failures from request.json() as a
400: wrap the request.json() call in a try/catch (or detect SyntaxError) so
malformed JSON returns NextResponse.json({ error: 'Invalid JSON' }, { status:
400 }) and does not fall through to the generic 500 handler; keep the existing
field validation and decryptAnswers logic (decryptAnswers, correctAnswersMap,
correctAnswerId) unchanged, and add an explicit error log (e.g., console.error
or the project logger) in the generic catch block to record unexpected errors
before returning the 500 response.
In @frontend/components/quiz/QuizCard.tsx:
- Around line 55-72: Clamp the computed percentage used in the UI to the 0–100
range before rendering: ensure the value derived from userProgress.bestScore and
userProgress.totalQuestions (used as percentage) is sanitized (e.g., compute a
local percentage variable inside QuizCard component and set percentage =
Math.min(100, Math.max(0, rawPercentage))) and then use that clamped percentage
for the text display and the progress bar width. Update references to percentage
in the span and the inline style to use the clamped variable so widths never go
below 0% or above 100%.
In @frontend/components/ui/confirm-modal.tsx:
- Around line 34-64: Add minimal accessibility attributes and Escape-to-close
handling to the modal: give the modal container role="dialog" and
aria-modal="true", wire aria-labelledby and aria-describedby to IDs for the
title and message (create stable IDs via useId or a simple unique constant),
ensure the title element has that title ID and the message has the description
ID, and make the dialog container focusable (tabIndex={-1}) and focus it on
mount. Also add a keydown listener in a useEffect that calls onCancel when
Escape is pressed and cleans up the listener on unmount; keep existing overlay
onClick handler. Update references to onCancel, onConfirm, title, message,
cancelText, confirmText, and confirmButtonClass accordingly.
In @frontend/drizzle/0013_brown_gamora.sql:
- Around line 1-3: Migration 0015_dear_legion.sql contains a duplicate ALTER
TABLE that re-adds the foreign key constraint quiz_attempts_user_id_users_id_fk
which was already created in 0013_brown_gamora.sql; open 0015_dear_legion.sql
and remove the ALTER TABLE ... ADD CONSTRAINT
"quiz_attempts_user_id_users_id_fk" FOREIGN KEY (...) ... statement (or if you
must keep it, replace it with a DROP CONSTRAINT IF EXISTS
"quiz_attempts_user_id_users_id_fk" before the ADD), ensuring only one creation
of quiz_attempts_user_id_users_id_fk across migrations.
In @frontend/drizzle/0015_dear_legion.sql:
- Line 1: The migration 0015_dear_legion.sql is adding a duplicate foreign key
constraint "quiz_attempts_user_id_users_id_fk" which already exists in
0013_brown_gamora.sql; fix by removing the redundant migration file
(0015_dear_legion.sql) or make the ALTER TABLE idempotent (use an IF NOT EXISTS
variant or conditional check supported by your SQL dialect) or regenerate
migrations from a clean schema so only one migration creates
"quiz_attempts_user_id_users_id_fk".
In @frontend/hooks/useQuizGuards.ts:
- Around line 95-113: The popstate handler (handlePopState inside the useEffect)
currently calls window.history.pushState on each canceled back attempt which can
bloat history; change it to use window.history.replaceState or maintain a single
guard token so you only set one history entry per quiz session, and ensure you
don't repeatedly add entries when statusRef.current !== 'in_progress' or
isQuittingRef.current is true. Also replace the hardcoded confirm string with
your localized prompt (via next-intl or the app's ConfirmModal flow) so the
confirm text is consistent; keep the existing cleanup/logic around
clearQuizSession(quizId), resetViolations, and onExit unchanged.
In @frontend/hooks/useQuizSession.ts:
- Around line 59-75: Remove the redundant savedAt assignment from the
sessionData object construction in useQuizSession; the saveQuizSession function
already stamps savedAt, so delete the savedAt: Date.now() property when building
sessionData and continue to call saveQuizSession(quizId, sessionData) as before
to let saveQuizSession set the timestamp.
- Around line 56-75: The persisted session currently includes
answers[].isCorrect even though correctness is recomputed server-side; update
the effect in useQuizSession (the mapping that builds QuizSessionData before
calling saveQuizSession) to only persist questionId, selectedAnswerId and
answeredAt (remove isCorrect), adjust the constructed session object passed to
saveQuizSession and any related type/interface (QuizSessionData or the answers
item type) to reflect the reduced shape so runtime and TS types align.
In @frontend/lib/quiz/quiz-crypto.ts:
- Around line 1-13: Add a server-only marker at the top of quiz-crypto.ts (e.g.,
the established "server-only" directive used in the repo) and harden
getEncryptionKey(): ensure process.env.QUIZ_ENCRYPTION_KEY exists, validate it
matches exactly 64 hex chars (or /^[0-9a-fA-F]{64}$/) and/or that
Buffer.from(key,'hex').length === 32, and throw a clear error if not; ensure
encryptAnswers, decryptAnswers, and createEncryptedAnswersBlob call the
validated getEncryptionKey() so they never operate with an invalid key.
In @frontend/lib/quiz/quiz-storage-keys.ts:
- Around line 1-5: Replace the hardcoded 'quiz-allow-restore' string in the
LanguageSwitcher component with the exported constant QUIZ_ALLOW_RESTORE_KEY:
import QUIZ_ALLOW_RESTORE_KEY from the quiz-storage-keys module and use that
constant wherever the literal is used (specifically the value used at the place
corresponding to the previous literal in the LanguageSwitcher component),
removing the unused constant from being idle; ensure imports compile and update
any tests or usages that expect the literal string to now reference
QUIZ_ALLOW_RESTORE_KEY.
In @frontend/messages/en.json:
- Around line 154-159: The exitModal JSON object uses 2-space indentation
inconsistent with the rest of the file; update the indentation for the
"exitModal" block so nested lines (title, message, confirm, cancel) use 4 spaces
to match the file's style, ensuring the keys remain unchanged and the object
formatting aligns with the surrounding JSON entries.
🧹 Nitpick comments (7)
frontend/components/quiz/QuizzesSection.tsx (1)
52-56: Variable shadowing:paramsshadows outer scope variable.On line 53,
paramsshadows theparamsfromuseParams()declared on line 36. While this doesn't cause a bug since the inner variable is scoped to this function, it reduces readability and could confuse maintainers.Suggested rename
const handleCategoryChange = (category: string) => { - const params = new URLSearchParams(searchParams.toString()); - params.set('category', category); - router.replace(`?${params.toString()}`, { scroll: false }); + const urlParams = new URLSearchParams(searchParams.toString()); + urlParams.set('category', category); + router.replace(`?${urlParams.toString()}`, { scroll: false }); };frontend/components/quiz/PendingResultHandler.tsx (1)
12-15: Redundant conditional check.The
if (pending)check on line 15 is unnecessary since the early return on line 13 already handles the case whenpendingis falsy.♻️ Suggested simplification
useEffect(() => { const pending = getPendingQuizResult(); if (!pending) return; - if (pending) { - fetch("/api/quiz/guest-result", { + fetch("/api/quiz/guest-result", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, quizId: pending.quizId, answers: pending.answers, violations: pending.violations, timeSpentSeconds: pending.timeSpentSeconds, }), }) .then(async (res) => { if (res.ok) { clearPendingQuizResult(); } else { console.error("Guest-result API error:", res.status); } }) .catch(err => { console.error("Guest-result fetch error:", err); }); - } }, [userId]);frontend/messages/pl.json (1)
154-159: JSON formatting inconsistency.The
exitModalblock has inconsistent indentation compared to its sibling keys (likesection,result, etc.). For maintainability and consistency with the rest of the file, adjust the indentation to match.♻️ Suggested formatting fix
"section": { "noQuizzes": "W tej kategorii nie ma jeszcze quizów." }, "exitModal": { - "title": "Wyjść z quizu?", - "message": "Twój postęp nie zostanie zapisany.", - "confirm": "Wyjdź", - "cancel": "Kontynuuj" -} + "title": "Wyjść z quizu?", + "message": "Twój postęp nie zostanie zapisany.", + "confirm": "Wyjdź", + "cancel": "Kontynuuj" + } },frontend/components/shared/LanguageSwitcher.tsx (2)
4-4: Minor formatting: missing space after comma.♻️ Suggested fix
-import { useParams, usePathname,useSearchParams } from 'next/navigation'; +import { useParams, usePathname, useSearchParams } from 'next/navigation';
18-19: Import and use theQUIZ_ALLOW_RESTORE_KEYconstant fromfrontend/lib/quiz/quiz-storage-keys.ts.The constant is defined in the shared storage keys file and currently duplicated as a local string. Replace the hardcoded
'quiz-allow-restore'with the imported constant to ensure consistency across the codebase and prevent key mismatches if the value changes.frontend/components/quiz/QuizCard.tsx (1)
55-66: Prefer next-intl pluralization over manual singular/plural branching.
Line 61 hardcodes English plural rules; this can break for locales with multiple plural forms.Proposed direction
- {userProgress.attemptsCount} {userProgress.attemptsCount === 1 ? t('attempt') : t('attempts')} + {t('attempts', { count: userProgress.attemptsCount })}frontend/hooks/useQuizSession.ts (1)
35-55: Avoid re-running restore effect due to unstableonRestore.
Because Line 54 depends ononRestore, a non-memoized callback can cause re-restore / unexpectedclearQuizSession()on re-renders.Suggested adjustment
-import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; export function useQuizSession({ quizId, state, onRestore }: UseQuizSessionParams): void { const reloadKey = getQuizReloadKey(quizId); + const onRestoreRef = useRef(onRestore); + onRestoreRef.current = onRestore; useEffect(() => { @@ if (isReload || allowRestore) { - onRestore(saved); + onRestoreRef.current(saved); } else { clearQuizSession(quizId); } - }, [quizId, reloadKey, onRestore]); + }, [quizId, reloadKey]);
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (29)
frontend/app/[locale]/login/page.tsxfrontend/app/[locale]/quiz/[slug]/page.tsxfrontend/app/[locale]/signup/page.tsxfrontend/app/api/quiz/verify-answer/route.tsfrontend/components/dashboard/StatsCard.tsxfrontend/components/quiz/PendingResultHandler.tsxfrontend/components/quiz/QuizCard.tsxfrontend/components/quiz/QuizContainer.tsxfrontend/components/quiz/QuizQuestion.tsxfrontend/components/quiz/QuizResult.tsxfrontend/components/quiz/QuizzesSection.tsxfrontend/components/shared/LanguageSwitcher.tsxfrontend/components/ui/confirm-modal.tsxfrontend/db/queries/quiz.tsfrontend/db/schema/quiz.tsfrontend/drizzle/0013_brown_gamora.sqlfrontend/drizzle/0015_dear_legion.sqlfrontend/drizzle/meta/0013_snapshot.jsonfrontend/drizzle/meta/0015_snapshot.jsonfrontend/drizzle/meta/_journal.jsonfrontend/hooks/useQuizGuards.tsfrontend/hooks/useQuizSession.tsfrontend/lib/quiz/guest-quiz.tsfrontend/lib/quiz/quiz-crypto.tsfrontend/lib/quiz/quiz-session.tsfrontend/lib/quiz/quiz-storage-keys.tsfrontend/messages/en.jsonfrontend/messages/pl.jsonfrontend/messages/uk.json
🧰 Additional context used
🧬 Code graph analysis (10)
frontend/db/queries/quiz.ts (1)
frontend/db/schema/questions.ts (1)
questions(15-37)
frontend/app/api/quiz/verify-answer/route.ts (1)
frontend/lib/quiz/quiz-crypto.ts (1)
decryptAnswers(42-64)
frontend/hooks/useQuizGuards.ts (2)
frontend/lib/quiz/quiz-storage-keys.ts (1)
getQuizReloadKey(3-5)frontend/lib/quiz/quiz-session.ts (1)
clearQuizSession(58-66)
frontend/components/quiz/QuizzesSection.tsx (1)
frontend/data/category.ts (1)
categoryData(11-22)
frontend/app/[locale]/quiz/[slug]/page.tsx (2)
frontend/db/queries/quiz.ts (2)
getQuizQuestionsRandomized(284-291)stripCorrectAnswers(63-78)frontend/lib/quiz/quiz-crypto.ts (1)
createEncryptedAnswersBlob(69-82)
frontend/components/quiz/PendingResultHandler.tsx (2)
frontend/scripts/shop-janitor-restock-stale.mjs (1)
res(54-64)frontend/lib/quiz/guest-quiz.ts (1)
clearPendingQuizResult(51-54)
frontend/hooks/useQuizSession.ts (2)
frontend/lib/quiz/quiz-session.ts (4)
QuizSessionData(4-17)loadQuizSession(30-56)clearQuizSession(58-66)saveQuizSession(19-28)frontend/lib/quiz/quiz-storage-keys.ts (2)
getQuizReloadKey(3-5)QUIZ_ALLOW_RESTORE_KEY(1-1)
frontend/components/ui/confirm-modal.tsx (1)
frontend/components/ui/button.tsx (1)
Button(42-42)
frontend/components/quiz/QuizQuestion.tsx (1)
frontend/db/queries/quiz.ts (1)
QuizQuestionClient(54-61)
frontend/db/schema/quiz.ts (1)
frontend/db/schema/users.ts (1)
users(11-42)
🔇 Additional comments (27)
frontend/drizzle/meta/_journal.json (1)
95-102: LGTM!The new migration entry (idx 13, tag
0013_brown_gamora) follows the correct format, maintains sequential indexing, and has a chronologically valid timestamp. This auto-generated journal entry properly tracks the new database migration for the quiz-related schema changes.frontend/components/dashboard/StatsCard.tsx (2)
55-61: LGTM!The "Start a Quiz" button now correctly links to
/quizzes, which aligns with the broader quiz flow routing updates in this PR.
84-86: No action needed. The route/q&ais correct and fully implemented in the application. The directory structure atfrontend/app/[locale]/q&a/confirms this is the intended route, and it's consistently referenced throughout the codebase including infrontend/lib/navigation.ts. While ampersand characters are unconventional in URLs, they are valid in path segments and do not cause parsing issues in this context.frontend/components/quiz/QuizzesSection.tsx (3)
46-50: LGTM!The URL-driven category state is well-implemented:
- Safe fallback with
categoryData[0]?.slug || 'git'- Proper validation against known categories before using URL value
- Non-null assertion on line 50 is safe since
validCategoryguaranteescategoryFromUrlis not null when trueThis correctly addresses issue #122 by preserving the category selection in URL params across language switches.
61-61: LGTM!The controlled
Tabscomponent correctly reflects and updates the URL-based category state.
37-38: Consider Suspense boundary foruseSearchParams.In Next.js App Router,
useSearchParams()must be inside a<Suspense>boundary when the route can be statically prerendered. Without it, the component tree will be forced to client-side rendering or the build will fail. Wrap this component (or a parent) in<Suspense fallback={...}>, or alternatively, acceptsearchParamsin a Server Component and pass them as props, or mark the route as dynamic.frontend/db/schema/quiz.ts (2)
133-135: LGTM! Foreign key constraint properly defined.The
onDelete: 'cascade'ensures referential integrity—quiz attempts are automatically cleaned up when a user is deleted. The column type (text) correctly matchesusers.id.
154-155: LGTM! Indexes support common query patterns.The standalone
userIdIdxis useful for queries that filter only byuserId(e.g., "get all attempts for a user"). WhileuserCompletedAtIdxalso coversuserId, it's optimized for ordered lookups includingcompletedAt. Both serve distinct query patterns.frontend/drizzle/meta/0013_snapshot.json (3)
576-605: LGTM! Snapshot correctly captures the new indexes.The index definitions for
quiz_attempts_user_id_idxandquiz_attempts_quiz_id_idxare consistent with the schema inquiz.tsand the migration in0013_brown_gamora.sql.
677-689: LGTM! Foreign key definition is consistent.The FK
quiz_attempts_user_id_users_id_fkwithonDelete: cascadecorrectly reflects the schema changes.
1225-1262: Unrelated schema addition:internal_job_statetable.This table appears unrelated to the quiz flow objectives in this PR. Consider whether this change should be in a separate PR for clearer change tracking, or confirm it's intentionally bundled.
frontend/messages/uk.json (1)
153-159: LGTM!The Ukrainian translations for the exit modal are well-structured and consistent with the file's indentation style. The translations appropriately convey the exit confirmation messaging.
frontend/lib/quiz/quiz-session.ts (4)
19-28: LGTM!The
saveQuizSessionfunction correctly handles SSR environments with an early return, uses try-catch for localStorage errors (which can throw in private browsing mode), and properly timestamps the session.
30-56: LGTM!The
loadQuizSessionfunction implements proper validation:
- SSR guard for server-side rendering
- TTL expiration check (30 minutes)
- Status validation to only restore
in_progresssessions- Automatic cleanup of stale/invalid sessions
- Error handling for parse failures
58-66: LGTM!The
clearQuizSessionfunction correctly handles SSR environments and localStorage errors.
7-11: Remove this comment — the security concern is not applicable.The
isCorrectfield stored in localStorage (line 10) is the result of server-side verification from the/api/quiz/verify-answerendpoint, not the answer key itself. The correct answer mapping is encrypted server-side and never exposed to the client. Storing the verification result in localStorage is appropriate for session persistence and does not undermine security. Answer correctness is only displayed in the UI whenquestionStatus === 'revealed', preventing exposure during the answering phase.frontend/app/[locale]/signup/page.tsx (1)
7-7: LGTM!The import path update aligns with the PR's reorganization of quiz utilities under
lib/quiz/. This is consistent with similar changes in other files (login page, PendingResultHandler).frontend/app/[locale]/login/page.tsx (1)
7-7: LGTM!Import path correctly updated to reflect the new module organization under
lib/quiz/.frontend/components/quiz/PendingResultHandler.tsx (1)
4-4: LGTM!Import path correctly updated to align with the new module structure.
frontend/db/queries/quiz.ts (1)
48-78: LGTM!Well-designed client-safe types and utility function. The
QuizAnswerClientandQuizQuestionClientinterfaces correctly omit theisCorrectfield to prevent client-side cheating, andstripCorrectAnswersprovides a clean transformation from server-side to client-safe data structures.frontend/components/shared/LanguageSwitcher.tsx (1)
65-72: LGTM!Query parameter preservation correctly addresses issue #122 (language switch resetting category filter). The sessionStorage flag for quiz page restoration integrates well with the broader quiz session management flow.
frontend/components/quiz/QuizCard.tsx (1)
31-53: Flex + line-clamp change achieves consistent card heights (nice).
This structure should help keep the CTA aligned across the grid.frontend/app/api/quiz/verify-answer/route.ts (1)
1-3: Ensure this route runs in the Node.js runtime (not Edge).
decryptAnswers()uses Node crypto/Buffer patterns; verify noruntime = 'edge'is set (here or globally) for this handler.frontend/components/ui/confirm-modal.tsx (1)
28-32: Confirm button styling: verify it doesn’t fightButton’s variants.
Since you’re passing raw Tailwind classes (Line 57), confirmButtondoesn’t enforce conflicting background/text classes that override these.Also applies to: 55-60
frontend/components/quiz/QuizResult.tsx (1)
16-45: /frontend/components/quiz/QuizQuestion.tsx (1)
46-76: Correct/incorrect labeling logic looks consistent with server-verified flow.frontend/app/[locale]/quiz/[slug]/page.tsx (1)
32-34: Passing encryptedAnswers to the client is OK, but ensure verify endpoint can’t be used as a “correctness oracle”.Even with encryption, a user can still brute-force by calling
/api/quiz/verify-answerfor each option unless the server ties verification to an attempt/session and rate-limits (or only allows one verification per question). Please sanity-check the API route behavior.Also applies to: 63-72
| export async function POST(request: NextRequest) { | ||
| try { | ||
| const body: VerifyRequest = await request.json(); | ||
| const { questionId, answerId, encryptedAnswers } = body; | ||
|
|
||
| if (!questionId || !answerId || !encryptedAnswers) { | ||
| return NextResponse.json( | ||
| { error: 'Missing required fields' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const correctAnswersMap = decryptAnswers(encryptedAnswers); | ||
|
|
||
| if (!correctAnswersMap) { | ||
| return NextResponse.json( | ||
| { error: 'Invalid encrypted data' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const correctAnswerId = correctAnswersMap[questionId]; | ||
|
|
||
| if (!correctAnswerId) { | ||
| return NextResponse.json( | ||
| { error: 'Question not found' }, | ||
| { status: 404 } | ||
| ); | ||
| } | ||
|
|
||
| return NextResponse.json({ | ||
| isCorrect: answerId === correctAnswerId, | ||
| }); | ||
| } catch { | ||
| return NextResponse.json( | ||
| { error: 'Internal server error' }, | ||
| { status: 500 } | ||
| ); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find and read the file
find . -name "verify-answer" -o -name "route.ts" | grep verify-answerRepository: DevLoversTeam/devlovers.net
Length of output: 155
🏁 Script executed:
# List files in the quiz API directory
fd route.ts frontend/app/api/quiz/Repository: DevLoversTeam/devlovers.net
Length of output: 197
🏁 Script executed:
# List the structure of the frontend/app/api directory
find frontend/app/api -type f -name "route.ts" 2>/dev/null | head -20Repository: DevLoversTeam/devlovers.net
Length of output: 971
🏁 Script executed:
# Read the verify-answer route file
cat -n frontend/app/api/quiz/verify-answer/route.tsRepository: DevLoversTeam/devlovers.net
Length of output: 1595
🏁 Script executed:
# Check how other API routes in the codebase handle request.json() errors
rg -A 5 "request\.json\(\)" frontend/app/api --type tsRepository: DevLoversTeam/devlovers.net
Length of output: 1601
🏁 Script executed:
# Check for error handling patterns in other routes
rg -A 10 "catch\s*\(" frontend/app/api --type ts -m 3Repository: DevLoversTeam/devlovers.net
Length of output: 21134
🏁 Script executed:
# Verify error handling in quiz/guest-result for comparison
cat -n frontend/app/api/quiz/guest-result/route.ts | head -35Repository: DevLoversTeam/devlovers.net
Length of output: 1371
🏁 Script executed:
# Check if there's a logError utility in the codebase
rg "logError" frontend --type ts | head -5Repository: DevLoversTeam/devlovers.net
Length of output: 529
Return 400 (not 500) for invalid JSON; log unexpected errors.
request.json() throws on malformed JSON, which is a client error, not a server error. The codebase consistently returns 400 for JSON parsing failures (see quiz/guest-result, auth/signup, auth/login). Also add explicit error logging to distinguish between client-side JSON errors and genuine server failures.
Proposed fix
export async function POST(request: NextRequest) {
try {
- const body: VerifyRequest = await request.json();
+ let body: VerifyRequest;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
+ }
const { questionId, answerId, encryptedAnswers } = body;
@@
- } catch {
+ } catch (e) {
+ console.error('verify-answer failed', e);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function POST(request: NextRequest) { | |
| try { | |
| const body: VerifyRequest = await request.json(); | |
| const { questionId, answerId, encryptedAnswers } = body; | |
| if (!questionId || !answerId || !encryptedAnswers) { | |
| return NextResponse.json( | |
| { error: 'Missing required fields' }, | |
| { status: 400 } | |
| ); | |
| } | |
| const correctAnswersMap = decryptAnswers(encryptedAnswers); | |
| if (!correctAnswersMap) { | |
| return NextResponse.json( | |
| { error: 'Invalid encrypted data' }, | |
| { status: 400 } | |
| ); | |
| } | |
| const correctAnswerId = correctAnswersMap[questionId]; | |
| if (!correctAnswerId) { | |
| return NextResponse.json( | |
| { error: 'Question not found' }, | |
| { status: 404 } | |
| ); | |
| } | |
| return NextResponse.json({ | |
| isCorrect: answerId === correctAnswerId, | |
| }); | |
| } catch { | |
| return NextResponse.json( | |
| { error: 'Internal server error' }, | |
| { status: 500 } | |
| ); | |
| } | |
| export async function POST(request: NextRequest) { | |
| try { | |
| let body: VerifyRequest; | |
| try { | |
| body = await request.json(); | |
| } catch { | |
| return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); | |
| } | |
| const { questionId, answerId, encryptedAnswers } = body; | |
| if (!questionId || !answerId || !encryptedAnswers) { | |
| return NextResponse.json( | |
| { error: 'Missing required fields' }, | |
| { status: 400 } | |
| ); | |
| } | |
| const correctAnswersMap = decryptAnswers(encryptedAnswers); | |
| if (!correctAnswersMap) { | |
| return NextResponse.json( | |
| { error: 'Invalid encrypted data' }, | |
| { status: 400 } | |
| ); | |
| } | |
| const correctAnswerId = correctAnswersMap[questionId]; | |
| if (!correctAnswerId) { | |
| return NextResponse.json( | |
| { error: 'Question not found' }, | |
| { status: 404 } | |
| ); | |
| } | |
| return NextResponse.json({ | |
| isCorrect: answerId === correctAnswerId, | |
| }); | |
| } catch (e) { | |
| console.error('verify-answer failed', e); | |
| return NextResponse.json( | |
| { error: 'Internal server error' }, | |
| { status: 500 } | |
| ); | |
| } | |
| } |
🤖 Prompt for AI Agents
In @frontend/app/api/quiz/verify-answer/route.ts around lines 10 - 48, In POST,
treat JSON parse failures from request.json() as a 400: wrap the request.json()
call in a try/catch (or detect SyntaxError) so malformed JSON returns
NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) and does not fall
through to the generic 500 handler; keep the existing field validation and
decryptAnswers logic (decryptAnswers, correctAnswersMap, correctAnswerId)
unchanged, and add an explicit error log (e.g., console.error or the project
logger) in the generic catch block to record unexpected errors before returning
the 500 response.
| const correctAnswersMap = decryptAnswers(encryptedAnswers); | ||
|
|
||
| if (!correctAnswersMap) { | ||
| return NextResponse.json( | ||
| { error: 'Invalid encrypted data' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const correctAnswerId = correctAnswersMap[questionId]; | ||
|
|
||
| if (!correctAnswerId) { | ||
| return NextResponse.json( | ||
| { error: 'Question not found' }, | ||
| { status: 404 } | ||
| ); | ||
| } | ||
|
|
||
| return NextResponse.json({ | ||
| isCorrect: answerId === correctAnswerId, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n frontend/app/api/quiz/verify-answer/route.tsRepository: DevLoversTeam/devlovers.net
Length of output: 1595
🏁 Script executed:
fd -name "middleware.ts" -o -name "middleware.js"Repository: DevLoversTeam/devlovers.net
Length of output: 301
🏁 Script executed:
rg -l "rate" --type ts --type tsx frontend/app/api/ | head -20Repository: DevLoversTeam/devlovers.net
Length of output: 98
🏁 Script executed:
rg "NextRequest" frontend/app/api/ -A 3 | grep -E "(auth|middleware)" | head -20Repository: DevLoversTeam/devlovers.net
Length of output: 1637
🏁 Script executed:
find . -name "middleware.ts" -o -name "middleware.js" | head -10Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
rg "rateLimit|Ratelimit|throttle" --type ts | head -30Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
rg "quiz/verify-answer" -A 5 -B 5Repository: DevLoversTeam/devlovers.net
Length of output: 908
🏁 Script executed:
rg "getCurrentUser|auth" frontend/app/api/quiz/ --type tsRepository: DevLoversTeam/devlovers.net
Length of output: 338
🏁 Script executed:
cat -n frontend/lib/auth.ts 2>/dev/null | head -50Repository: DevLoversTeam/devlovers.net
Length of output: 1517
Add authentication and rate limiting to prevent brute-force attempts.
This endpoint accepts unauthenticated requests and directly returns whether an answerId is correct (Line 41). A client can iterate through all possible answer IDs to determine correct answers without any constraints. The codebase has an auth system (getCurrentUser() in /lib/auth.ts) that's used in other quiz endpoints like guest-result, but it's missing here. Implement authentication (via getCurrentUser()) and add per-user/per-attempt rate limiting or constraints to prevent answer enumeration.
🤖 Prompt for AI Agents
In @frontend/app/api/quiz/verify-answer/route.ts around lines 22 - 42, This
endpoint leaks answer correctness and must enforce authentication and rate
limiting: call getCurrentUser() at the start of the handler and return a 401/403
when it returns null, then apply a per-user (fallback to IP) rate limit check
before using decryptAnswers/enforcing logic (use or add a rate limiter that
returns 429 when exceeded); ensure the rate-limit check occurs before returning
the isCorrect response and include the user id (or IP) when recording attempts
so brute-force attempts are blocked for that identity.
| <div className="mb-6"> | ||
| <div className="flex justify-between text-xs mb-1.5"> | ||
| <span className="text-gray-600 dark:text-gray-400"> | ||
| {t('best')} {userProgress.bestScore}/{userProgress.totalQuestions} | ||
| </span> | ||
| <span className="text-gray-500"> | ||
| {userProgress.attemptsCount} {userProgress.attemptsCount === 1 ? t('attempt') : t('attempts')} | ||
| </span> | ||
| <span className="font-medium text-gray-900 dark:text-gray-100"> | ||
| {percentage}% | ||
| </span> | ||
| </div> | ||
| <div className="h-1.5 bg-gray-200 dark:bg-neutral-800 rounded-full overflow-hidden"> | ||
| <div | ||
| className="h-full bg-blue-600 rounded-full transition-all" | ||
| style={{ width: `${percentage}%` }} | ||
| /> | ||
| </div> |
There was a problem hiding this comment.
Clamp percentage to [0, 100] before using it in UI.
If bestScore/totalQuestions is ever out of sync, Line 70 could render widths >100% (and negative widths if data is bad).
Proposed fix
- const percentage = userProgress && userProgress.totalQuestions > 0
- ? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100)
- : 0;
+ const rawPercentage =
+ userProgress && userProgress.totalQuestions > 0
+ ? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100)
+ : 0;
+ const percentage = Math.min(100, Math.max(0, rawPercentage));🤖 Prompt for AI Agents
In @frontend/components/quiz/QuizCard.tsx around lines 55 - 72, Clamp the
computed percentage used in the UI to the 0–100 range before rendering: ensure
the value derived from userProgress.bestScore and userProgress.totalQuestions
(used as percentage) is sanitized (e.g., compute a local percentage variable
inside QuizCard component and set percentage = Math.min(100, Math.max(0,
rawPercentage))) and then use that clamped percentage for the text display and
the progress bar width. Update references to percentage in the span and the
inline style to use the clamped variable so widths never go below 0% or above
100%.
| const confirmQuit = () => { | ||
| markQuitting(); | ||
| clearQuizSession(quizId); | ||
| resetViolations(); | ||
| const categoryParam = categorySlug ? `?category=${categorySlug}` : ''; | ||
| window.location.href = `/${locale}/quizzes${categoryParam}`; | ||
| }; |
There was a problem hiding this comment.
URL-build for quit should encode categorySlug (and prefer router navigation if possible).
Safer query param
- const categoryParam = categorySlug ? `?category=${categorySlug}` : '';
+ const categoryParam = categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : '';| return ( | ||
| <div className="fixed inset-0 z-50 flex items-center justify-center"> | ||
| <div | ||
| className="absolute inset-0 bg-black/50" | ||
| onClick={onCancel} | ||
| /> | ||
| <div className="relative bg-white dark:bg-neutral-900 rounded-xl p-6 max-w-sm mx-4 shadow-xl border border-gray-200 dark:border-neutral-800"> | ||
| <h3 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2"> | ||
| {title} | ||
| </h3> | ||
| <p className="text-gray-600 dark:text-gray-400 mb-6"> | ||
| {message} | ||
| </p> | ||
| <div className="flex gap-3"> | ||
| <Button | ||
| variant="outline" | ||
| onClick={onCancel} | ||
| className="flex-1" | ||
| > | ||
| {cancelText} | ||
| </Button> | ||
| <Button | ||
| onClick={onConfirm} | ||
| className={`flex-1 ${confirmButtonClass}`} | ||
| > | ||
| {confirmText} | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); |
There was a problem hiding this comment.
Add minimal modal accessibility + Escape-to-close.
This currently lacks role="dialog", aria-modal, label/description wiring, and keyboard handling (Esc).
Proposed fix
import { Button } from '@/components/ui/button';
+import { useEffect, useId } from 'react';
@@
export function ConfirmModal({
@@
}: ConfirmModalProps) {
if (!isOpen) return null;
+ const titleId = useId();
+ const messageId = useId();
+
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onCancel();
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [onCancel]);
@@
return (
- <div className="fixed inset-0 z-50 flex items-center justify-center">
+ <div
+ className="fixed inset-0 z-50 flex items-center justify-center"
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby={titleId}
+ aria-describedby={messageId}
+ >
<div
className="absolute inset-0 bg-black/50"
onClick={onCancel}
/>
<div className="relative bg-white dark:bg-neutral-900 rounded-xl p-6 max-w-sm mx-4 shadow-xl border border-gray-200 dark:border-neutral-800">
- <h3 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">
+ <h3 id={titleId} className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">
{title}
</h3>
- <p className="text-gray-600 dark:text-gray-400 mb-6">
+ <p id={messageId} className="text-gray-600 dark:text-gray-400 mb-6">
{message}
</p>🤖 Prompt for AI Agents
In @frontend/components/ui/confirm-modal.tsx around lines 34 - 64, Add minimal
accessibility attributes and Escape-to-close handling to the modal: give the
modal container role="dialog" and aria-modal="true", wire aria-labelledby and
aria-describedby to IDs for the title and message (create stable IDs via useId
or a simple unique constant), ensure the title element has that title ID and the
message has the description ID, and make the dialog container focusable
(tabIndex={-1}) and focus it on mount. Also add a keydown listener in a
useEffect that calls onCancel when Escape is pressed and cleans up the listener
on unmount; keep existing overlay onClick handler. Update references to
onCancel, onConfirm, title, message, cancelText, confirmText, and
confirmButtonClass accordingly.
| useEffect(() => { | ||
| if (state.status !== 'in_progress') return; | ||
|
|
||
| const sessionData: QuizSessionData = { | ||
| status: state.status, | ||
| currentIndex: state.currentIndex, | ||
| answers: state.answers.map(a => ({ | ||
| questionId: a.questionId, | ||
| selectedAnswerId: a.selectedAnswerId, | ||
| isCorrect: a.isCorrect, | ||
| answeredAt: a.answeredAt.getTime(), | ||
| })), | ||
| questionStatus: state.questionStatus, | ||
| selectedAnswerId: state.selectedAnswerId, | ||
| startedAt: state.startedAt?.getTime() ?? null, | ||
| savedAt: Date.now(), | ||
| }; | ||
|
|
||
| saveQuizSession(quizId, sessionData); | ||
| }, [quizId, state]); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "useQuizSession.ts" -o -name "*QuizSession*" | head -20Repository: DevLoversTeam/devlovers.net
Length of output: 105
🏁 Script executed:
fd "useQuizSession" --type fRepository: DevLoversTeam/devlovers.net
Length of output: 103
🏁 Script executed:
git ls-files | grep -i quizRepository: DevLoversTeam/devlovers.net
Length of output: 3671
🏁 Script executed:
cat -n frontend/hooks/useQuizSession.tsRepository: DevLoversTeam/devlovers.net
Length of output: 2603
🏁 Script executed:
cat -n frontend/lib/quiz/quiz-session.tsRepository: DevLoversTeam/devlovers.net
Length of output: 2284
🏁 Script executed:
cat -n frontend/app/\[locale\]/quiz/\[slug\]/page.tsx | head -150Repository: DevLoversTeam/devlovers.net
Length of output: 3275
🏁 Script executed:
cat -n frontend/actions/quiz.ts | head -150Repository: DevLoversTeam/devlovers.net
Length of output: 5200
🏁 Script executed:
cat -n frontend/app/api/quiz/verify-answer/route.tsRepository: DevLoversTeam/devlovers.net
Length of output: 1595
🏁 Script executed:
cat -n frontend/actions/quiz.ts | tail -100Repository: DevLoversTeam/devlovers.net
Length of output: 3401
🏁 Script executed:
cat -n frontend/components/quiz/QuizContainer.tsx | head -200Repository: DevLoversTeam/devlovers.net
Length of output: 7038
🏁 Script executed:
rg "onRestore" frontend/components/quiz/ -A 5Repository: DevLoversTeam/devlovers.net
Length of output: 483
🏁 Script executed:
rg "answers.*isCorrect" frontend/components/quiz/ -B 2 -A 2Repository: DevLoversTeam/devlovers.net
Length of output: 1400
🏁 Script executed:
cat -n frontend/components/quiz/QuizQuestion.tsx | head -100Repository: DevLoversTeam/devlovers.net
Length of output: 4258
🏁 Script executed:
cat -n frontend/components/quiz/QuizContainer.tsx | sed -n '200,350p'Repository: DevLoversTeam/devlovers.net
Length of output: 5349
🏁 Script executed:
rg "correctAnswers.*filter" frontend/components/quiz/ -B 3 -A 3Repository: DevLoversTeam/devlovers.net
Length of output: 1191
🏁 Script executed:
cat -n frontend/lib/quiz/guest-quiz.tsRepository: DevLoversTeam/devlovers.net
Length of output: 1726
🏁 Script executed:
rg "savePendingQuizResult\|pendingQuizResult" frontend/ -B 2 -A 5Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
rg "getPendingQuizResult" frontend/ -B 2 -A 10Repository: DevLoversTeam/devlovers.net
Length of output: 6505
🏁 Script executed:
rg "pendingQuizResult\|PendingQuizResult" frontend/components/ frontend/app/api/ -B 2 -A 5Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
cat -n frontend/app/api/quiz/guest-result/route.tsRepository: DevLoversTeam/devlovers.net
Length of output: 6631
🏁 Script executed:
cat -n frontend/components/quiz/PendingResultHandler.tsxRepository: DevLoversTeam/devlovers.net
Length of output: 1394
🏁 Script executed:
rg "state.answers.*map" frontend/components/quiz/QuizContainer.tsx -B 2 -A 2Repository: DevLoversTeam/devlovers.net
Length of output: 231
🏁 Script executed:
cat -n frontend/components/quiz/QuizContainer.tsx | sed -n '251,270p'Repository: DevLoversTeam/devlovers.net
Length of output: 799
🏁 Script executed:
rg "submitQuizAttempt.*answers" frontend/components/quiz/QuizContainer.tsx -B 2 -A 8Repository: DevLoversTeam/devlovers.net
Length of output: 53
Remove answers[].isCorrect from session persistence; server already recomputes correctness.
The persisted isCorrect values are unnecessary since both submitQuizAttempt() (actions/quiz.ts line 162) and the guest-result endpoint (guest-result/route.ts line 117) ignore client values and recalculate correctness directly from the database. Persist only {questionId, selectedAnswerId, answeredAt} to avoid misleading UI displays and reduce persistence overhead.
🤖 Prompt for AI Agents
In @frontend/hooks/useQuizSession.ts around lines 56 - 75, The persisted session
currently includes answers[].isCorrect even though correctness is recomputed
server-side; update the effect in useQuizSession (the mapping that builds
QuizSessionData before calling saveQuizSession) to only persist questionId,
selectedAnswerId and answeredAt (remove isCorrect), adjust the constructed
session object passed to saveQuizSession and any related type/interface
(QuizSessionData or the answers item type) to reflect the reduced shape so
runtime and TS types align.
| const sessionData: QuizSessionData = { | ||
| status: state.status, | ||
| currentIndex: state.currentIndex, | ||
| answers: state.answers.map(a => ({ | ||
| questionId: a.questionId, | ||
| selectedAnswerId: a.selectedAnswerId, | ||
| isCorrect: a.isCorrect, | ||
| answeredAt: a.answeredAt.getTime(), | ||
| })), | ||
| questionStatus: state.questionStatus, | ||
| selectedAnswerId: state.selectedAnswerId, | ||
| startedAt: state.startedAt?.getTime() ?? null, | ||
| savedAt: Date.now(), | ||
| }; | ||
|
|
||
| saveQuizSession(quizId, sessionData); | ||
| }, [quizId, state]); |
There was a problem hiding this comment.
Remove redundant savedAt assignment (lib already stamps it).
Line 71 is overwritten inside saveQuizSession() (per frontend/lib/quiz/quiz-session.ts). Keeping only one source reduces confusion.
Proposed fix
- const sessionData: QuizSessionData = {
+ const sessionData: QuizSessionData = {
status: state.status,
@@
selectedAnswerId: state.selectedAnswerId,
startedAt: state.startedAt?.getTime() ?? null,
- savedAt: Date.now(),
+ savedAt: 0,
};🤖 Prompt for AI Agents
In @frontend/hooks/useQuizSession.ts around lines 59 - 75, Remove the redundant
savedAt assignment from the sessionData object construction in useQuizSession;
the saveQuizSession function already stamps savedAt, so delete the savedAt:
Date.now() property when building sessionData and continue to call
saveQuizSession(quizId, sessionData) as before to let saveQuizSession set the
timestamp.
| import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; | ||
|
|
||
| const ALGORITHM = 'aes-256-gcm'; | ||
| const IV_LENGTH = 16; | ||
| const AUTH_TAG_LENGTH = 16; | ||
|
|
||
| function getEncryptionKey(): Buffer { | ||
| const key = process.env.QUIZ_ENCRYPTION_KEY; | ||
| if (!key) { | ||
| throw new Error('QUIZ_ENCRYPTION_KEY environment variable is not set'); | ||
| } | ||
| return Buffer.from(key, 'hex'); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, check the Next.js version in package.json
find . -name "package.json" -type f | head -5 | xargs cat | grep -A 10 -B 2 "next"Repository: DevLoversTeam/devlovers.net
Length of output: 1597
🏁 Script executed:
# Read the full quiz-crypto.ts file to see lines 22-37
cat -n frontend/lib/quiz/quiz-crypto.tsRepository: DevLoversTeam/devlovers.net
Length of output: 2907
🏁 Script executed:
# Check if server-only is already in dependencies
find . -name "package.json" -type f | xargs grep -l "server-only" 2>/dev/nullRepository: DevLoversTeam/devlovers.net
Length of output: 55
🏁 Script executed:
# Search for existing server-only imports in the codebase
rg "import.*server-only" --type ts --type tsxRepository: DevLoversTeam/devlovers.net
Length of output: 98
🏁 Script executed:
# Search for server-only imports with correct rg syntax
rg "server-only" --type tsRepository: DevLoversTeam/devlovers.net
Length of output: 665
🏁 Script executed:
# Check if server-only is a standard Next.js directive (built-in, no package needed)
web_search
"server-only" Next.js directive built-in packageRepository: DevLoversTeam/devlovers.net
Length of output: 171
🏁 Script executed:
# Search for where quiz-crypto.ts is imported
rg "quiz-crypto" --type ts --type tsxRepository: DevLoversTeam/devlovers.net
Length of output: 98
🏁 Script executed:
# Check if the file is used in client or server context
rg "encryptAnswers\|decryptAnswers" --type ts --type tsx -B 2 -A 2Repository: DevLoversTeam/devlovers.net
Length of output: 98
🏁 Script executed:
# Search for server-only imports correctly
rg "server-only" -t tsRepository: DevLoversTeam/devlovers.net
Length of output: 665
🏁 Script executed:
# Search for where quiz-crypto is imported
rg "quiz-crypto"Repository: DevLoversTeam/devlovers.net
Length of output: 284
🏁 Script executed:
# Search for usage of the encryption functions
rg "encryptAnswers|decryptAnswers" -A 2 -B 2Repository: DevLoversTeam/devlovers.net
Length of output: 1653
🌐 Web query:
Next.js server-only directive built-in feature
💡 Result:
Short answer
- Next.js (via React Server Components) provides a built-in "use server" directive that marks a function or whole module as server-only (Server Function / Server Action). Use it inline at the top of an async function or at the top of a file to make all exports server-side. [1][2]
Key details
- Two placements: module-level ('use server' at top of file) or function-level ('use server' at top of a function). Module-level actions can be imported into Client Components; inline actions work inside Server Components. [1][2]
- Typical uses: server mutations, DB access, form handlers — Next.js will execute the code only on the server and (for client-invoked actions) expose an auto-generated server endpoint. [2]
- Security: treat action inputs as untrusted; Next.js enforces POST-only invocation and host/origin checks (additional protections described in Next.js docs). [3]
Official docs / reading
- "use server" directive reference. [1]
- Server Actions / Server Functions overview and usage. [2]
- Security considerations for Server Actions. [3]
Sources
[1] Next.js — Directives: use server.
[2] Next.js — Server Actions and Mutations / Functions: Server Actions.
[3] Next.js — Security guidance for Server Components & Actions.
🏁 Script executed:
# Check the quiz page component to see if it's a Server or Client Component
head -20 frontend/app/[locale]/quiz/[slug]/page.tsxRepository: DevLoversTeam/devlovers.net
Length of output: 931
🏁 Script executed:
# Verify the package.json doesn't have server-only as a dependency (since it should be built-in)
cat frontend/package.json | grep -A 20 '"dependencies"'Repository: DevLoversTeam/devlovers.net
Length of output: 768
🏁 Script executed:
# Check crypto module documentation - verify AES-256-GCM key requirements
web_search
AES-256-GCM cipher key length bytes requirements Node.js cryptoRepository: DevLoversTeam/devlovers.net
Length of output: 171
🌐 Web query:
Node.js crypto AES-256-GCM key length 32 bytes requirements
💡 Result:
Short answer: aes-256-gcm requires a 256‑bit key (32 bytes). Node’s crypto.createCipheriv / createDecipheriv must be given a 32‑byte Buffer (or equivalent KeyObject) for "aes-256-gcm" — otherwise it will fail or be rejected by OpenSSL. Use crypto.randomBytes(32) or derive a 32‑byte key (e.g. PBKDF2/HKDF) if you need a key from a password. Also note GCM best-practices: 12‑byte IV recommended and 16‑byte (128‑bit) auth tag default. [1][2][3]
Sources:
- Node.js crypto docs (Cipheriv / key argument, authTag/IV guidance). [1]
- NIST / standards stating AES key sizes (128/192/256) for GCM. [2]
- RFC / practical examples and community guidance (key/IV/tag lengths, generation). [3]
References:
[1] Node.js Crypto documentation.
[2] NIST SP 800-38D / CSRC pages on GCM.
[3] RFCs and examples (RFC 5116, Node.js examples).
🏁 Script executed:
# Check if quiz-crypto.ts is properly guarded from client imports
# Look at how it's used in the quiz page - is it truly server-only?
cat frontend/app/[locale]/quiz/[slug]/page.tsx | head -50Repository: DevLoversTeam/devlovers.net
Length of output: 1849
🏁 Script executed:
# Verify the actual key length requirement for AES-256-GCM
python3 << 'EOF'
# AES-256 means 256-bit key = 32 bytes
# In hex encoding, each byte is 2 hex characters
# So 32 bytes = 64 hex characters
key_bytes = 32
key_hex_chars = key_bytes * 2
print(f"AES-256-GCM requires: {key_bytes} bytes = {key_hex_chars} hex characters")
EOFRepository: DevLoversTeam/devlovers.net
Length of output: 157
🏁 Script executed:
# Check if there are any tests for quiz-crypto to see how the key should be formatted
fd "quiz-crypto" --type fRepository: DevLoversTeam/devlovers.net
Length of output: 103
🌐 Web query:
Next.js 16 server-only directive built-in
💡 Result:
Short answer: Next.js 16 does not add a new built‑in "server‑only" directive — it uses the separate npm package import 'server-only' to mark modules as server‑only, while the 'use server' directive remains the way to declare Server Actions/functions. See the Next.js docs. [1][2]
Sources:
- Next.js v16 upgrade guide (mentions server‑only usage and
'use server'). [1] - Next.js rendering/composition docs (explains importing "server-only" to get a build‑time error if imported into client code). [2]
🏁 Script executed:
# Verify the quiz page is indeed a Server Component and not a Client Component
grep -n "use client" frontend/app/[locale]/quiz/[slug]/page.tsxRepository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
# Check if server-only is actually in dependencies or if it's only mocked
cat frontend/package.json | grep -C 5 "dependencies"Repository: DevLoversTeam/devlovers.net
Length of output: 573
🏁 Script executed:
# Look at the mock to understand how server-only is being handled
cat frontend/lib/tests/__mocks__/server-only.tsRepository: DevLoversTeam/devlovers.net
Length of output: 211
🏁 Script executed:
# Verify if the package.json in the actual project root (not frontend/) has server-only
cat package.json | grep -i "server-only" 2>/dev/null || echo "Not found in root"Repository: DevLoversTeam/devlovers.net
Length of output: 150
🏁 Script executed:
# Check if server-only is actually available as a built-in or via Next.js
# by looking at what happens when we try to import it
find . -name "*.ts" -o -name "*.tsx" | xargs grep -l "server-only" | head -3 | xargs cat | grep -A 5 "server-only"Repository: DevLoversTeam/devlovers.net
Length of output: 572
🏁 Script executed:
# Verify the complete dependencies list to be absolutely sure server-only isn't there
cat frontend/package.json | jq '.dependencies | keys[] | select(. | contains("server"))'Repository: DevLoversTeam/devlovers.net
Length of output: 97
🏁 Script executed:
# Double-check that server-only isn't listed anywhere in package.json
cat frontend/package.json | grep -i "server"Repository: DevLoversTeam/devlovers.net
Length of output: 112
🏁 Script executed:
# Check if there's an npm install or lockfile that might have server-only as transitive dependency
ls -la frontend/ | grep -E "package-lock|yarn.lock|pnpm-lock"Repository: DevLoversTeam/devlovers.net
Length of output: 140
🏁 Script executed:
# Check if server-only is in package-lock.json (transitive dependency)
grep -i '"server-only"' frontend/package-lock.json | head -5Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
# Also verify what Next.js 16 actually provides - check node_modules or lock file for server-only inclusion
grep -A 5 '"name": "next"' frontend/package-lock.json | head -20Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
# Check Next.js documentation or check if server-only might be a built-in in 16
# by looking at imports in node_modules
find frontend/node_modules/next -name "server-only*" 2>/dev/null | head -5Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
# Verify by checking what other files that use server-only did to get it working
head -5 frontend/lib/auth.tsRepository: DevLoversTeam/devlovers.net
Length of output: 199
🏁 Script executed:
# Check Next.js 16 node_modules to see if server-only comes as transitive dependency
ls -la frontend/node_modules/ | grep -i serverRepository: DevLoversTeam/devlovers.net
Length of output: 140
🏁 Script executed:
# Check if there's a special handling in next.config or tsconfig for server-only
cat frontend/next.config.js 2>/dev/null || cat frontend/next.config.ts 2>/dev/null || echo "No config found"Repository: DevLoversTeam/devlovers.net
Length of output: 1019
Add server-only marker and validate QUIZ_ENCRYPTION_KEY as 32-byte (64-char hex) for AES-256-GCM.
The module exports functions that use Node.js crypto and should never be bundled into client code. Validation is also missing: if the key isn't exactly 64 hex characters (32 bytes for AES-256-GCM), the error at runtime will be unclear. The pattern is already established elsewhere in the codebase.
Implementation
+import 'server-only';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
+const KEY_LENGTH_BYTES = 32;
+const KEY_LENGTH_HEX = KEY_LENGTH_BYTES * 2;
function getEncryptionKey(): Buffer {
const key = process.env.QUIZ_ENCRYPTION_KEY;
if (!key) {
throw new Error('QUIZ_ENCRYPTION_KEY environment variable is not set');
}
- return Buffer.from(key, 'hex');
+ if (key.length !== KEY_LENGTH_HEX || !/^[0-9a-fA-F]+$/.test(key)) {
+ throw new Error(`QUIZ_ENCRYPTION_KEY must be ${KEY_LENGTH_HEX} hex chars (${KEY_LENGTH_BYTES} bytes)`);
+ }
+ const buf = Buffer.from(key, 'hex');
+ if (buf.length !== KEY_LENGTH_BYTES) {
+ throw new Error(`QUIZ_ENCRYPTION_KEY must decode to ${KEY_LENGTH_BYTES} bytes`);
+ }
+ return buf;
}This also applies to encryptAnswers(), decryptAnswers(), and createEncryptedAnswersBlob() which all depend on the validated key.
🤖 Prompt for AI Agents
In @frontend/lib/quiz/quiz-crypto.ts around lines 1 - 13, Add a server-only
marker at the top of quiz-crypto.ts (e.g., the established "server-only"
directive used in the repo) and harden getEncryptionKey(): ensure
process.env.QUIZ_ENCRYPTION_KEY exists, validate it matches exactly 64 hex chars
(or /^[0-9a-fA-F]{64}$/) and/or that Buffer.from(key,'hex').length === 32, and
throw a clear error if not; ensure encryptAnswers, decryptAnswers, and
createEncryptedAnswersBlob call the validated getEncryptionKey() so they never
operate with an invalid key.
| export const QUIZ_ALLOW_RESTORE_KEY = 'quiz-allow-restore'; | ||
|
|
||
| export function getQuizReloadKey(quizId: string): string { | ||
| return `quiz-reload:${quizId}`; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n "quiz-allow-restore" --type=ts -g '!quiz-storage-keys.ts' frontend/Repository: DevLoversTeam/devlovers.net
Length of output: 169
🏁 Script executed:
cat -n frontend/components/shared/LanguageSwitcher.tsx | head -25 | tail -15Repository: DevLoversTeam/devlovers.net
Length of output: 671
Update LanguageSwitcher.tsx to use the new constant.
The constant QUIZ_ALLOW_RESTORE_KEY is created but not being used. The hardcoded 'quiz-allow-restore' string at frontend/components/shared/LanguageSwitcher.tsx:18 needs to import and use this constant instead. Without this change, the refactoring remains incomplete and the constant serves no purpose.
🤖 Prompt for AI Agents
In @frontend/lib/quiz/quiz-storage-keys.ts around lines 1 - 5, Replace the
hardcoded 'quiz-allow-restore' string in the LanguageSwitcher component with the
exported constant QUIZ_ALLOW_RESTORE_KEY: import QUIZ_ALLOW_RESTORE_KEY from the
quiz-storage-keys module and use that constant wherever the literal is used
(specifically the value used at the place corresponding to the previous literal
in the LanguageSwitcher component), removing the unused constant from being
idle; ensure imports compile and update any tests or usages that expect the
literal string to now reference QUIZ_ALLOW_RESTORE_KEY.
| "exitModal": { | ||
| "title": "Exit Quiz?", | ||
| "message": "Your progress will not be saved.", | ||
| "confirm": "Exit", | ||
| "cancel": "Continue" | ||
| } |
There was a problem hiding this comment.
Inconsistent indentation in exitModal object.
The exitModal content uses 2-space indentation while the rest of the file consistently uses 4 spaces for nested objects. This should be aligned for consistency.
Suggested fix
"exitModal": {
- "title": "Exit Quiz?",
- "message": "Your progress will not be saved.",
- "confirm": "Exit",
- "cancel": "Continue"
-}
+ "title": "Exit Quiz?",
+ "message": "Your progress will not be saved.",
+ "confirm": "Exit",
+ "cancel": "Continue"
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "exitModal": { | |
| "title": "Exit Quiz?", | |
| "message": "Your progress will not be saved.", | |
| "confirm": "Exit", | |
| "cancel": "Continue" | |
| } | |
| "exitModal": { | |
| "title": "Exit Quiz?", | |
| "message": "Your progress will not be saved.", | |
| "confirm": "Exit", | |
| "cancel": "Continue" | |
| } |
🤖 Prompt for AI Agents
In @frontend/messages/en.json around lines 154 - 159, The exitModal JSON object
uses 2-space indentation inconsistent with the rest of the file; update the
indentation for the "exitModal" block so nested lines (title, message, confirm,
cancel) use 4 spaces to match the file's style, ensuring the keys remain
unchanged and the object formatting aligns with the surrounding JSON entries.
Week 5
Summary
Implementation of Quiz features: security improvements, session persistence, and UX fixes.
Closes
Changes
#130: Security - Encrypted Answers (SP: 3)
lib/quiz/quiz-crypto.ts- AES-256-GCM encryption utilitiesapp/api/quiz/verify-answer/route.ts- Server-side answer verification endpointdb/queries/quiz.ts- Client-safe types (QuizQuestionClient, QuizAnswerClient)#124: Session Persistence + Quit Confirmation (SP: 4)
lib/quiz/quiz-session.ts- localStorage session utilitieshooks/useQuizSession.ts- Session persistence hookhooks/useQuizGuards.ts- Browser navigation guardscomponents/ui/confirm-modal.tsx- Reusable confirmation dialog#122: Language Switch Category Fix (SP: 1)
components/quiz/QuizzesSection.tsx- URL params sync for category#117: QuizCard Height (SP: 1)
components/quiz/QuizCard.tsx- Consistent card height with flex layout#115: Dashboard Link Fix (SP: 1)
Test plan
Environment
QUIZ_ENCRYPTION_KEYenv variable (32-byte hex string)Summary by CodeRabbit
New Features
Improvements
✏️ Tip: You can customize this high-level summary in your review settings.