Plan 10 Phase 0 - SekiroKenjii/Quizzivy GitHub Wiki
Exit criteria (§16): pnpm dev / pnpm test / pnpm build green; /login
renders; migrations clean. Extended with: api/openapi.yaml exists and both
sides generate from it; the four PG18 constructs are proven by a passing test.
Tasks are ordered so the phase can stop at any boundary and still build. T-0.2 – T-0.4 are owned by Thuong and block later phases, not this one; they are listed first so they can be started in parallel with everything else.
Branch per task off develop as feature/t-0-NN-<slug>.
Depends on: none
Touches: repo root, AGENTS.md
Size: S
Done when:
-
npx skills add neondatabase/postgres-skillshas been run andskills/postgres-best-practices/SKILL.mdplusreferences/are readable -
AGENTS.mdrecords the install path and that schema-design, indexing and migration guidance is binding (§13.1) -
AGENTS.mdrecords the one live conflict with the PG18 docs: the skill teaches the pre-18ADD CHECK NOT VALID → VALIDATE → SET NOT NULLpattern; PG18's nativeADD CONSTRAINT … NOT NULL … NOT VALIDsupersedes it, and per §13.1 the PostgreSQL docs win -
skills/is gitignored if the tool vendors it; the pinned reference is the upstream repo, not a copy
Depends on: none
Touches: Google Cloud console, .env.example
Size: S
Done when:
- A Web application OAuth client exists with authorized JavaScript origins
http://localhost:5173andhttps://app.quizzivy.com - Authorized redirect URIs cover both origins (§5.3 uses Authorization Code + PKCE, so a redirect URI is required even though the code is exchanged server-side)
- Client ID is recorded in
.env.exampleasVITE_GOOGLE_CLIENT_ID(public, §5.3); the client secret is handed over out-of-band and never committed - The OAuth consent screen lists the
email,profileandopenidscopes only — these are Google's non-sensitive scopes, so publishing needs no verification review. Anything else triggers one - Publishing status is "In production", not "Testing". Testing status only admits users on a hand-maintained test-user list, so every new student would be blocked until someone remembers to add them
-
make verify-googlepasses — it probes Google's token endpoint with the real credentials and an invalid code, soinvalid_grantproves the client works without any browser or consent screen - Full instructions:
docs/setup/google-oauth.md
Blocks: all of Phase 1's Google path (T-1.6 onward). No workaround — GIS cannot be meaningfully mocked end-to-end without a real client ID.
Depends on: none
Touches: Cloudflare dashboard, .env.example
Size: S
Done when:
- Bucket
quizzivy-mediaexists, private: no public listing, no public read (§11.2) - An API token scoped to that bucket only, with object read/write, exists
- The r2.dev public development URL is disabled and no custom domain is bound — Cloudflare offers public access in one click, and enabling it would make every listening file world-readable by URL
- The API token is Object Read & Write scoped to
quizzivy-mediaonly, not an Admin token -
.env.examplelistsR2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEYandS3_BUCKETwith empty values; the endpoint is derived, not configured -
make verify-r2passes all six checks, including that an unsigned request is refused — the one that proves §11.2's private bucket - Full instructions:
docs/setup/r2.md
Blocks: Phase 2 deployment only. Local development is unblocked by the MinIO service in T-0.6, which speaks the same S3 API.
Depends on: none Touches: developer machine Size: S Done when:
-
psql --versionreports 18.x (postgresql-client-18) -
goose --versionsucceeds -
docker pull postgres:18succeeds -
git flow versionsucceeds, or a decision is recorded to drive gitflow with plaingitbranch conventions instead
Depends on: none
Touches: repo settings, .github/pull_request_template.md
Size: S
Done when:
-
developexists and is the repository's default branch -
mainanddevelopboth require a passing CI check and one approving review before merge -
.github/pull_request_template.mdcarries the §14 definition of done as checkboxes, plus two conditional blocks that must be deleted or ticked: "DDL reviewed against §13 and the Neon skill" and "new public endpoint: rate-limited and leak-reviewed (§6.5)" - The template has a "New dependencies and why" section (§14, §18)
-
CONTRIBUTING.mdrecords the branch naming:feature/t-<phase>-<n>-<slug>offdevelop,release/phase-<n>,hotfix/<slug>
Depends on: T-0.5
Touches: repo root, docker-compose.yml, Makefile, .env.example
Size: M
Done when:
- Directory layout matches
00-overview.md§6:api/,web/,server/,migrations/,seed/,docs/ -
docs/quizzivy-spec-v0.3.mdis committed soAGENTS.mdcan reference it by a relative path -
docker compose up -dstartspostgres:18on 5432 and MinIO on 9000/9001 - The postgres
initdbscript creates rolesquizzivy_migrateandquizzivy_appand thequizzivydatabase owned by the former (20-data-model.md§1) — roles cannot be created by a migration - The MinIO init step creates a private
quizzivy-mediabucket -
make dev,make migrate,make seed,make gen,make testexist and are documented inREADME.md -
.env.exampleis complete and.envis gitignored (AGENTS.md) -
make devuseslocalhostfor both web and API — never a127.0.0.1/localhostsplit, which is cross-site and would mask the cookie behaviour described in00-overview.md§4.1
Depends on: T-0.6
Touches: api/openapi.yaml
Size: L
Done when:
- Every path in §15 is present with request and response schemas, including the endpoints §15 omits but §8 requires: admin dashboard aggregates, cross-assignment attempt lists (awaiting grading, flagged), test version history, and class member list
- The error envelope from
00-overview.md§7 is a shared component, and every errorcodeis enumerated (starting withACCOUNT_NOT_PROVISIONED,JOIN_CODE_INVALID,JOIN_CODE_EXPIRED,JOIN_CODE_EXHAUSTED,ATTEMPT_CLOSED,SESSION_SUPERSEDED,RATE_LIMITED) - The three join-code failure modes are distinct codes but carry no class-identifying detail (§6.5, §9)
- List responses share one
{ items, nextCursor }envelope (§13.8) - Student and admin question schemas are separate components;
isCorrect,sampleAnswer,acceptedAnswersandtranscriptdo not appear anywhere in a student schema (§13.5) - Public endpoints are tagged
publicso T-0.14's middleware can assert every one of them is rate-limited -
spectral lint api/openapi.yamlpasses in CI, withoperation-descriptionandoperation-operationIdraised toerror— this file is read by people as much as by generators - Contract assertions run mechanically, not by review: no dangling
$ref; no/app/*2xx response reaches a schema containingisCorrect,sampleAnsweroracceptedAnswersat any depth, andtranscriptonly from the result endpoint;AdminQuestionstill has the grading key; every public operation carries a rate limit and a 429;operationIds are unique; every paginated response uses the one{items, nextCursor}envelope - The assertions are mutation-tested — deliberately leaking a key, or dropping a rate limit, must fail the check. A check that cannot fail is not a check
- These live in
api/contract_check.pyfor now, because the web project does not exist until T-0.9. T-0.13 ports them into the Vitest suite so they run with everything else; delete the Python once it does
Depends on: T-0.7
Touches: Makefile, server/go.mod, server/gen/openapi/,
web/src/lib/api/schema.d.ts, .gitattributes, CI
Size: M
Ordering correction. As planned, T-0.8 sat before T-0.14 (Go skeleton) — but codegen needs a module to generate into, so T-0.8 creates the minimal
server/go.mod(module quizzivy) and T-0.14 builds the server on top of it. The plan had this dependency backwards.
Done when:
-
make genrunsoapi-codegen(strict server mode) intoserver/gen/openapi/andopenapi-typescriptintoweb/src/lib/api/schema.d.ts - Generator versions are pinned —
oapi-codegenby thetooldirective inserver/go.mod, the npm tools by variables in the Makefile. A floating version makes the drift check meaningless: output would differ for reasons unrelated to the contract - Go boundary tests assert the generated types honour §13.5:
StudentQuestionandAttemptSessionexpose none of the four forbidden keys at any depth,AdminQuestionstill carries all of them, andResultQuestioncarriestranscriptbut not the other three - Those tests are mutation-tested — leaking a key into the contract and regenerating must fail them
- Both outputs are committed, so the repo builds without the generators
- CI runs
make genthengit diff --exit-codeand fails on drift -
server/gen/andschema.d.tscarry a "generated — do not edit" header and are excluded from lint autofix - PR description states the reason for
openapi-typescript,ajvandoapi-codegen(§14)
Depends on: T-0.6
Touches: web/
Size: M
Done when:
- Vite + React 19 + TypeScript strict builds;
pnpm buildgreen - Tailwind v4 configured with the §12 palette as CSS variables — zinc scale,
zinc-900primary, semantic green/red/amber only. Theming goes through tokens so dark mode is addable without touching components (§12) - shadcn/ui initialized on the neutral/zinc base; Button, Input, Dialog, Table, Card installed as a starting set
- ESLint (typescript-eslint, react-hooks, jsx-a11y) + Prettier; CI fails on
lint errors (§2). The Vite template now ships
oxlintinstead — it is removed. §2 names ESLint and specificallyjsx-a11y, and accessibility linting is load-bearing here (§14 keyboard-operable, §16 Lighthouse ≥ 95). Revisiting oxlint later is a §2 change needing approval, not a default to drift into - Plugins are registered explicitly rather than composed through
extends— the plugins disagree on how they export flat configs, and composing their presets produced an error naming the wrong plugin -
lucide-reactis the only icon package inpackage.json(§2) - Test:
web/tests/units/styles/tokens.test.tsguards §12's colour rules. Note the subtlety it has to get right: Tailwind's zinc sits at hue ~286, squarely in the blue band, so a naive hue check would reject the mandated palette. What separates zinc from indigo is chroma — 0.006 versus 0.23 — so the rule is "saturated and in the blue band" - It also asserts
@thememaps tokens tovar(--token)and never to a literal colour, which is what makes §12's "dark mode addable without touching components" actually true - Mutation-tested: swapping the primary for indigo, and inlining a
literal into
@theme, must each fail it - TypeScript strict passes, no
any; lint clean
Depends on: T-0.9
Touches: web/src/app/, web/src/layouts/
Size: M
Done when:
- React Router v7 in SPA mode with three trees —
/admin/*,/app/*, and public (/login,/join/*) — split withlazyso a student never downloads admin code and an anonymous visitor downloads neither (§2) -
AdminLayout,StudentLayout,FocusLayout,PublicLayoutexist per §8/§9, with the collapsible sidebar ≤1280px and 768px minimum width -
/login,/403,/404render;/loginis the §16 exit criterion - Global error boundary with reload and a copyable error ID (§9)
- Test:
web/tests/integration/router-chunks.test.tsruns a real Vite build in-memory and asserts against the actual chunk graph — not by reading the router and trustinglazy. It checks the admin tree, the student tree and the focus shell are all absent from the entry chunk, that the admin tree is still built somewhere (so a broken import path cannot pass vacuously), and that no chunk contains both the admin and student trees - Mutation-tested: turning one
lazyinto a static import must fail it - Note for whoever touches the router: React Router
lazysuppliesComponent/loader/action/ErrorBoundarybut notchildren— the route tree has to be statically matchable. A first version returnedchildrenfromlazy; it type-checked, rendered the layout, and 404'd on every child route - Loading / error / empty states present; keyboard-operable (§14)
Depends on: T-0.9
Ordering correction. Done before T-0.10. §14 requires every string to go through
t(), and T-0.10 writes the 403/404/nav/error-boundary copy — so the planned order meant writing every one of those strings twice.
Touches: web/src/lib/i18n/
Size: S
Done when:
- i18next + react-i18next configured,
videfault andensecondary (§2) - date-fns + date-fns-tz with
Asia/Ho_Chi_Minhas the render timezone; all stored values are UTC (§13.2) -
react/jsx-no-literalsfails on a string literal in JSX children. Configured withignoreProps, because checking every attribute would flagclassName/id/typeand be unusable - A companion test covers what that rule cannot: literal text in the
attributes that are actually user-facing —
aria-label,placeholder,title,alt. An Englisharia-labelis read aloud to a Vietnamese student, which is precisely what AGENTS.md forbids - Both are mutation-tested
-
formatDateTimeand friends are the only place a timezone is applied, so "store UTC, renderAsia/Ho_Chi_Minh" (§13.2) is a property of the code rather than a convention. Tested across the UTC+7 date boundary, so it holds wherever CI runs - Test:
web/tests/units/i18n/parity.test.tsfails if any key exists in one locale and not the other - All strings via
t(), keys in bothvianden(§14)
Depends on: T-0.8, T-0.11
Touches: web/src/lib/api/client.ts, web/src/stores/auth.ts
Size: M
Done when:
- Native
fetchwrapper typed againstschema.d.ts; no axios (§2) - Access token read from the Zustand store in memory only — never
localStorage, neversessionStorage(§5.2) -
credentials: 'include'on/auth/*so the host-only refresh cookie is sent (00-overview.md§4.1) - On 401: one refresh, then retry; a second 401 logs out (§5.2)
- Refresh is single-flight — concurrent 401s await one shared promise. Without this, parallel query mounts trigger parallel rotations and §5.2's reuse detection revokes the family on every cold load
- Error envelope decoded into a typed
ApiErrorcarryingcode,messageandrequestId - Test:
client.refresh.test.ts— "five concurrent 401s issue exactly one POST /auth/refresh and all five requests succeed". Asserted by counting calls: an implementation that refreshes per request passes every functional test and still logs everyone out in production - Test: retries carry the new token, not the stale one
- Test: a later, separate 401 is allowed to refresh again — single-flight must not become refresh-once-ever
- Test: the login endpoint's own 401 does not trigger a refresh (that would loop, and would trip reuse detection)
- Mutation-tested: making refresh per-request must fail it
-
client.typecheck.tsasserts the contract binds at the type level, using@ts-expect-error— which is self-verifying, since TypeScript fails the build if the directive stops being needed. It covers unknown paths, wrong methods, missing body fields, wrong path-param names, and §13.5: a student payload must not exposesampleAnswer,transcriptorisCorrect. That is the third layer, aftercontract_check.pyand the Go reflection tests - Test:
client.refresh.test.ts— "a second 401 after refresh clears the store and redirects to /login" - TypeScript strict passes, no
any(§14)
Depends on: T-0.9, T-0.8
Touches: web/vite.config.ts, web/tests/, web/playwright.config.ts
Size: M
Done when:
- Vitest + Testing Library run via
pnpm test - The T-0.7 contract assertions are ported from
api/contract_check.pyintoweb/tests/units/contract/openapi-contract.test.ts, mutation tests included, and the Python file is deleted - MSW server configured; handlers are typed against
schema.d.ts - Every MSW fixture is validated against
api/openapi.yamlwithajvat test setup, so a mock cannot drift from the contract (00-overview.md§3) - Playwright runs against a real production build via
vite preview, not the dev server — §16's exit criteria are about what ships, and dev-only behaviour would make the tests agree with something users never receive - Browser context is
vi-VN/Asia/Ho_Chi_Minh, so assertions on Vietnamese copy cannot pass or fail for the wrong reason - A smoke suite proves the harness works end to end, including that a cold load produces no console errors
- Test:
web/tests/units/contract/msw-contract.test.tsproves the validation layer can fail — missing required field, undeclared field, wrong type, out-of-enum value, and a response status the contract never declares. Without itcontractJsoncould silently degrade into plainHttpResponse.jsonwith every test still green - Test:
web/tests/integration/render.test.tsxexercises the whole harness at once — Testing Library renders, the component fetches through the real API client, MSW answers with a contract-validated fixture, i18next supplies Vietnamese copy -
pnpm testandpnpm buildgreen (§16 exit criterion)
Depends on: T-0.8, T-0.6
Touches: server/cmd/api/, server/internal/httpx/, server/internal/ratelimit/
Size: M
Done when:
-
go run ./cmd/apiserves on 8080. (server/go.modalready exists — T-0.8 created it because codegen needed a module to target.) - Generated strict handlers from T-0.8 are mounted; routes come from the contract, not from hand-written mux entries
- pgx pool connects as
quizzivy_app(never the owner, §13.5) - Structured logging with a request ID that matches the error envelope's
requestId(00-overview.md§7) - CORS middleware: exact-origin allowlist,
Allow-Credentials: true,Vary: Origin, preflight forPATCH/DELETE+Authorization. Never* - Rate-limit middleware with per-IP and per-key buckets, emitting
429withRetry-After(§6.5). Applied per route, so it keys on the OpenAPI path template rather than a concrete URL —/admin/tests/{id}shares one bucket instead of creating one per test - The limiter is capacity-bounded with LRU eviction. An unbounded map keyed by client IP is a memory-exhaustion vector on precisely the endpoints §6.5 protects
- A rejected request consumes nothing, so a burst blocked by the minute rule does not also drain the hour's budget
-
X-Forwarded-Foris honoured only behind a trusted proxy — the header is client-controlled, and trusting it blindly lets an attacker forge a fresh identity per request and defeat the limit entirely - A startup assertion fails the process if any route tagged
publicinapi/openapi.yamlhas no rate-limit rule registered — this is what makes §14's public-endpoint checkbox structural rather than procedural -
GET /healthzreturns 200 with database reachability - Test:
httpx/cors_test.go— a request from an unlisted origin gets noAccess-Control-Allow-Originheader - Test:
ratelimit/limiter_test.go— 11th request in a minute returns 429 withRetry-After - Test:
httpx/public_routes_test.go— everypublic-tagged operation has a registered limiter
Depends on: T-0.6
Touches: migrations/
Size: M
Migrations: 00001_create_schema_and_extensions.sql,
00002_create_enums.sql, 00003_create_updated_at_trigger.sql
Done when:
- Schema
app; extensionspg_trgmandunaccent; theapp.immutable_unaccent()wrapper; and the documentedREVOKEfrom20-data-model.md§2 - Test:
db/unaccent_test.go—app.immutable_unaccent(lower('Đường'))isduongandlower('tiếng Việt')folds totieng viet, covering theĐstroke that plain Unicode decomposition misses - All seven enum types including
app.integrity_action -
app.set_updated_at()trigger function - Every file has a correct
-- +goose Down -
make migrateruns clean asquizzivy_migrate(§16 exit criterion) - Test:
server/internal/db/migrate_test.go—up,down,upleaves a byte-identical schema snapshot (enum labels, function volatility, column types and nullability, indexes, constraints) - The intermediate state is asserted empty too. A
Downthat leaves objects behind still letsup → down → uppass, so checking only the ends would miss the most common migration mistake - Mutation-tested: removing one
DROP TYPEfrom aDownmust fail it - DDL reviewed against §13 and the Neon skill (§14)
Depends on: T-0.15
Touches: server/internal/db/pg18_test.go
Size: S
Done when:
- Test asserts
uuidv7()exists, needs no extension, and thatuuid_extract_timestampon two sequential values is monotonic - Test asserts
VIRTUALis the kind chosen when neither keyword is given (pg_attribute.attgenerated = 'v'), and that the column reflects an update to its base columns on the next read - Test pins the exact rejection set from
20-data-model.md§10, matching on the error message:CREATE INDEX,UNIQUEandPRIMARY KEYon a virtual column all fail,CREATE STATISTICSfails, whileSET NOT NULL, a referencingCHECK, andsum()all succeed. The first draft of this plan had two of these backwards; the test is what stops that recurring - Test asserts
old./new.inRETURNINGwork inside a data-modifying CTE whose output feeds anINSERT(00-overview.md§4.4), not just in a bare statement - Test asserts
ALTER TABLE … ADD CONSTRAINT c NOT NULL col NOT VALIDsucceeds on a table containing a NULL, thatVALIDATE CONSTRAINTthen fails, and that it succeeds once the NULL is fixed - Test asserts the same
NOT VALIDclause is rejected inCREATE TABLE, documenting why the migrations in20-data-model.md§13 declareNOT NULLinline instead - Test asserts
unaccent()isSTABLEin both its 1-arg and 2-arg forms — the folklore that the 2-arg form isIMMUTABLEis false on 18.6 — and that a bareunaccent(col)in an index expression is rejected whileapp.immutable_unaccent(col)is accepted - Each assertion carries a comment citing its PostgreSQL 18 docs section (§13.1)
- A guard that the server really is 18.x — every other assertion here is
meaningless against 16/17, where
uuidv7()and virtual generated columns do not exist at all - Two of these exist because the first draft of the plan was wrong:
pg_trgmwas assumed to fold accents (it does not), and virtual generated columns were assumed to rejectNOT NULLandCHECK(they accept both). Both mistakes are now un-repeatable
Depends on: T-0.13, T-0.14, T-0.15
Touches: .github/workflows/ci.yml
Size: M
Done when:
- Four parallel jobs: contract (spectral, invariants, codegen drift),
web (lint, typecheck, format, unit, integration, build), e2e
(Playwright against a production build), server (vet, gofmt, tests,
migration up/down/up against a
postgres:18service container) - The
postgres:18service is version-pinned, andpg18_test.goasserts the major version — every construct §13 relies on is absent before 18 - Roles are provisioned by
scripts/provision-db.sh, the same scriptdocker/initdbcalls locally, so the two cannot drift - CI fails on lint errors (§2)
- The workflow runs on PRs into
developandmain, matching the T-0.5 branch protections - A failing job blocks merge; no
continue-on-erroranywhere -
pnpm dev/pnpm test/pnpm buildall green — Phase 0 exit criterion met (§16)
Generated from docs/plan/10-phase-0.md. Edit that file in the repository; changes made on this page are overwritten.