diff --git a/src/player/dto/emotion.dto.ts b/src/player/dto/emotion.dto.ts new file mode 100644 index 00000000..c21d2826 --- /dev/null +++ b/src/player/dto/emotion.dto.ts @@ -0,0 +1,9 @@ +import { Expose } from 'class-transformer'; + +export class EmotionDto { + @Expose() + emotion: string; + + @Expose() + date: Date; +} \ No newline at end of file diff --git a/src/player/dto/emotionCheck.dto.ts b/src/player/dto/emotionCheck.dto.ts new file mode 100644 index 00000000..4d8d5ca9 --- /dev/null +++ b/src/player/dto/emotionCheck.dto.ts @@ -0,0 +1,11 @@ +import { PlayerEmotion } from '../enum/playerEmotion.enum'; + +export class EmotionCheckDto { + constructor(isSent: boolean, currentEmotion: PlayerEmotion) { + this.isSent = isSent; + this.currentEmotion = currentEmotion; + } + + isSent: boolean; + currentEmotion: PlayerEmotion; +} \ No newline at end of file diff --git a/src/player/dto/player.dto.ts b/src/player/dto/player.dto.ts index c8c84c83..976d22be 100644 --- a/src/player/dto/player.dto.ts +++ b/src/player/dto/player.dto.ts @@ -7,6 +7,7 @@ import { GameStatisticsDto } from './gameStatistics.dto'; import { TaskDto } from './task.dto'; import { AvatarDto } from './avatar.dto'; import { Min } from 'class-validator'; +import { EmotionDto } from './emotion.dto'; @AddType('PlayerDto') export class PlayerDto { @@ -144,4 +145,13 @@ export class PlayerDto { @ExtractField() @Expose() clanRole_id?: string; + + /** + * A historical list of emotions recorded by the player on a daily basis. + * Each entry contains the emotion type and the timestamp of the recording. + * @type {[EmotionDto]} + */ + @Type(() => EmotionDto) + @Expose() + emotions?: EmotionDto[]; } diff --git a/src/player/dto/updateEmotion.dto.ts b/src/player/dto/updateEmotion.dto.ts new file mode 100644 index 00000000..26670ed1 --- /dev/null +++ b/src/player/dto/updateEmotion.dto.ts @@ -0,0 +1,11 @@ +import { IsEnum, IsNotEmpty } from 'class-validator'; +import { PlayerEmotion } from '../enum/playerEmotion.enum'; + +export class UpdateEmotionDto { + @IsEnum(PlayerEmotion, { + message: + 'Emotion must be one of these: Sorrow, Anger, Joy, Playful, Love, Blank', + }) + @IsNotEmpty() + emotion: PlayerEmotion; +} diff --git a/src/player/enum/playerEmotion.enum.ts b/src/player/enum/playerEmotion.enum.ts new file mode 100644 index 00000000..7de74214 --- /dev/null +++ b/src/player/enum/playerEmotion.enum.ts @@ -0,0 +1,8 @@ +export enum PlayerEmotion { + SORROW = 'Sorrow', + ANGER = 'Anger', + JOY = 'Joy', + PLAYFUL = 'Playful', + LOVE = 'Love', + BLANK = 'Blank', +} diff --git a/src/player/player.controller.ts b/src/player/player.controller.ts index 84dcf9ef..1d695e00 100644 --- a/src/player/player.controller.ts +++ b/src/player/player.controller.ts @@ -7,10 +7,12 @@ import { Param, Post, Put, + BadRequestException } from '@nestjs/common'; import { PlayerService } from './player.service'; import { CreatePlayerDto } from './dto/createPlayer.dto'; import { UpdatePlayerDto } from './dto/updatePlayer.dto'; +import { UpdateEmotionDto } from './dto/updateEmotion.dto'; import { PlayerDto } from './dto/player.dto'; import { _idDto } from '../common/dto/_id.dto'; import { BasicDELETE } from '../common/base/decorator/BasicDELETE.decorator'; @@ -18,6 +20,8 @@ import { ModelName } from '../common/enum/modelName.enum'; import { NoAuth } from '../auth/decorator/NoAuth.decorator'; import { Authorize } from '../authorization/decorator/Authorize'; import { Action } from '../authorization/enum/action.enum'; +import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; +import { User } from '../auth/user'; import { OffsetPaginate } from '../common/interceptor/request/offsetPagination.interceptor'; import { AddSearchQuery } from '../common/interceptor/request/addSearchQuery.interceptor'; import { GetAllQuery } from '../common/decorator/param/GetAllQuery'; @@ -30,6 +34,8 @@ import ApiResponseDescription from '../common/swagger/response/ApiResponseDescri import EventEmitterService from '../common/service/EventEmitterService/EventEmitter.service'; import { ServerTaskName } from '../dailyTasks/enum/serverTaskName.enum'; import { isEqual } from 'lodash'; +import { IServiceReturn } from '../common/service/basicService/IService'; +import { EmotionCheckDto } from './dto/emotionCheck.dto'; import { MongooseError } from 'mongoose'; @Controller('player') @@ -41,7 +47,7 @@ export default class PlayerController { /** * Create a player - * + * * @remarks Create a new Player. This is not recommended way of creating a new Player and it should be used only in edge cases. * The recommended way is to create it via /profile POST endpoint. * @@ -49,11 +55,7 @@ export default class PlayerController { * Notice, that the Profile object should not be used inside the game (except for logging-in). */ @ApiResponseDescription({ - success: { - dto: PlayerDto, - modelName: ModelName.PLAYER, - status: 201, - }, + success: { dto: PlayerDto, modelName: ModelName.PLAYER, status: 201 }, errors: [400, 401, 403, 409], hasAuth: false, }) @@ -64,16 +66,52 @@ export default class PlayerController { return this.service.createOne(body); } + /** + * Player emotion check + * Checks if the authenticated player has already submitted an emotion for the current day. + */ + @ApiResponseDescription({ + success: { status: 200 }, + errors: [401, 403, 404], + }) + @Get('/emotioncheck') + @Authorize({ action: Action.read, subject: PlayerDto }) + public async checkDailyEmotion( + @LoggedUser() user: User, + ): Promise> { + + return await this.service.checkIfEmotionSentToday(user.player_id); + } + + /** + * Registers the player's selected emotion for the current day. + */ + @ApiResponseDescription({ + success: { dto: null, modelName: ModelName.PLAYER, status: 204 }, + errors: [400, 401, 403, 409], + }) + @Post('/emotion') + @UniformResponse(ModelName.PLAYER, PlayerDto) + @Authorize({ action: Action.create, subject: PlayerDto }) + public async setDailyEmotion( + @LoggedUser() user: User, + @Body() body: UpdateEmotionDto, + ): Promise { + const [error] = await this.service.addEmotion(user.player_id, body.emotion); + + if (error) { + throw new BadRequestException(error[0].message); + } + + } + /** * Get player by _id * * @remarks Read Player data by its _id field */ @ApiResponseDescription({ - success: { - dto: PlayerDto, - modelName: ModelName.PLAYER, - }, + success: { dto: PlayerDto, modelName: ModelName.PLAYER }, errors: [400, 401, 404], }) @Get('/:_id') @@ -88,14 +126,11 @@ export default class PlayerController { /** * Get all players - * - * @remarks Read all created Players. Remember about the pagination + * + * @remarks Read all created Players. Remember about the pagination. */ @ApiResponseDescription({ - success: { - dto: PlayerDto, - modelName: ModelName.PLAYER, - }, + success: { dto: PlayerDto, modelName: ModelName.PLAYER }, errors: [401, 404], }) @Get() @@ -109,14 +144,12 @@ export default class PlayerController { /** * Update player - * Emit a server event if avatar clothes changed - * - * @remarks Update the Player, which _id is specified in the body. Only Player, which belong to the logged-in Profile can be changed. + * * Emit a server event if avatar clothes changed + * @remarks Update the Player, which _id is specified in the body. + * Only Player, which belong to the logged-in Profile can be changed. */ @ApiResponseDescription({ - success: { - status: 204, - }, + success: { status: 204 }, errors: [401, 403, 404, 409], }) @Put() @@ -125,10 +158,9 @@ export default class PlayerController { @UniformResponse() public async update(@Body() body: UpdatePlayerDto) { const [player, _] = await this.service.getPlayerById(body._id); - const playerUpdateResults = await this.service.updateOneById(body); - await this.emitEventIfAvatarChange(player, body); + return playerUpdateResults; if (playerUpdateResults instanceof MongooseError) return playerUpdateResults; @@ -136,12 +168,11 @@ export default class PlayerController { /** * Delete player by _id - * - * @remarks Delete Player by its _id field. Notice that only Player, which belongs to loggen-in user Profile can be deleted. + * @remarks Delete Player by its _id field. Notice that only Player, which belongs to a logged-in user Profile can be deleted. * In case when the Player is the only admin in some Clan and the Clan has some other Players, the Player can not be removed. * User should be asked to first determine at least one admin for the Clan. * - * Also, it is not recommended to delete the Player since it can itroduce unexpected behaviour for the user with Profile, + * Also, it is not recommended to delete the Player since it can introduce unexpected behaviour for the user with Profile, * but without Player. The better way to remove the Player is do it via /profile DELETE. * * Player removal basically means removing all data, which is related to the Player: @@ -149,9 +180,7 @@ export default class PlayerController { * In the case when the Profile does not have a Player, user can only login to the system, but can not play the game. */ @ApiResponseDescription({ - success: { - status: 204, - }, + success: { status: 204 }, errors: [400, 401, 403, 404], }) @Delete('/:_id') diff --git a/src/player/player.service.ts b/src/player/player.service.ts index 20cd0507..63c7d02f 100644 --- a/src/player/player.service.ts +++ b/src/player/player.service.ts @@ -17,12 +17,17 @@ import { } from '../common/interface/IHookImplementer'; import { UpdatePlayerDto } from './dto/updatePlayer.dto'; import { PlayerDto } from './dto/player.dto'; +import { EmotionCheckDto } from './dto/emotionCheck.dto'; import BasicService from '../common/service/basicService/BasicService'; +import ServiceError from '../common/service/basicService/ServiceError'; +import { SEReason } from '../common/service/basicService/SEReason'; import { TIServiceReadManyOptions, TReadByIdOptions, + IServiceReturn } from '../common/service/basicService/IService'; import EventEmitterService from '../common/service/EventEmitterService/EventEmitter.service'; +import { PlayerEmotion } from './enum/playerEmotion.enum'; @Injectable() @AddBasicService() @@ -248,4 +253,74 @@ export class PlayerService ); if (updateErrors) throw updateErrors; } -} + + /** + * Checks if the player has already submitted an emotion today. + * @param playerId - The unique identifier of the player. + * @returns - A classic tuple setup [boolean, ServiceError[]] indicating if an entry for today exists. + */ + async checkIfEmotionSentToday(playerId: string): Promise> { + const player = await this.model + .findById(playerId) + .select('emotions') + .exec(); + + if (!player) return [null, [new ServiceError({ reason: SEReason.NOT_FOUND })]]; + + const lastEntry = player.emotions[player.emotions.length - 1]; + + const today = new Date().setHours(0, 0, 0, 0); + const entryDate = lastEntry ? new Date(lastEntry.date).setHours(0, 0, 0, 0) : null; + + if (entryDate !== today) { + + return [false, null]; + } + + const emotionValue = lastEntry.emotion as PlayerEmotion; + + const isSent = emotionValue !== PlayerEmotion.BLANK; + + return [isSent, null]; + } + + /** + * Registers or updates the player's selected emotion for the current day. + * Uses atomic operators via basicService to ensure data integrity and DTO consistency. + * * @param playerId - The unique identifier of the player. + * @param emotion - The selected emotion enum value. + * @returns The updated player data or service errors. + */ + async addEmotion(playerId: string, emotion: PlayerEmotion): Promise> { + + const [player, errors] = await this.getPlayerById(playerId); + if (errors) return [null, errors]; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const index = (player.emotions || []).findIndex((e) => { + const entryDate = new Date(e.date); + entryDate.setHours(0, 0, 0, 0); + return entryDate.getTime() === today.getTime(); + }); + + let updateQuery: UpdateQuery; + if (index > -1) { + updateQuery = { + $set: { + [`emotions.${index}.emotion`]: emotion, + [`emotions.${index}.date`]: new Date(), + }, + }; + } else { + updateQuery = { + $push: { emotions: { emotion, date: new Date() } }, + }; + } + + const [_, updateErrors] = await this.basicService.updateOneById(playerId, updateQuery); + if (updateErrors) return [null, updateErrors]; + + return this.getPlayerById(playerId); + }} diff --git a/src/player/schemas/player.schema.ts b/src/player/schemas/player.schema.ts index 44339fd2..13e70f11 100644 --- a/src/player/schemas/player.schema.ts +++ b/src/player/schemas/player.schema.ts @@ -5,6 +5,8 @@ import { ExtractField } from '../../common/decorator/response/ExtractField'; import { GameStatistics } from '../gameStatistics.schema'; import { ObjectId } from 'mongodb'; import { Avatar, AvatarSchema } from './avatar.schema'; +import { PlayerEmotion } from '../enum/playerEmotion.enum'; +import { EmotionDto } from '../dto/emotion.dto'; export type PlayerDocument = HydratedDocument; @@ -64,6 +66,22 @@ export class Player { @Prop({ type: ObjectId, default: null }) clanRole_id: string | ObjectId | null; + @Prop({ + type: [ + { + emotion: { + type: String, + enum: Object.values(PlayerEmotion), + required: true, + }, + date: { type: Date, default: Date.now }, + }, + ], + _id: false, + default: [], + }) + emotions?: EmotionDto[]; + @ExtractField() _id: string; }