Initial commit

This commit is contained in:
2026-03-09 14:57:07 -07:00
commit b0de3ebedc
89 changed files with 17185 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/movieloop?schema=public"
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=redis_secret
# RabbitMQ
RABBITMQ_URL=amqp://rabbit:rabbit_secret@localhost:5672
# JWT
JWT_SECRET=change-me-to-a-random-secret
JWT_EXPIRATION=1d
# TMDB
TMDB_API_KEY=your-tmdb-api-key-here
TMDB_BASE_URL=https://api.themoviedb.org/3
# Prisma Field Encryption
PRISMA_FIELD_ENCRYPTION_KEY=change-me-to-a-32-byte-hex-key
# CORS
CORS_ORIGIN=http://localhost:5173
# App
PORT=3000
NODE_ENV=development
+42
View File
@@ -0,0 +1,42 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# TypeScript
*.tsbuildinfo
# Prisma
prisma/*.db
prisma/*.db-journal
# Docker
docker-compose.override.yml
# Test
coverage/
/generated/prisma
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
+18
View File
@@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
FROM node:22-alpine AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/generated ./generated
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package*.json ./
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
EXPOSE 3000
CMD ["node", "dist/src/main"]
+8
View File
@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate
EXPOSE 3000
CMD ["npm", "run", "start:dev"]
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+12596
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
{
"name": "movie-loop-backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@golevelup/nestjs-rabbitmq": "^7.1.2",
"@keyv/redis": "^5.1.6",
"@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.16",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.16",
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
"bullmq": "^5.70.4",
"cache-manager": "^7.2.8",
"cache-manager-ioredis-yet": "^2.1.2",
"cacheable": "^2.3.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"helmet": "^8.1.0",
"ioredis": "^5.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"prisma": "^7.4.2",
"prisma-field-encryption": "^1.6.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,83 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "game_sessions" (
"id" TEXT NOT NULL,
"user_id" TEXT,
"movie_a_id" INTEGER NOT NULL,
"movie_a_title" TEXT NOT NULL,
"movie_b_id" INTEGER NOT NULL,
"movie_b_title" TEXT NOT NULL,
"chain" JSONB NOT NULL DEFAULT '[]',
"score" JSONB,
"hints_used" INTEGER NOT NULL DEFAULT 0,
"completed" BOOLEAN NOT NULL DEFAULT false,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
"daily_challenge_id" TEXT,
CONSTRAINT "game_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "daily_challenges" (
"id" TEXT NOT NULL,
"date" DATE NOT NULL,
"movie_a_id" INTEGER NOT NULL,
"movie_a_title" TEXT NOT NULL,
"movie_b_id" INTEGER NOT NULL,
"movie_b_title" TEXT NOT NULL,
"par" INTEGER NOT NULL,
"difficulty" TEXT NOT NULL DEFAULT 'medium',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "daily_challenges_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "leaderboard_entries" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"date" DATE NOT NULL,
"score" INTEGER NOT NULL,
"chain_length" INTEGER NOT NULL,
"time_seconds" INTEGER NOT NULL,
"hints_used" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "leaderboard_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE UNIQUE INDEX "daily_challenges_date_key" ON "daily_challenges"("date");
-- CreateIndex
CREATE INDEX "leaderboard_entries_date_score_idx" ON "leaderboard_entries"("date", "score" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "leaderboard_entries_user_id_date_key" ON "leaderboard_entries"("user_id", "date");
-- AddForeignKey
ALTER TABLE "game_sessions" ADD CONSTRAINT "game_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "game_sessions" ADD CONSTRAINT "game_sessions_daily_challenge_id_fkey" FOREIGN KEY ("daily_challenge_id") REFERENCES "daily_challenges"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "leaderboard_entries" ADD CONSTRAINT "leaderboard_entries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,73 @@
-- AlterTable
ALTER TABLE "game_sessions" ADD COLUMN "difficulty" TEXT NOT NULL DEFAULT 'medium';
-- AlterTable
ALTER TABLE "users" ADD COLUMN "avatar_url" TEXT,
ADD COLUMN "display_name" TEXT;
-- CreateTable
CREATE TABLE "achievements" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"icon" TEXT NOT NULL DEFAULT 'trophy',
"category" TEXT NOT NULL DEFAULT 'general',
"threshold" INTEGER NOT NULL DEFAULT 1,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "achievements_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_achievements" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"achievement_id" TEXT NOT NULL,
"progress" INTEGER NOT NULL DEFAULT 0,
"unlocked_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_achievements_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "versus_matches" (
"id" TEXT NOT NULL,
"player1_id" TEXT NOT NULL,
"player2_id" TEXT,
"movie_a_id" INTEGER NOT NULL,
"movie_a_title" TEXT NOT NULL,
"movie_b_id" INTEGER NOT NULL,
"movie_b_title" TEXT NOT NULL,
"difficulty" TEXT NOT NULL DEFAULT 'medium',
"status" TEXT NOT NULL DEFAULT 'waiting',
"winner_id" TEXT,
"player1_score" JSONB,
"player2_score" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finished_at" TIMESTAMP(3),
CONSTRAINT "versus_matches_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "achievements_key_key" ON "achievements"("key");
-- CreateIndex
CREATE UNIQUE INDEX "user_achievements_user_id_achievement_id_key" ON "user_achievements"("user_id", "achievement_id");
-- CreateIndex
CREATE INDEX "versus_matches_status_idx" ON "versus_matches"("status");
-- AddForeignKey
ALTER TABLE "user_achievements" ADD CONSTRAINT "user_achievements_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_achievements" ADD CONSTRAINT "user_achievements_achievement_id_fkey" FOREIGN KEY ("achievement_id") REFERENCES "achievements"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "versus_matches" ADD CONSTRAINT "versus_matches_player1_id_fkey" FOREIGN KEY ("player1_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "versus_matches" ADD CONSTRAINT "versus_matches_player2_id_fkey" FOREIGN KEY ("player2_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "versus_matches" ADD COLUMN "lobby_name" TEXT,
ADD COLUMN "password_hash" TEXT,
ADD COLUMN "started_at" TIMESTAMP(3);
@@ -0,0 +1,53 @@
-- AlterTable
ALTER TABLE "versus_matches" ADD COLUMN "chain_length" INTEGER,
ADD COLUMN "expires_at" TIMESTAMP(3),
ADD COLUMN "is_async" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "async_versus_attempts" (
"id" TEXT NOT NULL,
"match_id" TEXT NOT NULL,
"player_id" TEXT NOT NULL,
"score" JSONB,
"chain_length" INTEGER,
"completed" BOOLEAN NOT NULL DEFAULT false,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
CONSTRAINT "async_versus_attempts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"data" JSONB,
"read" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "async_versus_attempts_match_id_completed_idx" ON "async_versus_attempts"("match_id", "completed");
-- CreateIndex
CREATE UNIQUE INDEX "async_versus_attempts_match_id_player_id_key" ON "async_versus_attempts"("match_id", "player_id");
-- CreateIndex
CREATE INDEX "notifications_user_id_read_idx" ON "notifications"("user_id", "read");
-- CreateIndex
CREATE INDEX "notifications_user_id_created_at_idx" ON "notifications"("user_id", "created_at" DESC);
-- AddForeignKey
ALTER TABLE "async_versus_attempts" ADD CONSTRAINT "async_versus_attempts_match_id_fkey" FOREIGN KEY ("match_id") REFERENCES "versus_matches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "async_versus_attempts" ADD CONSTRAINT "async_versus_attempts_player_id_fkey" FOREIGN KEY ("player_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+183
View File
@@ -0,0 +1,183 @@
generator client {
provider = "prisma-client"
output = "../generated/prisma"
moduleFormat = "cjs"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
/// @encrypted
email String @unique
/// @encrypted
username String @unique
password String
displayName String? @map("display_name")
avatarUrl String? @map("avatar_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
gameSessions GameSession[]
leaderboardEntries LeaderboardEntry[]
achievements UserAchievement[]
versusMatchesAsP1 VersusMatch[] @relation("player1")
versusMatchesAsP2 VersusMatch[] @relation("player2")
asyncVersusAttempts AsyncVersusAttempt[]
notifications Notification[]
@@map("users")
}
model GameSession {
id String @id @default(cuid())
userId String? @map("user_id")
movieAId Int @map("movie_a_id")
movieATitle String @map("movie_a_title")
movieBId Int @map("movie_b_id")
movieBTitle String @map("movie_b_title")
chain Json @default("[]")
score Json?
hintsUsed Int @default(0) @map("hints_used")
completed Boolean @default(false)
difficulty String @default("medium")
startedAt DateTime @default(now()) @map("started_at")
completedAt DateTime? @map("completed_at")
user User? @relation(fields: [userId], references: [id])
dailyChallengeId String? @map("daily_challenge_id")
dailyChallenge DailyChallenge? @relation(fields: [dailyChallengeId], references: [id])
@@map("game_sessions")
}
model DailyChallenge {
id String @id @default(cuid())
date DateTime @unique @db.Date
movieAId Int @map("movie_a_id")
movieATitle String @map("movie_a_title")
movieBId Int @map("movie_b_id")
movieBTitle String @map("movie_b_title")
par Int
difficulty String @default("medium")
createdAt DateTime @default(now()) @map("created_at")
gameSessions GameSession[]
@@map("daily_challenges")
}
model LeaderboardEntry {
id String @id @default(cuid())
userId String @map("user_id")
date DateTime @db.Date
score Int
chainLength Int @map("chain_length")
timeSeconds Int @map("time_seconds")
hintsUsed Int @default(0) @map("hints_used")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@unique([userId, date])
@@index([date, score(sort: Desc)])
@@map("leaderboard_entries")
}
model Achievement {
id String @id @default(cuid())
key String @unique
name String
description String
icon String @default("trophy")
category String @default("general")
threshold Int @default(1)
createdAt DateTime @default(now()) @map("created_at")
userAchievements UserAchievement[]
@@map("achievements")
}
model UserAchievement {
id String @id @default(cuid())
userId String @map("user_id")
achievementId String @map("achievement_id")
progress Int @default(0)
unlockedAt DateTime? @map("unlocked_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
achievement Achievement @relation(fields: [achievementId], references: [id])
@@unique([userId, achievementId])
@@map("user_achievements")
}
model VersusMatch {
id String @id @default(cuid())
player1Id String @map("player1_id")
player2Id String? @map("player2_id")
movieAId Int @map("movie_a_id")
movieATitle String @map("movie_a_title")
movieBId Int @map("movie_b_id")
movieBTitle String @map("movie_b_title")
difficulty String @default("medium")
status String @default("waiting")
lobbyName String? @map("lobby_name")
passwordHash String? @map("password_hash")
winnerId String? @map("winner_id")
player1Score Json? @map("player1_score")
player2Score Json? @map("player2_score")
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime? @map("started_at")
finishedAt DateTime? @map("finished_at")
isAsync Boolean @default(false) @map("is_async")
chainLength Int? @map("chain_length")
expiresAt DateTime? @map("expires_at")
player1 User @relation("player1", fields: [player1Id], references: [id])
player2 User? @relation("player2", fields: [player2Id], references: [id])
asyncAttempts AsyncVersusAttempt[]
@@index([status])
@@map("versus_matches")
}
model AsyncVersusAttempt {
id String @id @default(cuid())
matchId String @map("match_id")
playerId String @map("player_id")
score Json?
chainLength Int? @map("chain_length")
completed Boolean @default(false)
startedAt DateTime @default(now()) @map("started_at")
completedAt DateTime? @map("completed_at")
match VersusMatch @relation(fields: [matchId], references: [id])
player User @relation(fields: [playerId], references: [id])
@@unique([matchId, playerId])
@@index([matchId, completed])
@@map("async_versus_attempts")
}
model Notification {
id String @id @default(cuid())
userId String @map("user_id")
type String
title String
message String
data Json?
read Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
@@index([userId, read])
@@index([userId, createdAt(sort: Desc)])
@@map("notifications")
}
@@ -0,0 +1,24 @@
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AchievementsService } from './achievements.service';
@ApiTags('achievements')
@Controller('achievements')
export class AchievementsController {
constructor(private readonly achievementsService: AchievementsService) {}
@Get()
@ApiOperation({ summary: 'Get all achievement definitions' })
getAll() {
return this.achievementsService.getAllAchievements();
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user achievements with progress' })
getUserAchievements(@Request() req: { user: { sub: string } }) {
return this.achievementsService.getUserAchievements(req.user.sub);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AchievementsService } from './achievements.service';
import { AchievementsController } from './achievements.controller';
@Module({
controllers: [AchievementsController],
providers: [AchievementsService],
exports: [AchievementsService],
})
export class AchievementsModule {}
+224
View File
@@ -0,0 +1,224 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ACHIEVEMENT_DEFINITIONS } from './data/achievement-definitions';
@Injectable()
export class AchievementsService implements OnModuleInit {
private readonly logger = new Logger(AchievementsService.name);
constructor(private readonly prisma: PrismaService) {}
async onModuleInit() {
await this.seedAchievements();
}
private async seedAchievements() {
for (const def of ACHIEVEMENT_DEFINITIONS) {
await this.prisma.achievement.upsert({
where: { key: def.key },
update: {
name: def.name,
description: def.description,
icon: def.icon,
category: def.category,
threshold: def.threshold,
},
create: def,
});
}
this.logger.log(
`Seeded ${ACHIEVEMENT_DEFINITIONS.length} achievement definitions`,
);
}
async getAllAchievements() {
return this.prisma.achievement.findMany({
orderBy: [{ category: 'asc' }, { threshold: 'asc' }],
});
}
async getUserAchievements(userId: string) {
const achievements = await this.prisma.achievement.findMany({
orderBy: [{ category: 'asc' }, { threshold: 'asc' }],
include: {
userAchievements: {
where: { userId },
},
},
});
return achievements.map((a) => {
const ua = a.userAchievements[0];
return {
id: a.id,
key: a.key,
name: a.name,
description: a.description,
icon: a.icon,
category: a.category,
threshold: a.threshold,
progress: ua?.progress ?? 0,
unlocked: !!ua?.unlockedAt,
unlockedAt: ua?.unlockedAt ?? null,
};
});
}
async checkAndAward(
userId: string,
event: {
type: string;
scoreTotal?: number;
chainLength?: number;
elapsedSeconds?: number;
hintsUsed?: number;
actorPopularity?: number;
currentStreak?: number;
gamesPlayed?: number;
versusWins?: number;
},
) {
const awarded: string[] = [];
const checks: { key: string; condition: boolean }[] = [
{
key: 'speed_demon',
condition:
event.type === 'game_complete' &&
(event.elapsedSeconds ?? Infinity) < 60,
},
{
key: 'short_circuit',
condition:
event.type === 'game_complete' && (event.chainLength ?? 99) <= 5,
},
{
key: 'marathon_chain',
condition:
event.type === 'game_complete' && (event.chainLength ?? 0) >= 15,
},
{
key: 'high_roller',
condition:
event.type === 'game_complete' && (event.scoreTotal ?? 0) > 2000,
},
{
key: 'perfectionist',
condition:
event.type === 'game_complete' && (event.hintsUsed ?? 1) === 0,
},
{
key: 'deep_cut',
condition:
event.type === 'game_complete' && (event.actorPopularity ?? 100) < 5,
},
{
key: 'first_loop',
condition: event.type === 'game_complete',
},
{
key: 'on_a_roll',
condition:
event.type === 'streak_update' && (event.currentStreak ?? 0) >= 3,
},
{
key: 'dedicated',
condition:
event.type === 'streak_update' && (event.currentStreak ?? 0) >= 7,
},
{
key: 'unstoppable',
condition:
event.type === 'streak_update' && (event.currentStreak ?? 0) >= 30,
},
{
key: 'first_victory',
condition: event.type === 'versus_win',
},
];
for (const check of checks) {
if (!check.condition) continue;
const achievement = await this.prisma.achievement.findUnique({
where: { key: check.key },
});
if (!achievement) continue;
const ua = await this.prisma.userAchievement.upsert({
where: {
userId_achievementId: {
userId,
achievementId: achievement.id,
},
},
update: {
progress: { increment: 1 },
},
create: {
userId,
achievementId: achievement.id,
progress: 1,
},
});
// Check if threshold met and not yet unlocked
if (ua.progress >= achievement.threshold && !ua.unlockedAt) {
await this.prisma.userAchievement.update({
where: { id: ua.id },
data: { unlockedAt: new Date() },
});
awarded.push(achievement.key);
this.logger.log(
`Achievement unlocked: ${achievement.name} for user ${userId}`,
);
}
}
// Counter-based achievements
const counterChecks = [
{ key: 'lightning_fast', type: 'speed_demon' },
{ key: 'no_hints_master', type: 'perfectionist' },
{ key: 'indie_explorer', type: 'deep_cut' },
{ key: 'getting_started', type: 'first_loop' },
{ key: 'veteran', type: 'first_loop' },
{ key: 'movie_buff', type: 'first_loop' },
{ key: 'champion', type: 'first_victory' },
];
for (const cc of counterChecks) {
const parentCheck = checks.find((c) => c.key === cc.type);
if (!parentCheck?.condition) continue;
const achievement = await this.prisma.achievement.findUnique({
where: { key: cc.key },
});
if (!achievement) continue;
const ua = await this.prisma.userAchievement.upsert({
where: {
userId_achievementId: {
userId,
achievementId: achievement.id,
},
},
update: { progress: { increment: 1 } },
create: {
userId,
achievementId: achievement.id,
progress: 1,
},
});
if (ua.progress >= achievement.threshold && !ua.unlockedAt) {
await this.prisma.userAchievement.update({
where: { id: ua.id },
data: { unlockedAt: new Date() },
});
awarded.push(achievement.key);
}
}
return awarded;
}
}
@@ -0,0 +1,168 @@
export interface AchievementDefinition {
key: string;
name: string;
description: string;
icon: string;
category: string;
threshold: number;
}
export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
// Speed achievements
{
key: 'speed_demon',
name: 'Speed Demon',
description: 'Complete a loop in under 60 seconds',
icon: 'zap',
category: 'speed',
threshold: 1,
},
{
key: 'lightning_fast',
name: 'Lightning Fast',
description: 'Complete 5 loops in under 60 seconds',
icon: 'zap',
category: 'speed',
threshold: 5,
},
// Chain achievements
{
key: 'short_circuit',
name: 'Short Circuit',
description: 'Complete a loop with 5 or fewer links',
icon: 'link',
category: 'chain',
threshold: 1,
},
{
key: 'marathon_chain',
name: 'Marathon Chain',
description: 'Complete a loop with 15+ links',
icon: 'link',
category: 'chain',
threshold: 1,
},
// Obscurity achievements
{
key: 'deep_cut',
name: 'Deep Cut',
description: 'Use an actor with popularity under 5',
icon: 'eye-off',
category: 'obscurity',
threshold: 1,
},
{
key: 'indie_explorer',
name: 'Indie Explorer',
description: 'Use 10 actors with popularity under 10',
icon: 'compass',
category: 'obscurity',
threshold: 10,
},
// Streak achievements
{
key: 'on_a_roll',
name: 'On a Roll',
description: 'Play 3 days in a row',
icon: 'flame',
category: 'streak',
threshold: 3,
},
{
key: 'dedicated',
name: 'Dedicated',
description: 'Play 7 days in a row',
icon: 'flame',
category: 'streak',
threshold: 7,
},
{
key: 'unstoppable',
name: 'Unstoppable',
description: 'Play 30 days in a row',
icon: 'flame',
category: 'streak',
threshold: 30,
},
// Score achievements
{
key: 'high_roller',
name: 'High Roller',
description: 'Score over 2000 points in a single game',
icon: 'trophy',
category: 'score',
threshold: 1,
},
{
key: 'perfectionist',
name: 'Perfectionist',
description: 'Complete a game without using any hints',
icon: 'star',
category: 'score',
threshold: 1,
},
{
key: 'no_hints_master',
name: 'No Hints Master',
description: 'Complete 10 games without hints',
icon: 'star',
category: 'score',
threshold: 10,
},
// Games played achievements
{
key: 'first_loop',
name: 'First Loop',
description: 'Complete your first movie loop',
icon: 'circle',
category: 'general',
threshold: 1,
},
{
key: 'getting_started',
name: 'Getting Started',
description: 'Complete 10 games',
icon: 'play',
category: 'general',
threshold: 10,
},
{
key: 'veteran',
name: 'Veteran',
description: 'Complete 50 games',
icon: 'award',
category: 'general',
threshold: 50,
},
{
key: 'movie_buff',
name: 'Movie Buff',
description: 'Complete 100 games',
icon: 'film',
category: 'general',
threshold: 100,
},
// Versus achievements
{
key: 'first_victory',
name: 'First Victory',
description: 'Win your first versus match',
icon: 'swords',
category: 'versus',
threshold: 1,
},
{
key: 'champion',
name: 'Champion',
description: 'Win 10 versus matches',
icon: 'crown',
category: 'versus',
threshold: 10,
},
];
+55
View File
@@ -0,0 +1,55 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Body,
UseGuards,
Query,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AdminService } from './admin.service';
@ApiTags('admin')
@Controller('admin')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Post('daily-challenges/generate')
@ApiOperation({ summary: 'Generate a daily challenge for a specific date' })
generateChallenge(@Body() body: { date: string; difficulty?: string }) {
return this.adminService.generateChallenge(body.date, body.difficulty);
}
@Delete('daily-challenges/:id')
@ApiOperation({ summary: 'Delete a daily challenge' })
deleteChallenge(@Param('id') id: string) {
return this.adminService.deleteChallenge(id);
}
@Get('stats')
@ApiOperation({ summary: 'Get platform statistics' })
getStats() {
return this.adminService.getPlatformStats();
}
@Get('users')
@ApiOperation({ summary: 'List users' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
listUsers(@Query('page') page?: string, @Query('limit') limit?: string) {
return this.adminService.listUsers(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50,
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { DailyChallengesModule } from '../daily-challenges/daily-challenges.module';
import { AdminService } from './admin.service';
import { AdminController } from './admin.controller';
@Module({
imports: [DailyChallengesModule],
controllers: [AdminController],
providers: [AdminService],
})
export class AdminModule {}
+78
View File
@@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DailyChallengesService } from '../daily-challenges/daily-challenges.service';
@Injectable()
export class AdminService {
constructor(
private readonly prisma: PrismaService,
private readonly dailyChallengesService: DailyChallengesService,
) {}
async generateChallenge(dateStr: string, difficulty = 'medium') {
const date = new Date(dateStr + 'T00:00:00.000Z');
return this.dailyChallengesService.generateChallenge(date, difficulty);
}
async deleteChallenge(id: string) {
await this.prisma.dailyChallenge.delete({ where: { id } });
return { deleted: true };
}
async getPlatformStats() {
const [
totalUsers,
totalGames,
completedGames,
totalChallenges,
totalMatches,
] = await Promise.all([
this.prisma.user.count(),
this.prisma.gameSession.count(),
this.prisma.gameSession.count({ where: { completed: true } }),
this.prisma.dailyChallenge.count(),
this.prisma.versusMatch.count(),
]);
return {
totalUsers,
totalGames,
completedGames,
completionRate:
totalGames > 0 ? Math.round((completedGames / totalGames) * 100) : 0,
totalChallenges,
totalMatches,
};
}
async listUsers(page = 1, limit = 50) {
const [data, total] = await Promise.all([
this.prisma.user.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
username: true,
email: true,
createdAt: true,
_count: {
select: {
gameSessions: true,
leaderboardEntries: true,
},
},
},
}),
this.prisma.user.count(),
]);
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+24
View File
@@ -0,0 +1,24 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
@ApiTags('app')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Get('health')
@ApiOperation({ summary: 'Health check' })
getHealth() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
};
}
}
+59
View File
@@ -0,0 +1,59 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { BullModule } from '@nestjs/bullmq';
import { ScheduleModule } from '@nestjs/schedule';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { GamesModule } from './games/games.module';
import { MoviesModule } from './movies/movies.module';
import { DailyChallengesModule } from './daily-challenges/daily-challenges.module';
import { LeaderboardsModule } from './leaderboards/leaderboards.module';
import { AchievementsModule } from './achievements/achievements.module';
import { VersusModule } from './versus/versus.module';
import { AdminModule } from './admin/admin.module';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { QueueModule } from './queue/queue.module';
import { AppRabbitMQModule } from './rabbitmq/rabbitmq.module';
import { NotificationsModule } from './notifications/notifications.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot({
throttlers: [{ ttl: 60000, limit: 100 }],
}),
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get('REDIS_HOST', 'localhost'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get('REDIS_PASSWORD'),
},
}),
inject: [ConfigService],
}),
ScheduleModule.forRoot(),
PrismaModule,
RedisModule,
QueueModule,
AppRabbitMQModule,
NotificationsModule,
AuthModule,
UsersModule,
GamesModule,
MoviesModule,
DailyChallengesModule,
LeaderboardsModule,
AchievementsModule,
VersusModule,
AdminModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Controller, Post, Body, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
@UseGuards(LocalAuthGuard)
@ApiOperation({ summary: 'Login with email and password' })
@ApiBody({ type: LoginDto })
login(
@Request() req: { user: { id: string; email: string; username: string } },
) {
return this.authService.login(req.user);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRATION', '1d') as any,
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
+61
View File
@@ -0,0 +1,61 @@
import {
Injectable,
ConflictException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async validateUser(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
if (user && (await bcrypt.compare(password, user.password))) {
const { password: _, ...result } = user;
return result;
}
return null;
}
async login(user: { id: string; email: string; username: string }) {
const payload = { sub: user.id, email: user.email };
return {
access_token: this.jwtService.sign(payload),
user: { id: user.id, email: user.email, username: user.username },
};
}
async register(dto: RegisterDto) {
const existingEmail = await this.usersService.findByEmail(dto.email);
if (existingEmail) {
throw new ConflictException('Email already in use');
}
const existingUsername = await this.usersService.findByUsername(
dto.username,
);
if (existingUsername) {
throw new ConflictException('Username already taken');
}
const hashedPassword = await bcrypt.hash(dto.password, 10);
const user = await this.usersService.create(
dto.email,
dto.username,
hashedPassword,
);
const payload = { sub: user.id, email: user.email };
return {
access_token: this.jwtService.sign(payload),
user: { id: user.id, email: user.email, username: user.username },
};
}
}
+15
View File
@@ -0,0 +1,15 @@
import { IsEmail, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({
description: 'User email address',
example: 'player@example.com',
})
@IsEmail()
email: string;
@ApiProperty({ description: 'User password', example: 'securepass123' })
@IsString()
password: string;
}
+26
View File
@@ -0,0 +1,26 @@
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({
description: 'User email address',
example: 'player@example.com',
})
@IsEmail()
email: string;
@ApiProperty({ description: 'Display username', example: 'moviebuff42' })
@IsString()
@MinLength(3)
@MaxLength(30)
username: string;
@ApiProperty({
description: 'Password (min 8 characters)',
example: 'securepass123',
})
@IsString()
@MinLength(8)
@MaxLength(100)
password: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
+27
View File
@@ -0,0 +1,27 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '../../users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private readonly usersService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
});
}
async validate(payload: { sub: string; email: string }) {
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
return { sub: payload.sub, email: payload.email };
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({ usernameField: 'email' });
}
async validate(email: string, password: string) {
const user = await this.authService.validateUser(email, password);
if (!user) {
throw new UnauthorizedException('Invalid email or password');
}
return user;
}
}
@@ -0,0 +1,48 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.message
: 'Internal server error';
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} ${status}`,
exception instanceof Error ? exception.stack : undefined,
);
} else {
this.logger.warn(
`${request.method} ${request.url} ${status}: ${message}`,
);
}
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
@@ -0,0 +1,34 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { DailyChallengesService } from './daily-challenges.service';
@ApiTags('daily-challenges')
@Controller('daily-challenges')
export class DailyChallengesController {
constructor(
private readonly dailyChallengesService: DailyChallengesService,
) {}
@Get('today')
@ApiOperation({ summary: "Get today's daily challenge" })
getToday() {
return this.dailyChallengesService.getToday();
}
@Get()
@ApiOperation({ summary: 'Get daily challenge history' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
getHistory(@Query('page') page?: string, @Query('limit') limit?: string) {
return this.dailyChallengesService.getHistory(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Get(':date')
@ApiOperation({ summary: 'Get daily challenge by date (YYYY-MM-DD)' })
getByDate(@Param('date') date: string) {
return this.dailyChallengesService.getByDate(date);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { MoviesModule } from '../movies/movies.module';
import { DailyChallengesService } from './daily-challenges.service';
import { DailyChallengesController } from './daily-challenges.controller';
@Module({
imports: [MoviesModule],
controllers: [DailyChallengesController],
providers: [DailyChallengesService],
exports: [DailyChallengesService],
})
export class DailyChallengesModule {}
@@ -0,0 +1,120 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TmdbService } from '../movies/tmdb.service';
import { POPULAR_MOVIES } from './data/popular-movies';
@Injectable()
export class DailyChallengesService {
private readonly logger = new Logger(DailyChallengesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly tmdbService: TmdbService,
) {}
async getToday() {
const today = this.getUtcDate();
const existing = await this.prisma.dailyChallenge.findUnique({
where: { date: today },
});
if (existing) {
return existing;
}
return this.generateChallenge(today, 'medium');
}
async getByDate(dateStr: string) {
const date = new Date(dateStr + 'T00:00:00.000Z');
const challenge = await this.prisma.dailyChallenge.findUnique({
where: { date },
});
if (!challenge) {
throw new NotFoundException(`No challenge found for ${dateStr}`);
}
return challenge;
}
async getHistory(page = 1, limit = 20) {
const today = this.getUtcDate();
const [data, total] = await Promise.all([
this.prisma.dailyChallenge.findMany({
where: { date: { lte: today } },
orderBy: { date: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.dailyChallenge.count({
where: { date: { lte: today } },
}),
]);
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async generateChallenge(date: Date, difficulty: string) {
const pair = this.pickRandomPair();
const par = this.getDefaultPar(difficulty);
const [movieA, movieB] = await Promise.all([
this.tmdbService.getMovieDetails(pair.movieA.id),
this.tmdbService.getMovieDetails(pair.movieB.id),
]);
const challenge = await this.prisma.dailyChallenge.create({
data: {
date,
movieAId: pair.movieA.id,
movieATitle: movieA.title,
movieBId: pair.movieB.id,
movieBTitle: movieB.title,
par,
difficulty,
},
});
this.logger.log(
`Generated daily challenge for ${date.toISOString().split('T')[0]}: ${movieA.title}${movieB.title}`,
);
return challenge;
}
private pickRandomPair() {
const movies = [...POPULAR_MOVIES];
const idxA = Math.floor(Math.random() * movies.length);
const movieA = movies[idxA];
movies.splice(idxA, 1);
const idxB = Math.floor(Math.random() * movies.length);
const movieB = movies[idxB];
return { movieA, movieB };
}
private getDefaultPar(difficulty: string): number {
switch (difficulty) {
case 'easy':
return 4;
case 'hard':
return 8;
default:
return 6;
}
}
private getUtcDate(): Date {
const now = new Date();
return new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
}
}
@@ -0,0 +1,57 @@
export interface PopularMovie {
id: number;
title: string;
}
export const POPULAR_MOVIES: PopularMovie[] = [
{ id: 278, title: 'The Shawshank Redemption' },
{ id: 238, title: 'The Godfather' },
{ id: 155, title: 'The Dark Knight' },
{ id: 680, title: 'Pulp Fiction' },
{ id: 13, title: 'Forrest Gump' },
{ id: 27205, title: 'Inception' },
{ id: 603, title: 'The Matrix' },
{ id: 550, title: 'Fight Club' },
{ id: 389, title: '12 Angry Men' },
{ id: 424, title: "Schindler's List" },
{ id: 497, title: 'The Green Mile' },
{ id: 120, title: 'The Lord of the Rings: The Fellowship of the Ring' },
{ id: 122, title: 'The Lord of the Rings: The Two Towers' },
{ id: 121, title: 'The Lord of the Rings: The Return of the King' },
{ id: 11, title: 'Star Wars' },
{ id: 1891, title: 'The Empire Strikes Back' },
{ id: 1892, title: 'Return of the Jedi' },
{ id: 807, title: 'Se7en' },
{ id: 769, title: 'GoodFellas' },
{ id: 637, title: 'Life Is Beautiful' },
{ id: 539, title: 'Psycho' },
{ id: 578, title: 'Jaws' },
{ id: 329, title: 'Jurassic Park' },
{ id: 274, title: 'The Silence of the Lambs' },
{ id: 773, title: "Schindler's List" },
{ id: 857, title: 'Saving Private Ryan' },
{ id: 240, title: 'The Godfather Part II' },
{ id: 244786, title: 'Whiplash' },
{ id: 157336, title: 'Interstellar' },
{ id: 629, title: 'The Usual Suspects' },
{ id: 694, title: 'The Shining' },
{ id: 745, title: 'The Sixth Sense' },
{ id: 197, title: 'Braveheart' },
{ id: 346, title: 'Seven Samurai' },
{ id: 510, title: "One Flew Over the Cuckoo's Nest" },
{ id: 489, title: 'Good Will Hunting' },
{ id: 98, title: 'Gladiator' },
{ id: 862, title: 'Toy Story' },
{ id: 607, title: 'Men in Black' },
{ id: 73, title: 'American History X' },
{ id: 68718, title: 'Django Unchained' },
{ id: 16869, title: 'Inglourious Basterds' },
{ id: 575264, title: 'Mission: Impossible Dead Reckoning Part One' },
{ id: 299536, title: 'Avengers: Infinity War' },
{ id: 299534, title: 'Avengers: Endgame' },
{ id: 24428, title: 'The Avengers' },
{ id: 105, title: 'Back to the Future' },
{ id: 568, title: 'Apollo 13' },
{ id: 11324, title: 'Shutter Island' },
{ id: 1726, title: 'Iron Man' },
];
+49
View File
@@ -0,0 +1,49 @@
import {
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ChainLinkDto {
@ApiProperty({ enum: ['movie', 'actor'], description: 'Link type' })
@IsEnum(['movie', 'actor'])
type: 'movie' | 'actor';
@ApiProperty({ description: 'TMDB ID', example: 27205 })
@IsInt()
@Min(1)
id: number;
@ApiPropertyOptional({ description: 'Movie title' })
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({ description: 'Actor name' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ description: 'Poster path' })
@IsOptional()
@IsString()
posterPath?: string;
@ApiPropertyOptional({ description: 'Profile path' })
@IsOptional()
@IsString()
profilePath?: string;
@ApiPropertyOptional({ description: 'Release date' })
@IsOptional()
@IsString()
releaseDate?: string;
@ApiProperty({ description: 'TMDB popularity score', example: 45.2 })
@IsNumber()
popularity: number;
}
+17
View File
@@ -0,0 +1,17 @@
import { IsArray, IsInt, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import { ChainLinkDto } from './chain-link.dto';
export class CompleteGameDto {
@ApiProperty({ type: [ChainLinkDto], description: 'The completed chain' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => ChainLinkDto)
chain: ChainLinkDto[];
@ApiProperty({ description: 'Number of hints used', example: 1 })
@IsInt()
@Min(0)
hintsUsed: number;
}
+27
View File
@@ -0,0 +1,27 @@
import { IsInt, IsString, Min, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateGameDto {
@ApiProperty({ description: 'TMDB ID of movie A', example: 27205 })
@IsInt()
@Min(1)
movieAId: number;
@ApiProperty({ description: 'Title of movie A', example: 'Inception' })
@IsString()
@MinLength(1)
movieATitle: string;
@ApiProperty({ description: 'TMDB ID of movie B', example: 155 })
@IsInt()
@Min(1)
movieBId: number;
@ApiProperty({
description: 'Title of movie B',
example: 'The Dark Knight',
})
@IsString()
@MinLength(1)
movieBTitle: string;
}
+29
View File
@@ -0,0 +1,29 @@
import { Controller, Post, Get, Param, Body } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { GamesService } from './games.service';
import { CreateGameDto } from './dto/create-game.dto';
import { CompleteGameDto } from './dto/complete-game.dto';
@ApiTags('games')
@Controller('games')
export class GamesController {
constructor(private readonly gamesService: GamesService) {}
@Post()
@ApiOperation({ summary: 'Create a new game session' })
create(@Body() dto: CreateGameDto) {
return this.gamesService.create(dto);
}
@Get(':id')
@ApiOperation({ summary: 'Get a game session by ID' })
findOne(@Param('id') id: string) {
return this.gamesService.findOne(id);
}
@Post(':id/complete')
@ApiOperation({ summary: 'Complete a game session and calculate score' })
complete(@Param('id') id: string, @Body() dto: CompleteGameDto) {
return this.gamesService.complete(id, dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { GamesController } from './games.controller';
import { GamesService } from './games.service';
import { ScoreService } from './score.service';
@Module({
controllers: [GamesController],
providers: [GamesService, ScoreService],
})
export class GamesModule {}
+63
View File
@@ -0,0 +1,63 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ScoreService, ScoreBreakdown } from './score.service';
import { CreateGameDto } from './dto/create-game.dto';
import { CompleteGameDto } from './dto/complete-game.dto';
@Injectable()
export class GamesService {
constructor(
private readonly prisma: PrismaService,
private readonly scoreService: ScoreService,
) {}
async create(dto: CreateGameDto) {
const session = await this.prisma.gameSession.create({
data: {
movieAId: dto.movieAId,
movieATitle: dto.movieATitle,
movieBId: dto.movieBId,
movieBTitle: dto.movieBTitle,
},
});
return { id: session.id, startedAt: session.startedAt };
}
async findOne(id: string) {
const session = await this.prisma.gameSession.findUnique({
where: { id },
});
if (!session) {
throw new NotFoundException(`Game session ${id} not found`);
}
return session;
}
async complete(id: string, dto: CompleteGameDto): Promise<ScoreBreakdown> {
const session = await this.findOne(id);
const completedAt = new Date();
const score = this.scoreService.calculate(
dto.chain,
session.startedAt,
completedAt,
dto.hintsUsed,
);
await this.prisma.gameSession.update({
where: { id },
data: {
chain: dto.chain as any,
score: score as any,
hintsUsed: dto.hintsUsed,
completed: true,
completedAt,
},
});
return score;
}
}
+74
View File
@@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { ChainLinkDto } from './dto/chain-link.dto';
export interface ScoreBreakdown {
baseScore: number;
chainLength: number;
chainLengthBonus: number;
timeBonus: number;
obscurityBonus: number;
hintPenalty: number;
elapsedSeconds: number;
totalScore: number;
}
@Injectable()
export class ScoreService {
calculate(
chain: ChainLinkDto[],
startedAt: Date,
completedAt: Date,
hintsUsed: number,
): ScoreBreakdown {
const baseScore = 1000;
const chainLength = chain.length;
// Chain length bonus: shorter chains are rewarded (par = 8)
const chainLengthBonus = Math.max(0, (8 - chainLength) * 100);
// Time bonus
const elapsedSeconds = Math.floor(
(completedAt.getTime() - startedAt.getTime()) / 1000,
);
let timeBonus: number;
if (elapsedSeconds < 60) {
timeBonus = 500;
} else if (elapsedSeconds < 180) {
timeBonus = 300;
} else if (elapsedSeconds < 300) {
timeBonus = 100;
} else {
timeBonus = 0;
}
// Obscurity bonus (skip the first link which is the starting movie)
let obscurityBonus = 0;
for (let i = 1; i < chain.length; i++) {
const link = chain[i];
if (link.type === 'actor') {
if (link.popularity < 5) obscurityBonus += 250;
else if (link.popularity < 10) obscurityBonus += 150;
} else if (link.type === 'movie') {
if (link.popularity < 5) obscurityBonus += 250;
else if (link.popularity < 20) obscurityBonus += 100;
}
}
// Hint penalty
const hintPenalty = hintsUsed * 150;
const totalScore =
baseScore + chainLengthBonus + timeBonus + obscurityBonus - hintPenalty;
return {
baseScore,
chainLength,
chainLengthBonus,
timeBonus,
obscurityBonus,
hintPenalty,
elapsedSeconds,
totalScore: Math.max(0, totalScore),
};
}
}
@@ -0,0 +1,35 @@
import { IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export enum LeaderboardPeriod {
DAILY = 'daily',
WEEKLY = 'weekly',
ALL_TIME = 'all-time',
}
export class LeaderboardQueryDto {
@ApiPropertyOptional({
description: 'Leaderboard period',
enum: LeaderboardPeriod,
default: LeaderboardPeriod.DAILY,
})
@IsOptional()
@IsEnum(LeaderboardPeriod)
period?: LeaderboardPeriod = LeaderboardPeriod.DAILY;
@ApiPropertyOptional({ description: 'Page number', default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: 'Results per page', default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
+25
View File
@@ -0,0 +1,25 @@
import { IsInt, IsOptional, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class SubmitScoreDto {
@ApiProperty({ description: 'Total score', example: 1500 })
@IsInt()
@Min(0)
score: number;
@ApiProperty({ description: 'Number of links in the chain', example: 6 })
@IsInt()
@Min(1)
chainLength: number;
@ApiProperty({ description: 'Time in seconds to complete', example: 120 })
@IsInt()
@Min(0)
timeSeconds: number;
@ApiPropertyOptional({ description: 'Number of hints used', example: 1 })
@IsOptional()
@IsInt()
@Min(0)
hintsUsed?: number;
}
@@ -0,0 +1,57 @@
import {
Controller,
Get,
Post,
Body,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { LeaderboardsService } from './leaderboards.service';
import { SubmitScoreDto } from './dto/submit-score.dto';
import { LeaderboardQueryDto } from './dto/leaderboard-query.dto';
@ApiTags('leaderboards')
@Controller('leaderboards')
export class LeaderboardsController {
constructor(private readonly leaderboardsService: LeaderboardsService) {}
@Get()
@ApiOperation({ summary: 'Get leaderboard (daily, weekly, or all-time)' })
getLeaderboard(@Query() query: LeaderboardQueryDto) {
return this.leaderboardsService.getLeaderboard(
query.period!,
query.page!,
query.limit!,
);
}
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Submit a score to the leaderboard' })
submitScore(
@Request() req: { user: { sub: string } },
@Body() dto: SubmitScoreDto,
) {
return this.leaderboardsService.submitScore(req.user.sub, dto);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user stats and ranking' })
getUserStats(@Request() req: { user: { sub: string } }) {
return this.leaderboardsService.getUserStats(req.user.sub);
}
@Get('me/streak')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user streak info' })
getUserStreak(@Request() req: { user: { sub: string } }) {
return this.leaderboardsService.getUserStreak(req.user.sub);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LeaderboardsService } from './leaderboards.service';
import { LeaderboardsController } from './leaderboards.controller';
@Module({
controllers: [LeaderboardsController],
providers: [LeaderboardsService],
exports: [LeaderboardsService],
})
export class LeaderboardsModule {}
+211
View File
@@ -0,0 +1,211 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SubmitScoreDto } from './dto/submit-score.dto';
import { LeaderboardPeriod } from './dto/leaderboard-query.dto';
@Injectable()
export class LeaderboardsService {
constructor(private readonly prisma: PrismaService) {}
async submitScore(userId: string, dto: SubmitScoreDto) {
const today = this.getUtcDate();
const entry = await this.prisma.leaderboardEntry.upsert({
where: { userId_date: { userId, date: today } },
update: {
score: { set: dto.score },
chainLength: dto.chainLength,
timeSeconds: dto.timeSeconds,
hintsUsed: dto.hintsUsed ?? 0,
},
create: {
userId,
date: today,
score: dto.score,
chainLength: dto.chainLength,
timeSeconds: dto.timeSeconds,
hintsUsed: dto.hintsUsed ?? 0,
},
});
// Only keep the higher score
if (entry.score < dto.score) {
return this.prisma.leaderboardEntry.update({
where: { id: entry.id },
data: { score: dto.score },
});
}
return entry;
}
async getLeaderboard(period: LeaderboardPeriod, page: number, limit: number) {
const dateFilter = this.getDateFilter(period);
const [data, total] = await Promise.all([
this.prisma.leaderboardEntry.findMany({
where: dateFilter,
orderBy: { score: 'desc' },
skip: (page - 1) * limit,
take: limit,
include: {
user: { select: { username: true } },
},
}),
this.prisma.leaderboardEntry.count({ where: dateFilter }),
]);
return {
data: data.map((entry, idx) => ({
rank: (page - 1) * limit + idx + 1,
username: entry.user.username,
score: entry.score,
chainLength: entry.chainLength,
timeSeconds: entry.timeSeconds,
hintsUsed: entry.hintsUsed,
date: entry.date,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async getUserStats(userId: string) {
const entries = await this.prisma.leaderboardEntry.findMany({
where: { userId },
orderBy: { date: 'desc' },
});
if (entries.length === 0) {
return {
totalGamesPlayed: 0,
bestScore: 0,
averageScore: 0,
totalScore: 0,
currentStreak: 0,
longestStreak: 0,
recentScores: [],
};
}
const scores = entries.map((e) => e.score);
const currentStreak = this.calculateCurrentStreak(entries);
const longestStreak = this.calculateLongestStreak(entries);
return {
totalGamesPlayed: entries.length,
bestScore: Math.max(...scores),
averageScore: Math.round(
scores.reduce((a, b) => a + b, 0) / scores.length,
),
totalScore: scores.reduce((a, b) => a + b, 0),
currentStreak,
longestStreak,
recentScores: entries.slice(0, 10).map((e) => ({
score: e.score,
chainLength: e.chainLength,
timeSeconds: e.timeSeconds,
date: e.date,
})),
};
}
async getUserStreak(userId: string) {
const entries = await this.prisma.leaderboardEntry.findMany({
where: { userId },
orderBy: { date: 'desc' },
});
return {
currentStreak: this.calculateCurrentStreak(entries),
longestStreak: this.calculateLongestStreak(entries),
};
}
private calculateCurrentStreak(entries: { date: Date }[]): number {
if (entries.length === 0) return 0;
const today = this.getUtcDate();
const yesterday = new Date(today);
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const firstDate = entries[0].date;
const firstDateStr = firstDate.toISOString().split('T')[0];
const todayStr = today.toISOString().split('T')[0];
const yesterdayStr = yesterday.toISOString().split('T')[0];
if (firstDateStr !== todayStr && firstDateStr !== yesterdayStr) {
return 0;
}
let streak = 1;
for (let i = 1; i < entries.length; i++) {
const prevDate = new Date(entries[i - 1].date);
const currDate = new Date(entries[i].date);
const diffDays = Math.round(
(prevDate.getTime() - currDate.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays === 1) {
streak++;
} else {
break;
}
}
return streak;
}
private calculateLongestStreak(entries: { date: Date }[]): number {
if (entries.length === 0) return 0;
// Sort ascending for longest streak calculation
const sorted = [...entries].sort(
(a, b) => a.date.getTime() - b.date.getTime(),
);
let longest = 1;
let current = 1;
for (let i = 1; i < sorted.length; i++) {
const diffDays = Math.round(
(sorted[i].date.getTime() - sorted[i - 1].date.getTime()) /
(1000 * 60 * 60 * 24),
);
if (diffDays === 1) {
current++;
longest = Math.max(longest, current);
} else if (diffDays > 1) {
current = 1;
}
}
return longest;
}
private getDateFilter(period: LeaderboardPeriod) {
const today = this.getUtcDate();
switch (period) {
case LeaderboardPeriod.DAILY:
return { date: today };
case LeaderboardPeriod.WEEKLY: {
const weekStart = new Date(today);
const day = weekStart.getUTCDay();
const diff = day === 0 ? 6 : day - 1; // Monday start
weekStart.setUTCDate(weekStart.getUTCDate() - diff);
return { date: { gte: weekStart, lte: today } };
}
case LeaderboardPeriod.ALL_TIME:
return {};
}
}
private getUtcDate(): Date {
const now = new Date();
return new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Security
app.use(helmet());
app.enableCors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
});
// Global prefix
app.setGlobalPrefix('api/v1');
// Global exception filter
app.useGlobalFilters(new AllExceptionsFilter());
// Validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// Swagger
const config = new DocumentBuilder()
.setTitle('Movie Loop API')
.setDescription('API for "You Know Who Else Was In That Movie?" game')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
+25
View File
@@ -0,0 +1,25 @@
import {
IsString,
MinLength,
IsOptional,
IsInt,
Min,
Max,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class SearchQueryDto {
@ApiProperty({ description: 'Search query string', example: 'inception' })
@IsString()
@MinLength(1)
q: string;
@ApiPropertyOptional({ description: 'Page number (1-500)', example: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
page?: number;
}
+11
View File
@@ -0,0 +1,11 @@
import { IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class TmdbIdParam {
@ApiProperty({ description: 'TMDB ID', example: 27205 })
@Type(() => Number)
@IsInt()
@Min(1)
id: number;
}
+43
View File
@@ -0,0 +1,43 @@
import { Controller, Get, Param, Query, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { TmdbService } from './tmdb.service';
import { SearchQueryDto } from './dto/search-query.dto';
import { TmdbIdParam } from './dto/tmdb-id.param';
@ApiTags('movies')
@Controller('movies')
@UseInterceptors(CacheInterceptor)
export class MoviesController {
constructor(private readonly tmdbService: TmdbService) {}
@Get('discover')
@CacheTTL(3600000) // 1 hour
@ApiOperation({
summary: 'Discover popular movies for random pair generation',
})
discoverMovies(@Query('page') page?: number) {
return this.tmdbService.discoverMovies(page || 1);
}
@Get('search')
@CacheTTL(300000) // 5 minutes
@ApiOperation({ summary: 'Search movies by title' })
searchMovies(@Query() query: SearchQueryDto) {
return this.tmdbService.searchMovies(query.q, query.page);
}
@Get(':id')
@CacheTTL(86400000) // 24 hours
@ApiOperation({ summary: 'Get movie details by TMDB ID' })
getMovieDetails(@Param() params: TmdbIdParam) {
return this.tmdbService.getMovieDetails(params.id);
}
@Get(':id/credits')
@CacheTTL(86400000) // 24 hours
@ApiOperation({ summary: 'Get movie cast credits' })
getMovieCredits(@Param() params: TmdbIdParam) {
return this.tmdbService.getMovieCredits(params.id);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { TmdbService } from './tmdb.service';
import { MoviesController } from './movies.controller';
import { PersonsController } from './persons.controller';
@Module({
imports: [HttpModule.register({ timeout: 10000, maxRedirects: 3 })],
controllers: [MoviesController, PersonsController],
providers: [TmdbService],
exports: [TmdbService],
})
export class MoviesModule {}
+34
View File
@@ -0,0 +1,34 @@
import { Controller, Get, Param, Query, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { TmdbService } from './tmdb.service';
import { SearchQueryDto } from './dto/search-query.dto';
import { TmdbIdParam } from './dto/tmdb-id.param';
@ApiTags('persons')
@Controller('persons')
@UseInterceptors(CacheInterceptor)
export class PersonsController {
constructor(private readonly tmdbService: TmdbService) {}
@Get('search')
@CacheTTL(300000) // 5 minutes
@ApiOperation({ summary: 'Search persons by name' })
searchPersons(@Query() query: SearchQueryDto) {
return this.tmdbService.searchPersons(query.q, query.page);
}
@Get(':id')
@CacheTTL(86400000) // 24 hours
@ApiOperation({ summary: 'Get person details by TMDB ID' })
getPersonDetails(@Param() params: TmdbIdParam) {
return this.tmdbService.getPersonDetails(params.id);
}
@Get(':id/movie-credits')
@CacheTTL(86400000) // 24 hours
@ApiOperation({ summary: 'Get person movie credits' })
getPersonMovieCredits(@Param() params: TmdbIdParam) {
return this.tmdbService.getPersonMovieCredits(params.id);
}
}
+99
View File
@@ -0,0 +1,99 @@
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import {
TmdbSearchMovieResponse,
TmdbSearchPersonResponse,
TmdbMovieCreditsResponse,
TmdbPersonMovieCreditsResponse,
TmdbMovieDetailsResponse,
TmdbPersonDetailsResponse,
} from './types/tmdb.types';
@Injectable()
export class TmdbService {
private readonly logger = new Logger(TmdbService.name);
private readonly apiKey: string;
private readonly baseUrl: string;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
) {
this.apiKey = this.configService.getOrThrow<string>('TMDB_API_KEY');
this.baseUrl = this.configService.get<string>(
'TMDB_BASE_URL',
'https://api.themoviedb.org/3',
);
}
private async tmdbGet<T>(
path: string,
params: Record<string, string | number> = {},
): Promise<T> {
const url = `${this.baseUrl}${path}`;
try {
const response = await firstValueFrom(
this.httpService.get<T>(url, {
params: { ...params, api_key: this.apiKey },
}),
);
return response.data;
} catch (error: any) {
const status = error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR;
const message =
error.response?.data?.status_message || 'TMDB API request failed';
this.logger.error(`TMDB request failed: ${path}`, error.message);
throw new HttpException(message, status);
}
}
async searchMovies(
query: string,
page = 1,
): Promise<TmdbSearchMovieResponse> {
return this.tmdbGet<TmdbSearchMovieResponse>('/search/movie', {
query,
page,
});
}
async searchPersons(
query: string,
page = 1,
): Promise<TmdbSearchPersonResponse> {
return this.tmdbGet<TmdbSearchPersonResponse>('/search/person', {
query,
page,
});
}
async getMovieCredits(movieId: number): Promise<TmdbMovieCreditsResponse> {
return this.tmdbGet<TmdbMovieCreditsResponse>(`/movie/${movieId}/credits`);
}
async getPersonMovieCredits(
personId: number,
): Promise<TmdbPersonMovieCreditsResponse> {
return this.tmdbGet<TmdbPersonMovieCreditsResponse>(
`/person/${personId}/movie_credits`,
);
}
async getMovieDetails(movieId: number): Promise<TmdbMovieDetailsResponse> {
return this.tmdbGet<TmdbMovieDetailsResponse>(`/movie/${movieId}`);
}
async discoverMovies(page = 1): Promise<TmdbSearchMovieResponse> {
return this.tmdbGet<TmdbSearchMovieResponse>('/discover/movie', {
sort_by: 'popularity.desc',
'vote_count.gte': 500,
page,
});
}
async getPersonDetails(personId: number): Promise<TmdbPersonDetailsResponse> {
return this.tmdbGet<TmdbPersonDetailsResponse>(`/person/${personId}`);
}
}
+102
View File
@@ -0,0 +1,102 @@
// TMDB API response types
export interface TmdbMovieResult {
id: number;
title: string;
original_title: string;
overview: string;
poster_path: string | null;
backdrop_path: string | null;
release_date: string;
popularity: number;
vote_average: number;
vote_count: number;
genre_ids: number[];
adult: boolean;
}
export interface TmdbPersonResult {
id: number;
name: string;
profile_path: string | null;
popularity: number;
known_for_department: string;
known_for: TmdbMovieResult[];
adult: boolean;
}
export interface TmdbCastMember {
id: number;
name: string;
character: string;
profile_path: string | null;
popularity: number;
known_for_department: string;
order: number;
adult: boolean;
}
export interface TmdbPersonCastCredit {
id: number;
title: string;
original_title: string;
character: string;
poster_path: string | null;
release_date: string;
popularity: number;
vote_average: number;
vote_count: number;
adult: boolean;
}
export interface TmdbSearchMovieResponse {
page: number;
results: TmdbMovieResult[];
total_pages: number;
total_results: number;
}
export interface TmdbSearchPersonResponse {
page: number;
results: TmdbPersonResult[];
total_pages: number;
total_results: number;
}
export interface TmdbMovieCreditsResponse {
id: number;
cast: TmdbCastMember[];
}
export interface TmdbPersonMovieCreditsResponse {
id: number;
cast: TmdbPersonCastCredit[];
}
export interface TmdbMovieDetailsResponse extends TmdbMovieResult {
runtime: number | null;
budget: number;
revenue: number;
status: string;
tagline: string;
genres: { id: number; name: string }[];
production_companies: {
id: number;
name: string;
logo_path: string | null;
}[];
}
export interface TmdbPersonDetailsResponse {
id: number;
name: string;
biography: string;
profile_path: string | null;
birthday: string | null;
deathday: string | null;
place_of_birth: string | null;
popularity: number;
known_for_department: string;
also_known_as: string[];
adult: boolean;
}
@@ -0,0 +1,60 @@
import { Injectable, Logger } from '@nestjs/common';
import { RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import { NotificationsService } from './notifications.service';
import { NotificationsGateway } from './notifications.gateway';
interface VersusAttemptMessage {
creatorId: string;
challengerUsername: string;
matchId: string;
matchName: string;
challengerScore: number;
challengerChainLength: number;
}
@Injectable()
export class NotificationsConsumer {
private readonly logger = new Logger(NotificationsConsumer.name);
constructor(
private readonly notificationsService: NotificationsService,
private readonly notificationsGateway: NotificationsGateway,
) {}
@RabbitSubscribe({
exchange: 'notifications',
routingKey: 'versus.attempt.completed',
queue: 'notifications.versus.attempt',
})
async handleVersusAttemptCompleted(msg: VersusAttemptMessage) {
try {
const notification = await this.notificationsService.create(
msg.creatorId,
'versus.attempt.completed',
'New Challenger Result!',
`${msg.challengerUsername} completed your "${msg.matchName}" challenge with a chain of ${msg.challengerChainLength} links!`,
{
matchId: msg.matchId,
challengerScore: msg.challengerScore,
challengerChainLength: msg.challengerChainLength,
},
);
this.notificationsGateway.sendToUser(msg.creatorId, notification);
const unreadCount = await this.notificationsService.getUnreadCount(
msg.creatorId,
);
this.notificationsGateway.broadcastUnreadCount(
msg.creatorId,
unreadCount,
);
this.logger.log(
`Notification sent to ${msg.creatorId} for match ${msg.matchId}`,
);
} catch (error: any) {
this.logger.error(`Failed to process notification: ${error.message}`);
}
}
}
@@ -0,0 +1,80 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { NotificationsService } from './notifications.service';
@ApiTags('notifications')
@Controller('notifications')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
@ApiOperation({ summary: 'Get user notifications (paginated)' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
getNotifications(
@Request() req: { user: { sub: string } },
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.notificationsService.getUserNotifications(
req.user.sub,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Get('unread-count')
@ApiOperation({ summary: 'Get unread notification count' })
async getUnreadCount(@Request() req: { user: { sub: string } }) {
const count = await this.notificationsService.getUnreadCount(req.user.sub);
return { count };
}
@Patch(':id/read')
@ApiOperation({ summary: 'Mark a notification as read' })
markAsRead(
@Param('id') id: string,
@Request() req: { user: { sub: string } },
) {
return this.notificationsService.markAsRead(id, req.user.sub);
}
@Post('read-all')
@ApiOperation({ summary: 'Mark all notifications as read' })
markAllAsRead(@Request() req: { user: { sub: string } }) {
return this.notificationsService.markAllAsRead(req.user.sub);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a notification' })
deleteNotification(
@Param('id') id: string,
@Request() req: { user: { sub: string } },
) {
return this.notificationsService.delete(id, req.user.sub);
}
@Delete('read')
@ApiOperation({ summary: 'Delete all read notifications' })
deleteAllRead(@Request() req: { user: { sub: string } }) {
return this.notificationsService.deleteAllRead(req.user.sub);
}
}
@@ -0,0 +1,86 @@
import {
WebSocketGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
interface AuthenticatedSocket extends Socket {
data: {
user: { sub: string; email: string };
};
}
@WebSocketGateway({
cors: { origin: process.env.CORS_ORIGIN || 'http://localhost:5173', credentials: true },
namespace: '/notifications',
})
export class NotificationsGateway
implements OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
private readonly logger = new Logger(NotificationsGateway.name);
private readonly userSockets = new Map<string, Set<string>>();
constructor(private readonly jwtService: JwtService) {}
async handleConnection(client: AuthenticatedSocket) {
try {
const token = client.handshake.auth?.token;
if (!token) {
client.disconnect();
return;
}
const payload = this.jwtService.verify(token);
client.data.user = { sub: payload.sub, email: payload.email };
const userId = payload.sub;
if (!this.userSockets.has(userId)) {
this.userSockets.set(userId, new Set());
}
this.userSockets.get(userId)!.add(client.id);
this.logger.log(
`Notification client connected: ${client.id} (user: ${userId})`,
);
} catch {
this.logger.warn(`Auth failed for notification socket ${client.id}`);
client.disconnect();
}
}
async handleDisconnect(client: AuthenticatedSocket) {
const userId = client.data?.user?.sub;
if (userId) {
const sockets = this.userSockets.get(userId);
if (sockets) {
sockets.delete(client.id);
if (sockets.size === 0) {
this.userSockets.delete(userId);
}
}
}
this.logger.log(`Notification client disconnected: ${client.id}`);
}
sendToUser(userId: string, notification: object) {
const sockets = this.userSockets.get(userId);
if (!sockets) return;
for (const socketId of sockets) {
this.server.to(socketId).emit('notification', notification);
}
}
broadcastUnreadCount(userId: string, count: number) {
const sockets = this.userSockets.get(userId);
if (!sockets) return;
for (const socketId of sockets) {
this.server.to(socketId).emit('unread-count', count);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { NotificationsService } from './notifications.service';
import { NotificationsController } from './notifications.controller';
import { NotificationsConsumer } from './notifications.consumer';
import { NotificationsGateway } from './notifications.gateway';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
}),
inject: [ConfigService],
}),
],
controllers: [NotificationsController],
providers: [
NotificationsService,
NotificationsConsumer,
NotificationsGateway,
],
exports: [NotificationsService],
})
export class NotificationsModule {}
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class NotificationsService {
constructor(private readonly prisma: PrismaService) {}
async create(
userId: string,
type: string,
title: string,
message: string,
data?: object,
) {
return this.prisma.notification.create({
data: { userId, type, title, message, data: data ?? undefined },
});
}
async getUserNotifications(userId: string, page = 1, limit = 20) {
const [data, total, unreadCount] = await Promise.all([
this.prisma.notification.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.notification.count({ where: { userId } }),
this.prisma.notification.count({ where: { userId, read: false } }),
]);
return { data, total, unreadCount, page, limit };
}
async getUnreadCount(userId: string) {
return this.prisma.notification.count({
where: { userId, read: false },
});
}
async markAsRead(id: string, userId: string) {
return this.prisma.notification.updateMany({
where: { id, userId },
data: { read: true },
});
}
async markAllAsRead(userId: string) {
return this.prisma.notification.updateMany({
where: { userId, read: false },
data: { read: true },
});
}
async delete(id: string, userId: string) {
return this.prisma.notification.deleteMany({
where: { id, userId },
});
}
async deleteAllRead(userId: string) {
return this.prisma.notification.deleteMany({
where: { userId, read: true },
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+24
View File
@@ -0,0 +1,24 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '../../generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL as string,
});
super({ adapter });
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { DailyChallengesService } from '../daily-challenges/daily-challenges.service';
@Processor('daily-challenge')
export class DailyChallengeProcessor extends WorkerHost {
private readonly logger = new Logger(DailyChallengeProcessor.name);
constructor(private readonly dailyChallengesService: DailyChallengesService) {
super();
}
async process(job: Job<{ date: string; difficulty: string }>) {
this.logger.log(`Processing daily challenge job: ${job.id}`);
const date = new Date(job.data.date + 'T00:00:00.000Z');
const difficulty = job.data.difficulty || 'medium';
const challenge = await this.dailyChallengesService.generateChallenge(
date,
difficulty,
);
this.logger.log(
`Daily challenge generated: ${challenge.movieATitle}${challenge.movieBTitle}`,
);
return challenge;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { DailyChallengesModule } from '../daily-challenges/daily-challenges.module';
import { DailyChallengeProcessor } from './daily-challenge.processor';
@Module({
imports: [
BullModule.registerQueue({ name: 'daily-challenge' }),
DailyChallengesModule,
],
providers: [DailyChallengeProcessor],
})
export class QueueModule {}
+20
View File
@@ -0,0 +1,20 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq';
@Global()
@Module({
imports: [
RabbitMQModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
exchanges: [{ name: 'notifications', type: 'topic' }],
uri: configService.getOrThrow<string>('RABBITMQ_URL'),
connectionInitOptions: { wait: false },
}),
inject: [ConfigService],
}),
],
exports: [RabbitMQModule],
})
export class AppRabbitMQModule {}
+25
View File
@@ -0,0 +1,25 @@
import { Global, Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { redisStore } from 'cache-manager-ioredis-yet';
@Global()
@Module({
imports: [
CacheModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
const store = await redisStore({
host: configService.get<string>('REDIS_HOST', 'localhost'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get<string>('REDIS_PASSWORD'),
ttl: 600000, // 10 minutes default
});
return { store };
},
}),
],
exports: [CacheModule],
})
export class RedisModule {}
+26
View File
@@ -0,0 +1,26 @@
import {
IsOptional,
IsString,
MinLength,
MaxLength,
IsUrl,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateProfileDto {
@ApiPropertyOptional({ description: 'Display name', example: 'Movie Master' })
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(50)
displayName?: string;
@ApiPropertyOptional({
description: 'Avatar URL',
example: 'https://example.com/avatar.jpg',
})
@IsOptional()
@IsString()
@MaxLength(500)
avatarUrl?: string;
}
+37
View File
@@ -0,0 +1,37 @@
import {
Controller,
Get,
Patch,
Body,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UsersService } from './users.service';
import { UpdateProfileDto } from './dto/update-profile.dto';
@ApiTags('users')
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current user profile' })
getProfile(@Request() req: { user: { sub: string } }) {
return this.usersService.getProfile(req.user.sub);
}
@Patch('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Update current user profile' })
updateProfile(
@Request() req: { user: { sub: string } },
@Body() dto: UpdateProfileDto,
) {
return this.usersService.updateProfile(req.user.sub, dto);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+69
View File
@@ -0,0 +1,69 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateProfileDto } from './dto/update-profile.dto';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async findByEmail(email: string) {
return this.prisma.user.findUnique({ where: { email } });
}
async findByUsername(username: string) {
return this.prisma.user.findUnique({ where: { username } });
}
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
async create(email: string, username: string, hashedPassword: string) {
return this.prisma.user.create({
data: { email, username, password: hashedPassword },
});
}
async getProfile(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
username: true,
displayName: true,
avatarUrl: true,
createdAt: true,
_count: {
select: {
gameSessions: { where: { completed: true } },
leaderboardEntries: true,
},
},
},
});
if (!user) throw new NotFoundException('User not found');
return user;
}
async updateProfile(userId: string, dto: UpdateProfileDto) {
const user = await this.findById(userId);
if (!user) throw new NotFoundException('User not found');
return this.prisma.user.update({
where: { id: userId },
data: {
...(dto.displayName !== undefined && { displayName: dto.displayName }),
...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }),
},
select: {
id: true,
email: true,
username: true,
displayName: true,
avatarUrl: true,
},
});
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VersusService } from './versus.service';
@Injectable()
export class AsyncExpirationService {
private readonly logger = new Logger(AsyncExpirationService.name);
constructor(private readonly versusService: VersusService) {}
@Cron(CronExpression.EVERY_HOUR)
async handleExpiration() {
try {
const count = await this.versusService.expireAsyncMatches();
if (count > 0) {
this.logger.log(`Expired ${count} async versus matches`);
}
} catch (error: any) {
this.logger.error(`Failed to expire async matches: ${error.message}`);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import {
IsString,
IsOptional,
IsBoolean,
MinLength,
MaxLength,
IsIn,
} from 'class-validator';
export class CreateMatchDto {
@IsString()
@IsOptional()
@IsIn(['easy', 'medium', 'hard'])
difficulty?: string;
@IsString()
@IsOptional()
@MinLength(1)
@MaxLength(50)
lobbyName?: string;
@IsString()
@IsOptional()
@MinLength(1)
@MaxLength(50)
password?: string;
@IsBoolean()
@IsOptional()
isAsync?: boolean;
}
+8
View File
@@ -0,0 +1,8 @@
import { IsString, IsOptional, MaxLength } from 'class-validator';
export class JoinMatchDto {
@IsString()
@IsOptional()
@MaxLength(50)
password?: string;
}
+10
View File
@@ -0,0 +1,10 @@
import { IsObject, IsInt, Min } from 'class-validator';
export class SubmitAsyncScoreDto {
@IsObject()
score: object;
@IsInt()
@Min(1)
chainLength: number;
}
+166
View File
@@ -0,0 +1,166 @@
import {
Controller,
Get,
Post,
Param,
Body,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VersusService } from './versus.service';
import { CreateMatchDto } from './dto/create-match.dto';
import { JoinMatchDto } from './dto/join-match.dto';
import { SubmitAsyncScoreDto } from './dto/submit-async-score.dto';
@ApiTags('versus')
@Controller('versus')
export class VersusController {
constructor(private readonly versusService: VersusService) {}
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create a new versus match' })
createMatch(
@Request() req: { user: { sub: string } },
@Body() body: CreateMatchDto,
) {
return this.versusService.createMatch(
req.user.sub,
body.difficulty ?? 'medium',
body.lobbyName,
body.password,
body.isAsync,
);
}
@Get('waiting')
@ApiOperation({ summary: 'Get available matches to join' })
@ApiQuery({ name: 'excludeUser', required: false })
@ApiQuery({ name: 'mode', required: false, enum: ['sync', 'async', 'all'] })
getWaitingMatches(
@Query('excludeUser') excludeUser?: string,
@Query('mode') mode?: string,
) {
return this.versusService.findWaitingMatches(excludeUser, mode);
}
@Post(':id/join')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Join a versus match' })
joinMatch(
@Param('id') id: string,
@Request() req: { user: { sub: string } },
@Body() body: JoinMatchDto,
) {
return this.versusService.joinMatch(id, req.user.sub, body.password);
}
@Post(':id/cancel')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Cancel a waiting match' })
cancelMatch(
@Param('id') id: string,
@Request() req: { user: { sub: string } },
) {
return this.versusService.cancelMatch(id, req.user.sub);
}
@Post(':id/creator-score')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Submit creator score for async match' })
submitCreatorScore(
@Param('id') id: string,
@Request() req: { user: { sub: string } },
@Body() body: SubmitAsyncScoreDto,
) {
return this.versusService.submitCreatorScore(
id,
req.user.sub,
body.score,
body.chainLength,
);
}
@Post(':id/async-attempt')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Start an async attempt on a match' })
startAsyncAttempt(
@Param('id') id: string,
@Request() req: { user: { sub: string } },
@Body() body: JoinMatchDto,
) {
return this.versusService.startAsyncAttempt(
id,
req.user.sub,
body.password,
);
}
@Post(':id/async-attempt/submit')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Submit async attempt result' })
submitAsyncAttempt(
@Param('id') id: string,
@Request() req: { user: { sub: string } },
@Body() body: SubmitAsyncScoreDto,
) {
return this.versusService.submitAsyncAttempt(
id,
req.user.sub,
body.score,
body.chainLength,
);
}
@Get(':id/leaderboard')
@ApiOperation({ summary: 'Get async match leaderboard' })
getAsyncLeaderboard(@Param('id') id: string) {
return this.versusService.getAsyncMatchLeaderboard(id);
}
@Get(':id')
@ApiOperation({ summary: 'Get match details' })
getMatch(@Param('id') id: string) {
return this.versusService.getMatch(id);
}
@Get('me/waiting')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get my waiting matches' })
getMyWaitingMatches(@Request() req: { user: { sub: string } }) {
return this.versusService.findMyWaitingMatches(req.user.sub);
}
@Get('me/history')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get user match history' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
getUserMatches(
@Request() req: { user: { sub: string } },
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.versusService.getUserMatches(
req.user.sub,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
}
+249
View File
@@ -0,0 +1,249 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
MessageBody,
ConnectedSocket,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { VersusService } from './versus.service';
interface AuthenticatedSocket extends Socket {
data: {
user: { sub: string; email: string };
matchId?: string;
};
}
@WebSocketGateway({
cors: { origin: process.env.CORS_ORIGIN || 'http://localhost:5173', credentials: true },
namespace: '/versus',
})
export class VersusGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(VersusGateway.name);
constructor(
private readonly versusService: VersusService,
private readonly jwtService: JwtService,
) {}
async handleConnection(client: AuthenticatedSocket) {
try {
const token = client.handshake.auth?.token;
if (!token) {
client.disconnect();
return;
}
const payload = this.jwtService.verify(token);
client.data.user = { sub: payload.sub, email: payload.email };
this.logger.log(`Client connected: ${client.id} (user: ${payload.sub})`);
} catch {
this.logger.warn(`Auth failed for socket ${client.id}`);
client.disconnect();
}
}
async handleDisconnect(client: AuthenticatedSocket) {
const userId = client.data?.user?.sub;
const matchId = client.data?.matchId;
if (!userId || !matchId) {
this.logger.log(`Client disconnected: ${client.id}`);
return;
}
try {
const match = await this.versusService.getMatch(matchId);
if (match.status === 'waiting') {
if (match.player1.id === userId) {
// Creator left — cancel match
await this.versusService.cancelMatch(matchId, userId);
this.server.to(`match:${matchId}`).emit('match-cancelled');
}
} else if (match.status !== 'playing') {
// In lobby but not playing yet
if (match.player2?.id === userId) {
// Player 2 left — revert to waiting
await this.versusService.revertToWaiting(matchId);
this.server.to(`match:${matchId}`).emit('player-left', { userId });
} else if (match.player1.id === userId) {
await this.versusService.cancelMatch(matchId, userId);
this.server.to(`match:${matchId}`).emit('match-cancelled');
}
}
// If playing, match continues — player can reconnect
} catch (error: any) {
this.logger.error(`Disconnect cleanup failed: ${error.message}`);
}
this.logger.log(`Client disconnected: ${client.id} (user: ${userId})`);
}
@SubscribeMessage('join-lobby')
async handleJoinLobby(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { matchId: string },
) {
try {
const match = await this.versusService.getMatch(data.matchId);
const userId = client.data.user.sub;
// Verify this user is a player in this match
if (match.player1.id !== userId && match.player2?.id !== userId) {
client.emit('error', { message: 'You are not a player in this match' });
return;
}
client.join(`match:${data.matchId}`);
client.data.matchId = data.matchId;
// Send lobby state to the joining client
client.emit('lobby-state', {
matchId: match.id,
status: match.status,
difficulty: match.difficulty,
lobbyName: match.lobbyName,
player1: match.player1,
player2: match.player2,
});
// If both players are present, notify the room
if (match.player2) {
this.server.to(`match:${data.matchId}`).emit('player-joined', {
player1: match.player1,
player2: match.player2,
});
}
this.logger.log(`User ${userId} joined lobby: ${data.matchId}`);
} catch (error: any) {
client.emit('error', { message: error.message });
}
}
@SubscribeMessage('start-countdown')
async handleStartCountdown(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { matchId: string },
) {
try {
const match = await this.versusService.getMatch(data.matchId);
const userId = client.data.user.sub;
// Only player1 (creator) can start
if (match.player1.id !== userId) {
client.emit('error', {
message: 'Only the match creator can start the game',
});
return;
}
// Must have both players
if (!match.player2) {
client.emit('error', { message: 'Waiting for opponent to join' });
return;
}
const room = `match:${data.matchId}`;
// Countdown sequence
this.server.to(room).emit('countdown', { value: 3 });
await this.delay(1000);
this.server.to(room).emit('countdown', { value: 2 });
await this.delay(1000);
this.server.to(room).emit('countdown', { value: 1 });
await this.delay(1000);
// Start the match
const started = await this.versusService.startMatch(data.matchId);
const startTime = started.startedAt!.getTime();
this.server.to(room).emit('game-start', {
movieA: { id: started.movieAId, title: started.movieATitle },
movieB: { id: started.movieBId, title: started.movieBTitle },
startTime,
});
this.logger.log(`Match started: ${data.matchId}`);
} catch (error: any) {
client.emit('error', { message: error.message });
}
}
@SubscribeMessage('chain-update')
handleChainUpdate(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { matchId: string; chainLength: number },
) {
client.to(`match:${data.matchId}`).emit('opponent-progress', {
userId: client.data.user.sub,
chainLength: data.chainLength,
});
}
@SubscribeMessage('match-complete')
async handleMatchComplete(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { matchId: string; score: object },
) {
try {
const userId = client.data.user.sub;
const result = await this.versusService.submitScore(
data.matchId,
userId,
data.score,
);
if (result.status === 'finished') {
this.server.to(`match:${data.matchId}`).emit('match-finished', result);
} else {
this.server
.to(`match:${data.matchId}`)
.emit('opponent-finished', { userId });
}
} catch (error: any) {
client.emit('error', { message: error.message });
}
}
@SubscribeMessage('leave-lobby')
async handleLeaveLobby(
@ConnectedSocket() client: AuthenticatedSocket,
@MessageBody() data: { matchId: string },
) {
try {
const userId = client.data.user.sub;
const match = await this.versusService.getMatch(data.matchId);
if (match.player1.id === userId) {
// Creator leaves — cancel match
await this.versusService.cancelMatch(data.matchId, userId);
this.server.to(`match:${data.matchId}`).emit('match-cancelled');
} else if (match.player2?.id === userId) {
// Player2 leaves — revert to waiting
await this.versusService.revertToWaiting(data.matchId);
this.server.to(`match:${data.matchId}`).emit('player-left', { userId });
}
client.leave(`match:${data.matchId}`);
client.data.matchId = undefined;
} catch (error: any) {
client.emit('error', { message: error.message });
}
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { ScheduleModule } from '@nestjs/schedule';
import { MoviesModule } from '../movies/movies.module';
import { VersusService } from './versus.service';
import { VersusController } from './versus.controller';
import { VersusGateway } from './versus.gateway';
import { AsyncExpirationService } from './async-expiration.service';
@Module({
imports: [
MoviesModule,
ScheduleModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>('JWT_SECRET'),
}),
inject: [ConfigService],
}),
],
controllers: [VersusController],
providers: [VersusService, VersusGateway, AsyncExpirationService],
exports: [VersusService],
})
export class VersusModule {}
+523
View File
@@ -0,0 +1,523 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { createHash } from 'crypto';
import { AmqpConnection } from '@golevelup/nestjs-rabbitmq';
import { PrismaService } from '../prisma/prisma.service';
import { TmdbService } from '../movies/tmdb.service';
import { POPULAR_MOVIES } from '../daily-challenges/data/popular-movies';
@Injectable()
export class VersusService {
constructor(
private readonly prisma: PrismaService,
private readonly tmdbService: TmdbService,
private readonly amqpConnection: AmqpConnection,
) {}
async createMatch(
player1Id: string,
difficulty: string,
lobbyName?: string,
password?: string,
isAsync?: boolean,
) {
const pair = this.pickRandomPair();
const [movieA, movieB] = await Promise.all([
this.tmdbService.getMovieDetails(pair.movieA.id),
this.tmdbService.getMovieDetails(pair.movieB.id),
]);
const passwordHash = password
? createHash('sha256').update(password).digest('hex')
: null;
const match = await this.prisma.versusMatch.create({
data: {
player1Id,
movieAId: pair.movieA.id,
movieATitle: movieA.title,
movieBId: pair.movieB.id,
movieBTitle: movieB.title,
difficulty,
status: 'waiting',
lobbyName: lobbyName || null,
passwordHash,
isAsync: isAsync || false,
expiresAt: isAsync ? new Date(Date.now() + 24 * 60 * 60 * 1000) : null,
},
include: {
player1: { select: { id: true, username: true } },
},
});
if (isAsync) {
// For async matches, return movie titles so creator can play immediately
const { passwordHash: _ph, ...rest } = match;
return { ...rest, hasPassword: !!_ph };
}
// Don't expose movie titles or password hash in response for waiting sync matches
const {
passwordHash: _ph,
movieATitle: _ma,
movieBTitle: _mb,
...rest
} = match;
return { ...rest, hasPassword: !!_ph };
}
async joinMatch(matchId: string, player2Id: string, password?: string) {
const match = await this.prisma.versusMatch.findUnique({
where: { id: matchId },
});
if (!match) throw new NotFoundException('Match not found');
if (match.status !== 'waiting')
throw new BadRequestException('Match is not available');
if (match.player1Id === player2Id)
throw new BadRequestException('Cannot join your own match');
// Verify password if match is password-protected
if (match.passwordHash) {
if (!password) {
throw new BadRequestException('This match requires a password');
}
const hash = createHash('sha256').update(password).digest('hex');
if (hash !== match.passwordHash) {
throw new BadRequestException('Incorrect password');
}
}
// Don't change status to 'playing' here — that happens after countdown via gateway
return this.prisma.versusMatch.update({
where: { id: matchId },
data: { player2Id },
include: {
player1: { select: { id: true, username: true } },
player2: { select: { id: true, username: true } },
},
});
}
async startMatch(matchId: string) {
return this.prisma.versusMatch.update({
where: { id: matchId },
data: { status: 'playing', startedAt: new Date() },
include: {
player1: { select: { id: true, username: true } },
player2: { select: { id: true, username: true } },
},
});
}
async cancelMatch(matchId: string, userId: string) {
const match = await this.prisma.versusMatch.findUnique({
where: { id: matchId },
});
if (!match) throw new NotFoundException('Match not found');
if (match.player1Id !== userId)
throw new ForbiddenException('Only the creator can cancel');
if (match.status !== 'waiting')
throw new BadRequestException('Can only cancel waiting matches');
return this.prisma.versusMatch.update({
where: { id: matchId },
data: { status: 'cancelled' },
});
}
async revertToWaiting(matchId: string) {
return this.prisma.versusMatch.update({
where: { id: matchId },
data: { player2Id: null, status: 'waiting' },
});
}
async findMyWaitingMatches(userId: string) {
const matches = await this.prisma.versusMatch.findMany({
where: {
player1Id: userId,
OR: [{ status: 'waiting' }, { status: 'open', isAsync: true }],
},
orderBy: { createdAt: 'desc' },
include: {
player1: { select: { id: true, username: true } },
_count: { select: { asyncAttempts: { where: { completed: true } } } },
},
});
return matches.map(
({ passwordHash, movieATitle, movieBTitle, _count, ...rest }) => ({
...rest,
hasPassword: !!passwordHash,
attemptCount: _count.asyncAttempts,
}),
);
}
async findWaitingMatches(excludeUserId?: string, mode?: string) {
const conditions: any[] = [];
if (mode === 'async') {
conditions.push({
status: 'open',
isAsync: true,
expiresAt: { gt: new Date() },
});
} else if (mode === 'sync') {
conditions.push({
status: 'waiting',
isAsync: false,
});
} else {
// 'all' or undefined — show both sync waiting and async open
conditions.push(
{ status: 'waiting', isAsync: false },
{ status: 'open', isAsync: true, expiresAt: { gt: new Date() } },
);
}
const matches = await this.prisma.versusMatch.findMany({
where: {
OR: conditions,
...(excludeUserId ? { player1Id: { not: excludeUserId } } : {}),
},
orderBy: { createdAt: 'desc' },
take: 20,
include: {
player1: { select: { id: true, username: true } },
_count: { select: { asyncAttempts: { where: { completed: true } } } },
},
});
return matches.map(
({ passwordHash, movieATitle, movieBTitle, _count, ...rest }) => ({
...rest,
hasPassword: !!passwordHash,
attemptCount: _count.asyncAttempts,
}),
);
}
async getMatch(matchId: string) {
const match = await this.prisma.versusMatch.findUnique({
where: { id: matchId },
include: {
player1: { select: { id: true, username: true } },
player2: { select: { id: true, username: true } },
},
});
if (!match) throw new NotFoundException('Match not found');
return match;
}
async submitScore(matchId: string, userId: string, score: object) {
const match = await this.getMatch(matchId);
if (match.player1.id !== userId && match.player2?.id !== userId) {
throw new BadRequestException('You are not a player in this match');
}
const isPlayer1 = match.player1.id === userId;
const updateData = isPlayer1
? { player1Score: score }
: { player2Score: score };
const updated = await this.prisma.versusMatch.update({
where: { id: matchId },
data: updateData,
});
// Check if both players have finished
if (updated.player1Score && updated.player2Score) {
const p1Total = (updated.player1Score as any).totalScore ?? 0;
const p2Total = (updated.player2Score as any).totalScore ?? 0;
const winnerId =
p1Total > p2Total
? match.player1.id
: p2Total > p1Total
? match.player2!.id
: null;
return this.prisma.versusMatch.update({
where: { id: matchId },
data: { status: 'finished', winnerId, finishedAt: new Date() },
include: {
player1: { select: { id: true, username: true } },
player2: { select: { id: true, username: true } },
},
});
}
return updated;
}
async submitCreatorScore(
matchId: string,
userId: string,
score: object,
chainLength: number,
) {
const match = await this.getMatch(matchId);
if (match.player1Id !== userId) {
throw new ForbiddenException(
'Only the creator can submit the initial score',
);
}
if (!match.isAsync) {
throw new BadRequestException('This is not an async match');
}
if (match.status !== 'waiting') {
throw new BadRequestException('Creator score already submitted');
}
return this.prisma.versusMatch.update({
where: { id: matchId },
data: {
player1Score: score,
chainLength,
status: 'open',
startedAt: new Date(),
},
include: {
player1: { select: { id: true, username: true } },
},
});
}
async startAsyncAttempt(
matchId: string,
playerId: string,
password?: string,
) {
const match = await this.prisma.versusMatch.findUnique({
where: { id: matchId },
});
if (!match) throw new NotFoundException('Match not found');
if (!match.isAsync)
throw new BadRequestException('This is not an async match');
if (match.status !== 'open')
throw new BadRequestException('Match is not open for attempts');
if (match.expiresAt && match.expiresAt < new Date()) {
throw new BadRequestException('This match has expired');
}
if (match.player1Id === playerId) {
throw new BadRequestException('Creator cannot challenge their own match');
}
// Verify password if match is password-protected
if (match.passwordHash) {
if (!password) {
throw new BadRequestException('This match requires a password');
}
const hash = createHash('sha256').update(password).digest('hex');
if (hash !== match.passwordHash) {
throw new BadRequestException('Incorrect password');
}
}
// Check for existing attempt
const existing = await this.prisma.asyncVersusAttempt.findUnique({
where: { matchId_playerId: { matchId, playerId } },
});
if (existing) {
throw new BadRequestException('You have already attempted this match');
}
const attempt = await this.prisma.asyncVersusAttempt.create({
data: { matchId, playerId },
});
return {
attempt,
movieAId: match.movieAId,
movieATitle: match.movieATitle,
movieBId: match.movieBId,
movieBTitle: match.movieBTitle,
chainLength: match.chainLength,
};
}
async submitAsyncAttempt(
matchId: string,
playerId: string,
score: object,
chainLength: number,
) {
const attempt = await this.prisma.asyncVersusAttempt.findUnique({
where: { matchId_playerId: { matchId, playerId } },
});
if (!attempt) throw new NotFoundException('Attempt not found');
if (attempt.completed)
throw new BadRequestException('Attempt already submitted');
const updated = await this.prisma.asyncVersusAttempt.update({
where: { id: attempt.id },
data: {
score,
chainLength,
completed: true,
completedAt: new Date(),
},
include: {
player: { select: { id: true, username: true } },
},
});
// Get match info for notification
const match = await this.prisma.versusMatch.findUnique({
where: { id: matchId },
});
if (match) {
// Publish RabbitMQ notification
try {
this.amqpConnection.publish(
'notifications',
'versus.attempt.completed',
{
creatorId: match.player1Id,
challengerUsername: updated.player.username,
matchId,
matchName: match.lobbyName || 'Async Challenge',
challengerScore: (score as any).totalScore ?? 0,
challengerChainLength: chainLength,
},
);
} catch {
// Non-critical — notification delivery is best-effort
}
}
return updated;
}
async getAsyncMatchLeaderboard(matchId: string) {
const match = await this.prisma.versusMatch.findUnique({
where: { id: matchId },
include: {
player1: { select: { id: true, username: true } },
asyncAttempts: {
where: { completed: true },
orderBy: { completedAt: 'asc' },
include: {
player: { select: { id: true, username: true } },
},
},
},
});
if (!match) throw new NotFoundException('Match not found');
if (!match.isAsync)
throw new BadRequestException('This is not an async match');
const creatorEntry = {
playerId: match.player1Id,
username: match.player1.username,
score: match.player1Score,
chainLength: match.chainLength,
isCreator: true,
};
const challengerEntries = match.asyncAttempts.map((a) => ({
playerId: a.playerId,
username: a.player.username,
score: a.score,
chainLength: a.chainLength,
isCreator: false,
}));
// Sort all entries by totalScore descending
const allEntries = [creatorEntry, ...challengerEntries].sort((a, b) => {
const aScore = (a.score as any)?.totalScore ?? 0;
const bScore = (b.score as any)?.totalScore ?? 0;
return bScore - aScore;
});
// Add ranks
const ranked = allEntries.map((entry, i) => ({ ...entry, rank: i + 1 }));
return {
match: {
id: match.id,
difficulty: match.difficulty,
lobbyName: match.lobbyName,
expiresAt: match.expiresAt,
status: match.status,
movieATitle: match.movieATitle,
movieBTitle: match.movieBTitle,
},
creator: match.player1,
leaderboard: ranked,
};
}
async expireAsyncMatches() {
const result = await this.prisma.versusMatch.updateMany({
where: {
isAsync: true,
status: 'open',
expiresAt: { lt: new Date() },
},
data: { status: 'expired' },
});
return result.count;
}
async getUserMatches(userId: string, page = 1, limit = 20) {
const [data, total] = await Promise.all([
this.prisma.versusMatch.findMany({
where: {
OR: [{ player1Id: userId }, { player2Id: userId }],
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
include: {
player1: { select: { id: true, username: true } },
player2: { select: { id: true, username: true } },
},
}),
this.prisma.versusMatch.count({
where: {
OR: [{ player1Id: userId }, { player2Id: userId }],
},
}),
]);
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async hasUserAttempted(matchId: string, userId: string): Promise<boolean> {
const attempt = await this.prisma.asyncVersusAttempt.findUnique({
where: { matchId_playerId: { matchId, playerId: userId } },
});
return !!attempt;
}
private pickRandomPair() {
const movies = [...POPULAR_MOVIES];
const idxA = Math.floor(Math.random() * movies.length);
const movieA = movies[idxA];
movies.splice(idxA, 1);
const idxB = Math.floor(Math.random() * movies.length);
const movieB = movies[idxB];
return { movieA, movieB };
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"noFallthroughCasesInSwitch": true
}
}