diff --git a/package-lock.json b/package-lock.json index f309c75dab840a2d15dcc60299b2a0e8f20b10ea..4fb0133c28f38376fdc2d290c85f361d66566540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4096,6 +4096,14 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.7.2.tgz", "integrity": "sha512-AnnKk7hFQFmU/2I9YSQf3xw44ctnSFCfp3zE0N6W174gqe9fWG/2rKaKxROK7CcI3XtERpjEKFqts8o319Kf7A==" }, + "graphql-subscriptions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-2.0.0.tgz", + "integrity": "sha512-s6k2b8mmt9gF9pEfkxsaO1lTxaySfKoEJzEfmwguBbQ//Oq23hIXCfR1hm4kdh5hnR20RdwB+s3BCb+0duHSZA==", + "requires": { + "iterall": "^1.3.0" + } + }, "graphql-tag": { "version": "2.12.6", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", diff --git a/package.json b/package.json index 8a14835a6471624a96b102402d5fbadf55d19257..9fb2c099b19b489354d5076f832e7f1e5f2c76ea 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "apollo-server-express": "^3.5.0", "class-validator": "^0.13.2", "graphql": "^15.7.2", + "graphql-subscriptions": "^2.0.0", "pg": "^8.7.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", diff --git a/schema.gql b/schema.gql index 663470b7af09b428ab877763b182c2f13b547893..bf5c35c9e8362f3861fa70248a75f2db447909e5 100644 --- a/schema.gql +++ b/schema.gql @@ -54,6 +54,16 @@ type StatusModel { success: Boolean! } +type BroadcastPlayer { + UUID: String! + gameCode: String! + name: String! +} + +type StartGame { + gameCode: String! +} + type Query { quizzes: [Quiz!]! getQuizById(id: String!): Quiz @@ -118,3 +128,9 @@ input JoinPlayerInput { gameCode: String! name: String! } + +type Subscription { + onWaitForJoiningPlayerToGame(gameCode: String!, playerUUID: String!): BroadcastPlayer! + onWaitForStartingGame(gameCode: String!, playerUUID: String!): StartGame! + onDeletePlayerFromGame(gameCode: String!, playerUUID: String!): BroadcastPlayer! +} diff --git a/src/answers/answers.service.ts b/src/answers/answers.service.ts index d420625793519c0d1fb92fdf35337e7174cb68bb..bd88b56beceef3434600c4342f40ce3df9a0eb69 100644 --- a/src/answers/answers.service.ts +++ b/src/answers/answers.service.ts @@ -7,6 +7,7 @@ import { UpdateAnswerInput } from './dto/update-answer.input'; import { CREATE_ANSWER_BEFORE_DELETE, DELETE_CORRECT_ANSWER_ERROR, + MIN_ANSWERS, NO_ANSWER, } from '../constants'; @@ -28,7 +29,7 @@ export class AnswersService { if (!answer) throw new Error(NO_ANSWER); else if (answer.isCorrect) throw new Error(DELETE_CORRECT_ANSWER_ERROR); - else if (answer.question.answers.length < 3) { + else if (answer.question.answers.length < MIN_ANSWERS) { throw new Error(CREATE_ANSWER_BEFORE_DELETE); } diff --git a/src/app.module.ts b/src/app.module.ts index 86c46056756f48656454708fbac9f56ecccbd17f..3fe967354b0de7aa3c5fb04fcee197a51da278b8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { Player } from './players/models/player.model'; imports: [ GraphQLModule.forRoot({ autoSchemaFile: 'schema.gql', + installSubscriptionHandlers: true, }), ConfigModule.forRoot({ envFilePath: '.env', diff --git a/src/common/dto/subscriptions.args.ts b/src/common/dto/subscriptions.args.ts new file mode 100644 index 0000000000000000000000000000000000000000..a55f42d888866c65273011d8855128d8dcd831ee --- /dev/null +++ b/src/common/dto/subscriptions.args.ts @@ -0,0 +1,13 @@ +import { ArgsType, Field } from '@nestjs/graphql'; +import { IsUUID } from 'class-validator'; + +@ArgsType() +export class SubscriptionsArgs { + @Field({ nullable: false }) + @IsUUID('all') + gameCode: string; + + @Field({ nullable: false }) + @IsUUID('all') + playerUUID: string; +} diff --git a/src/constants.ts b/src/constants.ts index 0f417d01d4310f4baece94655b33dd22de931463..0a47651dbd0219b100cf74f67c0066a12c7574e7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,3 +12,5 @@ export const SMTHNG_WENT_WRONG = 'something went wrong'; export const GAME_NOT_FINISHED = 'the game is not finished yet'; export const CREAT_QUESTION_BEFORE_DELETE = 'create new question before delete'; export const NO_QUESTION = 'no such question'; +export const MIN_QUESTIONS = 2; +export const MIN_ANSWERS = 3; diff --git a/src/games/dto/start-game.type.ts b/src/games/dto/start-game.type.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f3ef2261371900e94fedd1eb6cafac0e9e25482 --- /dev/null +++ b/src/games/dto/start-game.type.ts @@ -0,0 +1,9 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { IsUUID } from 'class-validator'; + +@ObjectType() +export class StartGame { + @Field({ nullable: false }) + @IsUUID('all') + gameCode: string; +} diff --git a/src/games/games.resolver.ts b/src/games/games.resolver.ts index 858b69fc832440336263894995142137c99f0a82..d1a44011e5e73a4856d6554ad371169505be4582 100644 --- a/src/games/games.resolver.ts +++ b/src/games/games.resolver.ts @@ -1,4 +1,4 @@ -import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql'; import { GamesService } from './games.service'; import { Game } from './models/game.model'; import { StatusModel } from '../common/models/status.model'; @@ -6,6 +6,13 @@ import { Player } from '../players/models/player.model'; import { JoinPlayerInput } from './dto/join-player.input'; import { DeletePlayerArgs } from './dto/delete-player.args'; import { AnswerQuestionArgs } from './dto/answer-question.args'; +import { PubSub } from 'graphql-subscriptions'; +import { BroadcastPlayer } from '../players/dto/broadcast-player.type'; +import { StartGame } from './dto/start-game.type'; +import { SubscriptionsArgs } from '../common/dto/subscriptions.args'; +import { GameStatus } from './enums/statuses.enum'; + +const pubSub = new PubSub(); @Resolver() export class GamesResolver { @@ -24,22 +31,35 @@ export class GamesResolver { @Mutation((returns) => Game) async startGameByCode(@Args('code') code: string): Promise { - return await this.gamesService.startGameByCode(code); + const game = await this.gamesService.startGameByCode(code); + pubSub.publish('onWaitForStartingGame', { + onWaitForStartingGame: { gameCode: game.CODE }, + }); + return game; } @Mutation((returns) => StatusModel) async deletePlayerFromGame( @Args() deletePlayerData: DeletePlayerArgs, ): Promise { - const success = await this.gamesService.deletePlayer(deletePlayerData); - return { success }; + const player = await this.gamesService.deletePlayer(deletePlayerData); + pubSub.publish('onDeletePlayerFromGame', { + ...player, + ...deletePlayerData, + }); + return { success: true }; } @Mutation((returns) => Player) async joinPlayerToGame( @Args('joinPlayerInput') joinPlayerInput: JoinPlayerInput, ): Promise { - return await this.gamesService.joinPlayer(joinPlayerInput); + const player = await this.gamesService.joinPlayer(joinPlayerInput); + pubSub.publish('onWaitForJoiningPlayerToGame', { + ...player, + ...joinPlayerInput, + }); + return player; } @Mutation((returns) => Boolean) @@ -63,4 +83,54 @@ export class GamesResolver { async reportGameByCode(@Args('code') code: string): Promise { return await this.gamesService.getGameResult(code); } + + @Subscription((returns) => BroadcastPlayer, { + name: 'onWaitForJoiningPlayerToGame', + async filter(this: GamesResolver, _, args) { + return ( + (await this.gamesService.validatePlayer(args)) && + (await this.gamesService.validateGameStatus( + GameStatus.WAITING_FOR_PLAYERS, + args.gameCode, + )) + ); // спросить норм ли так + }, + async resolve(this: GamesResolver, payload, args) { + const { gameCode, name, id: UUID } = payload; + return { gameCode, name, UUID }; + }, + }) + playerJoined(@Args() subscriptionData: SubscriptionsArgs) { + return pubSub.asyncIterator('onWaitForJoiningPlayerToGame'); + } + + @Subscription((returns) => StartGame, { + name: 'onWaitForStartingGame', + async filter(this: GamesResolver, _, args) { + return await this.gamesService.validatePlayer(args); + }, + }) + async gameStarted(@Args() subscriptionData: SubscriptionsArgs) { + return pubSub.asyncIterator('onWaitForStartingGame'); + } + + @Subscription((returns) => BroadcastPlayer, { + name: 'onDeletePlayerFromGame', + async filter(this: GamesResolver, _, args) { + return ( + (await this.gamesService.validatePlayer(args)) && + (await this.gamesService.validateGameStatus( + GameStatus.PLAYING, + args.gameCode, + )) + ); // спросить норм ли так + }, + resolve: (payload) => { + const { gameCode, name, playerId: UUID } = payload; + return { gameCode, name, UUID }; + }, + }) + async playerDeleted(@Args() subscriptionData: SubscriptionsArgs) { + return pubSub.asyncIterator('onDeletePlayerFromGame'); + } } diff --git a/src/games/games.service.ts b/src/games/games.service.ts index 8510e8a709c45b5c04e53ed8a91d1bed44c9f3f9..793529282a82fd74d1a8ea170c985cc900e6b287 100644 --- a/src/games/games.service.ts +++ b/src/games/games.service.ts @@ -22,6 +22,7 @@ import { NO_QUIZ, SMTHNG_WENT_WRONG, } from '../constants'; +import { SubscriptionsArgs } from '../common/dto/subscriptions.args'; @Injectable() export class GamesService { @@ -81,20 +82,21 @@ export class GamesService { } const player = await this.playerService.createPlayer(data.name); - game.players = [player]; + game.players.push(player); await this.gamesRepository.save(game); return player; } - async deletePlayer(data: DeletePlayerArgs): Promise { + async deletePlayer(data: DeletePlayerArgs): Promise { const game = await this.getGameByCode(data.gameCode); - const isPlayerInGame = game.players.find( - (elem) => elem.id === data.playerId, - ); - if (!isPlayerInGame) throw new Error(NO_PLAYER); + const player = game.players.find((elem) => elem.id === data.playerId); + if (!player) throw new Error(NO_PLAYER); + + const isDeleted = await this.playerService.deletePlayer(data.playerId); + if (!isDeleted) throw new Error(SMTHNG_WENT_WRONG); - return await this.playerService.deletePlayer(data.playerId); + return player; } async answerQuestion(data: AnswerQuestionArgs): Promise { @@ -135,4 +137,19 @@ export class GamesService { return game; } + + async validatePlayer(data: SubscriptionsArgs): Promise { + const player = await this.playerService.getPlayerById(data.playerUUID); + if (!player) throw new Error(NO_PLAYER); + + return player.game.CODE === data.gameCode; + } + + async validateGameStatus( + status: GameStatus, + gameCode: string, + ): Promise { + const game = await this.gamesRepository.findOne({ CODE: gameCode }); + return game.status === status; + } } diff --git a/src/players/dto/broadcast-player.type.ts b/src/players/dto/broadcast-player.type.ts new file mode 100644 index 0000000000000000000000000000000000000000..cbba16a236040d25fa218b4b9e00e782621e8590 --- /dev/null +++ b/src/players/dto/broadcast-player.type.ts @@ -0,0 +1,17 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { IsUUID } from 'class-validator'; + +@ObjectType() +export class BroadcastPlayer { + @Field({ nullable: false }) + @IsUUID('all') + UUID: string; + + @Field({ nullable: false }) + @IsUUID('all') + gameCode: string; + + @Field({ nullable: false }) + @IsUUID('all') + name: string; +} diff --git a/src/questions/questions.service.ts b/src/questions/questions.service.ts index dc5d0fffed0633de868a4b141489aaec61d26753..15786c5906c8f416c0bc288249832d0938f67c36 100644 --- a/src/questions/questions.service.ts +++ b/src/questions/questions.service.ts @@ -6,7 +6,11 @@ import { CreateQuestionInput } from './dto/create-question.input'; import { AnswersService } from '../answers/answers.service'; import { UpdateQuestionInput } from './dto/update-question.input'; import { QuizzesService } from '../quizzes/quizzes.service'; -import { CREAT_QUESTION_BEFORE_DELETE, NO_QUESTION } from '../constants'; +import { + CREAT_QUESTION_BEFORE_DELETE, + MIN_QUESTIONS, + NO_QUESTION, +} from '../constants'; @Injectable() export class QuestionsService { @@ -37,7 +41,7 @@ export class QuestionsService { }); if (!question) throw new Error(NO_QUESTION); - else if (question.quiz.questions.length < 2) { + else if (question.quiz.questions.length < MIN_QUESTIONS) { throw new Error(CREAT_QUESTION_BEFORE_DELETE); }