Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DevQuiz

GraphQL API where developers test their knowledge on Python, SQL, Docker and more, with XP, levels, streaks, badges, leaderboards and invite-code challenges.

FastAPI + Strawberry GraphQL + SQLModel over async PostgreSQL (Neon). There are no REST endpoints except GET /health — everything goes through POST /graphql.

Layout

main.py                  # from app.main import app
migrate.py               # one-off schema sync
app/
├── main.py              # FastAPI app, lifespan, CORS, GraphQLRouter at /graphql
├── database.py          # engine, get_session, build_async_url, sync_schema
├── core/
│   ├── config.py        # pydantic-settings: DATABASE_URL, SECRET_KEY, CORS_ORIGINS
│   └── security.py      # bcrypt hashing, JWT create/decode, token → user
├── models/              # SQLModel tables
│   ├── user.py topic.py question.py choice.py quiz.py
│   ├── stats.py         # UserStats, Badge, UserBadge
│   ├── challenge.py     # Challenge, ChallengeResult
│   └── timestamps.py    # shared created_at / updated_at columns
├── services/
│   ├── stats_service.py     # XP, levels, streaks — advances UserStats
│   ├── badge_service.py     # badge catalogue, seeding, award rules
│   └── challenge_service.py # invite-code generation and lookup
└── graphql/
    ├── types.py         # Strawberry types and inputs
    ├── queries.py       # Query resolvers
    ├── mutations.py     # Mutation resolvers
    ├── context.py       # get_context, require_user, load_choices
    └── schema.py        # strawberry.Schema(query=Query, mutation=Mutation)

Setup

uv sync
cp .env.example .env      # then fill in DATABASE_URL and SECRET_KEY
uv run fastapi dev

GraphiQL is served at http://127.0.0.1:8000/graphql.

DATABASE_URL takes the plain Neon string (postgresql://...?sslmode=require); build_async_url swaps the scheme to postgresql+asyncpg and strips the libpq query params, since asyncpg does not understand them. TLS is requested through connect_args={"ssl": "require"} instead.

CORS — pointing the frontend at this API

The browser blocks a cross-origin call unless this API names the frontend's origin. Set it in .env, comma-separated:

CORS_ORIGINS=https://devquiz.vercel.app,https://www.devquiz.app,http://localhost:5173

An origin is scheme + host + port, with no trailing slash and no path — https://devquiz.vercel.app, not https://devquiz.vercel.app/.

Local dev needs no configuration: localhost:3000, localhost:5173, localhost:8080 and the 127.0.0.1 equivalents are allowed by default. Add your production domain once the frontend is deployed, and set the same variable in the FastAPI Cloud environment.

Two things worth knowing:

  • Preview deployments get a fresh subdomain per commit. List the ones you use, or set CORS_ALLOW_ALL=true in a throwaway environment. Do not ship that.
  • allow_credentials is on, and browsers reject a literal * in that mode, so CORS_ALLOW_ALL echoes the caller's Origin back instead of sending a star.

Schema sync

There is no Alembic setup yet. On startup app.database.sync_schema() runs SQLModel.metadata.create_all and then a short list of idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS statements.

That second step matters because create_all only creates missing tables — it never adds a column to a table that already exists.

To sync without restarting the app:

uv run python migrate.py

created_at and updated_at fill in automatically: the app sets them via default_factory, the DB backs them with server_default=now(), and updated_at carries onupdate=now() so any UPDATE refreshes it.

The badge catalogue is seeded on startup, also idempotently.

Auth

register / login return an AuthPayload with a JWT valid for 7 days:

Authorization: Bearer <access_token>

Queries

Field Auth Notes
topics / topic(id)
questions(topicId, level) full questions, answer key included
quiz(topicId!, level, limit = 10) N random questions, answers withheld
leaderboard(limit = 10) global, by XP
topicLeaderboard(topicId!, limit = 10) best percentage per user, then attempts
publicProfile(username!) stats + badges
challengeLeaderboard(code!) challenge info + ranked results
me JWT
myHistory JWT past attempts, newest first
myStats JWT XP, level, streak, totals
myBadges JWT newest first
getHint(questionId!) JWT falls back to "No hint available for this question"

Mutations

Field Auth Notes
register(email, username, password) password 8–72 bytes (bcrypt limit)
login(email, password)
createTopic(name, description) JWT names are unique
createQuestion(topicId, text, level, explanation, choices, hint) JWT ≥2 choices, exactly one correct
submitQuiz(topicId, answers) JWT grades, stores the attempt, updates XP/streak/badges
createChallenge(topicId, level, limit) JWT returns an 8-char invite code
joinChallenge(code) JWT returns the challenge's questions — see note below
submitChallenge(code, answers) JWT grades, stores a ChallengeResult, updates XP/streak/badges

joinChallenge returns questions, not a result

The original spec typed it QuizSubmitResultType but trailed off mid-sentence ("User answers quiz... wait, this needs answers") — a result cannot be produced before any answers exist. It is implemented as the draw step:

joinChallenge(code) -> [PublicQuestionType]   # answers hidden
submitChallenge(code, answers) -> QuizSubmitResultType

XP, levels, streaks

Per finished quiz: 10 XP per correct answer, +50 for a perfect score, and a streak bonus of +20 at 3 days or +50 at 7. The streak bonuses are tiers, not cumulative — a 7-day streak pays 50, not 70.

The streak is advanced before XP is computed, so a quiz is paid the bonus for the streak it just extended.

Levels: Beginner 0–199, Junior 200–499, Mid 500–999, Senior 1000–1999, Expert 2000+.

Streak: same day → unchanged, yesterday → +1, anything older or never → reset to 1.

Badges

Seeded at startup and awarded after every submitQuiz / submitChallenge: First Quiz, Perfect Score, 10 Quizzes, 50 Quizzes, Streak 3, Streak 7, Senior Dev, Expert Dev, Challenger, Challenge Win.

Thresholds use >= rather than ==, so a counter that jumps past the exact value still earns the badge; a user never receives the same badge twice.

Notes

  • The user table is named users; user is a reserved word in PostgreSQL.
  • questions is public and does expose isCorrect and hint. Only quiz and joinChallenge hide the answer key. Put questions behind auth if the client is untrusted.
  • Usernames are unique — publicProfile looks users up by username.
  • Grading validates the whole payload before writing, so an invalid answer never leaves a partial attempt behind. The attempt, the stats update and any new badges commit in a single transaction.
  • Challenge Win goes to whoever holds the top percentage at submit time; it is not revoked if someone later beats them.
  • No avatars. Uploads need object storage (S3/MinIO) — a container filesystem is wiped on every deploy — so the feature is left out until that integration exists.

Deploy

uv run fastapi deploy

Set DATABASE_URL, SECRET_KEY and CORS_ORIGINS in the FastAPI Cloud environment.