Expand the seeded pool from 12 to 36 questions (migration 0020) and select 10 per session preferring questions the couple has never seen, then least recently used, with random tiebreaks, so repeat quizzes stop serving the same set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5.3 KiB
Couples quiz
Maintained reference for the shareable "how well do you know each other?" quiz: a couple works through the same set of questions, each partner answers about themselves and guesses about the other, and the pair only sees a score once both have finished.
Ownership
| Concern | Location |
|---|---|
| Schema and seed content | apps/api/drizzle/0019_quiz.sql, apps/api/drizzle/0020_quiz_question_pool.sql |
| HTTP contract | apps/api/src/domains/quiz/routes.ts |
| Session and scoring rules | apps/api/src/domains/quiz/repository.ts |
| Pure scoring function | apps/api/src/domains/quiz/service.ts (computeQuizScore) |
Session lifecycle
POST /v1/quiz/sessions ──► active session (10 questions, question order fixed at creation)
│
├── each partner submits a full answer set (self + guess per question)
│
└── once BOTH partners have a complete set ──► session completes, score is computed
Only one active session exists per couple at a time (quiz_sessions_one_active_per_couple_idx,
a partial unique index on couple_id where status = 'active'). Starting a
quiz while one is already active returns that same session (200) instead of
creating a second one; two simultaneous starts race safely because the
partial unique index, not application locking, is the source of truth.
When a new session is created, the 10 questions are chosen from the active
pool with a couple-scoped preference order: questions this couple has never
had in any prior session (completed or active) come first, then the
least-recently-used ones, with a random tiebreak within each group. This
keeps repeat quizzes varied instead of always drawing the same
sort_order-first questions. The seeded pool (0019_quiz.sql plus
0020_quiz_question_pool.sql) has 36 active questions, so two consecutive
sessions for the same couple are guaranteed not to overlap.
Answers are submitted as one complete batch per call (quiz_answers, upsert
on (session_id, user_id, question_id)). A submission must cover every
question in the session exactly once, with every index inside that
question's option list, or it is rejected with 400 invalid_answers. A
session that has already completed rejects further submissions with
409 session_completed.
Scoring
Each question contributes up to two hits: partner A's guess about B counts if
it matches B's self-answer, and separately B's guess about A counts if it
matches A's self-answer. score_percent = round(100 * hits / (2 * question_count)). computeQuizScore in service.ts is the pure,
unit-tested implementation; the repository only assembles the per-question
pairs and persists the result.
Couple isolation
Every session and result lookup is scoped to the caller's own couple. A
session ID that belongs to another couple returns 404 session_not_found,
never a hint that it exists elsewhere. Starting a quiz requires both a couple
(409 no_couple) and a paired partner (409 partner_required).
HTTP contract
| Method | Path | Notes |
|---|---|---|
| POST | /v1/quiz/sessions |
201 on a new session, 200 when an active one already exists |
| GET | /v1/quiz/sessions/current |
{ session: SessionPayload | null }, most recent session for the couple |
| POST | /v1/quiz/sessions/:sessionId/answers |
{ answers: [{ question_id, self_index, guess_index }] } |
| GET | /v1/quiz/sessions/:sessionId/result |
409 session_not_completed before both partners finish |
SessionPayload never includes the partner's raw answers before completion;
per-question partner detail (partner_self_index, partner_guess_index,
partner_guessed_right) is only exposed by the /result endpoint of a
completed session.
Tests
apps/api/test/quiz.service.test.ts: unit tests forcomputeQuizScoreapps/api/test/quiz.routes.test.ts: HTTP contract and error mappingapps/api/test/quiz.integration.test.ts: real PostgreSQL, covering the full happy path with a hand-computed score, idempotent session start, couple isolation, incomplete answer set rejection, result blocked before completion, no question overlap between a completed session and the next one for the same couple, and that selection is scoped per couple
Mobile client
| Concern | Location |
|---|---|
| Feature screens, hooks, API client | apps/mobile/src/features/quiz/ |
| Zustand store (generation-counter pattern, cleared on sign-out) | apps/mobile/src/store/quizStore.ts |
| Discovery entry point | apps/mobile/src/components/discovery/QuizTile.tsx |
| Route wrapper | apps/mobile/app/(app)/quiz.tsx |
The screen walks not-paired, intro, answering (one question at a time, self
then guess), waiting-on-partner, and results states off useQuizScreen.
Answers are held client-side per question until every question in the
session has both a self and a guess index, then submitted as the one batch
the API expects; the store never calls the answers endpoint more than once
per session. Results render a shareable card (QuizResultsShareCard /
QuizShareModal, following the SharePreviewModal capture pattern) carrying
only the match score and Sauci branding, never question text or answers, so a
screenshot cannot leak either partner's content.
Not in this version
Question authoring tooling in the admin app is out of scope for this change.