Plan 00 Overview - SekiroKenjii/Quizzivy GitHub Wiki

00 β€” Architecture Overview

Implementation plan for docs/quizzivy-spec-v0.3.md. This file records the decisions the spec left open, the ones I made against it, and the mechanics of local development. Phase task lists are in 10- … 15-; the schema is in 20-data-model.md.

Spec sections are referenced by number and never restated.

Current programme: Phase R, the redesign β€” overview, releases R1–R11 in 71-r1.md … 81-r11.md, design gaps in docs/design/gaps.md.

Previous milestone: Word exam import, with a separate teacher UX plan and the supplied production target specification. These are planning documents; implementation progress and remaining contract decisions are tracked in the milestone plan.


1. Verified platform facts

The spec commits to four PostgreSQL 18 constructs (Β§13.6). All four were checked against https://www.postgresql.org/docs/18/ before any DDL was designed. All four work, but three carry caveats Β§13 does not mention.

Construct Docs Verdict
uuidv7() functions-uuid Works as Β§13.2 assumes. Built-in, no extension. Signature is uuidv7([shift interval]).
Virtual generated columns ddl-generated-columns VIRTUAL is the PG18 default β€” "A generated column is by default of the virtual kind." final_score works, and sum() over it works. Caveat: they cannot be indexed, cannot carry UNIQUE or PRIMARY KEY, and cannot have extended statistics; they are excluded from logical replication ("only supported for stored generated columns"). They can take NOT NULL and can be referenced by a CHECK.
OLD / NEW in RETURNING dml-returning Works in INSERT/UPDATE/DELETE/MERGE; aliases renameable. Caveat: Β§13.4's "one statement instead of read-then-write" only holds inside a data-modifying CTE (Β§4.4 below). A bare UPDATE … RETURNING followed by an INSERT is still two round trips.
NOT NULL … NOT VALID sql-altertable, release-18 Real in PG18 β€” release note: "Allow ALTER TABLE to set the NOT VALID attribute of NOT NULL constraints." Caveat: only via the table-constraint form ADD CONSTRAINT c NOT NULL col NOT VALID. It is not in the CREATE TABLE grammar (which shows NOT NULL [ NO INHERIT ] only) and not available via SET NOT NULL.

All four were additionally verified against a live postgres:18.6 container during T-0.1, not only read. The exact error strings are recorded in server/internal/db/pg18_test.go (T-0.16). Two claims in the first draft of this plan were wrong and are corrected above: virtual generated columns do accept NOT NULL and do accept a CHECK; only indexes, UNIQUE, PRIMARY KEY and extended statistics are rejected.

Consequence for this project: NOT NULL NOT VALID is unreachable on a greenfield schema. Every table in 20-data-model.md declares NOT NULL inline at CREATE TABLE time, where it is free. The construct becomes relevant only when a nullable column is tightened against existing production rows β€” realistically Phase 5 or post-v1. T-0.16 pins the behaviour with a test so it is proven rather than remembered.

Where the Neon postgres-best-practices skill conflicts with the PG18 docs, the docs win (Β§13.1). The one live conflict: the skill teaches the pre-18 ADD CHECK NOT VALID β†’ VALIDATE β†’ SET NOT NULL dance. On PG18 use the native not-null form above.


2. Frontend vs backend sequencing

Decision: (a) β€” contract-first, both sides generated from one OpenAPI document.

The load-bearing risks in v1 are all server semantics: the authoritative deadline_at, server-side maxPlays (Β§11.4), the publish snapshot (Β§13.3), the shuffle seed, session takeover, and refresh rotation (Β§5.2). A frontend built against hand-written MSW mocks would encode a plausible fiction of each, and Phase 3 would be a rewrite rather than an integration. Writing the contract first costs one Phase 0 task and makes the mocks derived rather than invented.

With one developer, "in parallel" in practice means vertical slices: a backend endpoint and the screen that consumes it as a PR pair within the same phase. The generated contract is what makes that safe.


