Initial commit
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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).
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+12596
File diff suppressed because it is too large
Load Diff
+104
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthGuard extends AuthGuard('local') {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
|
||||
export class JoinMatchDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
password?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsObject, IsInt, Min } from 'class-validator';
|
||||
|
||||
export class SubmitAsyncScoreDto {
|
||||
@IsObject()
|
||||
score: object;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
chainLength: number;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user