movieloop-backend
The API for "You Know Who Else Was In That Movie?" — a trivia-puzzle game in the spirit of Six Degrees of Kevin Bacon. Players are given two movies and build a chain of actor→movie links that loops back to the start; every link is validated against TMDB, and the server scores the result.
NestJS 11 · Prisma 7 on PostgreSQL 17 · Redis 7 (cache, presence, BullMQ) · RabbitMQ 4 (notification events) · socket.io.
The frontend lives in a sibling repository, movieloop-frontend. The two are orchestrated by the compose files in the parent folder, which is deliberately not itself a git repository.
What the server owns
- Game sessions —
POST /gamescreates a session;POST /games/:id/validate-linkchecks each actor/movie link against TMDB as the player builds;POST /games/:id/hintserves up to three server-chosen hints;POST /games/:id/completescores the server-held chain. The client never supplies a chain, a popularity value, a hint count or a score — that is the whole point. - Scoring — base + chain-length + time bonus + obscurity bonus (from TMDB popularity), minus hint and duplicate penalties.
src/games/score.service.ts. - Daily challenges — three per day (easy / medium / hard), generated at 00:00 UTC by a BullMQ job scheduler so that only one replica does it.
- Multiplayer — synchronous Versus (two players, one puzzle, live progress over sockets), Game Night rooms (up to a table of players, host-started), and Async Versus (challenge a friend, they play the same pair later). All three report scores server-side.
- Social — friends, direct chat, in-app notifications with optional Web Push, presence.
- Leaderboards, achievements, streaks, profiles, an admin surface.
Global route prefix is /api/v1. Interactive docs are at /api/docs when NODE_ENV is not production.
Running it
With Docker (the normal way)
From the parent folder:
docker compose up -d # postgres, redis, rabbitmq, backend (hot reload), frontend (Vite)
docker compose logs -f backend
The dev image (docker/Dockerfile.dev) mounts the source and runs prisma migrate deploy then nest start --watch, so a fresh volume comes up migrated; the production image does the same through docker/entrypoint.sh. Datastore ports are bound to 127.0.0.1 only. The backend waits for all three datastores to report healthy before starting. The compose file sets TSC_WATCHFILE=DynamicPriorityPolling for the container: a Docker Desktop bind mount on Windows delivers no file events, so without it the watcher compiles once and never again.
Natively
You still need the datastores — docker compose up -d postgres redis rabbitmq is the easy way.
cp .env.example .env # then fill in TMDB_API_KEY at minimum
npm ci # postinstall runs `prisma generate`
npx prisma migrate dev
npm run start:dev # http://localhost:3000/api/v1
Configuration
All configuration is by environment variable. .env.example is the template; the container reads the same names.
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL |
yes | PostgreSQL connection string. |
REDIS_HOST, REDIS_PORT, REDIS_PASSWORD |
yes | One Redis instance serves the TMDB cache (cache:: keys), presence (presence:) and BullMQ (bull:). Eviction policy must be volatile-lru, not allkeys-lru — see the compose file for why. REDIS_HOST must be a DNS-resolvable name (a compose service name, a DNS name), an IP literal, or localhost — it is resolved through DNS directly, not the hosts file, so an extra_hosts-style alias will not work. |
RABBITMQ_URL |
yes | AMQP URL. The app boots and serves without the broker; notification events are buffered until it returns. |
JWT_SECRET |
yes | Signing key for access tokens. |
JWT_EXPIRATION, JWT_LONG_EXPIRATION |
no | Token lifetimes (1d / 30d by default; the long one applies to "remember me"). |
TMDB_API_KEY |
yes | TMDB v3 API key. |
TMDB_BASE_URL |
no | Defaults to https://api.themoviedb.org/3. |
PRISMA_FIELD_ENCRYPTION_KEY |
yes | 32-byte hex key. users.email is stored AES-256-GCM encrypted; lookups go through an HMAC email_hash column. Usernames are deliberately not encrypted — they are public and searched by substring. |
CORS_ORIGIN |
yes | Allowed browser origin(s) for HTTP and sockets. |
ADMIN_SECRET |
no | Enables the one-shot POST /admin/promote bootstrap. Minimum 32 characters, constant-time compared, throttled to 3/hour, and only works while no admin exists. Leave unset once you have one. |
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT |
no | Web Push. Without them push delivery is skipped; everything else still works. |
LOG_LEVEL |
no | pino level; debug by default, info when NODE_ENV=production. |
PORT, NODE_ENV |
no | 3000; production switches logs to JSON and disables Swagger. |
Never commit .env. gitleaks runs in CI on every push.
Development
npm run lint # eslint --fix; CI runs it with --max-warnings 0 and no --fix
npx tsc --noEmit
npm test # jest, ~1,400 unit tests, no database needed
npm run test:cov # enforces 90% lines/branches/functions/statements globally
npm run build # -> dist/
npm run start:prod # node dist/src/main
Coverage thresholds and the reasoning behind them live in jest.config.js. Every controller route is covered by an authorization contract test (src/common/auth-contract.spec.ts): a route must either declare a guard or be on the explicit public allowlist there, with its reason — adding a public route means editing that file on purpose.
Database
Schema is prisma/schema.prisma; migrations are in prisma/migrations and are applied with prisma migrate deploy at container start. Locally:
npx prisma migrate dev --name describe_the_change
npx prisma studio
One migration enables the pg_trgm extension for username search, so the migrating role needs CREATE EXTENSION (the compose superuser does).
Validation and the API contract
Request and response shapes are zod schemas wrapped with nestjs-zod (createZodDto). Every object schema is .strict(), so unknown fields are rejected, and validation failures return a structured errors[] ({ code, path, message } per field) — over HTTP as a 400, over sockets on the error event. Response schemas (@ZodResponse) strip anything the schema does not name, which is what keeps passwordHash and unrevealed puzzle titles off the wire.
Use createZodDto for new DTOs; class-validator is not installed.
Layout
src/
├── auth/ JWT login/register, guards (JwtAuthGuard, OptionalJwtAuthGuard, AdminGuard)
├── games/ sessions, link validation, hints, scoring
├── movies/ TMDB proxy + TmdbService (24h Redis cache, retry, circuit breaker)
├── daily-challenges/ generation (BullMQ scheduler), difficulty analysis, history
├── versus/ sync + async versus matches; gateway on /versus
├── game-night/ rooms; gateway on /game-night
├── chat/ direct messages; gateway on /chat; nightly cleanup job
├── friends/, user-relationship/
├── notifications/ in-app + push, presence, RabbitMQ consumers; gateway on /notifications
├── leaderboards/, achievements/, users/, admin/
├── health/ /health (readiness), /health/live, /health/ready — terminus
├── common/ filters, throttling, logging (pino + correlation ids), utils, shared DTOs
├── prisma/ PrismaService with the field-encryption extension
├── redis/, queue/, rabbitmq/
└── main.ts
Sockets
Four socket.io namespaces (/notifications, /chat, /versus, /game-night), each authenticating with the JWT in auth.token at connect. Message bodies are validated by the same zod pipe as HTTP. A handler that only broadcasts still re-checks membership from client.data, never from the payload.
Health
GET /health (and /health/ready) probes Postgres and Redis and reports RabbitMQ as degraded rather than down, so a broker outage does not take the API out of rotation. GET /health/live checks nothing but the process. Both compose files and the production reverse proxy use /health.
Releasing
Versions follow semver and every behavioural change updates CHANGELOG.md in the same commit. On a push to main, CI lints, typechecks, tests, scans (gitleaks, semgrep, Trivy fs + image), builds the image, refuses to proceed if v<version> already exists as a git tag or as a Harbor image tag, pushes to Harbor, signs with cosign, and pushes the git tag back. Bump package.json before merging or the run fails on purpose.
scripts/local-build-test.sh in the parent folder builds both production images and boots a full smoke stack against them before you push — it covers what the lint and scan jobs do not: boot, migrations, runtime.
Deployment notes
- Run one replica of the migration step; the entrypoint documents this. Scheduled work is safe across replicas (BullMQ), and so is everything else.
- The production image (
docker/Dockerfile) is a three-stage build: deps → build → runtime withnpm ci --omit=dev,apk upgrade, non-rootnode.typescriptstays in the runtime image because Prisma 7 needs it to loadprisma.config.ts. - After the WP-8 release, the five notification queues gained dead-letter arguments. RabbitMQ will not alter a live queue: on first deploy, delete the five
notifications.*queues once and they are recreated correctly. Details are in the changelog under 2.2.0. - Redis is a soft dependency. The process boots and serves without it: readiness reports
redis: down, the TMDB cache degrades to a miss, presence and queues reconnect on their own, and the job schedules register the moment Redis connects. Redis clients resolve the hostname off the libuv threadpool (src/redis/redis-connection.ts) so a Redis outage cannot slow anything else's DNS; the images also setUV_THREADPOOL_SIZE=16for the clients that cannot be configured that way.
License
Private. All rights reserved.