Initial commit

This commit is contained in:
2025-05-06 12:38:01 +00:00
commit c92637aba4
49 changed files with 14963 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
SESSION_SECRET=dev
CLIENT_ENDPOINT=http://localhost:3000
API_ENDPOINT=http://localhost:8765
API_PREFIX=/api
DISCORD_CLIENTID=
DISCORD_CLIENTSECRET=
PG_ENDPOINT=postgres://127.0.0.1:concept
PG_PASSWORD=postgres
PG_USER=test
DEV_MODE=true
+401
View File
@@ -0,0 +1,401 @@
# Created by .ignore support plugin (hsz.mobi)
### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff:
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/dictionaries
# Sensitive or high-churn files:
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.xml
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
# Gradle:
.idea/**/gradle.xml
.idea/**/libraries
# CMake
cmake-build-debug/
# Mongo Explorer plugin:
.idea/**/mongoSettings.xml
## File-based project format:
*.iws
## Plugin-specific files:
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
### VisualStudio template
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
**/Properties/launchSettings.json
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Typescript v1 declaration files
typings/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
coverage/
### macOS template
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
=======
# Local
.env
dist
.webpack
.serverless/**/*.zip
mongodb-keyfile
+3
View File
@@ -0,0 +1,3 @@
[submodule "events"]
path = events
url = git@git.legonzaur.fr:concept/events.git
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"orta.vscode-jest",
"dbaeumer.vscode-eslint",
"vue.volar"
]
}
+23
View File
@@ -0,0 +1,23 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch via NPM",
"request": "launch",
"runtimeArgs": ["run", "start:dev", "-b", "swc"],
"runtimeExecutable": "npm",
"skipFiles": [
"<node_internals>/**",
"${workspaceFolder}/node_modules/**/*.js",
"${workspaceFolder}/lib/**/*.js"
],
"type": "node",
"console": "internalConsole",
"outputCapture": "std",
"internalConsoleOptions": "openOnSessionStart",
},
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"javascript.preferences.quoteStyle": "single",
"typescript.preferences.quoteStyle": "single",
"eslint.format.enable": true,
"eslint.useFlatConfig": true,
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"files.exclude": {
"**/dist": true,
"**/node_modules": true
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "echo",
"type": "shell",
"command": "docker compose up -d",
"runOptions": {"runOn": "folderOpen"},
}
]
}
+14
View File
@@ -0,0 +1,14 @@
FROM node:lts-alpine AS build-stage
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx nest build
# étape de production
FROM node:lts-alpine AS production-stage
WORKDIR /app
COPY --from=build-stage /app/dist/ /app
COPY --from=build-stage /app/node_modules/ /app/node_modules
EXPOSE 8765
CMD ["node", "main"]
+73
View File
@@ -0,0 +1,73 @@
<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://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## 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).
+34
View File
@@ -0,0 +1,34 @@
services:
postgres:
image: postgres
ports:
- 5432:5432
volumes:
- pg_data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=test
pgadmin:
image: dpage/pgadmin4
ports:
- 8080:80
environment:
- PGADMIN_DEFAULT_EMAIL=postgres@postgres.com
- PGADMIN_DEFAULT_PASSWORD=postgres
- PGADMIN_DISABLE_POSTFIX=true
volumes:
- pgadmin_data:/var/lib/pgadmin
redis:
image: redis
ports:
- 6379:6379
volumes:
- redis_data:/data
command: "redis-server --save 60 1"
volumes:
pg_data:
pgadmin_data:
redis_data:
+66
View File
@@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin';
import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{
ignores: ['**/.eslintrc.js'],
},
...compat.extends(
'plugin:@typescript-eslint/recommended-type-checked',
'plugin:prettier/recommended',
),
{
plugins: {
'@typescript-eslint': typescriptEslintEslintPlugin,
},
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parser: tsParser,
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
},
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'require-await': 'off',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
];
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": ["@nestjs/swagger"]
}
}
+12661
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
{
"name": "nest-typescript-starter",
"private": true,
"version": "1.0.0",
"description": "Nest TypeScript starter repository",
"license": "MIT",
"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/jest/bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"migration:generate": "npx ts-node -P ./tsconfig.json -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate -d ./src/db/typeorm.config.ts ./src/db/migrations/migration",
"migration:create": "npx ts-node -P ./tsconfig.json -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create -d ./src/db/typeorm.config.ts ./src/db/migrations/migration",
"migration:run": "npx ts-node -P ./tsconfig.json -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:run -d ./src/db/typeorm.config.ts",
"migration:revert": "npx ts-node -P ./tsconfig.json -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:revert -d ./src/db/typeorm.config.ts"
},
"dependencies": {
"@nestjs/common": "^11.1.0",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.0",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.1.0",
"@nestjs/swagger": "^11.2.0",
"@nestjs/typeorm": "^11.0.0",
"@types/ranjs": "^1.22.7",
"connect-redis": "^8.0.3",
"express-session": "^1.18.1",
"pg": "^8.15.6",
"random": "^5.4.0",
"redis": "^5.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"typeorm": "^0.3.22"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.26.0",
"@nestjs/cli": "^11.0.7",
"@nestjs/schematics": "^11.0.5",
"@nestjs/testing": "^11.1.0",
"@swc/cli": "^0.7.3",
"@swc/core": "^1.11.24",
"@types/express": "^5.0.1",
"@types/express-session": "^1.18.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.15.12",
"@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^8.32.0",
"@typescript-eslint/parser": "^8.32.0",
"@typescript-eslint/typescript-estree": "^8.32.0",
"eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.4.0",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.5.3",
"source-map-support": "^0.5.21",
"sqlite3": "^5.1.7",
"supertest": "^7.1.0",
"ts-jest": "^29.3.2",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "./",
"modulePaths": [
"<rootDir>"
],
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+43
View File
@@ -0,0 +1,43 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { GamesModule } from './games/games.module';
import { UsersModule } from './users/users.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './users/entities/user.entity';
import { Game } from './games/entities/game.entity';
import { Topic } from './word/entities/topic.entity';
import { Word } from './word/entities/word.entity';
import { Player } from './games/entities/player.entity';
import { ConceptInTurn } from './games/entities/concept_in_turn.entity';
import { ConfigService } from '@nestjs/config';
import { WordModule } from './word/words.module';
import { Message } from './games/entities/message.entity';
import { LobbyModule } from './lobby/lobby.module';
const configService = new ConfigService();
@Module({
imports: [
EventEmitterModule.forRoot(),
ConfigModule.forRoot({
envFilePath: '.env',
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'postgres',
host: configService.get('PG_HOST'),
// url: process.env.PG_ENDPOINT,
password: configService.get('PG_PASSWORD'),
username: configService.get('PG_USER'),
database: 'concept',
synchronize: configService.get('DEV_MODE') == 'true',
entities: [User, Player, Game, Message, ConceptInTurn, Topic, Word],
}),
LobbyModule,
GamesModule,
UsersModule,
WordModule,
],
})
export class AppModule {}
+26
View File
@@ -0,0 +1,26 @@
import { DataSource } from 'typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
ConfigModule.forRoot({
envFilePath: '.env',
isGlobal: true,
});
const configService = new ConfigService();
const AppDataSource = new DataSource({
type: 'postgres',
host: configService.get('PG_HOST'),
// url: process.env.PG_ENDPOINT,
password: configService.get('PG_PASSWORD'),
username: configService.get('PG_USER'),
database: 'concept',
applicationName: 'concept',
synchronize: false,
entities: ['**/*.entity.ts'],
migrations: ['src/db/migrations/*-migration.ts'],
migrationsRun: false,
logging: true,
});
export default AppDataSource;
+156
View File
@@ -0,0 +1,156 @@
import {
Body,
Controller,
Get,
HttpException,
HttpStatus,
Param,
Post,
Sse,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../../users/guards/auth.guard';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Game } from '../entities/game.entity';
import { GamePipe, GameStartedPipe } from '../pipe/game.pipe';
import { Observable, fromEvent, map } from 'rxjs';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { GameService } from '../services/games.service';
import { ConfigService } from '@nestjs/config';
// import { WordResponseDTO } from 'events/dto/wordresponse.dto';
import { WordRecieveDTO } from 'src/word/dto/wordrecieve.dto';
import { LobbyService } from 'src/lobby/lobby.service';
import { WordResponseDTO } from 'events/dto/wordresponse.dto';
import { GetUser } from 'src/users/user.pipe';
import { User } from 'src/users/entities/user.entity';
@ApiTags('game')
@Controller('games/:id')
export class GamesController {
constructor(
private readonly lobbyService: LobbyService,
private readonly gameService: GameService,
private eventEmitter: EventEmitter2,
private configService: ConfigService,
) {}
@Post('/start')
@ApiOperation({ summary: 'Start game' })
@ApiResponse({
status: 201,
description: 'Game successfully started.',
})
@ApiResponse({
status: 400,
description: 'Bad Request',
})
@ApiResponse({
status: 403,
description: 'You can only start games you own',
})
@UseGuards(AuthGuard)
async start(
@Param('id') id: string,
@GetUser() user: User,
@Param('id', GamePipe) game: Game,
) {
if (game.started) {
throw new HttpException('Game already started', HttpStatus.BAD_REQUEST);
}
if (game.players.length < 2 && !this.configService.get('DEV_MODE')) {
throw new HttpException(
'Not enough teams to start the game',
HttpStatus.BAD_REQUEST,
);
}
if (game.ownerId != user.id) {
throw new HttpException(
'You can only start games you own',
HttpStatus.FORBIDDEN,
);
}
await this.gameService.start(game);
return;
}
@Post('surrender')
@ApiOperation({ summary: 'Leave a game that has already started' })
@UseGuards(AuthGuard)
surrender(
@Param('id') id: string,
@GetUser() user: User,
@Param('id', GameStartedPipe) game: Game,
) {
return this.lobbyService.leaveGame(game, user);
}
@Post('kick/:playerId')
@ApiOperation({ summary: 'Kick a player' })
@UseGuards(AuthGuard)
kick(
@GetUser() user: User,
@Param('id') id: string,
@Param('id', GamePipe) game: Game,
@Param('playerId') playerId: string,
) {
if (user.id != game.ownerId) {
throw new HttpException(
'You do do not own this game',
HttpStatus.FORBIDDEN,
);
}
return this.lobbyService.kickPlayer(game, user, playerId);
}
@Get('currentWord')
@ApiOperation({ summary: 'Read Current Word' })
@UseGuards(AuthGuard)
getCurrentWord(
@GetUser() user: User,
@Param('id') id: string,
@Param('id', GameStartedPipe) game: Game,
) {
if (user.id != game.currentPlayer.userId) {
throw new HttpException(
'It is not your turn to play',
HttpStatus.FORBIDDEN,
);
}
return new WordResponseDTO(game.currentWord);
}
@Post('/message')
@ApiOperation({ summary: 'try to guess a word' })
@ApiBody({ type: WordRecieveDTO })
@UseGuards(AuthGuard)
async postMessage(
@GetUser() user: User,
@Param('id') id: string,
@Param('id', GameStartedPipe) game: Game,
@Body('value') value: string,
) {
const player = game.players.find((p) => p.userId == user.id);
if (!player) {
throw new HttpException('You are not in this game', HttpStatus.FORBIDDEN);
}
if (player.hasGuessed) {
throw new HttpException(
'You cannot send messages if you have already guessed the word',
HttpStatus.FORBIDDEN,
);
}
await this.gameService.postMessage(game, player, value);
}
@Sse('/subscribe')
subscribe(@Param('id') id: string): Observable<{ data: string }> {
return fromEvent(this.eventEmitter, 'sse.game.' + id).pipe(
map((payload: any) => {
return {
data: JSON.stringify(payload),
};
}),
);
}
}
+7
View File
@@ -0,0 +1,7 @@
export class MessageRecieveDTO {
constructor(partial: Partial<MessageRecieveDTO>) {
this.value = partial.value;
}
value: string;
}
+59
View File
@@ -0,0 +1,59 @@
import {
Column,
Entity,
JoinColumn,
JoinTable,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Player } from './player.entity';
import { User } from 'src/users/entities/user.entity';
import { Word } from 'src/word/entities/word.entity';
import { Message } from './message.entity';
@Entity()
export class Game {
@PrimaryGeneratedColumn()
id: number;
@Column()
seed: string;
@JoinColumn({ name: 'ownerId' })
@ManyToOne(() => User)
owner: Promise<User>;
@Column({ name: 'ownerId' })
ownerId: string;
@OneToMany(() => Player, (player) => player.game, {
eager: true,
})
@JoinTable()
players: Player[];
@OneToOne(() => Player, { nullable: true, eager: true })
@JoinColumn()
currentPlayer: Player;
@ManyToOne(() => Word, { nullable: true, eager: true })
currentWord: Word;
@Column({ default: false })
started: boolean;
@Column({ default: 0 })
turn: number = 0;
@OneToMany(() => Message, (msg) => msg.game, {
eager: true,
})
_messages: Message[];
get messages() {
if (!this._messages) return [];
return [...this._messages].sort((a, b) => a.id - b.id);
}
}
+30
View File
@@ -0,0 +1,30 @@
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Player } from './player.entity';
import { Game } from './game.entity';
@Entity()
export class Message {
@PrimaryGeneratedColumn()
id: number;
@Column()
value: string;
@JoinColumn({ name: 'authorId' })
@ManyToOne(() => Player, { onDelete: 'CASCADE', eager: true, nullable: true })
author?: Player;
get authorId() {
return this.author?.userId;
}
@ManyToOne(() => Game, { onDelete: 'CASCADE' })
game: Promise<Game>;
}
+31
View File
@@ -0,0 +1,31 @@
import { User } from '../../users/entities/user.entity';
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Game } from './game.entity';
@Entity()
export class Player {
@PrimaryGeneratedColumn()
id: number;
@JoinColumn({ name: 'userId' })
@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: Promise<User>;
@Column({ name: 'userId' })
userId: string;
@ManyToOne(() => Game, { onDelete: 'CASCADE' })
game: Game;
@Column({ default: 0 })
score: number = 0;
@Column({ default: false })
hasGuessed: boolean = false;
}
+24
View File
@@ -0,0 +1,24 @@
import { Module, forwardRef } from '@nestjs/common';
import { Game } from './entities/game.entity';
import { UsersModule } from 'src/users/users.module';
import { GamesController } from './controllers/games.controller';
import { GameService } from './services/games.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Player } from './entities/player.entity';
import { WordModule } from 'src/word/words.module';
import { Message } from './entities/message.entity';
import { LobbyModule } from 'src/lobby/lobby.module';
@Module({
imports: [
UsersModule,
WordModule,
forwardRef(() => LobbyModule),
TypeOrmModule.forFeature([Game]),
TypeOrmModule.forFeature([Player]),
TypeOrmModule.forFeature([Message]),
],
controllers: [GamesController],
providers: [GameService],
})
export class GamesModule {}
+46
View File
@@ -0,0 +1,46 @@
import {
PipeTransform,
Injectable,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Game } from '../entities/game.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@Injectable()
export class GamePipe implements PipeTransform {
constructor(
@InjectRepository(Game) private gameRepository: Repository<Game>,
) {}
async transform(value: number) {
const game = await this.gameRepository.findOneBy({ id: value });
if (!game) {
throw new HttpException('Game not found', HttpStatus.NOT_FOUND);
}
return game;
}
}
@Injectable()
export class GameStartedPipe implements PipeTransform {
constructor(
@InjectRepository(Game) private gameRepository: Repository<Game>,
) {}
async transform(value: number) {
const game = await this.gameRepository.findOneBy({ id: value });
if (!game) {
throw new HttpException('Game not found', HttpStatus.NOT_FOUND);
}
if (!game.started) {
throw new HttpException(
'The game must be started',
HttpStatus.BAD_REQUEST,
);
}
return game;
}
}
+127
View File
@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Game } from '../entities/game.entity';
import { Repository } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Player } from '../entities/player.entity';
import {
GameEndTurnEvent,
GameMessageDeleteEvent,
GameMessageEvent,
GameStartEvent,
} from 'events/game.events';
import random from 'random';
import { Message } from '../entities/message.entity';
import { WordService } from 'src/word/word.service';
@Injectable()
export class GameService {
constructor(
@InjectRepository(Game) private gameRepository: Repository<Game>,
@InjectRepository(Message) private messageResporitory: Repository<Message>,
@InjectRepository(Player) private playerRepository: Repository<Player>,
private readonly wordService: WordService,
private eventEmitter: EventEmitter2,
) {}
async start(game: Game) {
const rng = random.clone(game.seed);
game.started = true;
game.currentPlayer = rng.choice(game.players);
const words = await this.getShuffledWordList(game);
game.currentWord = words[0];
await this.gameRepository.save(game);
this.eventEmitter.emit(
'sse.game.' + game.id,
new GameStartEvent(game.currentPlayer),
);
}
async postMessage(game: Game, author: Player, value: string) {
const message = this.messageResporitory.create();
message.game = Promise.resolve(game);
if (
value.toLowerCase() == game.currentWord.value.toLowerCase() &&
author.id != game.currentPlayer.id
) {
message.value = author.userId;
author.hasGuessed = true;
author.score++;
game.currentPlayer.score++;
await this.playerRepository.save([author, game.currentPlayer]);
} else {
message.author = author;
message.value = value;
}
await this.messageResporitory.save(message);
this.eventEmitter.emit(
'sse.game.' + game.id,
new GameMessageEvent(message),
);
if (game.messages.length > 3) {
await this.deleteMessage(game, game.messages[0]);
}
if (
game.players
.filter((p) => p.id != game.currentPlayer.id)
.every((p) => p.hasGuessed)
) {
await this.endTurn(game);
}
}
private async deleteMessage(game: Game, message: Message) {
const oldMessageId = message.id;
await this.messageResporitory.remove(message);
this.eventEmitter.emit(
'sse.game.' + game.id,
new GameMessageDeleteEvent(oldMessageId),
);
}
async endTurn(game: Game) {
const smartPlayers = game.players.filter((p) => p.hasGuessed);
smartPlayers.forEach((p) => (p.hasGuessed = false));
const wordList = await this.getShuffledWordList(game);
game.turn++;
game.currentWord = wordList[game.turn % wordList.length];
const playerIndex = game.players.findIndex(
(p) => p.userId == game.currentPlayer.userId,
);
game.currentPlayer = game.players[(playerIndex + 1) % game.players.length];
// await this.currentConceptReporitory.remove(game.currentConcept)
await this.gameRepository.save(game);
await this.playerRepository.save(smartPlayers);
await this.messageResporitory.delete({ game });
this.eventEmitter.emit(
'sse.game.' + game.id,
new GameEndTurnEvent(game.currentPlayer),
);
}
/**
* Shuffles the word list using a per-game seeded rng
*/
async getShuffledWordList(game: Game) {
const rng = random.clone(game.seed);
const words = await this.wordService.listActive();
return words
.map((value) => ({ value, sort: rng.float() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value);
}
end(_game: Game, _winner: Player) {
throw Error('Not Implemented');
}
}
+40
View File
@@ -0,0 +1,40 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LobbyController } from './lobby.controller';
import { LobbyService } from './lobby.service';
import { Game } from '../games/entities/game.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Player } from '../games/entities/player.entity';
import { GameService } from '../games/services/games.service';
import { User } from 'src/users/entities/user.entity';
import { EventEmitterModule } from '@nestjs/event-emitter';
describe('LobbyController', () => {
let controller: LobbyController;
let _service: LobbyService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [LobbyController],
providers: [LobbyService, GameService],
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
entities: [Game, Player, User],
synchronize: true,
}),
TypeOrmModule.forFeature([Game, Player, User]),
EventEmitterModule.forRoot(),
],
}).compile();
controller = module.get<LobbyController>(LobbyController);
_service = module.get<LobbyService>(LobbyService);
});
describe('create', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
});
+134
View File
@@ -0,0 +1,134 @@
import {
Controller,
Get,
Post,
Param,
Delete,
UseGuards,
HttpException,
HttpStatus,
Sse,
} from '@nestjs/common';
import { LobbyService } from './lobby.service';
import { AuthGuard } from '../users/guards/auth.guard';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { GamePipe } from '../games/pipe/game.pipe';
import { Game } from '../games/entities/game.entity';
import { Observable, fromEvent, map } from 'rxjs';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { GameResponseDTO } from '../../events/dto/gameresponse.dto';
import { GetUser } from 'src/users/user.pipe';
import { User } from 'src/users/entities/user.entity';
@ApiTags('lobby')
@Controller('games')
export class LobbyController {
constructor(
private readonly lobbyService: LobbyService,
private eventEmitter: EventEmitter2,
) {}
@Post()
@ApiOperation({ summary: 'Create game' })
@ApiResponse({
status: 201,
description: 'Game successfully created.',
// type: ReadGameDto,
})
@ApiResponse({
status: 400,
description: 'You can only create one game at a time',
})
@ApiResponse({ status: 403, description: 'Forbidden.' })
@UseGuards(AuthGuard)
async create(@GetUser() user: User) {
const games = await this.lobbyService.findByOwner(user);
if (games) {
throw new HttpException(
'You can only create one game at a time',
HttpStatus.BAD_REQUEST,
);
}
const createdGame = await this.lobbyService.create(user);
return new GameResponseDTO(createdGame);
}
@Get()
@ApiOperation({ summary: 'List all games' })
async findAll(): Promise<GameResponseDTO[]> {
const games = await this.lobbyService.findAll();
return games.map((g) => new GameResponseDTO(g));
}
@Sse('/subscribe')
subscribe(): Observable<{ data: string }> {
return fromEvent(this.eventEmitter, 'sse.lobby').pipe(
map((payload) => {
return {
data: JSON.stringify(payload),
};
}),
);
}
@Get(':id')
@ApiOperation({ summary: 'Get game information' })
findOne(
@Param('id') id: string,
@Param('id', GamePipe) game: Game,
): GameResponseDTO {
return new GameResponseDTO(game);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete game' })
@UseGuards(AuthGuard)
async remove(
@Param('id') id: string,
@Param('id', GamePipe) game: Game,
@GetUser() user: User,
) {
if (game.ownerId != user.id) {
throw new HttpException(
'You can only delete games you own',
HttpStatus.FORBIDDEN,
);
}
await this.lobbyService.remove(game);
}
@Post(':id/join')
@ApiOperation({ summary: 'Join game' })
@UseGuards(AuthGuard)
async join(
@Param('id') id: string,
@Param('id', GamePipe) game: Game,
@GetUser() user: User,
) {
if (game.started) {
throw new HttpException('Game already started', HttpStatus.BAD_REQUEST);
}
if (game.players.some((p) => p.userId == user.id)) {
throw new HttpException(
'You are already in this game',
HttpStatus.BAD_REQUEST,
);
}
return await this.lobbyService.joinGame(game, user);
}
@Post(':id/leave')
@ApiOperation({ summary: "Leave a game that hasn't already started" })
@UseGuards(AuthGuard)
leave(
@Param('id') id: string,
@Param('id', GamePipe) game: Game,
@GetUser() user: User,
) {
if (game.started) {
throw new HttpException('Game already started', HttpStatus.BAD_REQUEST);
}
return this.lobbyService.leaveGame(game, user);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Module, forwardRef } from '@nestjs/common';
import { UsersModule } from 'src/users/users.module';
import { LobbyController } from './lobby.controller';
import { LobbyService } from './lobby.service';
import { GamesModule } from 'src/games/games.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Game } from 'src/games/entities/game.entity';
import { Player } from 'src/games/entities/player.entity';
@Module({
imports: [
UsersModule,
forwardRef(() => GamesModule),
TypeOrmModule.forFeature([Game]),
TypeOrmModule.forFeature([Player]),
],
controllers: [LobbyController],
providers: [LobbyService],
exports: [LobbyService],
})
export class LobbyModule {}
+34
View File
@@ -0,0 +1,34 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LobbyService } from './lobby.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { User } from 'src/users/entities/user.entity';
import { Game } from '../games/entities/game.entity';
import { Player } from '../games/entities/player.entity';
import { GameService } from '../games/services/games.service';
describe('LobbyService', () => {
let service: LobbyService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [LobbyService, GameService],
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
entities: [Game, Player, User],
synchronize: true,
}),
TypeOrmModule.forFeature([Game, Player, User]),
EventEmitterModule.forRoot(),
],
}).compile();
service = module.get<LobbyService>(LobbyService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from 'src/users/entities/user.entity';
import { randomUUID } from 'node:crypto';
import {
LobbyCreateEvent,
LobbyDeleteEvent,
LobbyJoinEvent,
} from 'events/lobby.events';
import { GameResponseDTO } from 'events/dto/gameresponse.dto';
import { GameDeleteEvent, GameJoinEvent } from 'events/game.events';
import { Player } from 'src/games/entities/player.entity';
import { Game } from 'src/games/entities/game.entity';
@Injectable()
export class LobbyService {
constructor(
@InjectRepository(Game) private gameRepository: Repository<Game>,
@InjectRepository(Player) private playerRepository: Repository<Player>,
private eventEmitter: EventEmitter2,
) {}
async create(owner: User) {
const createdGame = this.gameRepository.create();
createdGame.owner = Promise.resolve(owner);
createdGame.seed = randomUUID();
await this.gameRepository.save(createdGame);
const g = new GameResponseDTO(createdGame);
this.eventEmitter.emit('sse.lobby', new LobbyCreateEvent(g));
await this.joinGame(createdGame, owner);
return createdGame;
}
async joinGame(game: Game, user: User) {
const player = this.playerRepository.create();
player.user = Promise.resolve(user);
player.game = game;
await this.playerRepository.save(player);
this.eventEmitter.emit('sse.lobby', new LobbyJoinEvent(player, game));
this.eventEmitter.emit('sse.game.' + game.id, new GameJoinEvent(player));
}
findByOwner(user: User) {
return this.gameRepository.findOneBy({ owner: user });
}
leaveGame(_game: Game, _user: User) {
throw Error('not implemented');
}
kickPlayer(_game: Game, _user: User, _kickedPlayerId: string) {
throw Error('not implemented');
}
async findAll(): Promise<Game[]> {
return this.gameRepository.find();
}
async remove(game: Game) {
this.eventEmitter.emit('sse.lobby', new LobbyDeleteEvent(game.id));
this.eventEmitter.emit('sse.game.' + game.id, new GameDeleteEvent());
await this.gameRepository.remove(game);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NestFactory } from '@nestjs/core';
import * as session from 'express-session';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';
async function bootstrap() {
const redisClient = createClient();
redisClient.connect().catch(console.error);
const app = await NestFactory.create(AppModule);
app.enableCors({ origin: 'http://localhost:3000', credentials: true });
app.setGlobalPrefix(process.env.API_PREFIX);
const config = new DocumentBuilder()
.setTitle('Concept Game')
.setDescription('Concept')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
app.use(
session.default({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
name: 'concept.session',
store: new RedisStore({ client: redisClient, prefix: 'concept:' }),
}),
);
await app.listen(8765);
}
void bootstrap();
+13
View File
@@ -0,0 +1,13 @@
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryColumn({})
id: string;
@Column()
avatar: string;
@Column()
username: string;
}
+32
View File
@@ -0,0 +1,32 @@
import {
Injectable,
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request } from 'express';
import { SessionData } from 'express-session';
import { UsersService } from '../users.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private userService: UsersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const http = context.switchToHttp();
const request = http.getRequest<Request>();
if (!(request.session as SessionData).user) {
throw new HttpException('Please log in', HttpStatus.UNAUTHORIZED);
}
const user = await this.userService.findUser(request.session.user.id);
if (!user) {
throw new HttpException(
'Session expired. Please log in',
HttpStatus.UNAUTHORIZED,
);
}
request.user = user;
return true;
}
}
+20
View File
@@ -0,0 +1,20 @@
import {
createParamDecorator,
ExecutionContext,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request } from 'express';
export const GetUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<Request>();
if (!request.user) {
throw new HttpException(
'You are not authentified',
HttpStatus.UNAUTHORIZED,
);
}
return request.user;
},
);
+66
View File
@@ -0,0 +1,66 @@
import {
Controller,
Get,
Query,
Session,
HttpException,
HttpStatus,
Redirect,
Param,
UseGuards,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { UserResponseDTO } from '../../events/dto/userresponse.dto';
import { ApiTags } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { SessionData } from 'express-session';
import { AuthGuard } from './guards/auth.guard';
const configService = new ConfigService();
@ApiTags('auth')
@Controller()
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('/login')
@Redirect()
async login(@Query('code') code: string, @Session() session: SessionData) {
await this.usersService.createDiscordToken({ code }, session);
await this.usersService.discordLogin(session);
return { url: configService.get<string>('CLIENT_ENDPOINT') };
}
@Get('/logout')
@Redirect()
async logout(@Session() session: SessionData) {
await this.usersService.discordLogout(session);
return { url: configService.get<string>('CLIENT_ENDPOINT') };
}
@Get('/whoami')
@UseGuards(AuthGuard)
async remove(@Session() session: SessionData): Promise<UserResponseDTO> {
if (!session.tokenData || !session.user) {
throw new HttpException(
"Session doesn't contains auth information",
HttpStatus.UNAUTHORIZED,
);
}
if (new Date(session.tokenData.expires) > new Date()) {
return this.usersService.findUser(session.user.id);
}
await this.usersService.discordLogin(session);
return new UserResponseDTO(session.user);
}
@Get('/users/:id')
async get(@Param('id') id: string) {
const user = await this.usersService.findUser(id);
if (!user) {
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
}
return new UserResponseDTO(user);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { User } from './entities/user.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
+169
View File
@@ -0,0 +1,169 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { UserResponseDTO } from '../../events/dto/userresponse.dto';
import { URLSearchParams } from 'url';
import { User } from './entities/user.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { SessionData } from 'express-session';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User) private usersRepository: Repository<User>,
private configService: ConfigService,
) {}
async createDiscordToken(
{
code,
refresh_token,
}: {
code?: string;
refresh_token?: string;
},
session: SessionData,
): Promise<void> {
let body;
if (code) {
body = new URLSearchParams({
// client_id: this.configService.get('DISCORD_CLIENTID'),
// client_secret: this.configService.get('DISCORD_CLIENTSECRET'),
code,
grant_type: 'authorization_code',
redirect_uri:
this.configService.get('API_ENDPOINT') +
this.configService.get('API_PREFIX') +
'/login',
});
} else if (refresh_token) {
body = new URLSearchParams({
// client_id: this.configService.get('DISCORD_CLIENTID'),
// client_secret: this.configService.get('DISCORD_CLIENTSECRET'),
refresh_token,
grant_type: 'refresh_token',
});
} else {
throw new HttpException(
'Invalid internal call',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const req = await fetch('https://discord.com/api/v10/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(this.configService.get('DISCORD_CLIENTID') + ':' + this.configService.get('DISCORD_CLIENTSECRET')).toString('base64')}`,
},
body,
});
if (!req.ok) {
const data = await req.json();
if (data.error == 'invalid_grant') {
throw new HttpException(
'Invalid code in request',
HttpStatus.UNAUTHORIZED,
);
} else {
console.error(data);
throw new HttpException(
"Couldn't fetch Discord api",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
const data = await req.json();
if (!data.token_type || !data.access_token) {
console.error(data);
throw new HttpException(
'Invalid Discord API response',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const expires = new Date(Date.now() + data.expires_in);
session.tokenData = {
access_token: data.access_token,
token_type: data.token_type,
refresh_token: data.refresh_token,
expires,
};
}
async revokeDiscordToken(refresh_token: string) {
const body = new URLSearchParams({
client_id: this.configService.get('DISCORD_CLIENTID'),
client_secret: this.configService.get('DISCORD_CLIENTSECRET'),
token: refresh_token,
token_type_hint: 'refresh_token',
});
await fetch('https://discord.com/api/oauth2/token/revoke', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});
}
async discordLogin(
session: SessionData,
recursive: boolean = false,
): Promise<User> {
const req2 = await fetch('https://discord.com/api/users/@me', {
headers: {
authorization:
session.tokenData.token_type + ' ' + session.tokenData.access_token,
},
});
if (!req2.ok) {
const error = await req2.json();
if (error.error == 'invalid_grant' && !recursive) {
await this.createDiscordToken(
{
refresh_token: session.tokenData.refresh_token,
},
session,
);
return this.discordLogin(session);
} else {
console.error(error);
throw new HttpException(
"Couldn't fetch Discord api",
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
const data2 = await req2.json();
const user = await this.getOrCreateUser(
data2.id,
new UserResponseDTO(data2),
);
session.user = user;
return user;
}
async getOrCreateUser(id: string, data?: UserResponseDTO) {
let user = await this.usersRepository.findOneBy({ id });
if (!user) {
user = this.usersRepository.create({ ...data, id });
} else {
Object.assign(user, data);
}
await this.usersRepository.save(user);
return user;
}
async findUser(id: string) {
return await this.usersRepository.findOneBy({ id });
}
async discordLogout(session: SessionData) {
await this.revokeDiscordToken(session.tokenData.access_token);
delete session.user;
delete session.tokenData;
}
}
+7
View File
@@ -0,0 +1,7 @@
export class WordRecieveDTO {
constructor(partial: Partial<WordRecieveDTO>) {
Object.assign(this, partial);
}
value: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { Entity, ManyToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Word } from './word.entity';
@Entity()
export class Topic {
@PrimaryGeneratedColumn()
id: number;
name: string;
@ManyToMany((_type) => Word, (word) => word.topics)
words: Promise<Word[]>;
}
+34
View File
@@ -0,0 +1,34 @@
import { User } from 'src/users/entities/user.entity';
import {
Column,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Topic } from './topic.entity';
@Entity()
export class Word {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
value: string;
@JoinColumn({ name: 'ownerId' })
@ManyToOne(() => User)
owner: Promise<User>;
@Column({ name: 'ownerId' })
ownerId: string;
@Column({ default: false })
enabled: boolean;
@ManyToMany(() => Topic, (topic) => topic.words)
@JoinTable()
topics: Topic[];
}
+20
View File
@@ -0,0 +1,20 @@
import {
HttpException,
HttpStatus,
Injectable,
PipeTransform,
} from '@nestjs/common';
import { WordService } from '../word.service';
@Injectable()
export class WordPipe implements PipeTransform {
constructor(private readonly wordService: WordService) {}
async transform(id: number) {
const word = await this.wordService.getById(id);
if (!word) {
throw new HttpException('Word not found', HttpStatus.NOT_FOUND);
}
return word;
}
}
+102
View File
@@ -0,0 +1,102 @@
import {
Body,
Controller,
Delete,
Get,
HttpException,
HttpStatus,
Param,
ParseIntPipe,
Post,
Put,
Sse,
UseGuards,
} from '@nestjs/common';
import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from 'src/users/guards/auth.guard';
import { WordRecieveDTO } from './dto/wordrecieve.dto';
import { WordPipe } from './pipe/word.pipe';
import { Word } from './entities/word.entity';
import { GetUser } from 'src/users/user.pipe';
import { User } from 'src/users/entities/user.entity';
import { fromEvent, map, Observable } from 'rxjs';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { WordService } from './word.service';
import { WordResponseDTO } from 'events/dto/wordresponse.dto';
@ApiTags('words')
@Controller('words')
export class WordController {
constructor(
private readonly wordService: WordService,
private eventEmitter: EventEmitter2,
) {}
@Sse('/subscribe')
subscribe(): Observable<{ data: string }> {
return fromEvent(this.eventEmitter, this.wordService.sse_prefix).pipe(
map((payload) => {
return {
data: JSON.stringify(payload),
};
}),
);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a word' })
@UseGuards(AuthGuard)
async delete(@Param('id') id: number) {
const word = await this.wordService.getById(id);
if (!word) {
throw new HttpException('Word not found', HttpStatus.NOT_FOUND);
}
await this.wordService.delete(word);
}
@Post('/')
@ApiOperation({ summary: 'Submit a word' })
@ApiBody({ type: WordRecieveDTO })
@UseGuards(AuthGuard)
async create(@Body('value') value: string, @GetUser() user: User) {
const word = await this.wordService.create(value, user);
return new WordResponseDTO(word);
}
@Get('/')
@ApiOperation({ summary: 'List enabled words' })
async read() {
return (await this.wordService.listActive()).map(
(w) => new WordResponseDTO(w),
);
}
@Get('/all')
@ApiOperation({ summary: 'List all words' })
async readAll() {
return (await this.wordService.list()).map((w) => new WordResponseDTO(w));
}
@Put(':id/enable')
@ApiOperation({ summary: 'Enable a word' })
@UseGuards(AuthGuard)
async enable(
@Param('id', ParseIntPipe) _id: string,
@Param('id', WordPipe) word: Word,
) {
await this.wordService.enable(word);
return new WordResponseDTO(word);
}
@Put(':id/disable')
@ApiOperation({ summary: 'Disable a word' })
@UseGuards(AuthGuard)
async disable(
@Param('id', ParseIntPipe) _id: string,
@Param('id', WordPipe) word: Word,
) {
await this.wordService.disable(word);
return new WordResponseDTO(word);
}
}
+67
View File
@@ -0,0 +1,67 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from 'src/users/entities/user.entity';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Word } from './entities/word.entity';
import {
WordDeleteEvent,
WordCreateEvent,
WordEnableEvent,
WordDisableEvent,
} from 'events/word.events';
@Injectable()
export class WordService {
sse_prefix = 'sse.word';
constructor(
@InjectRepository(Word) private wordRepository: Repository<Word>,
private eventEmitter: EventEmitter2,
) {}
async getById(id: number) {
return await this.wordRepository.findOneBy({ id });
}
async delete(word: Word) {
const wordId = word.id;
await this.wordRepository.remove(word);
this.eventEmitter.emit(this.sse_prefix, new WordDeleteEvent(wordId));
}
async create(value: string, owner: User) {
const word = this.wordRepository.create();
word.owner = Promise.resolve(owner);
word.value = value;
try {
await this.wordRepository.save(word);
this.eventEmitter.emit(this.sse_prefix, new WordCreateEvent(word));
return word;
} catch {
throw new HttpException('Word already exists', HttpStatus.CONFLICT);
}
}
async listActive() {
const words = await this.wordRepository.findBy({ enabled: true });
return words;
}
async list() {
const words = await this.wordRepository.find();
return words;
}
async enable(word: Word) {
word.enabled = true;
await this.wordRepository.save(word);
this.eventEmitter.emit(this.sse_prefix, new WordEnableEvent(word.id));
}
async disable(word: Word) {
word.enabled = false;
await this.wordRepository.save(word);
this.eventEmitter.emit(this.sse_prefix, new WordDisableEvent(word.id));
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Word } from './entities/word.entity';
import { WordController } from './word.controller';
import { UsersModule } from 'src/users/users.module';
import { WordService } from './word.service';
@Module({
imports: [UsersModule, TypeOrmModule.forFeature([Word])],
controllers: [WordController],
providers: [WordService],
exports: [WordService],
})
export class WordModule {}
+28
View File
@@ -0,0 +1,28 @@
// import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { AppModule } from './../src/app.module';
import { INestApplication } from '@nestjs/common';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
// it('/ (GET)', () => {
// return request(app.getHttpServer())
// .get('/')
// .expect(200)
// .expect('Hello World!');
// });
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": "\\.e2e-spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
}
}
+18
View File
@@ -0,0 +1,18 @@
import { type User } from 'src/users/entities/user.entity';
declare module 'express-session' {
interface SessionData {
user?: User;
tokenData: {
access_token: string;
token_type: string;
refresh_token: string;
expires: Date;
};
}
}
declare module 'express' {
interface Request extends Request {
user: User;
}
}