diff --git a/schema.gql b/schema.gql index 95c03f6ff58a429c01ad3e3644217adfa56fdd6c..663470b7af09b428ab877763b182c2f13b547893 100644 --- a/schema.gql +++ b/schema.gql @@ -20,6 +20,28 @@ type Question { answers: [Answer!]! } +"""player """ +type Player { + id: ID! + name: String! + game: Game! + answers: [Answer!]! +} + +"""game """ +type Game { + CODE: String! + status: GameStatus! + quiz: Quiz! + players: [Player!] +} + +enum GameStatus { + WAITING_FOR_PLAYERS + PLAYING + FINISHED +} + """quiz """ type Quiz { id: ID! @@ -27,7 +49,7 @@ type Quiz { questions: [Question!]! } -"""deleting status""" +"""delete status""" type StatusModel { success: Boolean! } @@ -36,6 +58,9 @@ type Query { quizzes: [Quiz!]! getQuizById(id: String!): Quiz getQuestionById(id: String!): Question + activatedGames: [Game!]! + activatedGamesByCode(code: String!): Game + reportGameByCode(code: String!): Game! } type Mutation { @@ -47,6 +72,12 @@ type Mutation { deleteAnswerById(id: String!): StatusModel! updateAnswerById(updateAnsData: UpdateAnswerInput!): Answer! updateRightAnswer(id: String!): [Answer!]! + activateGame(quizId: String!): Game! + deactivateGameByCode(code: String!): StatusModel! + startGameByCode(code: String!): Game! + deletePlayerFromGame(gameCode: String!, playerId: String!): StatusModel! + joinPlayerToGame(joinPlayerInput: JoinPlayerInput!): Player! + answerQuestionById(questionId: String!, playerId: String!, answerId: String!): Boolean! } input CreateQuizInput { @@ -82,3 +113,8 @@ input UpdateAnswerInput { text: String! imgURL: String } + +input JoinPlayerInput { + gameCode: String! + name: String! +} diff --git a/src/answers/answers.service.ts b/src/answers/answers.service.ts index 623dbded87c29aa82cef2e693a89a1b7d89941f4..d420625793519c0d1fb92fdf35337e7174cb68bb 100644 --- a/src/answers/answers.service.ts +++ b/src/answers/answers.service.ts @@ -58,4 +58,10 @@ export class AnswersService { return await this.answersRepository.save([answer, changedAnswer]); } + + async getAnswerById(id: string): Promise { + return await this.answersRepository.findOne(id, { + relations: ['question'], + }); + } } diff --git a/src/answers/models/answer.model.ts b/src/answers/models/answer.model.ts index febdbca48f50586a4dbb9f4271c3438204c96917..be831eb6fa539f9625ec12c42fb9623ccfd0d804 100644 --- a/src/answers/models/answer.model.ts +++ b/src/answers/models/answer.model.ts @@ -1,11 +1,5 @@ import { Field, ID, ObjectType } from '@nestjs/graphql'; -import { - Column, - Entity, - Generated, - ManyToOne, - PrimaryGeneratedColumn, -} from 'typeorm'; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { Question } from '../../questions/models/question.model'; import { IsOptional } from 'class-validator'; @@ -14,7 +8,6 @@ import { IsOptional } from 'class-validator'; export class Answer { @Field((type) => ID) @PrimaryGeneratedColumn('uuid') - @Generated('uuid') id: string; @Field() diff --git a/src/app.module.ts b/src/app.module.ts index 75843f1bf0c283e6d007c66d739ff2ab83856356..86c46056756f48656454708fbac9f56ecccbd17f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,10 @@ import { QuestionsModule } from './questions/questions.module'; import { AnswersModule } from './answers/answers.module'; import { Question } from './questions/models/question.model'; import { Answer } from './answers/models/answer.model'; +import { GamesModule } from './games/games.module'; +import { Game } from './games/models/game.model'; +import { PlayersModule } from './players/players.module'; +import { Player } from './players/models/player.model'; @Module({ imports: [ @@ -24,12 +28,14 @@ import { Answer } from './answers/models/answer.model'; username: process.env.POSTGRES_USER || 'postgres', password: process.env.POSTGRES_PASSWORD || 'root', database: process.env.POSTGRES_DB || 'kahoot', - entities: [Quiz, Question, Answer], + entities: [Quiz, Question, Answer, Game, Player], synchronize: true, }), QuizzesModule, QuestionsModule, AnswersModule, + GamesModule, + PlayersModule, ], }) export class AppModule {} diff --git a/src/common/models/status.model.ts b/src/common/models/status.model.ts index 6bcdd3bea5dea57c1acf4574a5e6949d89506dfc..e3fb493974c58329a206cb7241d80f4e8df3c136 100644 --- a/src/common/models/status.model.ts +++ b/src/common/models/status.model.ts @@ -1,6 +1,6 @@ import { Field, ObjectType } from '@nestjs/graphql'; -@ObjectType({ description: 'deleting status' }) +@ObjectType({ description: 'delete status' }) export class StatusModel { @Field({ nullable: false }) success: boolean; diff --git a/src/games/dto/answer-question.args.ts b/src/games/dto/answer-question.args.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a3a480efe6fbcc5e8e27a0656c4ad5461b58b8e --- /dev/null +++ b/src/games/dto/answer-question.args.ts @@ -0,0 +1,17 @@ +import { ArgsType, Field } from '@nestjs/graphql'; +import { IsUUID } from 'class-validator'; + +@ArgsType() +export class AnswerQuestionArgs { + @Field((type) => String, { nullable: false }) + @IsUUID('all') + questionId: string; + + @Field((type) => String, { nullable: false }) + @IsUUID('all') + playerId: string; + + @Field((type) => String, { nullable: false }) + @IsUUID('all') + answerId: string; +} diff --git a/src/games/dto/delete-player.args.ts b/src/games/dto/delete-player.args.ts new file mode 100644 index 0000000000000000000000000000000000000000..d890da553a00ea04663aa5b2feb7dec37d0a39aa --- /dev/null +++ b/src/games/dto/delete-player.args.ts @@ -0,0 +1,13 @@ +import { ArgsType, Field } from '@nestjs/graphql'; +import { IsUUID } from 'class-validator'; + +@ArgsType() +export class DeletePlayerArgs { + @Field((type) => String, { nullable: false }) + @IsUUID('all') + gameCode: string; + + @Field((type) => String, { nullable: false }) + @IsUUID('all') + playerId: string; +} diff --git a/src/games/dto/join-player.input.ts b/src/games/dto/join-player.input.ts new file mode 100644 index 0000000000000000000000000000000000000000..271df008a0d6c821a274b59c629ecf2523310469 --- /dev/null +++ b/src/games/dto/join-player.input.ts @@ -0,0 +1,13 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { IsUUID, Length } from 'class-validator'; + +@InputType() +export class JoinPlayerInput { + @Field({ nullable: false }) + @IsUUID('all') + gameCode: string; + + @Field({ nullable: false }) + @Length(2, 10) + name: string; +} diff --git a/src/games/enums/statuses.enum.ts b/src/games/enums/statuses.enum.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb9b353a2017e14eb7c772b97658ed6d3fc3322a --- /dev/null +++ b/src/games/enums/statuses.enum.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum GameStatus { + WAITING_FOR_PLAYERS, + PLAYING, + FINISHED, +} + +registerEnumType(GameStatus, { + name: 'GameStatus', +}); diff --git a/src/games/games.module.ts b/src/games/games.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..f44ccb1d541dd2152224af315a18b1efa9439755 --- /dev/null +++ b/src/games/games.module.ts @@ -0,0 +1,19 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { GamesService } from './games.service'; +import { GamesResolver } from './games.resolver'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Game } from './models/game.model'; +import { QuizzesModule } from '../quizzes/quizzes.module'; +import { PlayersModule } from '../players/players.module'; +import { AnswersModule } from '../answers/answers.module'; + +@Module({ + providers: [GamesService, GamesResolver], + imports: [ + TypeOrmModule.forFeature([Game]), + forwardRef(() => QuizzesModule), + forwardRef(() => PlayersModule), + forwardRef(() => AnswersModule), + ], +}) +export class GamesModule {} diff --git a/src/games/games.resolver.ts b/src/games/games.resolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..858b69fc832440336263894995142137c99f0a82 --- /dev/null +++ b/src/games/games.resolver.ts @@ -0,0 +1,66 @@ +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { GamesService } from './games.service'; +import { Game } from './models/game.model'; +import { StatusModel } from '../common/models/status.model'; +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'; + +@Resolver() +export class GamesResolver { + constructor(private readonly gamesService: GamesService) {} + + @Mutation((returns) => Game) + async activateGame(@Args('quizId') quizId: string): Promise { + return await this.gamesService.createGame(quizId); + } + + @Mutation((returns) => StatusModel) + async deactivateGameByCode(@Args('code') code: string) { + const success = await this.gamesService.deleteGame(code); + return { success }; + } + + @Mutation((returns) => Game) + async startGameByCode(@Args('code') code: string): Promise { + return await this.gamesService.startGameByCode(code); + } + + @Mutation((returns) => StatusModel) + async deletePlayerFromGame( + @Args() deletePlayerData: DeletePlayerArgs, + ): Promise { + const success = await this.gamesService.deletePlayer(deletePlayerData); + return { success }; + } + + @Mutation((returns) => Player) + async joinPlayerToGame( + @Args('joinPlayerInput') joinPlayerInput: JoinPlayerInput, + ): Promise { + return await this.gamesService.joinPlayer(joinPlayerInput); + } + + @Mutation((returns) => Boolean) + async answerQuestionById( + @Args() answerQuestionData: AnswerQuestionArgs, + ): Promise { + return this.gamesService.answerQuestion(answerQuestionData); + } + + @Query((returns) => [Game]) + async activatedGames(): Promise { + return await this.gamesService.getAllGames(); + } + + @Query((returns) => Game, { nullable: true }) + async activatedGamesByCode(@Args('code') code: string): Promise { + return await this.gamesService.getGameByCode(code); + } + + @Query((returns) => Game) + async reportGameByCode(@Args('code') code: string): Promise { + return await this.gamesService.getGameResult(code); + } +} diff --git a/src/games/games.service.ts b/src/games/games.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..8510e8a709c45b5c04e53ed8a91d1bed44c9f3f9 --- /dev/null +++ b/src/games/games.service.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Game } from './models/game.model'; +import { Repository } from 'typeorm'; +import { QuizzesService } from '../quizzes/quizzes.service'; +import { GameStatus } from './enums/statuses.enum'; +import { Player } from '../players/models/player.model'; +import { PlayersService } from '../players/players.service'; +import { JoinPlayerInput } from './dto/join-player.input'; +import { DeletePlayerArgs } from './dto/delete-player.args'; +import { AnswerQuestionArgs } from './dto/answer-question.args'; +import { AnswersService } from '../answers/answers.service'; +import { + ALREADY_ANSWERED, + GAME_HAS_STARTED, + GAME_IS_NOT_PLAYING, + GAME_NOT_ACTIVATED, + GAME_NOT_FINISHED, + NO_ANSWER, + NO_GAME, + NO_PLAYER, + NO_QUIZ, + SMTHNG_WENT_WRONG, +} from '../constants'; + +@Injectable() +export class GamesService { + constructor( + @InjectRepository(Game) + private readonly gamesRepository: Repository, + private readonly quizzesService: QuizzesService, + private readonly playerService: PlayersService, + private readonly answerService: AnswersService, + ) {} + + async createGame(quizId: string): Promise { + const quiz = await this.quizzesService.getQuizById(quizId); + if (!quiz) throw new Error(NO_QUIZ); + + const game = new Game(); + game.quiz = quiz; + return this.gamesRepository.save(game); + } + + async deleteGame(code: string): Promise { + const { affected } = await this.gamesRepository.delete({ CODE: code }); + return !!affected; + } + + async getAllGames(): Promise { + return await this.gamesRepository.find({ + relations: ['players'], + }); + } + + async getGameByCode(code: string): Promise { + return await this.gamesRepository.findOne( + { CODE: code }, + { + relations: ['players'], + }, + ); + } + + async startGameByCode(code: string): Promise { + const game = await this.getGameByCode(code); + if (!game) throw new Error(NO_GAME); + else if (game.status === GameStatus.PLAYING) { + throw new Error(GAME_HAS_STARTED); + } + + game.status = GameStatus.PLAYING; + return await this.gamesRepository.save(game); + } + + async joinPlayer(data: JoinPlayerInput): Promise { + const game = await this.getGameByCode(data.gameCode); + if (!game) throw new Error(NO_GAME); + else if (game.status !== GameStatus.WAITING_FOR_PLAYERS) { + throw new Error(GAME_NOT_ACTIVATED); + } + + const player = await this.playerService.createPlayer(data.name); + game.players = [player]; + await this.gamesRepository.save(game); + + return player; + } + + 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); + + return await this.playerService.deletePlayer(data.playerId); + } + + async answerQuestion(data: AnswerQuestionArgs): Promise { + const player = await this.playerService.getPlayerById(data.playerId); + if (!player) throw new Error(NO_PLAYER); + else if (player.game.status !== GameStatus.PLAYING) { + throw new Error(GAME_IS_NOT_PLAYING); + } + + const wasAnswered = player.answers.find( + (elem) => elem.question.id === data.questionId, + ); + if (wasAnswered) { + throw new Error(ALREADY_ANSWERED); + } + + const answer = await this.answerService.getAnswerById(data.answerId); + if (!answer || answer.question.id !== data.questionId) { + throw new Error(NO_ANSWER); + } + + player.answers.push(answer); + const updated = await this.playerService.updatePlayer(player); + if (!updated) throw new Error(SMTHNG_WENT_WRONG); + + return answer.isCorrect; + } + + async getGameResult(code: string): Promise { + const game = await this.gamesRepository.findOne( + { CODE: code }, + { relations: ['players', 'players.answers'] }, + ); + if (!game) throw new Error(NO_GAME); + else if (game.status !== GameStatus.FINISHED) { + throw new Error(GAME_NOT_FINISHED); + } + + return game; + } +} diff --git a/src/games/models/game.model.ts b/src/games/models/game.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..2990657d6d1142c26099a450acc9ee407c100d53 --- /dev/null +++ b/src/games/models/game.model.ts @@ -0,0 +1,44 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { + Column, + Entity, + Generated, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { IsInt } from 'class-validator'; +import { Quiz } from '../../quizzes/models/quiz.model'; +import { Player } from '../../players/models/player.model'; +import { GameStatus } from '../enums/statuses.enum'; + +@ObjectType({ description: 'game ' }) +@Entity() +export class Game { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Field({ nullable: false }) + @Column({ unique: true }) + @Generated('uuid') + CODE: string; + + @Field((type) => GameStatus, { nullable: false, defaultValue: 0 }) + @Column({ nullable: false, default: 0 }) + @IsInt() + status: number; + + @Field((type) => Quiz) + @ManyToOne(() => Quiz, (quiz: Quiz) => quiz.games, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + quiz: Quiz; + + @Field((type) => [Player], { nullable: true }) + @OneToMany(() => Player, (player: Player) => player.game, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + players?: Player[] | null; +} diff --git a/src/players/models/player.model.ts b/src/players/models/player.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..4750b4a5f460c7aa5d0353997ba209a3c39215fb --- /dev/null +++ b/src/players/models/player.model.ts @@ -0,0 +1,37 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { + Column, + Entity, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + JoinTable, +} from 'typeorm'; +import { Length } from 'class-validator'; +import { Game } from '../../games/models/game.model'; +import { Answer } from '../../answers/models/answer.model'; + +@ObjectType({ description: 'player ' }) +@Entity() +export class Player { + @Field((type) => ID) + @PrimaryGeneratedColumn('uuid') + id: string; + + @Field({ nullable: false }) + @Column({ nullable: false }) + @Length(2, 10) + name: string; + + @Field((type) => Game) + @ManyToOne(() => Game, (game: Game) => game.players, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + game: Game; + + @Field((type) => [Answer]) + @ManyToMany(() => Answer) + @JoinTable() + answers: Answer[]; +} diff --git a/src/players/players.module.ts b/src/players/players.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b025b01122a0f48f5ba5093ba292f3a82a2018a --- /dev/null +++ b/src/players/players.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PlayersService } from './players.service'; +import { PlayersResolver } from './players.resolver'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Player } from './models/player.model'; + +@Module({ + providers: [PlayersService, PlayersResolver], + imports: [TypeOrmModule.forFeature([Player])], + exports: [PlayersService], +}) +export class PlayersModule {} diff --git a/src/players/players.resolver.ts b/src/players/players.resolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbb10f234ad089aff1cb154e3310986420f2267f --- /dev/null +++ b/src/players/players.resolver.ts @@ -0,0 +1,4 @@ +import { Resolver } from '@nestjs/graphql'; + +@Resolver() +export class PlayersResolver {} diff --git a/src/players/players.service.ts b/src/players/players.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad03d9ccb56f380c9554fc84498953743be0f9ab --- /dev/null +++ b/src/players/players.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Player } from './models/player.model'; +import { Repository } from 'typeorm'; + +@Injectable() +export class PlayersService { + constructor( + @InjectRepository(Player) + private readonly playersRepository: Repository, + ) {} + + async createPlayer(name: string): Promise { + const player = new Player(); + player.name = name; + return await this.playersRepository.save(player); + } + + async deletePlayer(id: string): Promise { + const { affected } = await this.playersRepository.delete(id); + return !!affected; + } + + async getPlayerById(id: string): Promise { + return this.playersRepository.findOne(id, { + relations: ['game', 'answers', 'answers.question'], + }); + } + + async updatePlayer(data: Player): Promise { + return await this.playersRepository.save(data); + } +} diff --git a/src/questions/models/question.model.ts b/src/questions/models/question.model.ts index 5637efebe0902de170524d581c044997c3f84a26..3f0437bcba1fd0b9dd6fe79f2a19e35123bc3c34 100644 --- a/src/questions/models/question.model.ts +++ b/src/questions/models/question.model.ts @@ -2,7 +2,6 @@ import { Field, ID, ObjectType } from '@nestjs/graphql'; import { Column, Entity, - Generated, ManyToOne, OneToMany, PrimaryGeneratedColumn, @@ -15,7 +14,6 @@ import { Answer } from '../../answers/models/answer.model'; export class Question { @Field((type) => ID) @PrimaryGeneratedColumn('uuid') - @Generated('uuid') id: string; @Field({ nullable: false }) @@ -29,12 +27,14 @@ export class Question { @Field((type) => Quiz) @ManyToOne(() => Quiz, (quiz) => quiz.questions, { onDelete: 'CASCADE', + onUpdate: 'CASCADE', }) quiz: Quiz; @Field((type) => [Answer]) @OneToMany(() => Answer, (answer) => answer.question, { onDelete: 'CASCADE', + onUpdate: 'CASCADE', }) answers: Answer[]; } diff --git a/src/quizzes/models/quiz.model.ts b/src/quizzes/models/quiz.model.ts index 22cbf1ef02bf87a4db0521b528c52f720609d826..d28a2f9a33e2eee2985fbcb862cca3bc12fd31c1 100644 --- a/src/quizzes/models/quiz.model.ts +++ b/src/quizzes/models/quiz.model.ts @@ -1,19 +1,13 @@ import { Field, ID, ObjectType } from '@nestjs/graphql'; -import { - Column, - Entity, - Generated, - OneToMany, - PrimaryGeneratedColumn, -} from 'typeorm'; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { Question } from '../../questions/models/question.model'; +import { Game } from '../../games/models/game.model'; @ObjectType({ description: 'quiz ' }) @Entity() export class Quiz { @Field((type) => ID) @PrimaryGeneratedColumn('uuid') - @Generated('uuid') id: string; @Field((type) => String) @@ -23,6 +17,13 @@ export class Quiz { @Field((type) => [Question]) @OneToMany(() => Question, (question) => question.quiz, { onDelete: 'CASCADE', + onUpdate: 'CASCADE', }) questions: Question[]; + + @OneToMany(() => Game, (game: Game) => game.quiz, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + games: Game[]; }