Growth Quiz Session Tracking
The seven public quizzes (/financial-literacy and the six lead
quizzes under app/(public)/(financial-quizzes)/) are anonymous,
SEO-focused landing surfaces that paid ads point at. This document
covers how anonymous quiz sessions are captured in Supabase and linked
to the resulting Clerk signup so growth can answer:
- Which paid channel / campaign drove which quiz outcome?
- Which quiz sessions turned into free signups, and how fast?
- How does answer selection differ across ad segments?
Data model
Two tables, both RLS deny-all + service-role writes only, matching the pattern already used byoutage_notifications, referrals, and
silvia_call_logs (see 20260517_05_lock_down_internal_writes.sql).
public.quiz_sessions
An additional
UNIQUE (session_id, quiz_id) constraint is added by
migration 20260829_01_quiz_answers_composite_fk.sql so it can serve
as the target of the composite FK on quiz_answers (see below).
public.quiz_answers
UNIQUE (session_id, question_id) lets the client upsert on each
“Next Question” click without generating dupes when the user goes
back and changes an earlier answer.
Request flow
All four write endpoints live inapp/api/quiz/ and use a
module-singleton service-role Supabase client. Every route validates
its body with zod, verifies session_id is a real UUID, verifies
quiz_id is in the whitelist, and reserves a sliding-window Redis
slot before touching Postgres. Rate-limit keys differ by route:
/start, /answer, and /complete key on client IP (they are
anonymous); /attribute-signup keys on the authenticated userId
so a shared NAT (office WiFi, mobile carrier, campus network)
cannot drop the eleventh legitimate signup in an hour. The rate
limiter fails open on Redis errors so a Redis blip cannot take the
quizzes offline (the write path is best-effort telemetry, not auth).
-
User opens a quiz page. No server call yet. The quiz hook
generates or reads a tab-scoped
session_idfromsessionStorage['silvia_quiz_session:<quiz_id>'], and checks a session-scopedsilvia_quiz_session_completed:<quiz_id>marker inlocalStorage. The marker stores the specific completedsession_id(not just a boolean), so it only triggers rotation when the tab’s active id matches: a completion in another tab does not invalidate this tab’s in-progress attempt, and a delayed complete-ack for a previous attempt after a retake does not lock the retake. The hook also captures the current URL, referrer, and query params intosessionStorage['silvia_quiz_attribution']viareadOrCaptureAttribution. A fresh landing (any UTM or ad-click id present) overwrites the stored snapshot. An internal click keeps it so the retirement-quiz row still credits the ad the user first landed on. -
User picks their first answer.
POST /api/quiz/startfires once per session. The server upserts thequiz_sessionsrow (attribution, IP hash, geo, user agent), withignoreDuplicates=trueonsession_idso the same client hitting/starttwice never overwrites the first-touch attribution. -
User clicks Next.
POST /api/quiz/answerupserts aquiz_answersrow keyed on(session_id, question_id). The API route SELECTs the parent session first and rejects with 409quiz_id_mismatchif the client-suppliedquiz_iddisagrees. The composite FK is the DB-level backstop for the same check. Fire-and-forget withkeepalive: trueso a rapid CTA click after “Get My Result” doesn’t abort the last answer’s beacon. The client-side helpersendQuizAnswerchains onto (a) any in-flightsendQuizStartand (b) the previous in-flightsendQuizAnswerfor the same session before POSTing, so /answer_N never lands ahead of its parent row or ahead of /answer_N-1. Best-effort by design: the underlyingposthelper never throws (it returns{ ok:false }on network / non-2xx), so a failed /start will not halt subsequent /answer submissions. In the rare case where /start’s server-side write fails, subsequent /answer POSTs will see 404unknown_session; those rows are lost but the client UI continues to render normally, and the completed row on the next attempt is unaffected. Recovery is by user retake, not automatic. -
User completes the quiz.
POST /api/quiz/completeupdates thequiz_sessionsrow withscore,outcome_id,result_data, andcompleted_at. Matches on(quiz_id, session_id)ANDcompleted_at IS NULLso a stray re-fire (two tabs, resurrected suspended tab, hostile client replaying a stale beacon) can’t silently overwrite the persisted score/outcome. Zero-row updates are re-classified on the server: unknown session returns 404,quiz_idmismatch returns 409, and an already-completed session returns 200{ ok: true, already_completed: true }so a benign double-fire from the same client is idempotent. Client-side,sendQuizCompleteawaits the in-flightsendQuizStartAND the tail of the answer-chain for the same session before POSTing — the completion can never beat its parent row or any earlier answer to the server (because /answer POSTs are chained onto each other insendQuizAnswer, awaiting the latest is awaiting the whole chain). This closes the “answer + complete fired in the same tick, complete arrives first, /complete returns 404unknown_session, hook rotates the local id anyway → orphaned incomplete row” race.markQuizSessionCompleted(quizId, sessionId)runs ONLY on server ack and is passed the SPECIFIC sessionId that completed (not the hook’s live id) so a retake between POST and ack does not lock the retake as completed. Fire-and-forget withkeepalive: truefor the same reason as/answer. -
User clicks the signup CTA. The CTA href is
?plan=free&quiz=…&quiz_outcome=…&quiz_score=…(as before) plus&quiz_session=<uuid>&quiz_id=<slug>. Clicking also stashes the pair insessionStorage(silvia_quiz_pending_signup) so an in-tab Clerk auth flow that drops query params on sub-paths (/sign-up/verify-email-address, etc.) still has a way to recover the linkage. Same-tab OAuth is covered by both carriers. -
/sign-uppage. A Suspense effect readsquiz_sessionandquiz_idfrom the URL (whitelisted) and stashes them insessionStorage. On a plain/sign-upwith no quiz params, or with a pair that fails validation, it clears any stale stash, mirroring the plan-intent behavior. -
/auth/callback. After Clerk resolves andisNewSignupis true, the callback reads the stashed pair and awaitsPOST /api/quiz/attribute-signup { quiz_id, session_id }. That handler:- Rejects unauthenticated calls (
auth()must succeed). - Rate-limits per authenticated
userId(not per IP) so a shared NAT (office WiFi, mobile carrier, campus network) doesn’t drop the eleventh legitimate signup in an hour. - Looks up the session, refuses
quiz_idmismatches. - If already attributed to the same user, returns success.
- If already attributed to a different user, returns 409 (first-wins).
- Otherwise updates
clerk_idandattributed_atand returns success.
quiz_signup_attributedPostHog event ONLY when the server acks the attribution AND the server did not reportalready_attributed=true. A 401 / 409 / 4xx from the server is a terminal outcome — the stash is cleared and no event emits. A 5xx or network failure is TREATED AS RETRYABLE: up to 3 total attempts (initial + 300ms + 800ms backoff) all completed BEFORErouter.push, so the retries don’t depend on the callback re-mounting after navigation (which it typically won’t:callbackStarted.currentblocks re-entry and OAuth returns often land on a different origin). If every attempt fails the stash is preserved as a defensive fallback for edge cases (user manually navigates back to /auth/callback, secondary auth event in the same tab), and aquiz_signup_attribution_deferredPostHog breadcrumb fires so growth can alert on persistently- failing attribution attempts (e.g. Supabase outage during a marketing spike). - Rejects unauthenticated calls (
Middleware treatment
The three anonymous write routes (/api/quiz/start, /answer,
/complete) are listed in isPublicRoute in middleware.ts so
Clerk skips them entirely. A Clerk outage does not silently break
quiz telemetry, the ad spend keeps flowing regardless of our
identity provider’s uptime. /api/quiz/attribute-signup is
deliberately kept off that list because it needs auth() to bind
the row to a real clerk_id.
Every quiz LANDING page already lives under app/(public)/ and the
middleware’s signed-out-user branch admits public pages via
NextResponse.next(). No isProtectedAppRoute change is required
and no noindex is applied. These are SEO-intended pages.
PII posture
- Raw IPs are never persisted. Only
sha256(salt || ip)lands in the DB, usingQUIZ_IP_HASH_SALTfrom Vercel env. The salt is required outside of development, test, and Vercel preview.lib/quiz-tracking/server.tsthrows on module load when the env var is absent and the runtime cannot be proven to be one of those three. A misconfigured deploy 500s the first/api/quiz/*request rather than silently persisting precomputable hashes. - Raw quiz answers ARE persisted, but they are quiz option ids or
bounded numeric inputs. No free text and no personally
identifying information. The retirement-readiness numeric fields
(age, savings, income) DO carry personal financial context. The
growth team should treat
quiz_answersfor that quiz as sensitive and query aggregates only. RLS deny-all plus service-role-only writes keep this out of the browser and any authenticated PostgREST client. session_idis not linkable to the visitor without joining onclerk_id(which is only present after signup and is scoped to our own Clerk namespace).
Testing
lib/quiz-tracking/__tests__/attribution.test.ts. Attribution parser, storage rules, and sanitizer whitelist.lib/quiz-tracking/__tests__/session.test.ts. Session-id lifetime, pending-signup handoff, signup href builder, and the “rotate on completed” invariant added bymarkQuizSessionCompleted.- Both suites run in the existing
vitest.financial-literacy.config.tswhich usesjsdomand the shared setup file. Existing quiz tests (lib/financial-literacy,lib/lead-quizzes) continue to pass. - End-to-end write path was manually validated against staging
Supabase before commit (INSERT, UPSERT answer, UPDATE complete,
UPDATE clerk_id, DELETE cascade). The composite-FK migration
was verified against staging by asserting that a mismatched
quiz_idinsert raisesforeign_key_violation.