3. How Β§15 stays in sync

api/openapi.yaml β€” hand-authored OpenAPI 3.1 β€” is the single source of truth. It supersedes Β§15, which becomes documentation. Everything else is generated.

Target Tool Output
Go server oapi-codegen (strict server mode) server/gen/openapi/ β€” interfaces, request/response types, param binding
TS types openapi-typescript web/src/lib/api/schema.d.ts β€” types only, zero runtime
MSW fixtures ajv at test time fixtures validated against the OpenAPI schema so mocks cannot drift

Zod stays hand-written. Β§2 makes zod the source of validation and TS types; in practice its real job is react-hook-form input validation, which is a narrower shape than the wire type (a password field has a min length the API schema does not express). So each feature keeps its schemas.ts, and each request schema carries a type-level assertion:

// web/src/features/auth/schemas.ts
type Expect<T extends true> = T;
type Equal<A, B> = (<G>() => G extends A ? 1 : 2) extends
  (<G>() => G extends B ? 1 : 2) ? true : false;

export const loginSchema = z.object({ email: z.string().email(), password: z.string().min(8) });

type _check = Expect<Equal<
  z.infer<typeof loginSchema>,
  paths['/auth/login']['post']['requestBody']['content']['application/json']
>>;

Drift fails tsc, not review. No third code generator.

CI enforcement: regenerate all outputs, then git diff --exit-code. A stale generated file is a red build. Generated files are committed so the repo is buildable without the toolchain.

New dependencies introduced here, with reasons for the PR description (AGENTS.md rule): openapi-typescript (dev β€” type generation), ajv (dev β€” fixture validation), oapi-codegen (Go tool β€” server generation).


4. Architecture decisions

4.1 Deployment topology and the refresh cookie

app.quizzivy.com serves the SPA; api.quizzivy.com serves the Go API. Both are subdomains of one registrable domain, which makes every request between them same-site even though it is cross-origin.

This matters because Β§5.2 specifies SameSite=Lax, and Lax cookies are never sent on cross-site fetch. On this topology they are sent, so Β§5.2 works verbatim. On genuinely cross-site origins (a Vercel frontend against a Fly.io backend, say) /auth/refresh would silently never authenticate. The topology is load-bearing; it is not a deployment detail to revisit casually.

  • Refresh cookie: httpOnly; Secure; SameSite=Lax; Path=/auth, and host-only β€” no Domain attribute, so app.quizzivy.com never receives it. The API is at the root of its host, so Path=/auth matches /auth/refresh exactly.
  • CORS: exact-origin allowlist (never *, which is illegal with credentials), Access-Control-Allow-Credentials: true, Vary: Origin, and preflight handling for PATCH/DELETE plus the Authorization and JSON content-type headers.

4.2 Single-flight refresh

Β§5.4 says the app calls GET /auth/me on load and Β§5.2 says a 401 triggers one refresh-and-retry. With TanStack Query mounting several queries at once, a cold load fires N parallel requests, all 401, all call POST /auth/refresh. Rotation plus Β§5.2's reuse detection then revokes the whole family and logs the user out β€” on every page refresh.

src/lib/api/client.ts therefore holds one shared in-flight refresh promise. Concurrent 401s await the same promise; only one rotation happens. This is unit-tested (T-0.12) because it is invisible until it isn't.

4.3 Beacon authentication

Β§10.6 flushes integrity events on pagehide via navigator.sendBeacon. sendBeacon cannot set request headers, so it cannot carry Authorization: Bearer. And the access token is ~15 minutes (Β§5.2) while a test runs 45–90, so at pagehide the token is usually expired anyway β€” exactly when the flush matters most.

So POST /app/assignments/:id/attempts returns an opaque beacon token, valid until deadline_at, stored as attempts.beacon_token_hash. The beacon body carries it:

