> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Quiz tracking

# 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`

| Column | Purpose |
| - | - |
| `id` | Row PK |
| `session_id` (UNIQUE) | Client-generated UUID stored in `sessionStorage['silvia_quiz_session:<quiz_id>']`. Tab-scoped so two tabs of the same quiz get separate attempts (avoids racing upserts on `(session_id, question_id)`), but survives a same-tab refresh. Rotated after `/api/quiz/complete` acks (marker lives in `localStorage['silvia_quiz_session_completed:<quiz_id>']`) so a returning visitor's next attempt in the same tab lands in a new row instead of overwriting the previous score |
| `quiz_id` | Slug from `lib/lead-quizzes/directory` plus `financial-literacy` |
| `quiz_version` | Optional. The financial-literacy quiz stamps `FINANCIAL_LITERACY_QUIZ_VERSION` here |
| `started_at`, `completed_at` | Lifecycle. `completed_at` is null until the last question |
| `score`, `outcome_id`, `result_data` | Final score / band plus full structured breakdown (category scores, insights, metrics, answer reviews) for offline analysis |
| `landing_url` | Current landing URL for the visit, origin + pathname only. Refreshed by `readOrCaptureAttribution` on every landing so a same-tab internal click to a different quiz updates the path while the earlier UTM / click-ID snapshot is preserved. Query strings are intentionally stripped so ad-URL email addresses, invite codes, or magic-link tokens do not land in the DB dump surface — campaign data survives as explicit UTM / click-id fields |
| `referrer` | Cross-origin referrer, origin only. Full paths and query strings are stripped for the same PII reason as `landing_url` |
| `utm_source/medium/campaign/term/content` | Standard UTMs |
| `gclid`, `fbclid`, `ttclid`, `msclkid` | Ad-click identifiers |
| `ip_hash` | `sha256(salt \|\| ip)`, never a raw IP |
| `user_agent` | Trimmed UA (512 char cap) |
| `country`, `region`, `city` | From Vercel's raw `x-vercel-ip-*` headers (falls back to the middleware-stamped `x-user-*` when the request bypasses the raw source). Reading raw headers directly is intentional so the `isPublicRoute` short-circuit in `middleware.ts` does not silently drop geolocation on the three anonymous write routes |
| `clerk_id`, `attributed_at` | Set once by `/api/quiz/attribute-signup` after Clerk auth resolves |
| `created_at`, `updated_at` | Row bookkeeping (`updated_at` maintained by trigger) |

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`

| Column | Purpose |
| - | - |
| `id` | Row PK |
| `(session_id, quiz_id)` | Composite FK to `quiz_sessions(session_id, quiz_id)` with `ON DELETE CASCADE`. Enforces parent-quiz identity at the DB layer so a client cannot attach an answer labeled for quiz A to a session created for quiz B. Added by migration `20260829_01_quiz_answers_composite_fk.sql`, replacing the earlier single-column FK on `session_id` alone |
| `question_index`, `question_id` | Question ordering plus stable id |
| `answer_value` | Chosen option id, or numeric string for retirement inputs |
| `is_correct` | Only set for knowledge questions with a `correctOptionId` |
| `answered_at` | Last-write timestamp |

`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`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.