TehRiehlDeal 59632417f9
backend-ci / lint (push) Canceled after 0s
backend-ci / typecheck (push) Canceled after 0s
backend-ci / test (push) Canceled after 0s
backend-ci / secrets-scan (push) Canceled after 0s
backend-ci / sast (push) Canceled after 0s
backend-ci / fs-scan (push) Canceled after 0s
backend-ci / build (push) Canceled after 0s
backend-ci / push (push) Canceled after 0s
Added renovate ignore deps
2026-10-26 17:39:32 -07:00
2026-03-09 14:57:07 -07:00
2026-03-09 14:57:07 -07:00
2026-05-05 17:15:50 -07:00
2026-03-09 14:57:07 -07:00
2026-10-26 16:48:51 -07:00
2026-03-09 14:57:07 -07:00
2026-10-26 17:39:32 -07:00
2026-03-09 14:58:48 -07:00

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 /games creates a session; POST /games/:id/validate-link checks each actor/movie link against TMDB as the player builds; POST /games/:id/hint serves up to three server-chosen hints; POST /games/:id/complete scores 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 with npm ci --omit=dev, apk upgrade, non-root node. typescript stays in the runtime image because Prisma 7 needs it to load prisma.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 set UV_THREADPOOL_SIZE=16 for the clients that cannot be configured that way.

License

Private. All rights reserved.

S
Description
No description provided
Readme MIT
2.4 MiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 99.3%
JavaScript 0.4%
Dockerfile 0.3%