navigator.sendBeacon(
  `${API}/app/attempts/${id}/events`,
  new Blob([JSON.stringify({ beaconToken, sessionId, events })], { type: 'text/plain' })
);

text/plain is a CORS-safelisted content type, so the beacon skips preflight β€” which is necessary, because a preflight on unload is not reliably delivered. The endpoint accepts only event appends; it grants no read access and cannot mutate answers, so the weaker credential buys no additional authority.

4.4 Audit via data-modifying CTE

Β§13.4 wants the audit diff captured in the same statement as the mutation. That requires the RETURNING to feed an INSERT, which means a CTE:

WITH updated AS (
  UPDATE app.attempts SET deadline_at = $2
   WHERE id = $1
  RETURNING id, old.deadline_at AS prev, new.deadline_at AS next
)
INSERT INTO app.audit_log (actor_user_id, action, entity, entity_id, ip, user_agent, diff)
SELECT $3, 'attempt.extend', 'attempt', id, $4, $5,
       jsonb_build_object('deadline_at', jsonb_build_object('old', prev, 'new', next))
  FROM updated;

Written as two statements it is a read-then-write with a race. T-4.2 uses this form and T-0.16 proves old/new survive inside a CTE.

4.5 Assignment status is derived, not stored

Β§7 types Assignment.status as 'scheduled' | 'open' | 'closed'. Storing it requires a scheduler to flip rows at opens_at and closes_at. Deriving it from opens_at, closes_at and a nullable closed_at (manual early close) needs nothing, cannot go stale, and produces the same frontend type. Derived.

4.6 Rate limiting

In-process: golang.org/x/time/rate limiters in a bounded LRU, keyed independently by client IP and by join-code hash, per Β§6.5's two limits. One API instance at this scale, so no Redis and no shared state. The limiter interface is narrow enough that swapping in a distributed backend later is one implementation, not a refactor. 429 responses carry Retry-After.

Note on Β§6.5's stated rationale: an 8-character code from a 32-symbol alphabet is 2^40 β‰ˆ 1.1 Γ— 10^12, and only a handful of codes are live at a time, so random probing is not a realistic attack even unthrottled. The rate limit is still correct β€” it bounds a cheap nuisance β€” but the threat that actually matters is a forwarded code. The mitigations that address that are expiry, max_uses, and the Β§6.4 member list. See 40-open-items.md on changing the max_uses default.

4.7 Other reversible calls

  • attempt_events.kind stays text, not an enum. Β§13.2 prefers enums for closed sets, and Β§10.1's list looks closed β€” but an append-only telemetry log reliably gains kinds, and each one would be a migration plus a deploy ordering problem. Lookup tables and enums both cost more than they return here.
  • Local object storage is MinIO, S3-compatible, so aws-sdk-go-v2/s3 targets it and R2 with only endpoint config differing. This decouples Phases 0–2 from the R2 credential blocker.
  • Migrations are sequential and zero-padded (00001_…), one concern each (Β§13.7). Sequential over timestamped because there is one developer and one branch line, so ordering conflicts cannot arise and the numbers stay readable.
  • REVOKE CREATE ON SCHEMA public FROM PUBLIC (Β§13.2) is kept in migration 00001 as documentation, annotated as a no-op β€” it has been the default since PG15.

5. Where this plan disagrees with the spec

Both of these were raised in the pre-plan critique and decided by Thuong. They are recorded here so the reasoning is not relitigated in a later session.

Version snapshot shape. I proposed keeping test_version_questions as a real table (it is the FK target for attempt_answers and holds ordinal/points/type) while collapsing test_version_options, test_version_blanks and test_version_blank_answers into one jsonb column, on the grounds that snapshot leaves are written once, always read whole, never joined, and never filtered β€” and that Β§13.3's justification for normalizing them ("per-question analytics later") is explicitly deferred by Β§1.3 and Β§16. Thuong chose full normalization, which preserves per-option SQL analytics as a plain query at the cost of four extra tables and a longer publish routine. 20-data-model.md implements the normalized form.

