diff --git a/.gitignore b/.gitignore index 5e5939e1ab6ae7837562203271abf4e03d1b1519..f28de014e4e466a79afa198425ef6a4f4050f490 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ lerna-debug.log* !.vscode/extensions.json !/.env +/.todo diff --git a/schema.gql b/schema.gql index eea880cc96d6b0ad7023b4c92dd1de837b94eaf3..3ee271e87ccbea8e2d8dca63cadaa4746f63c1d5 100644 --- a/schema.gql +++ b/schema.gql @@ -20,12 +20,27 @@ type Question { answers: [Answer!]! } +"""message""" +type ChatMessage { + id: ID! + message: String! + game: Game! + player: Player! + created_at: DateTime! +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + """player """ type Player { id: ID! name: String! game: Game! answers: [Answer!]! + chatMessages: [ChatMessage!]! } """game """ @@ -34,6 +49,7 @@ type Game { status: GameStatus! quiz: Quiz! players: [Player!] + chatMessages: [ChatMessage!] } enum GameStatus { @@ -60,6 +76,10 @@ type BroadcastPlayer { name: String! } +type StartGame { + gameCode: String! +} + type BroadcastPlayerForChartGame { player: BroadcastPlayer! isRight: Boolean! @@ -79,8 +99,11 @@ type BroadcastPlayingGame { answers: [BroadcastAnswerForChartGame!]! } -type StartGame { - gameCode: String! +type BroadcastChatGame { + UUID: ID! + message: String! + player: BroadcastPlayer! + time: DateTime! } type Query { @@ -90,6 +113,12 @@ type Query { activatedGames: [Game!]! activatedGamesByCode(code: String!): Game reportGameByCode(code: String!): Game! + chatMessageOfGameByCode(code: ID!, offset: Int = 0, limit: Int = 100, order: ChatTimeOrder = ASC): [ChatMessage!]! +} + +enum ChatTimeOrder { + DESC + ASC } type Mutation { @@ -104,9 +133,10 @@ type Mutation { activateGame(quizId: String!): Game! deactivateGameByCode(code: String!): StatusModel! startGameByCode(code: String!): Game! - deletePlayerFromGame(gameCode: String!, playerId: String!): StatusModel! + deletePlayerFromGame(gameCode: String!, playerId: ID!): StatusModel! joinPlayerToGame(joinPlayerInput: JoinPlayerInput!): Player! answerQuestionById(questionId: String!, playerId: String!, answerId: String!): Boolean! + sendMessageToChat(message: String!, playerUUID: ID!): ChatMessage! } input CreateQuizInput { @@ -153,4 +183,5 @@ type Subscription { onWaitForStartingGame(gameCode: String!, playerUUID: String!): StartGame! onDeletePlayerFromGame(gameCode: String!, playerUUID: String!): BroadcastPlayer! onPlayingGame(gameCode: String!, playerUUID: String!): BroadcastPlayingGame! + onChatGame(gameCode: String!, playerUUID: String!): BroadcastChatGame! } diff --git a/src/app.module.ts b/src/app.module.ts index ce83987a15bbbbca35b9a6775bd6a0db98cf063c..17d0d110466ad6daf3c0331f34f0e706dc908dc3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,8 @@ import { PlayersModule } from './players/players.module'; import { Player } from './players/models/player.model'; import { BroadcastModule } from './broadcast/broadcast.module'; import { PubSubModule } from './common/modules/pubSub.module'; +import { ChatModule } from './chat/chat.module'; +import { ChatMessage } from './chat/models/message.model'; @Module({ imports: [ @@ -31,7 +33,7 @@ import { PubSubModule } from './common/modules/pubSub.module'; username: process.env.POSTGRES_USER || 'postgres', password: process.env.POSTGRES_PASSWORD || 'root', database: process.env.POSTGRES_DB || 'kahoot', - entities: [Quiz, Question, Answer, Game, Player], + entities: [Quiz, Question, Answer, Game, Player, ChatMessage], synchronize: true, }), QuizzesModule, @@ -41,6 +43,7 @@ import { PubSubModule } from './common/modules/pubSub.module'; PlayersModule, BroadcastModule, PubSubModule, + ChatModule, ], }) export class AppModule {} diff --git a/src/broadcast/broadcast.repository.ts b/src/broadcast/broadcast.repository.ts index ee1ee3c8fd2073b09dfc30602d33d83688f052cd..ca139939a4e23f4f1581ffb0a36953e482a02f43 100644 --- a/src/broadcast/broadcast.repository.ts +++ b/src/broadcast/broadcast.repository.ts @@ -25,8 +25,6 @@ export class BroadcastRepository { } deleteGame(code: string): void { - if (this.getGameByCode(code)) { - delete this.gameStorage[code]; - } + delete this.gameStorage[code]; } } diff --git a/src/broadcast/broadcast.service.ts b/src/broadcast/broadcast.service.ts index 231cc9ca93ad39704b9071a44546958b7f824a21..68ffa75de7d08da2f40ade48764dce2d88b6532d 100644 --- a/src/broadcast/broadcast.service.ts +++ b/src/broadcast/broadcast.service.ts @@ -61,18 +61,18 @@ export class BroadcastService { this.broadcastRepository.addPlayers(brPlayersArray, game.CODE); } - async playGame(game: Game) { + async playGame(game: Game): Promise { this.broadcastRepository.statusUpdate(game.CODE, game.status); const gameFromStorage = this.broadcastRepository.getGameByCode(game.CODE); - const brGame = new BroadcastPlayingGame(); let numberOfQuestion = 0; - brGame.startTimeSec = TIME_TO_ONE_QUESTION_SEC; - brGame.currentTimeSec = TIME_TO_ONE_QUESTION_SEC; - brGame.gameCode = gameFromStorage.CODE; - brGame.gameStatus = gameFromStorage.status; - brGame.answers = gameFromStorage.answers; - brGame.currentQuestionUUID = - gameFromStorage.quiz.questions[numberOfQuestion].id; + const brGame: BroadcastPlayingGame = { + startTimeSec: TIME_TO_ONE_QUESTION_SEC, + currentTimeSec: TIME_TO_ONE_QUESTION_SEC, + gameCode: gameFromStorage.CODE, + gameStatus: gameFromStorage.status, + answers: gameFromStorage.answers, + currentQuestionUUID: gameFromStorage.quiz.questions[numberOfQuestion].id, + }; const start = async () => { this.pubSub.publish(GAME_PROCESS_EVENT, { @@ -88,11 +88,11 @@ export class BroadcastService { ); brGame.gameStatus = GameStatus.FINISHED; return brGame; - } else { - brGame.currentQuestionUUID = - gameFromStorage.quiz.questions[numberOfQuestion].id; - brGame.currentTimeSec = TIME_TO_ONE_QUESTION_SEC; } + + brGame.currentQuestionUUID = + gameFromStorage.quiz.questions[numberOfQuestion].id; + brGame.currentTimeSec = TIME_TO_ONE_QUESTION_SEC; } setTimeout(start, 1000); }; @@ -114,7 +114,9 @@ export class BroadcastService { game.players = newPlayersArr; } - deleteGame(code: string): void { - this.broadcastRepository.deleteGame(code); + deleteGame(code: string) { + if (this.broadcastRepository.getGameByCode(code)) { + this.broadcastRepository.deleteGame(code); + } } } diff --git a/src/broadcast/dto/broadcast-chat-game.type.ts b/src/broadcast/dto/broadcast-chat-game.type.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c0f105b3b2f3b137f4c651ae58df1c77fdc9826 --- /dev/null +++ b/src/broadcast/dto/broadcast-chat-game.type.ts @@ -0,0 +1,21 @@ +import { Field, GraphQLISODateTime, ID, ObjectType } from '@nestjs/graphql'; +import { IsString, IsUUID, Length } from 'class-validator'; +import { BroadcastPlayer } from './broadcast-player.type'; + +@ObjectType() +export class BroadcastChatGame { + @Field((type) => ID, { nullable: false }) + @IsUUID('all') + UUID: string; + + @Field((type) => String, { nullable: false }) + @Length(1, 50) + @IsString() + message: string; + + @Field((type) => BroadcastPlayer, { nullable: false }) + player: BroadcastPlayer; + + @Field((type) => GraphQLISODateTime, { nullable: false }) + time: Date; +} diff --git a/src/chat/chat.module.ts b/src/chat/chat.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c8fda583efa58eedb97fb1608cbda25790a8e9d --- /dev/null +++ b/src/chat/chat.module.ts @@ -0,0 +1,17 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { ChatService } from './chat.service'; +import { ChatResolver } from './chat.resolver'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ChatMessage } from './models/message.model'; +import { PlayersModule } from '../players/players.module'; +import { GamesModule } from '../games/games.module'; + +@Module({ + providers: [ChatService, ChatResolver], + imports: [ + TypeOrmModule.forFeature([ChatMessage]), + forwardRef(() => PlayersModule), + forwardRef(() => GamesModule), + ], +}) +export class ChatModule {} diff --git a/src/chat/chat.resolver.ts b/src/chat/chat.resolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..6545bebafd95e8294b85e5f1b761c723b4c43efc --- /dev/null +++ b/src/chat/chat.resolver.ts @@ -0,0 +1,34 @@ +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { ChatService } from './chat.service'; +import { ChatMessage } from './models/message.model'; +import { CreateMessageArgs } from './dto/create-message.args'; +import { GetMessagesArgs } from './dto/get-messages.args'; +import { Inject } from '@nestjs/common'; +import { PUB_SUB } from '../common/modules/pubSub.module'; +import { PubSub } from 'graphql-subscriptions'; +import { MESSAGE_TO_CHAT_EVENT } from '../constants'; + +@Resolver() +export class ChatResolver { + constructor( + private readonly chatService: ChatService, + @Inject(PUB_SUB) + private readonly pubSub: PubSub, + ) {} + + @Mutation(() => ChatMessage) + async sendMessageToChat( + @Args() createMsgData: CreateMessageArgs, + ): Promise { + const msg = await this.chatService.createMessage(createMsgData); + await this.pubSub.publish(MESSAGE_TO_CHAT_EVENT, { onChatGame: msg }); + return msg; + } + + @Query(() => [ChatMessage]) + async chatMessageOfGameByCode( + @Args() getMsgData: GetMessagesArgs, + ): Promise { + return await this.chatService.getMessagesByGameCode(getMsgData); + } +} diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..478277b21ef258e77333e90722ca60573b4f2075 --- /dev/null +++ b/src/chat/chat.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { ChatMessage } from './models/message.model'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CreateMessageArgs } from './dto/create-message.args'; +import { PlayersService } from '../players/players.service'; +import { NO_PLAYER } from '../constants'; +import { GetMessagesArgs } from './dto/get-messages.args'; +import { GamesService } from '../games/games.service'; + +@Injectable() +export class ChatService { + constructor( + @InjectRepository(ChatMessage) + private readonly chatRepository: Repository, + private readonly playersService: PlayersService, + private readonly gamesService: GamesService, + ) {} + + async createMessage(data: CreateMessageArgs): Promise { + const player = await this.playersService.getPlayerById(data.playerUUID); + if (!player) throw new Error(NO_PLAYER); + + const chatMessage: ChatMessage = { + message: data.message, + player, + game: player.game, + }; + + return await this.chatRepository.save(chatMessage); + } + + async getMessagesByGameCode(data: GetMessagesArgs): Promise { + const { chatMessages } = await this.gamesService.getGameByCode(data.code); + return chatMessages; + } +} diff --git a/src/chat/dto/create-message.args.ts b/src/chat/dto/create-message.args.ts new file mode 100644 index 0000000000000000000000000000000000000000..aba7e0dccdd4cec1b33ed114fb231a9cb6ee7ffc --- /dev/null +++ b/src/chat/dto/create-message.args.ts @@ -0,0 +1,14 @@ +import { ArgsType, Field, ID } from '@nestjs/graphql'; +import { IsString, IsUUID, Length } from 'class-validator'; + +@ArgsType() +export class CreateMessageArgs { + @Field((type) => String, { nullable: false }) + @IsString() + @Length(1, 50) + message: string; + + @Field((type) => ID, { nullable: false }) + @IsUUID('all') + playerUUID: string; +} diff --git a/src/chat/dto/get-messages.args.ts b/src/chat/dto/get-messages.args.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fb8a7de73688d3d3d67240ef6ed59be5bdbcc15 --- /dev/null +++ b/src/chat/dto/get-messages.args.ts @@ -0,0 +1,25 @@ +import { ArgsType, Field, ID, Int } from '@nestjs/graphql'; +import { IsEnum, IsInt, IsUUID } from 'class-validator'; +import { ChatTimeOrder } from '../enums/chatTimeOrder.enum'; + +@ArgsType() +export class GetMessagesArgs { + @Field((type) => ID, { nullable: false }) + @IsUUID('all') + code: string; + + @Field((type) => Int, { nullable: false, defaultValue: 0 }) + @IsInt() + offset: number; + + @Field((type) => Int, { nullable: false, defaultValue: 100 }) + @IsInt() + limit: number; + + @Field((type) => ChatTimeOrder, { + nullable: false, + defaultValue: ChatTimeOrder.ASC, + }) + @IsEnum(ChatTimeOrder) + order: ChatTimeOrder; +} diff --git a/src/chat/enums/chatTimeOrder.enum.ts b/src/chat/enums/chatTimeOrder.enum.ts new file mode 100644 index 0000000000000000000000000000000000000000..01967710218fe18d361c9e6d933bc46e56fd5723 --- /dev/null +++ b/src/chat/enums/chatTimeOrder.enum.ts @@ -0,0 +1,10 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum ChatTimeOrder { + DESC, + ASC, +} + +registerEnumType(ChatTimeOrder, { + name: 'ChatTimeOrder', +}); diff --git a/src/chat/models/message.model.ts b/src/chat/models/message.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..6de837da2cfea1f77149fe251dba2d338d6f8f2a --- /dev/null +++ b/src/chat/models/message.model.ts @@ -0,0 +1,44 @@ +import { Field, GraphQLISODateTime, ID, ObjectType } from '@nestjs/graphql'; +import { + Column, + CreateDateColumn, + Entity, + Generated, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { IsString, Length } from 'class-validator'; +import { Game } from '../../games/models/game.model'; +import { Player } from '../../players/models/player.model'; + +@ObjectType({ description: 'message' }) +@Entity('ChatMessage') +export class ChatMessage { + @Field((type) => ID) + @PrimaryGeneratedColumn('uuid') + id?: string; + + @Field((type) => String, { nullable: false }) + @Column({ nullable: false }) + @IsString() + @Length(1, 50) + message: string; + + @Field((type) => Game, { nullable: false }) + @ManyToOne(() => Game, (game: Game) => game.chatMessages, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + game: Game; + + @Field((type) => Player, { nullable: false }) + @ManyToOne(() => Player, (player: Player) => player.chatMessages, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + player: Player; + + @Field((type) => GraphQLISODateTime, { nullable: false }) + @CreateDateColumn() + created_at?: Date; +} diff --git a/src/constants.ts b/src/constants.ts index bfe6ab84b280be5cffd0954c867fb06936a8dfb2..259bb3a11bd035231db1e91149ea61c33c95c478 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,3 +20,4 @@ export const PLAYER_JOINED_EVENT = 'onWaitForJoiningPlayerToGame'; export const GAME_STARTED_EVENT = 'onWaitForStartingGame'; export const PLAYER_DELETED_EVENT = 'onDeletePlayerFromGame'; export const GAME_PROCESS_EVENT = 'onPlayingGame'; +export const MESSAGE_TO_CHAT_EVENT = 'onChatGame'; diff --git a/src/games/dto/delete-player.args.ts b/src/games/dto/delete-player.args.ts index d890da553a00ea04663aa5b2feb7dec37d0a39aa..f3ca6f7c4426dd15d4919fbf59055d7ed676c4ec 100644 --- a/src/games/dto/delete-player.args.ts +++ b/src/games/dto/delete-player.args.ts @@ -1,4 +1,4 @@ -import { ArgsType, Field } from '@nestjs/graphql'; +import { ArgsType, Field, ID } from '@nestjs/graphql'; import { IsUUID } from 'class-validator'; @ArgsType() @@ -7,7 +7,7 @@ export class DeletePlayerArgs { @IsUUID('all') gameCode: string; - @Field((type) => String, { nullable: false }) + @Field((type) => ID, { nullable: false }) @IsUUID('all') playerId: string; } diff --git a/src/games/games.resolver.ts b/src/games/games.resolver.ts index 7f69c1df59cdba29235cb9544e3ea8230aed0d1c..79e0174b44d5e6abbb8b63048f6666f4cdfae5fc 100644 --- a/src/games/games.resolver.ts +++ b/src/games/games.resolver.ts @@ -15,6 +15,7 @@ import { GAME_NOT_ACTIVATED, GAME_PROCESS_EVENT, GAME_STARTED_EVENT, + MESSAGE_TO_CHAT_EVENT, NO_PLAYER, PLAYER_DELETED_EVENT, PLAYER_JOINED_EVENT, @@ -23,6 +24,7 @@ import { Inject } from '@nestjs/common'; import { PubSub } from 'graphql-subscriptions'; import { PUB_SUB } from '../common/modules/pubSub.module'; import { GameStatus } from './enums/statuses.enum'; +import { BroadcastChatGame } from '../broadcast/dto/broadcast-chat-game.type'; @Resolver() export class GamesResolver { @@ -42,11 +44,9 @@ export class GamesResolver { @Mutation((returns) => StatusModel) async deactivateGameByCode(@Args('code') code: string) { - const success = await this.gamesService.deleteGame(code); - if (success) { - this.broadcastService.deleteGame(code); - } - return { success }; + const isDeleted = await this.gamesService.deleteGame(code); + if (isDeleted) this.broadcastService.deleteGame(code); + return { success: isDeleted }; } @Mutation((returns) => Game) @@ -116,7 +116,7 @@ export class GamesResolver { filter: (payload, args) => { return payload.gameCode === args.gameCode; }, - async resolve(this: GamesResolver, payload) { + resolve(this: GamesResolver, payload): BroadcastPlayer { const { gameCode, name, id: UUID } = payload; return { gameCode, name, UUID }; }, @@ -155,7 +155,7 @@ export class GamesResolver { filter: (payload, args) => { return payload.gameCode === args.gameCode; }, - resolve: (payload) => { + resolve: (payload): BroadcastPlayer => { const { gameCode, name, playerId: UUID } = payload; return { gameCode, name, UUID }; }, @@ -182,4 +182,32 @@ export class GamesResolver { return this.pubSub.asyncIterator(GAME_PROCESS_EVENT); } + + @Subscription((returns) => BroadcastChatGame, { + name: 'onChatGame', + filter: (payload, args) => { + return payload.onChatGame.game.CODE === args.gameCode; + }, + resolve: (payload): BroadcastChatGame => { + const { message, id: UUID, created_at: time } = payload.onChatGame; + return { + UUID, + message, + time, + player: { + gameCode: payload.onChatGame.game.id, + name: payload.onChatGame.player.name, + UUID: payload.onChatGame.player.id, + }, + }; + }, + }) + async messageSent(@Args() subscriptionData: SubscriptionsArgs) { + const isPlayerValid = await this.gamesService.validatePlayer( + subscriptionData, + ); + if (!isPlayerValid) throw new Error(NO_PLAYER); + + return this.pubSub.asyncIterator(MESSAGE_TO_CHAT_EVENT); + } } diff --git a/src/games/games.service.ts b/src/games/games.service.ts index 9bf331031f89c1dae286bfef541d223f1c37e8a8..2e2fc6f385944cdcd925e2bd6b9d93972387c94b 100644 --- a/src/games/games.service.ts +++ b/src/games/games.service.ts @@ -60,7 +60,7 @@ export class GamesService { return await this.gamesRepository.findOne( { CODE: code }, { - relations: ['players', 'quiz'], + relations: ['players', 'quiz', 'chatMessages'], }, ); } diff --git a/src/games/models/game.model.ts b/src/games/models/game.model.ts index 2990657d6d1142c26099a450acc9ee407c100d53..c09c028f95a809f1b59079e4a3122bf8704d3a0d 100644 --- a/src/games/models/game.model.ts +++ b/src/games/models/game.model.ts @@ -11,6 +11,7 @@ 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'; +import { ChatMessage } from '../../chat/models/message.model'; @ObjectType({ description: 'game ' }) @Entity() @@ -41,4 +42,15 @@ export class Game { onUpdate: 'CASCADE', }) players?: Player[] | null; + + @Field((type) => [ChatMessage], { nullable: true }) + @OneToMany( + () => ChatMessage, + (chatMessage: ChatMessage) => chatMessage.game, + { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + ) + chatMessages?: ChatMessage[]; } diff --git a/src/players/models/player.model.ts b/src/players/models/player.model.ts index 4750b4a5f460c7aa5d0353997ba209a3c39215fb..7fd8df21617874732d270d33ccb33ea0e2df7d28 100644 --- a/src/players/models/player.model.ts +++ b/src/players/models/player.model.ts @@ -6,10 +6,12 @@ import { ManyToOne, PrimaryGeneratedColumn, JoinTable, + OneToMany, } from 'typeorm'; import { Length } from 'class-validator'; import { Game } from '../../games/models/game.model'; import { Answer } from '../../answers/models/answer.model'; +import { ChatMessage } from '../../chat/models/message.model'; @ObjectType({ description: 'player ' }) @Entity() @@ -34,4 +36,11 @@ export class Player { @ManyToMany(() => Answer) @JoinTable() answers: Answer[]; + + @Field((type) => [ChatMessage]) + @OneToMany(() => ChatMessage, (message: ChatMessage) => message.player, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + chatMessages: ChatMessage[]; }