Skip to main content

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 by outage_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 in app/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).
  1. User opens a quiz page. No server call yet. The quiz hook generates or reads a tab-scoped session_id from sessionStorage['silvia_quiz_session:<quiz_id>'], and checks a session-scoped silvia_quiz_session_completed:<quiz_id> marker in localStorage. The marker stores the specific completed session_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 into sessionStorage['silvia_quiz_attribution'] via readOrCaptureAttribution. 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.
  2. User picks their first answer. POST /api/quiz/start fires once per session. The server upserts the quiz_sessions row (attribution, IP hash, geo, user agent), with ignoreDuplicates=true on session_id so the same client hitting /start twice never overwrites the first-touch attribution.
  3. User clicks Next. POST /api/quiz/answer upserts a quiz_answers row keyed on (session_id, question_id). The API route SELECTs the parent session first and rejects with 409 quiz_id_mismatch if the client-supplied quiz_id disagrees. The composite FK is the DB-level backstop for the same check. Fire-and-forget with keepalive: true so a rapid CTA click after “Get My Result” doesn’t abort the last answer’s beacon. The client-side helper sendQuizAnswer chains onto (a) any in-flight sendQuizStart and (b) the previous in-flight sendQuizAnswer for 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 underlying post helper 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 404 unknown_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.
  4. User completes the quiz. POST /api/quiz/complete updates the quiz_sessions row with score, outcome_id, result_data, and completed_at. Matches on (quiz_id, session_id) AND completed_at IS NULL so 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_id mismatch 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, sendQuizComplete awaits the in-flight sendQuizStart AND 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 in sendQuizAnswer, awaiting the latest is awaiting the whole chain). This closes the “answer + complete fired in the same tick, complete arrives first, /complete returns 404 unknown_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 with keepalive: true for the same reason as /answer.
  5. 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 in sessionStorage (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.
  6. /sign-up page. A Suspense effect reads quiz_session and quiz_id from the URL (whitelisted) and stashes them in sessionStorage. On a plain /sign-up with no quiz params, or with a pair that fails validation, it clears any stale stash, mirroring the plan-intent behavior.
  7. /auth/callback. After Clerk resolves and isNewSignup is true, the callback reads the stashed pair and awaits POST /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_id mismatches.
    • If already attributed to the same user, returns success.
    • If already attributed to a different user, returns 409 (first-wins).
    • Otherwise updates clerk_id and attributed_at and returns success.
    The callback captures the quiz_signup_attributed PostHog event ONLY when the server acks the attribution AND the server did not report already_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 BEFORE router.push, so the retries don’t depend on the callback re-mounting after navigation (which it typically won’t: callbackStarted.current blocks 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 a quiz_signup_attribution_deferred PostHog breadcrumb fires so growth can alert on persistently- failing attribution attempts (e.g. Supabase outage during a marketing spike).

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, using QUIZ_IP_HASH_SALT from Vercel env. The salt is required outside of development, test, and Vercel preview. lib/quiz-tracking/server.ts throws 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_answers for 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_id is not linkable to the visitor without joining on clerk_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 by markQuizSessionCompleted.
  • Both suites run in the existing vitest.financial-literacy.config.ts which uses jsdom and 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_id insert raises foreign_key_violation.