Audio duration probing. I proposed adding ffprobe to the backend image, since VBR MP3 without a Xing header and unusual MP4 atom layouts are exactly the cases hand-rolled parsers get wrong. Thuong chose pure-Go, avoiding a non-Go binary in the container at the cost of roughly a day of work and residual edge-case risk, tracked as R-08 in 30-risks.md and mitigated by the fixture corpus in T-2.2.

Google sign-in library (approved deviation from Β§2). Β§2 fixes Google Identity Services as the library and Β§5.3 requires Authorization Code + PKCE. Those turned out to be incompatible: Google's authorization server supports PKCE β€” its discovery document advertises code_challenge_methods_supported: ["plain","S256"] β€” but the GIS JavaScript wrapper google.accounts.oauth2.initCodeClient documents no way to pass a code_challenge. Thuong approved building the authorization request directly, which keeps Β§5.3's PKCE intact at the cost of not using the GIS SDK.

Concretely: generate a code_verifier, derive an S256 code_challenge, add a state value, and open Google's authorization_endpoint in a popup. The callback posts { code, codeVerifier, redirectUri } to POST /auth/google, exactly as api/openapi.yaml already specifies. No GIS script tag, and no dependency on undocumented wrapper behaviour. The "TiαΊΏp tα»₯c vα»›i Google" button is ours either way β€” Β§12 dictates charcoal, not Google's default styling.

Two further disagreements were accepted and are implemented as described: 30-risks.md R-05 (the clientSeq collision, Β§13.3 deviation D-01) and the Β§16 exit-criteria corrections in 11-phase-1.md and 12-phase-2.md.


6. Repository layout

Monorepo. Β§3's frontend structure is preserved verbatim under web/.

Quizzivy/
β”œβ”€ AGENTS.md                     standing rules for every session
β”œβ”€ docs/
β”‚  β”œβ”€ quizzivy-spec-v0.3.md      source of truth (moved into the repo)
β”‚  └─ plan/                      this plan
β”œβ”€ api/
β”‚  └─ openapi.yaml               THE API contract; Β§15 is documentation
β”œβ”€ web/                          quizzivy-web (Β§4)
β”‚  └─ src/                       exactly Β§3 β€” app/ features/ components/
β”‚                                layouts/ lib/ hooks/ stores/ styles/
β”œβ”€ server/                       Go module `quizzivy` (Β§4)
β”‚  β”œβ”€ cmd/api/main.go
β”‚  β”œβ”€ internal/
β”‚  β”‚  β”œβ”€ auth/  join/  classes/  media/  questions/  tests/
β”‚  β”‚  β”œβ”€ assignments/  attempts/  grading/  integrity/  audit/
β”‚  β”‚  β”œβ”€ storage/               S3/R2 client
β”‚  β”‚  β”œβ”€ media/probe/           pure-Go mp3 + mp4 duration (T-2.2)
β”‚  β”‚  β”œβ”€ httpx/                 error envelope, cursor codec, middleware
β”‚  β”‚  └─ ratelimit/
β”‚  β”œβ”€ gen/openapi/              generated, committed, never hand-edited
β”‚  └─ go.mod
β”œβ”€ migrations/                   goose SQL, forward-only, 00001…
β”œβ”€ seed/                         never in migrations (Β§13.7)
β”œβ”€ docker-compose.yml            postgres:18 + minio
└─ .github/workflows/ci.yml

web/src/features/<name>/ follows Β§3's convention unchanged: api.ts, schemas.ts, components/, pages/, optional store.ts, index.ts for public exports only. Features import each other only through index.ts; components/shared never imports from features/.

Backend packages mirror the feature folders deliberately, so a vertical slice is one frontend folder plus one backend package plus one section of api/openapi.yaml.


7. Error envelope

Β§15 implies one (403 ACCOUNT_NOT_PROVISIONED) but never defines it. Every non-2xx response body is:

{
  "error": {
    "code": "ACCOUNT_NOT_PROVISIONED",
    "message": "TΓ i khoαΊ£n chΖ°a được cαΊ₯p quyền.",
    "details": { "field": "joinCode" },
    "requestId": "01931f4e-..."
  }
}
  • code β€” SCREAMING_SNAKE, stable, the only thing clients branch on. Enumerated in api/openapi.yaml.
  • message β€” already localized server-side via Accept-Language, vi default. Clients display it; they do not construct copy from code, except for the two codes the contract's ErrorCode description names (#183).
  • details β€” optional, shape depends on code. Field-level validation errors land here for react-hook-form.
  • requestId β€” the copyable error ID Β§9's global error boundary shows.

429 additionally carries Retry-After (Β§6.5).

499 is the one status without an envelope. A request whose client cancelled it (the browser aborting a fetch on navigation, or resetting the connection while the answer is written) and that failed because of it is answered 499 with no body and logged at status:499 with no error line: it is not a server error, and docs/setup/operations.md ("Request logs") says what still is one. The contract does not list it, because nobody is there to read it.

8. Pagination

Offset, uniform across list endpoints: page + limit in, { items, page, pageSize, total } out (O-20, which overrides Β§13.8's keyset rule for the admin lists -- the teacher wants numbered pages, and at this scale a row shifting under a page turn is the cheaper problem). Because PKs are uuidv7() and therefore time-ordered, most lists sort by id DESC alone and need no separate created_at index; 20-data-model.md records which tables deviate.


9. Local development

docker compose up -d          # postgres:18 on 5432, minio on 9000/9001
make migrate                  # goose up, as quizzivy_migrate
make seed                     # seed/ β€” admin user, one class, sample questions
make gen                      # oapi-codegen + openapi-typescript
cd server && go run ./cmd/api # :8080
cd web && pnpm dev            # :5173, proxying /api to :8080

make dev runs the last two together.

Hosts. Local dev uses localhost:5173 and localhost:8080, which are same-site (both localhost), so cookie behaviour matches production. Do not introduce a 127.0.0.1 vs localhost split β€” that is cross-site and will produce a bug that does not reproduce in staging.

Environment. .env.example is committed and stays current; .env is ignored. VITE_GOOGLE_CLIENT_ID and VITE_API_BASE_URL are the only public values (Β§5.3). The Google client secret, R2 credentials, and the JWT signing key are backend-only.

Two database roles (Β§13.5): quizzivy_migrate owns the schema and runs goose; quizzivy_app has DML plus USAGE and is what the API connects as. Compose provisions both, so a missing grant fails locally rather than in production.

10. Git workflow

Gitflow, as instructed.

Branch Purpose
main Released only. Tagged v0.x. Never committed to directly.
develop Integration. Every feature PR targets this.
feature/<task-id>-<slug> One task from a phase file = one branch = one PR.
release/phase-<n> Cut when a phase's tasks are all merged; stabilization only.
hotfix/<slug> From main, merged to both main and develop.

One task is one reviewable PR (Β§18), so feature/t-1-04-google-oauth maps to T-1.4. Phase completion is a release branch merged to main and back to develop, which is what makes Β§16's "each phase ends deployable" a branch state rather than an assertion.

The PR template carries the Β§14 definition of done, plus the two conditional blocks (DDL review; public-endpoint rate limit and leak review) so they cannot be skipped silently.

One honest note: for a single developer deploying to one environment, develop plus main doubles merge overhead for no isolation benefit, and release/* branches are ceremony. It is being followed because it was specified, and it does buy a clean phase boundary. If it becomes friction, see 40-open-items.md.


Generated from docs/plan/00-overview.md. Edit that file in the repository; changes made on this page are overwritten.

⚠️ **GitHub.com Fallback** ⚠️