diff --git a/nest-cli.json b/nest-cli.json index 7285ea52b..7cd4db7dd 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -9,6 +9,23 @@ "include": "src/playerTasks/playerTasks.json", "outDir": "dist" } + ], + "plugins": [ + { + "name": "@nestjs/swagger", + "options": { + "dtoFileNameSuffix": [".dto.ts", ".schema.ts", "APIError.ts"], + "controllerFileNameSuffix": ".controller.ts", + + "dtoKeyOfComment": "description", + "controllerKeyOfComment": "summary", + + "classValidatorShim": true, + "introspectComments": true, + "skipAutoHttpCode": true, + "esmCompatible": false + } + } ] } } diff --git a/src/__tests__/clanShop/clanShopService/buyItem.test.ts b/src/__tests__/clanShop/clanShopService/buyItem.test.ts index 7ef28591b..6102aad37 100644 --- a/src/__tests__/clanShop/clanShopService/buyItem.test.ts +++ b/src/__tests__/clanShop/clanShopService/buyItem.test.ts @@ -105,7 +105,6 @@ describe('ClanShopService.buyItem() test suite', () => { additional: null, field: 'gameCoins', message: 'Clan does not have enough coins to buy the item', - objectType: 'ServiceError', reason: 'LESS_THAN_MIN', value: null, }, diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 24c33e6c5..69ed5ed8f 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -5,6 +5,9 @@ import { ThrowAuthErrorIfFound } from './decorator/ThrowAuthErrorIfFound.decorat import { NoAuth } from './decorator/NoAuth.decorator'; import { AUTH_SERVICE } from './constant'; import BoxAuthService from './box/BoxAuthService'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; +import { ModelName } from '../common/enum/modelName.enum'; +import { ProfileDto } from '../profile/dto/profile.dto'; @NoAuth() @Controller('auth') @@ -14,6 +17,21 @@ export class AuthController { private readonly authService: AuthService | BoxAuthService, ) {} + /** + * Log in to the system. + * + * @remarks After the profile with player was created, the user can log in to the system and get a JWT token to access resources. + * + * If the user provides the correct credentials, the access token will be returned, which should be used as a Bearer token in the Authorization header. + */ + @ApiResponseDescription({ + success: { + status: 201, + modelName: ModelName.CLAN, + type: ProfileDto, + }, + errors: [400, 401], + }) @Post('/signIn') @ThrowAuthErrorIfFound() public signIn(@Body() body: SignInDto) { diff --git a/src/auth/dto/signIn.dto.ts b/src/auth/dto/signIn.dto.ts index 8f805cbc4..3430db6fb 100644 --- a/src/auth/dto/signIn.dto.ts +++ b/src/auth/dto/signIn.dto.ts @@ -3,9 +3,19 @@ import AddType from '../../common/base/decorator/AddType.decorator'; @AddType('SignInDto') export class SignInDto { + /** + * Unique username used by the player to sign in + * + * @example "DragonSlayer42" + */ @IsString() username: string; + /** + * Password for player authentication + * + * @example "myStrongP@ssw0rd" + */ @IsString() password: string; } diff --git a/src/box/box.controller.ts b/src/box/box.controller.ts index bc24be362..d18c47156 100644 --- a/src/box/box.controller.ts +++ b/src/box/box.controller.ts @@ -31,6 +31,8 @@ import { BoxUser } from './auth/BoxUser'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { BoxAuthGuard } from './auth/boxAuth.guard'; import SessionStarterService from './sessionStarter/sessionStarter.service'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; +import { BoxDto } from './dto/box.dto'; @Controller('box') @UseGuards(BoxAuthGuard) @@ -42,6 +44,24 @@ export class BoxController { private readonly sessionStarter: SessionStarterService, ) {} + /** + * Claim tester account. + * + * @remarks Tester can claim his/her account for the testing box. + * + * Notice that the tester should know the shared testers password in order to be able to clain the account. + * This password is available after the group admin has started the session. + * + * Notice that the endpoint should be called only from browser, since a cookie should be added by the API + */ + @ApiResponseDescription({ + success: { + status: 200, + type: ClaimAccountResponseDto, + }, + errors: [400, 403, 404], + hasAuth: false, + }) @NoAuth() @Get('claim-account') @UniformResponse(undefined, ClaimAccountResponseDto) @@ -65,6 +85,22 @@ export class BoxController { return res.send(data); } + /** + * Create a testing box. + * + * @remarks Create a testing box. + * + * Notice that in order t0 create the testing box a group admin password need to be obtained from backend team + */ + @ApiResponseDescription({ + success: { + status: 201, + dto: CreatedBoxDto, + modelName: ModelName.BOX, + }, + errors: [400, 404], + hasAuth: false, + }) @NoAuth() @Post() @UniformResponse(ModelName.BOX, CreatedBoxDto) @@ -81,6 +117,21 @@ export class BoxController { return [{ ...createdBox, accessToken: groupAdminAccessToken }, null]; } + /** + * Reset testing box. + * + * @remarks Reset testing box data, which means removing all the data created during the testing session and returning the box state to the PREPARING stage. + * For more information refer to the testing box documentation. + * + * Notice that only box admin can do the action. + */ + @ApiResponseDescription({ + success: { + dto: CreatedBoxDto, + modelName: ModelName.BOX, + }, + errors: [401, 403, 404], + }) @Put('reset') @UniformResponse(ModelName.BOX) @IsGroupAdmin() @@ -102,6 +153,19 @@ export class BoxController { return [{ ...createdBox, accessToken: groupAdminAccessToken }, null]; } + /** + * Delete box data. + * + * @remarks Delete box data associated with the logged-in user. + * + * Notice that the box can be removed only by the box admin. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 404], + }) @Delete() @IsGroupAdmin() @UniformResponse() @@ -109,6 +173,21 @@ export class BoxController { return await this.service.deleteBox(user.box_id); } + /** + * Start testing session + * + * @remarks Endpoint for starting testing session. + * + * Notice that only box admin can start a testing session. + * + * Notice that the minimum box data should be initialized at the moment of starting of testing session. That data is at least 2 tester accounts added. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [401, 403, 404], + }) @Post('/start') @UniformResponse(ModelName.BOX) @IsGroupAdmin() @@ -117,6 +196,19 @@ export class BoxController { if (errors) return [null, errors]; } + /** + * Get box by _id, For time of development only + * + * @remarks Endpoint for getting box data by its _id + */ + @ApiResponseDescription({ + success: { + dto: BoxDto, + modelName: ModelName.BOX, + }, + errors: [404], + hasAuth: false, + }) //For time of development only @NoAuth() @Get('/:_id') @@ -129,6 +221,18 @@ export class BoxController { return this.service.readOneById(param._id, { includeRefs }); } + /** + * Delete box by _id + * + * @remarks Endpoint for deleting a box by _id + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [404], + hasAuth: false, + }) //For time of development only @NoAuth() @Delete('/:_id') diff --git a/src/box/box.service.ts b/src/box/box.service.ts index 807a424b1..e24bdfe12 100644 --- a/src/box/box.service.ts +++ b/src/box/box.service.ts @@ -170,7 +170,7 @@ export class BoxService { private getTesterAccount(box: BoxDto): Tester { const account = box.testers.find((tester) => { return tester.isClaimed !== true; - }); + }) as unknown as Tester; if (!account) { throw new ServiceError({ reason: SEReason.NOT_FOUND, diff --git a/src/box/dailyTask/dailyTask.controller.ts b/src/box/dailyTask/dailyTask.controller.ts index d9dbd4f5e..13003414e 100644 --- a/src/box/dailyTask/dailyTask.controller.ts +++ b/src/box/dailyTask/dailyTask.controller.ts @@ -13,7 +13,10 @@ import { APIError } from '../../common/controller/APIError'; import { APIErrorReason } from '../../common/controller/APIErrorReason'; import BoxAuthHandler from '../auth/BoxAuthHandler'; import { IsGroupAdmin } from '../auth/decorator/IsGroupAdmin'; +import ApiResponseDescription from '../../common/swagger/response/ApiResponseDescription'; +import SwaggerTags from '../../common/swagger/tags/SwaggerTags.decorator'; +@SwaggerTags('Box') @Controller('/box/dailyTask') export class DailyTaskController { constructor( @@ -21,6 +24,21 @@ export class DailyTaskController { private readonly boxAuthHandler: BoxAuthHandler, ) {} + /** + * Add a daily task to box + * + * @remarks Add a daily task to array of predefined daily tasks. + * + * Notice that the logged-in player has to be a group admin + */ + @ApiResponseDescription({ + success: { + status: 201, + dto: PredefinedDailyTaskDto, + modelName: ModelName.DAILY_TASK, + }, + errors: [400, 401, 403, 404], + }) @Post() @IsGroupAdmin() @UniformResponse(ModelName.DAILY_TASK, PredefinedDailyTaskDto) @@ -31,6 +49,22 @@ export class DailyTaskController { return this.taskService.addOne(user.box_id, body); } + /** + * Add multiple daily tasks + * + * @remarks Add multiple daily tasks at once to daily tasks array of the box. + * + * Notice that only group admin can add the tasks. + */ + @ApiResponseDescription({ + success: { + status: 201, + dto: PredefinedDailyTaskDto, + modelName: ModelName.DAILY_TASK, + returnsArray: true, + }, + errors: [400, 401, 403, 404], + }) @Post('/multiple') @IsGroupAdmin() @UniformResponse(ModelName.DAILY_TASK, PredefinedDailyTaskDto) @@ -55,6 +89,19 @@ export class DailyTaskController { return this.taskService.addMultiple(user.box_id, body); } + /** + * Update box daily task + * + * @remarks Update a predefined daily task of a box by its _id. + * + * Notice that only group admin can update the tasks + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404], + }) @Put() @IsGroupAdmin() @UniformResponse(ModelName.DAILY_TASK) @@ -69,6 +116,19 @@ export class DailyTaskController { if (errors) return [null, errors]; } + /** + * Delete box daily task by _id + * + * @remarks Delete daily task from predefined daily tasks array of the box. + * + * Notice that only group admin can delete the tasks + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404], + }) @Delete('/:_id') @IsGroupAdmin() @UniformResponse(ModelName.DAILY_TASK) diff --git a/src/box/dailyTask/dto/createPredefinedDailyTask.dto.ts b/src/box/dailyTask/dto/createPredefinedDailyTask.dto.ts index fe1e408ee..8876bfad8 100644 --- a/src/box/dailyTask/dto/createPredefinedDailyTask.dto.ts +++ b/src/box/dailyTask/dto/createPredefinedDailyTask.dto.ts @@ -2,21 +2,51 @@ import { IsEnum, IsNumber, IsString } from 'class-validator'; import { ServerTaskName } from '../../../dailyTasks/enum/serverTaskName.enum'; export class CreatePredefinedDailyTaskDto { + /** + * Type of the predefined server task (e.g., collect items, win battles) + * + * @example "write_chat_message" + */ @IsEnum(ServerTaskName) type: ServerTaskName; + /** + * Human-readable title of the task shown to players + * + * @example "Collect 10 Soul Orbs" + */ @IsString() title: string; + /** + * Required amount to complete the task (e.g., collect 10 orbs) + * + * @example 10 + */ @IsNumber() amount: number; + /** + * Points awarded upon task completion + * + * @example 50 + */ @IsNumber() points: number; + /** + * In-game currency reward for completing the task + * + * @example 100 + */ @IsNumber() coins: number; + /** + * Time limit in minutes to complete the task (0 = no limit) + * + * @example 30 + */ @IsNumber() timeLimitMinutes: number; } diff --git a/src/box/dailyTask/dto/predefinedDailyTask.dto.ts b/src/box/dailyTask/dto/predefinedDailyTask.dto.ts index d4b627861..e0bbe9806 100644 --- a/src/box/dailyTask/dto/predefinedDailyTask.dto.ts +++ b/src/box/dailyTask/dto/predefinedDailyTask.dto.ts @@ -3,25 +3,60 @@ import { ServerTaskName } from '../../../dailyTasks/enum/serverTaskName.enum'; import { ExtractField } from '../../../common/decorator/response/ExtractField'; export class PredefinedDailyTaskDto { + /** + * Unique ID of the predefined daily task + * + * @example "664a1234de9f1a0012f3f123" + */ @ExtractField() @Expose() _id: string; + /** + * Task type recognized by the game server + * + * @example "write_chat_message" + */ @Expose() type: ServerTaskName; + /** + * Player-facing task title + * + * @example "Win 3 PvP Battles" + */ @Expose() title: string; + /** + * Task completion requirement + * + * @example 3 + */ @Expose() amount: number; + /** + * Number of points the task is worth + * + * @example 75 + */ @Expose() points: number; + /** + * Coins rewarded for finishing the task + * + * @example 200 + */ @Expose() coins: number; + /** + * Time limit in minutes (if any) + * + * @example 15 + */ @Expose() timeLimitMinutes: number; } diff --git a/src/box/dailyTask/dto/updatePredefinedDailyTask.dto.ts b/src/box/dailyTask/dto/updatePredefinedDailyTask.dto.ts index 185870bc7..233bee39e 100644 --- a/src/box/dailyTask/dto/updatePredefinedDailyTask.dto.ts +++ b/src/box/dailyTask/dto/updatePredefinedDailyTask.dto.ts @@ -8,29 +8,64 @@ import { import { ServerTaskName } from '../../../dailyTasks/enum/serverTaskName.enum'; export class UpdatePredefinedDailyTaskDto { + /** + * Unique ID of the task to be updated + * + * @example "664a1234de9f1a0012f3f123" + */ @IsMongoId() _id: string; + /** + * Updated task type + * + * @example "write_chat_message" + */ @IsOptional() @IsEnum(ServerTaskName) type?: ServerTaskName; + /** + * New task title + * + * @example "Defeat 2 Dungeon Bosses" + */ @IsOptional() @IsString() title?: string; + /** + * New completion amount + * + * @example 2 + */ @IsOptional() @IsNumber() amount?: number; + /** + * New point reward + * + * @example 120 + */ @IsOptional() @IsNumber() points?: number; + /** + * New coin reward + * + * @example 300 + */ @IsOptional() @IsNumber() coins?: number; + /** + * Updated time limit in minutes + * + * @example 45 + */ @IsOptional() @IsNumber() timeLimitMinutes?: number; diff --git a/src/box/dto/box.dto.ts b/src/box/dto/box.dto.ts index e786be08d..c101d204f 100644 --- a/src/box/dto/box.dto.ts +++ b/src/box/dto/box.dto.ts @@ -1,58 +1,133 @@ import { Expose, Type } from 'class-transformer'; import { ExtractField } from '../../common/decorator/response/ExtractField'; import { SessionStage } from '../enum/SessionStage.enum'; -import { ObjectId } from 'mongodb'; -import { Tester } from '../schemas/tester.schema'; import { DailyTask } from '../../dailyTasks/dailyTasks.schema'; +import { TesterDto } from './tester.dto'; export class BoxDto { + /** + * Unique identifier of the game box session + * + * @example "663a5d9cde9f1a0012f3b456" + */ @ExtractField() @Expose() _id: string; + /** + * Admin password used to manage the box session + * + * @example "adminSecret123" + */ @Expose() adminPassword: string; + /** + * Current stage of the session + * + * @example "Preparing" + */ @Expose() sessionStage: SessionStage; + /** + * Shared password for testers to access the session + * + * @example "testerPass456" + */ @Expose() testersSharedPassword: string | null; + /** + * Timestamp (in ms) when the box will be removed + * + * @example 1716553200000 + */ @Expose() boxRemovalTime: number; + /** + * Timestamp (in ms) when the session will reset + * + * @example 1716639600000 + */ @Expose() sessionResetTime: number; + /** + * ID of the admin's profile + * + * @example "663a5a9fde9f1a0012f3a111" + */ @Expose() - adminProfile_id: ObjectId; + adminProfile_id: string; + /** + * ID of the admin's in-game player + * + * @example "663a5a9fde9f1a0012f3a112" + */ @Expose() - adminPlayer_id: ObjectId; + adminPlayer_id: string; + /** + * List of clan IDs associated with the box + * + * @example ["663a5b2cde9f1a0012f3a220"] + */ @Expose() - clan_ids: ObjectId[]; + clan_ids: string[]; + /** + * IDs of SoulHomes linked to the box + * + * @example ["663a5c1ade9f1a0012f3a330"] + */ @Expose() - soulHome_ids: ObjectId[]; + soulHome_ids: string[]; + /** + * IDs of rooms available in this box session + * + * @example ["663a5c8bde9f1a0012f3a440"] + */ @Expose() - room_ids: ObjectId[]; + room_ids: string[]; + /** + * IDs of inventory stocks in this session + * + * @example ["663a5d0ade9f1a0012f3a550"] + */ @Expose() - stock_ids: ObjectId[]; + stock_ids: string[]; + /** + * ID of the chat linked to this box session + * + * @example "663a5d7cde9f1a0012f3a660" + */ @Expose() - chat_id: ObjectId; + chat_id: string; + /** + * List of testers currently connected to this session + */ @Expose() - @Type(() => Tester) - testers: Tester[]; + @Type(() => TesterDto) + testers: TesterDto[]; + /** + * List of IDs of users who claimed accounts during this session + * + * @example ["user123", "guest789"] + */ @Expose() accountClaimersIds: string[]; + /** + * Daily tasks generated for this box session + */ @Expose() @Type(() => DailyTask) dailyTasks: DailyTask[]; diff --git a/src/box/dto/claimAccountResponse.dto.ts b/src/box/dto/claimAccountResponse.dto.ts index 554bce783..5d1b19868 100644 --- a/src/box/dto/claimAccountResponse.dto.ts +++ b/src/box/dto/claimAccountResponse.dto.ts @@ -7,51 +7,115 @@ import AddType from '../../common/base/decorator/AddType.decorator'; @AddType('ClaimAccountResponseDto') export class ClaimAccountResponseDto { + /** + * Unique ID of the claimed account + * + * @example "663a6e4fde9f1a0012f3d001" + */ @ExtractField() @Expose() _id: string; + /** + * Points accumulated by the player + * + * @example 1250 + */ @Expose() points: number; + /** + * Maximum number of items the player's backpack can hold + * + * @example 50 + */ @Expose() backpackCapacity: number; + /** + * Whether the player is above 13 years old + * + * @example true + */ @Expose() above13?: boolean | null; + /** + * Whether parental authorization has been granted + * + * @example false + */ @Expose() parentalAuth: boolean | null; + /** + * Game statistics related to this account + */ @Type(() => GameStatisticsDto) @Expose() gameStatistics: GameStatisticsDto; + /** + * List of character IDs available to the player + * + * @example ["663a6f1cde9f1a0012f3d100", "663a6f9bde9f1a0012f3d200"] + */ @Expose() battleCharacter_ids?: string[]; + /** + * ID of the currently selected avatar + * + * @example 3 + */ @Expose() currentAvatarId?: number; + /** + * ID of the player's profile + * + * @example "663a6f1cde9f1a0012f3d100" + */ @ExtractField() @Expose() profile_id: string; + /** + * ID of the clan the player belongs to + * + * @example "663a6f9bde9f1a0012f3d200" + */ @ExtractField() @Expose() clan_id: string; + /** + * Information about the player's clan + */ @Type(() => ClanDto) @Expose() Clan: ClanDto; + /** + * Custom characters created by the player + */ @Type(() => CustomCharacterDto) @Expose() CustomCharacter: CustomCharacterDto[]; + /** + * Access token to authenticate future API requests + * + * @example "eyJhbGciOiJIUzI1NiIsInR..." + */ @Expose() accessToken: string; + /** + * Player's account password + * + * @example "securePass456" + */ @Expose() password: string; } diff --git a/src/box/dto/createBox.dto.ts b/src/box/dto/createBox.dto.ts index 248b15e8b..84ae8097c 100644 --- a/src/box/dto/createBox.dto.ts +++ b/src/box/dto/createBox.dto.ts @@ -7,12 +7,27 @@ import { } from 'class-validator'; export class CreateBoxDto { + /** + * Password used to administrate the session + * + * @example "adminBoxPass" + */ @IsString() adminPassword: string; + /** + * Name of the admin player creating the session + * + * @example "GameMaster01" + */ @IsString() playerName: string; + /** + * Optional names of clans participating in the session (exactly 2) + * + * @example ["FireClan", "ShadowClan"] + */ @IsOptional() @IsArray() @ArrayMinSize(2) diff --git a/src/box/dto/createdBox.dto.ts b/src/box/dto/createdBox.dto.ts index 2bcfaf273..a4659184b 100644 --- a/src/box/dto/createdBox.dto.ts +++ b/src/box/dto/createdBox.dto.ts @@ -1,61 +1,128 @@ import { Player } from '../../player/schemas/player.schema'; import { Clan } from '../../clan/clan.schema'; -import { Chat } from '../../chat/chat.schema'; import { Expose, Type } from 'class-transformer'; import { SessionStage } from '../enum/SessionStage.enum'; -import { ObjectId } from 'mongodb'; import { ExtractField } from '../../common/decorator/response/ExtractField'; import { PlayerDto } from '../../player/dto/player.dto'; import { ClanDto } from '../../clan/dto/clan.dto'; import { ChatDto } from '../../chat/dto/chat.dto'; export class CreatedBoxDto { + /** + * Unique ID of the newly created box + * + * @example "663a709ade9f1a0012f3d310" + */ @ExtractField() @Expose() _id: string; + /** + * Token used by the admin to manage the box session + * + * @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + */ @Expose() accessToken: string; + /** + * Current stage of the game session + * + * @example "Preparing" + */ @Expose() sessionStage: SessionStage; + /** + * Timestamp (in ms) when this session will expire + * + * @example 1717003200000 + */ @Expose() boxRemovalTime: number; + /** + * Timestamp (in ms) when this session will reset + * + * @example 1717089600000 + */ @Expose() sessionResetTime: number; + /** + * ID of the admin's profile + * + * @example "663a70ecde9f1a0012f3d420" + */ @Expose() - adminProfile_id: ObjectId; + adminProfile_id: string; + /** + * ID of the admin's in-game player + * + * @example "663a70ecde9f1a0012f3d421" + */ @Expose() - adminPlayer_id: ObjectId; + adminPlayer_id: string; + /** + * IDs of clans in the created session + * + * @example ["663a711dde9f1a0012f3d500"] + */ @Expose() - clan_ids: ObjectId[]; + clan_ids: string[]; + /** + * IDs of SoulHomes generated for the session + * + * @example ["663a717ade9f1a0012f3d600"] + */ @Expose() - soulHome_ids: ObjectId[]; + soulHome_ids: string[]; + /** + * IDs of game rooms created in this session + * + * @example ["663a719fde9f1a0012f3d700"] + */ @Expose() - room_ids: ObjectId[]; + room_ids: string[]; + /** + * IDs of inventory stocks initialized + * + * @example ["663a71eade9f1a0012f3d800"] + */ @Expose() - stock_ids: ObjectId[]; + stock_ids: string[]; + /** + * ID of the chat created for the session + * + * @example "663a7223de9f1a0012f3d900" + */ @Expose() - chat_id: ObjectId; + chat_id: string; + /** + * Player object for the admin player + */ @Type(() => PlayerDto) @Expose() adminPlayer: Player; + /** + * Clans associated with this session + */ @Type(() => ClanDto) @Expose() clans: Clan[]; + /** + * Chat room linked to this session + */ @Type(() => ChatDto) @Expose() - chat: Chat; + chat: ChatDto; } diff --git a/src/box/dto/tester.dto.ts b/src/box/dto/tester.dto.ts new file mode 100644 index 000000000..509343310 --- /dev/null +++ b/src/box/dto/tester.dto.ts @@ -0,0 +1,19 @@ +export class TesterDto { + /** + * Profile _id of the tester + * @example "663a5d7cde9f1a0012f3a660" + */ + profile_id: string; + + /** + * Profile _id of the tester + * @example "663a5d0ade9f1a0012f3a550" + */ + player_id: string; + + /** + * Has the account already been claimed by some device + * @example true + */ + isClaimed: boolean; +} diff --git a/src/box/dto/updateBox.dto.ts b/src/box/dto/updateBox.dto.ts index 7f905db6f..ab583407c 100644 --- a/src/box/dto/updateBox.dto.ts +++ b/src/box/dto/updateBox.dto.ts @@ -14,72 +14,148 @@ import { Tester } from '../schemas/tester.schema'; import { DailyTask } from '../../dailyTasks/dailyTasks.schema'; export class UpdateBoxDto { + /** + * Unique ID of the box to update + * + * @example "663a7352de9f1a0012f3da10" + */ @IsMongoId() _id: string; + /** + * New admin password, if updating + * + * @example "newAdminPass123" + */ @IsOptional() @IsString() adminPassword?: string; + /** + * New session stage, if updating + * + * @example "Testing" + */ @IsOptional() @IsEnum(SessionStage) sessionStage?: SessionStage; + /** + * New shared password for testers + * + * @example "newTesterPass" + */ @IsOptional() @IsString() testersSharedPassword?: string | null; + /** + * Updated timestamp (in ms) for when the box will be removed + * + * @example 1717500000000 + */ @IsOptional() @IsNumber() boxRemovalTime?: number; + /** + * Updated timestamp (in ms) for when the session will reset + * + * @example 1717586400000 + */ @IsOptional() @IsNumber() sessionResetTime?: number; + /** + * Updated admin profile ID + * + * @example "663a739ade9f1a0012f3db00" + */ @IsOptional() @IsMongoId() adminProfile_id?: ObjectId; + /** + * Updated admin player ID + * + * @example "663a739ade9f1a0012f3db01" + */ @IsOptional() @IsMongoId() adminPlayer_id?: ObjectId; + /** + * Updated list of clan IDs + * + * @example ["663a73c6de9f1a0012f3db10"] + */ @IsOptional() @IsArray() @IsMongoId({ each: true }) clan_ids?: ObjectId[]; + /** + * Updated SoulHome IDs + * + * @example ["663a7409de9f1a0012f3db20"] + */ @IsOptional() @IsArray() @IsMongoId({ each: true }) soulHome_ids?: ObjectId[]; + /** + * Updated room IDs + * + * @example ["663a743bde9f1a0012f3db30"] + */ @IsOptional() @IsArray() @IsMongoId({ each: true }) room_ids?: ObjectId[]; + /** + * Updated stock IDs + * + * @example ["663a7475de9f1a0012f3db40"] + */ @IsOptional() @IsArray() @IsMongoId({ each: true }) stock_ids?: ObjectId[]; + /** + * Updated chat ID + * + * @example "663a74b6de9f1a0012f3db50" + */ @IsOptional() @IsMongoId() chat_id?: ObjectId; + /** + * Updated list of testers + */ @IsOptional() @IsArray() @ValidateNested() @Type(() => Tester) testers?: Tester[]; + /** + * Updated list of account claimers' IDs + * + * @example ["663a74b6de9f1a0012f3db50", "663a743bde9f1a0012f3db30"] + */ @IsOptional() @IsArray() @IsString({ each: true }) accountClaimersIds?: string[]; + /** + * Updated daily tasks + */ @IsOptional() @IsArray() @ValidateNested() diff --git a/src/box/tester/dto/define.testers.dto.ts b/src/box/tester/dto/define.testers.dto.ts index 8001263be..c4379295c 100644 --- a/src/box/tester/dto/define.testers.dto.ts +++ b/src/box/tester/dto/define.testers.dto.ts @@ -1,12 +1,22 @@ import { IsNumber, IsOptional, Max, Min } from 'class-validator'; export default class DefineTestersDto { + /** + * Number of testers to add to the box + * + * @example 3 + */ @IsOptional() @IsNumber() @Min(1) @Max(99) amountToAdd: number; + /** + * Number of testers to remove from the box + * + * @example 2 + */ @IsOptional() @IsNumber() @Min(1) diff --git a/src/box/tester/tester.controller.ts b/src/box/tester/tester.controller.ts index 66d022616..448257d74 100644 --- a/src/box/tester/tester.controller.ts +++ b/src/box/tester/tester.controller.ts @@ -8,11 +8,25 @@ import { APIError } from '../../common/controller/APIError'; import { APIErrorReason } from '../../common/controller/APIErrorReason'; import { LoggedUser } from '../../common/decorator/param/LoggedUser.decorator'; import { BoxUser } from '../auth/BoxUser'; +import SwaggerTags from '../../common/swagger/tags/SwaggerTags.decorator'; +import ApiResponseDescription from '../../common/swagger/response/ApiResponseDescription'; +@SwaggerTags('Box') @Controller('/box/testers') export class TesterController { constructor(private testerService: TesterService) {} + /** + * Define testers amount + * + * @remarks Endpoint for adjusting amount of testers in the box. Notice that only box admin can do it + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404], + }) @Post() @IsGroupAdmin() @UniformResponse(ModelName.BOX) diff --git a/src/chat/chat.controller.ts b/src/chat/chat.controller.ts index 5c01da97d..b9143dab1 100644 --- a/src/chat/chat.controller.ts +++ b/src/chat/chat.controller.ts @@ -35,6 +35,7 @@ import { Action } from '../authorization/enum/action.enum'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { User } from '../auth/user'; import { UniformResponse } from '../common/decorator/response/UniformResponse'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; @Controller('chat') export class ChatController { @@ -43,6 +44,23 @@ export class ChatController { private readonly requestHelperService: RequestHelperService, ) {} + /** + * Create a chat. + * + * @remarks Create a new Chat. The Chat is an object containing messages. + * + * Notice, that currently there is no restrictions on who can create a Chat. + * + * Notice that the Message objects are inner objects of Chat and can not be used enewhere else than in the Chat. There is also no separate collection for the Message in the DB. + */ + @ApiResponseDescription({ + success: { + status: 201, + dto: ChatDto, + modelName: ModelName.CHAT, + }, + errors: [400, 401, 409], + }) @Post() @Authorize({ action: Action.create, subject: ChatDto }) @BasicPOST(ChatDto) @@ -50,6 +68,18 @@ export class ChatController { return this.service.createOne(body); } + /** + * Get chat by _id. + * + * @remarks Get chat by _id. + */ + @ApiResponseDescription({ + success: { + dto: ChatDto, + modelName: ModelName.CHAT, + }, + errors: [404], + }) @Get('/:_id') @Authorize({ action: Action.read, subject: ChatDto }) @BasicGET(ModelName.CHAT, ChatDto) @@ -59,6 +89,22 @@ export class ChatController { return this.service.readOneById(param._id, request['mongoPopulate']); } + /** + * Get all existing chats. + * + * @remarks Read all created Chats. Remember about the pagination. + * + * Notice, that use of messages array is not advised and can be removed at some point in the future. + * For accessing messages of the Chat please use the /chat/:_id/message endpoint. + */ + @ApiResponseDescription({ + success: { + dto: ChatDto, + modelName: ModelName.CHAT, + returnsArray: true, + }, + errors: [404], + }) @Get() @Authorize({ action: Action.read, subject: ChatDto }) @UniformResponse(ModelName.CHAT, ChatDto) @@ -70,6 +116,19 @@ export class ChatController { return this.service.readAll(query); } + /** + * Update chat by _id + * + * @remarks Update the Chat, which _id is specified in the body. + * + * Notice that currently anybody is able to change any Chat. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 404, 409], + }) @Put() @Authorize({ action: Action.update, subject: UpdateChatDto }) @BasicPUT(ModelName.CHAT) @@ -77,6 +136,19 @@ export class ChatController { return this.service.updateOneById(body); } + /** + * Delete chat by _id + * + * @remarks Delete Chat by its _id field. + * + * Notice that currently anybody can delete any Chat. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 404], + }) @Delete('/:_id') @Authorize({ action: Action.delete, subject: UpdateChatDto }) @BasicDELETE(ModelName.CHAT) @@ -84,6 +156,23 @@ export class ChatController { return this.service.deleteOneById(param._id); } + /** + * Create a new Message + * + * @remarks Create a new Message. Message represent the object of message sent by a Player. + * + * Notice that currently there are no authorization required. + * + * Notice, that the messages does not have an usual _id field generated by data base. Instead the Photon id should be used. + * + * Notice that the messages is contained in the array of a Chat collection. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 404, 409], + }) @Post('/:chat_id/messages') @Authorize({ action: Action.create, subject: ChatDto }) @HttpCode(204) @@ -100,6 +189,18 @@ export class ChatController { ); } + /** + * Read a messages of a Chat by _id + * + * @remarks Read a message by _id + */ + @ApiResponseDescription({ + success: { + dto: MessageDto, + modelName: ModelName.CHAT, + }, + errors: [400, 404], + }) @Get('/:chat_id/messages/:_id') @Authorize({ action: Action.read, subject: MessageDto }) @BasicGET(APIObjectName.MESSAGE, MessageDto) @@ -107,6 +208,19 @@ export class ChatController { return this.service.readOneMessageById(param.chat_id, param._id); } + /** + * Read all messages of a Chat + * + * @remarks Read all messages of specified Chat. Remember about the pagination + */ + @ApiResponseDescription({ + success: { + dto: MessageDto, + modelName: ModelName.CHAT, + returnsArray: true, + }, + errors: [400, 404], + }) @Get('/:chat_id/messages') @Authorize({ action: Action.read, subject: MessageDto }) @OffsetPaginate(ModelName.CHAT) diff --git a/src/chat/dto/chat.dto.ts b/src/chat/dto/chat.dto.ts index 20e6f58d6..8110ee596 100644 --- a/src/chat/dto/chat.dto.ts +++ b/src/chat/dto/chat.dto.ts @@ -1,13 +1,25 @@ import { Expose } from 'class-transformer'; import { ExtractField } from '../../common/decorator/response/ExtractField'; import AddType from '../../common/base/decorator/AddType.decorator'; +import { ApiProperty } from '@nestjs/swagger'; @AddType('ChatDto') export class ChatDto { + /** + * Unique ID of the chat + * + * @example "665b1f29c3f4fa0012e7a911" + */ @ExtractField() @Expose() _id: string; + /** + * Display name for the chat + * + * @example "Clan Chat" + */ + @ApiProperty({ uniqueItems: true }) @Expose() name?: string; } diff --git a/src/chat/dto/createChat.dto.ts b/src/chat/dto/createChat.dto.ts index 4e805beda..996a6fd56 100644 --- a/src/chat/dto/createChat.dto.ts +++ b/src/chat/dto/createChat.dto.ts @@ -1,9 +1,16 @@ import { IsString } from 'class-validator'; import { Optional } from '@nestjs/common'; import AddType from '../../common/base/decorator/AddType.decorator'; +import { ApiProperty } from '@nestjs/swagger'; @AddType('CreateChatDto') export class CreateChatDto { + /** + * Optional name for the new chat + * + * @example "Battle Room Chat" + */ + @ApiProperty({ uniqueItems: true }) @Optional() @IsString() name?: string; diff --git a/src/chat/dto/createMessage.dto.ts b/src/chat/dto/createMessage.dto.ts index cb2dedb7e..cdfd43ae5 100644 --- a/src/chat/dto/createMessage.dto.ts +++ b/src/chat/dto/createMessage.dto.ts @@ -3,15 +3,35 @@ import AddType from '../../common/base/decorator/AddType.decorator'; @AddType('CreateMessageDto') export class CreateMessageDto { + /** + * Unique numeric message ID within the chat + * + * @example 101 + */ @IsInt() id: number; + /** + * Username of the player sending the message + * + * @example "ShadowKnight" + */ @IsString() senderUsername: string; + /** + * Text content of the message + * + * @example "Let’s meet at Soul Arena!" + */ @IsString() content: string; + /** + * Numeric code representing the emotion or tone (e.g., happy, angry) + * + * @example 3 + */ @IsInt() feeling: number; } diff --git a/src/chat/dto/message.dto.ts b/src/chat/dto/message.dto.ts index d12cc6565..b126f25ac 100644 --- a/src/chat/dto/message.dto.ts +++ b/src/chat/dto/message.dto.ts @@ -3,15 +3,35 @@ import AddType from '../../common/base/decorator/AddType.decorator'; @AddType('MessageDto') export class MessageDto { + /** + * Unique message ID within the chat + * + * @example 101 + */ @Expose() id: number; + /** + * Username of the message sender + * + * @example "SkyBlade" + */ @Expose() senderUsername: string; + /** + * Message text sent by the player + * + * @example "We captured the room!" + */ @Expose() content: string; + /** + * Feeling code representing the tone of the message + * + * @example 1 + */ @Expose() feeling: number; } diff --git a/src/chat/dto/messageParam.ts b/src/chat/dto/messageParam.ts index 3f90a1efb..df603cc7d 100644 --- a/src/chat/dto/messageParam.ts +++ b/src/chat/dto/messageParam.ts @@ -3,14 +3,29 @@ import AddType from '../../common/base/decorator/AddType.decorator'; @AddType('messageParam') export class messageParam { + /** + * ID of the chat containing the message + * + * @example "665b1f29c3f4fa0012e7a911" + */ @IsMongoId() chat_id: string; + /** + * ID of the message to retrieve or modify + * + * @example 101 + */ @IsInt() _id: number; } export class chat_idParam { + /** + * ID of the chat to operate on + * + * @example "665b1f29c3f4fa0012e7a911" + */ @IsMongoId() chat_id: string; } diff --git a/src/chat/dto/updateChat.dto.ts b/src/chat/dto/updateChat.dto.ts index 257c9f053..0336c6d57 100644 --- a/src/chat/dto/updateChat.dto.ts +++ b/src/chat/dto/updateChat.dto.ts @@ -2,13 +2,25 @@ import { IsMongoId, IsString } from 'class-validator'; import { IsChatExists } from '../decorator/validation/IsChatExists.decorator'; import { Optional } from '@nestjs/common'; import AddType from '../../common/base/decorator/AddType.decorator'; +import { ApiProperty } from '@nestjs/swagger'; @AddType('UpdateChatDto') export class UpdateChatDto { + /** + * ID of the chat to update + * + * @example "665b1f29c3f4fa0012e7a911" + */ @IsChatExists() @IsMongoId() _id: string; + /** + * New name for the chat + * + * @example "Alliance Leaders" + */ + @ApiProperty({ uniqueItems: true }) @Optional() @IsString() name?: string; diff --git a/src/chat/dto/updateMessage.dto.ts b/src/chat/dto/updateMessage.dto.ts index 55aed1921..d3c4239a7 100644 --- a/src/chat/dto/updateMessage.dto.ts +++ b/src/chat/dto/updateMessage.dto.ts @@ -3,13 +3,28 @@ import AddType from '../../common/base/decorator/AddType.decorator'; @AddType('UpdateMessageDto') export class UpdateMessageDto { + /** + * ID of the message to update + * + * @example 101 + */ @IsInt() id: number; + /** + * Updated message content + * + * @example "Let’s regroup at Soul Gate!" + */ @IsOptional() @IsString() content?: string; + /** + * Updated feeling value (emotion indicator) + * + * @example 2 + */ @IsOptional() @IsInt() feeling?: number; diff --git a/src/clan/clan.controller.ts b/src/clan/clan.controller.ts index 6f693bf9a..85a335e5d 100644 --- a/src/clan/clan.controller.ts +++ b/src/clan/clan.controller.ts @@ -37,12 +37,17 @@ import { publicReferences } from './clan.schema'; import { RoomService } from '../clanInventory/room/room.service'; import { ItemService } from '../clanInventory/item/item.service'; import { PlayerService } from '../player/player.service'; -import { ClanItemsResponseDto } from './join/dto/clanItemsResponse.dto'; import HasClanRights from './role/decorator/guard/HasClanRights'; import { ClanBasicRight } from './role/enum/clanBasicRight.enum'; import DetermineClanId from '../common/guard/clanId.guard'; import { APIError } from '../common/controller/APIError'; import { APIErrorReason } from '../common/controller/APIErrorReason'; +import { ApiStandardErrors } from '../common/swagger/response/errors/ApiStandardErrors.decorator'; +import { ApiSuccessResponse } from '../common/swagger/response/success/ApiSuccessResponse.decorator'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; +import ClanItemsDto from './dto/clanItems.dto'; +import { ApiExtraModels } from '@nestjs/swagger'; +import { ItemDto } from '../clanInventory/item/dto/item.dto'; @Controller('clan') export class ClanController { @@ -54,6 +59,25 @@ export class ClanController { private readonly playerService: PlayerService, ) {} + /** + * Create a new Clan. + * + * @remarks The creator of the Clan becomes its admin. + * Notice that if Player is creating a new Clan, he/she becomes a member of it, + * that means that if Player is member of some Clan it can not create a new one, before leaving the old one. + * + * Also, endpoint creates Clan's Stock, as well as Clan's SoulHome and Rooms. + * + * For the created Clan a set of default Items will be added to Stock and to one of the SoulHome Rooms. + */ + @ApiResponseDescription({ + success: { + dto: ClanDto, + status: 201, + modelName: ModelName.CLAN, + }, + errors: [400, 401, 403, 409], + }) @Post() @Authorize({ action: Action.create, subject: ClanDto }) @UniformResponse(ModelName.CLAN) @@ -61,8 +85,23 @@ export class ClanController { return this.service.createOne(body, user.player_id); } + /** + * Get items of the logged-in user clan. + * + * @remarks Get items of the logged-in user clan. + * + * Notice that it will return 403 if the logged-in player is not in any clan. + */ + @ApiResponseDescription({ + success: { + dto: ClanItemsDto, + modelName: ModelName.ITEM, + }, + errors: [401, 403], + }) + @ApiExtraModels(ItemDto) @Get('items') - @UniformResponse(ModelName.ITEM, ClanItemsResponseDto) + @UniformResponse(ModelName.ITEM, ClanItemsDto) async getClanItems(@LoggedUser() user: User) { const clanId = await this.playerService.getPlayerClanId(user.player_id); const [clan, clanErrors] = await this.service.readOneById(clanId, { @@ -84,6 +123,19 @@ export class ClanController { return { stockItems, soulHomeItems }; } + /** + * Get Clan by _id. + * + * @remarks Read Clan data by its _id field + */ + @ApiResponseDescription({ + success: { + dto: ClanDto, + modelName: ModelName.CLAN, + }, + errors: [400, 404], + hasAuth: false, + }) @Get('/:_id') @NoAuth() @UniformResponse(ModelName.CLAN) @@ -94,7 +146,26 @@ export class ClanController { return this.service.readOneById(param._id, { includeRefs }); } + /** + * Read all clans. + * + * @remarks Read all created Clans + */ + @ApiResponseDescription({ + success: { + dto: ClanDto, + modelName: ModelName.CLAN, + returnsArray: true, + }, + errors: [404], + hasAuth: false, + }) @Get() + @ApiSuccessResponse(ClanDto, { + modelName: ModelName.CLAN, + returnsArray: true, + }) + @ApiStandardErrors(404) @NoAuth() @UniformResponse(ModelName.CLAN, ClanDto) @OffsetPaginate(ModelName.CLAN) @@ -104,6 +175,19 @@ export class ClanController { return this.service.readAll(query); } + /** + * Update a clan + * + * @remarks Update the Clan, which _id is specified in the body. + * + * Notice that the player must be in the same clan and it must have a basic right "Edit clan data" + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404, 409], + }) @Put() @DetermineClanId() @HasClanRights([ClanBasicRight.EDIT_CLAN_DATA]) @@ -121,11 +205,25 @@ export class ClanController { }), ], ]; - const [, errors] = await this.service.updateOneById(body); if (errors) return [null, errors]; } + /** + * Delete a clan + * + * @remarks Delete Clan its _id field. + * + * Notice that only Clan admins can delete the Clan. + * + * Notice that the player must be in the same clan and it must have a basic right "Edit clan data" + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404], + }) @Delete('/:_id') @Authorize({ action: Action.delete, subject: UpdateClanDto }) @UniformResponse() @@ -134,6 +232,23 @@ export class ClanController { if (errors) return [null, errors]; } + /** + * Player requests join to clan + * + * @remarks Request to join a Clan. + * + * Notice that if the Clan is open the Player will be joined automatically without admin approval. + * + * Notice that if the Player was in another Clan then he/she will be removed from the old one and if in this Clan was no other Players, it will be removed. + */ + @ApiResponseDescription({ + success: { + dto: JoinDto, + modelName: ModelName.JOIN, + status: 201, + }, + errors: [400, 401, 403, 404], + }) @Post('join') @Authorize({ action: Action.create, subject: JoinDto }) @BasicPOST(JoinDto) @@ -141,6 +256,19 @@ export class ClanController { return this.joinService.handleJoinRequest(body); } + /** + * Player requests leave a clan + * + * @remarks Request to leave a Clan. + * + * Notice that Player can leave any Clan without admin approval. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [401, 404], + }) @Post('leave') @HttpCode(204) @Authorize({ action: Action.create, subject: PlayerLeaveClanDto }) @@ -148,6 +276,19 @@ export class ClanController { return this.joinService.leaveClan(user.player_id); } + /** + * Exclude the player from clan. + * + * @remarks Request exclude the player from clan. + * + * Notice that only Clan admin can remove the Player + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 404], + }) @Post('exclude') @HttpCode(204) @DetermineClanId() diff --git a/src/clan/dto/clan.dto.ts b/src/clan/dto/clan.dto.ts index dcab8d19d..a765486c0 100644 --- a/src/clan/dto/clan.dto.ts +++ b/src/clan/dto/clan.dto.ts @@ -1,7 +1,6 @@ import { Expose, Type } from 'class-transformer'; import { PlayerDto } from '../../player/dto/player.dto'; import { ExtractField } from '../../common/decorator/response/ExtractField'; -import AddType from '../../common/base/decorator/AddType.decorator'; import { Language } from '../../common/enum/language.enum'; import { AgeRange } from '../enum/ageRange.enum'; import { Goal } from '../enum/goal.enum'; @@ -9,73 +8,165 @@ import { StockDto } from '../../clanInventory/stock/dto/stock.dto'; import { SoulHomeDto } from '../../clanInventory/soulhome/dto/soulhome.dto'; import { ClanLogoDto } from './clanLogo.dto'; import ClanRoleDto from '../role/dto/clanRole.dto'; +import { ClanLabel } from '../enum/clanLabel.enum'; -@AddType('ClanDto') +/** + * DTO for reading clan data. + */ export class ClanDto { + /** + * Unique identifier of the clan + * + * @example "67fe4e2d8a54d4cc39266a43" + */ @ExtractField() @Expose() _id: string; + /** + * Name of the clan + * + * @example "Warriors Of Light" + */ @Expose() name: string; + /** + * Short tag representing the clan + * + * @example "WoL" + */ @Expose() tag: string; + /** + * Clan logo + */ @Type(() => ClanLogoDto) @Expose() clanLogo: ClanLogoDto; + /** + * List of labels describing the clan. + * @example ["ELÄINRAKKAAT", "SYVÄLLISET"] + */ @Expose() - labels: string[]; + labels: ClanLabel[]; + /** + * Amount of game coins the clan owns + * + * @example 1500 + */ @Expose() gameCoins: number; + /** + * Total points accumulated by the clan + * + * @example 320 + */ @Expose() points: number; + /** + * List of user IDs that are administrators of the clan + * + * @example ["67fe4e2d8a54d4cc39266a41", "67fe4e2d8a54d4cc39266a42"] + */ @ExtractField() @Expose() admin_ids: string[]; + /** + * Number of players currently in the clan + * + * @example 12 + */ @Expose() playerCount: number; + /** + * Number of items stored by the clan + * + * @example 45 + */ @Expose() itemCount: number; + /** + * Number of stock units the clan owns + * + * @example 9 + */ @Expose() stockCount: number; + /** + * Allowed age range for clan members + * + * @example "All" + */ @Expose() ageRange: AgeRange; + /** + * Goal or purpose of the clan + * + * @example "Competitive" + */ @Expose() goal: Goal; + /** + * Clan's motto or phrase + * + * @example "Victory through unity" + */ @Expose() phrase: string; + /** + * Preferred language used in the clan + * + * @example "English" + */ @Expose() language: Language; + /** + * Indicates if members can join the clan without admin approvals + * + * @example true + */ @Expose() isOpen: boolean; + /** + * Clan roles + */ @Type(() => ClanRoleDto) @Expose() roles: ClanRoleDto[]; + /** + * Clan members, optional, upon request + */ @Type(() => PlayerDto) @Expose() - Player: PlayerDto[]; + Player?: PlayerDto[]; + /** + * Clan stock, optional, upon request + */ @Type(() => StockDto) @Expose() - Stock: StockDto; + Stock?: StockDto; + /** + * Clan soul home, upon request + */ @Type(() => SoulHomeDto) @Expose() - SoulHome: SoulHomeDto; + SoulHome?: SoulHomeDto; } diff --git a/src/clan/dto/clanItems.dto.ts b/src/clan/dto/clanItems.dto.ts new file mode 100644 index 000000000..11a1df7dd --- /dev/null +++ b/src/clan/dto/clanItems.dto.ts @@ -0,0 +1,13 @@ +import { ItemDto } from '../../clanInventory/item/dto/item.dto'; + +export default class ClanItemsDto { + /** + * Clan items in its stock + */ + stockItems: ItemDto[]; + + /** + * Clan items in its soul home rooms + */ + soulHomeItems: ItemDto[]; +} diff --git a/src/clan/dto/clanLogo.dto.ts b/src/clan/dto/clanLogo.dto.ts index 35573299e..fd67fe601 100644 --- a/src/clan/dto/clanLogo.dto.ts +++ b/src/clan/dto/clanLogo.dto.ts @@ -1,14 +1,22 @@ import { IsArray, IsEnum, IsHexColor } from 'class-validator'; import { LogoType } from '../enum/logoType.enum'; -import AddType from '../../common/base/decorator/AddType.decorator'; import { Expose } from 'class-transformer'; -@AddType('ClanLogoDto') export class ClanLogoDto { + /** + * Type of the logo used by the clan + * + * @example "Heart" + */ @Expose() @IsEnum(LogoType) logoType: LogoType; + /** + * Colors used in the logo, defined as hex codes + * + * @example ["#FFFFFF", "#000000"] + */ @Expose() @IsArray() @IsHexColor({ each: true }) diff --git a/src/clan/dto/createClan.dto.ts b/src/clan/dto/createClan.dto.ts index 301dcadac..28972166f 100644 --- a/src/clan/dto/createClan.dto.ts +++ b/src/clan/dto/createClan.dto.ts @@ -8,48 +8,89 @@ import { ValidateNested, MaxLength, } from 'class-validator'; -import AddType from '../../common/base/decorator/AddType.decorator'; import { ClanLabel } from '../enum/clanLabel.enum'; import { AgeRange } from '../enum/ageRange.enum'; import { Language } from '../../common/enum/language.enum'; import { Goal } from '../enum/goal.enum'; import { Type } from 'class-transformer'; import { ClanLogoDto } from './clanLogo.dto'; +import { ApiProperty } from '@nestjs/swagger'; -@AddType('CreateClanDto') +/** + * DTO for creating a clan. + */ export class CreateClanDto { + /** + * Unique name of the clan (max 20 characters). + * @example "my_clan" + */ + @ApiProperty({ + uniqueItems: true, + }) @IsString() @MaxLength(20) name: string; + /** + * Short tag used to identify the clan. + * @example "CLN123" + */ @IsString() tag: string; + /** + * Optional logo of the clan. + * @example { logoType: "Heart", pieceColors: [#FFFFFF] } + */ @Type(() => ClanLogoDto) @IsOptional() @ValidateNested() clanLogo?: ClanLogoDto; + /** + * List of labels describing the clan (max 5). + * @example ["ELÄINRAKKAAT", "SYVÄLLISET"] + */ @IsArray() @ArrayMaxSize(5) @IsEnum(ClanLabel, { each: true }) labels: ClanLabel[]; + /** + * Whether the clan is open to new members. + * @example true + */ @IsBoolean() @IsOptional() isOpen?: boolean; + /** + * Optional age range preference for clan members. + * @example "ALL" + */ @IsEnum(AgeRange) @IsOptional() ageRange?: AgeRange; + /** + * Optional goal or focus of the clan. + * @example "Fiilistely" + */ @IsEnum(Goal) @IsOptional() goal?: Goal; + /** + * Clan motto or phrase. + * @example "Victory or nothing!" + */ @IsString() phrase: string; + /** + * Preferred language used in the clan. + * @example "English" + */ @IsEnum(Language) @IsOptional() language?: Language; diff --git a/src/clan/dto/updateClan.dto.ts b/src/clan/dto/updateClan.dto.ts index b963a4aaf..6cf4d0453 100644 --- a/src/clan/dto/updateClan.dto.ts +++ b/src/clan/dto/updateClan.dto.ts @@ -23,58 +23,115 @@ import { Type } from 'class-transformer'; @AddType('UpdateClanDto') export class UpdateClanDto { + /** + * ID of the clan to update + * + * @example "67fe4e2d8a54d4cc39266a43" + */ @IsClanExists() @IsMongoId() _id: string; + /** + * New name of the clan (optional) + * + * @example "Warriors Of Light" + */ @IsString() @IsOptional() @MaxLength(20) name?: string; + /** + * New tag for the clan (optional) + * + * @example "WOL" + */ @IsString() @IsOptional() tag?: string; + /** + * New logo configuration for the clan (optional) + */ @ValidateNested() @Type(() => ClanLogoDto) @IsOptional() clanLogo?: ClanLogoDto; + /** + * Updated labels for the clan (max 5, optional) + * + * @example ["ELÄINRAKKAAT", "SYVÄLLISET"] + */ @IsArray() @ArrayMaxSize(5) @IsEnum(ClanLabel, { each: true }) @IsOptional() labels?: ClanLabel[]; - //TODO: validate is player exists does not work + /** + * Player IDs to be added as clan administrators (optional) + * + * @example ["67fe4e2d8a54d4cc39266a42", "67fe4e2d8a54d4cc39266a41"] + */ @IsArray() @ArrayNotEmpty() @Validate(IsPlayerExists) @IsOptional() admin_idsToAdd?: string[]; + /** + * Player IDs to be removed from clan administrators (optional) + * + * @example ["67fe4e2d8a54d4cc39266a43"] + */ @IsArray() @ArrayNotEmpty() @IsOptional() admin_idsToDelete?: string[]; + /** + * Whether the clan is open to join without admin approval (optional) + * + * @example true + */ @IsBoolean() @IsOptional() isOpen?: boolean; + /** + * Age range restriction for clan members (optional) + * + * @example "All" + */ @IsEnum(AgeRange) @IsOptional() ageRange?: AgeRange; + /** + * Goal or focus of the clan (optional) + * + * @example "Competitive" + */ @IsEnum(Goal) @IsOptional() goal?: Goal; + /** + * Clan's motto or slogan (optional) + * + * @example "Together we rise" + */ @IsString() @IsOptional() phrase?: string; + /** + * Preferred language of the clan (optional) + * + * @example "English" + */ @IsEnum(Language) @IsOptional() language?: Language; diff --git a/src/clan/join/dto/clanItemsResponse.dto.ts b/src/clan/join/dto/clanItemsResponse.dto.ts deleted file mode 100644 index 977562d2f..000000000 --- a/src/clan/join/dto/clanItemsResponse.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Expose, Type } from 'class-transformer'; -import { ItemDto } from '../../../clanInventory/item/dto/item.dto'; - -export class ClanItemsResponseDto { - @Expose() - @Type(() => ItemDto) - stockItems: ItemDto[]; - - @Expose() - @Type(() => ItemDto) - soulHomeItems: ItemDto[]; -} diff --git a/src/clan/join/dto/join.dto.ts b/src/clan/join/dto/join.dto.ts index 0315d8a67..1817dd0a1 100644 --- a/src/clan/join/dto/join.dto.ts +++ b/src/clan/join/dto/join.dto.ts @@ -4,19 +4,39 @@ import { ExtractField } from '../../../common/decorator/response/ExtractField'; @AddType('JoinDto') export class JoinDto { + /** + * Unique identifier of the join request + * @example "665f9e42b4b74d098aac4d22" + */ @ExtractField() @Expose() _id: string; + /** + * The ID of the clan to join + * @example "6643ec9cbeddb7e88fc76ae1" + */ @Expose() - clan_id: string; // the clan id we are trying to join + clan_id: string; + /** + * The ID of the player requesting to join + * @example "6643ec9cbeddb7e88fc76ae3" + */ @Expose() - player_id: string; // the player who is trying to join + player_id: string; + /** + * Optional message sent by the player when joining a private clan + * @example "Hey! I'd love to join your clan and help out!" + */ @Expose() - join_message: string; // join message if clan is private + join_message: string; + /** + * Indicates whether the join request was accepted + * @example true + */ @Expose() - accepted: boolean; // whether you are accepted or you arent + accepted: boolean; } diff --git a/src/clan/join/dto/joinRequest.dto.ts b/src/clan/join/dto/joinRequest.dto.ts index 3f160fe35..cffa7f137 100644 --- a/src/clan/join/dto/joinRequest.dto.ts +++ b/src/clan/join/dto/joinRequest.dto.ts @@ -5,15 +5,27 @@ import { IsPlayerExists } from '../../../player/decorator/validation/IsPlayerExi @AddType('JoinRequestDto') export class JoinRequestDto { + /** + * The ID of the clan the player is attempting to join + * @example "6643ec9cbeddb7e88fc76ae1" + */ @IsClanExists() @IsMongoId() - clan_id: string; // the clan id we are trying to join + clan_id: string; + /** + * The ID of the player submitting the join request + * @example "6643ec9cbeddb7e88fc76ae3" + */ @IsPlayerExists() @IsMongoId() - player_id: string; // the player who is trying to join + player_id: string; + /** + * Optional message provided with the join request + * @example "Looking forward to playing with you all!" + */ @IsString() @IsOptional() - join_message: string; // join message if clan is private + join_message: string; } diff --git a/src/clan/join/dto/joinResult.dto.ts b/src/clan/join/dto/joinResult.dto.ts index 4b03e6b20..f73fecb47 100644 --- a/src/clan/join/dto/joinResult.dto.ts +++ b/src/clan/join/dto/joinResult.dto.ts @@ -3,9 +3,17 @@ import AddType from '../../../common/base/decorator/AddType.decorator'; @AddType('JoinResultDto') export class JoinResultDto { + /** + * ID of the join request + * @example "665fa038b4b74d098aac4e77" + */ @IsMongoId() _id: string; + /** + * Indicates whether the join request has been accepted + * @example true + */ @IsOptional() @IsBoolean() accepted: boolean; diff --git a/src/clan/join/dto/removePlayer.dto.ts b/src/clan/join/dto/removePlayer.dto.ts index edff1af57..465c2bbc3 100644 --- a/src/clan/join/dto/removePlayer.dto.ts +++ b/src/clan/join/dto/removePlayer.dto.ts @@ -1,9 +1,12 @@ import { IsMongoId } from 'class-validator'; -import AddType from '../../../common/base/decorator/AddType.decorator'; import { IsPlayerExists } from '../../../player/decorator/validation/IsPlayerExists.decorator'; export class RemovePlayerDTO { + /** + * The ID of the player to be removed from the clan + * @example "6643ec9cbeddb7e88fc76ae3" + */ @IsPlayerExists() @IsMongoId() - player_id: string; // whom to remove from Clan + player_id: string; } diff --git a/src/clan/role/clanRole.controller.ts b/src/clan/role/clanRole.controller.ts index fda06df76..ab3535383 100644 --- a/src/clan/role/clanRole.controller.ts +++ b/src/clan/role/clanRole.controller.ts @@ -16,11 +16,31 @@ import { APIErrorReason } from '../../common/controller/APIErrorReason'; import { UpdateClanRoleDto } from './dto/updateClanRole.dto'; import ServiceError from '../../common/service/basicService/ServiceError'; import SetClanRoleDto from './dto/setClanRole.dto'; +import SwaggerTags from '../../common/swagger/tags/SwaggerTags.decorator'; +import ApiResponseDescription from '../../common/swagger/response/ApiResponseDescription'; +@SwaggerTags('Clan') @Controller('clan/role') export class ClanRoleController { public constructor(private readonly service: ClanRoleService) {} + /** + * Create a new clan role. + * + * @remarks Create a new clan role. + * + * Notice that in order to create a new role clan member must have a basic right "Manage role". + * + * The role must also be unique in a clan, unique name and unique rights. + */ + @ApiResponseDescription({ + success: { + dto: ClanRoleDto, + modelName: ModelName.CLAN, + status: 201, + }, + errors: [400, 401, 403, 404, 409], + }) @Post() @HasClanRights([ClanBasicRight.MANAGE_ROLE]) @DetermineClanId() @@ -32,6 +52,21 @@ export class ClanRoleController { return this.service.createOne(body, user.clan_id); } + /** + * Update a clan role. + * + * @remarks Update a clan role. + * + * Notice that in order to update a role, clan member must have a basic right "Manage role". + * + * The role must also be unique in a clan, unique name and unique rights. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404, 409], + }) @Put() @HasClanRights([ClanBasicRight.MANAGE_ROLE]) @DetermineClanId() @@ -45,6 +80,19 @@ export class ClanRoleController { return this.handleErrorReturnIfFound(errors); } + /** + * Delete clan role by _id + * + * @remarks Delete a clan role. + * + * Notice that in order to delete a role, clan member must have a basic right "Manage role". + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404, 409], + }) @Delete('/:_id') @HasClanRights([ClanBasicRight.MANAGE_ROLE]) @DetermineClanId() @@ -58,6 +106,19 @@ export class ClanRoleController { return this.handleErrorReturnIfFound(errors); } + /** + * Set a role for a clan member + * + * @remarks Set a default or named role to a specified clan member. + * + * Notice that the role giver and the clan member must be in the same clan. Also the giver must have the basic clan role "Edit member rights" + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404], + }) @Put('set') @HasClanRights([ClanBasicRight.EDIT_MEMBER_RIGHTS]) @DetermineClanId() diff --git a/src/clan/role/dto/clanRole.dto.ts b/src/clan/role/dto/clanRole.dto.ts index 428055a72..f64c18f31 100644 --- a/src/clan/role/dto/clanRole.dto.ts +++ b/src/clan/role/dto/clanRole.dto.ts @@ -4,16 +4,36 @@ import { Expose } from 'class-transformer'; import { ExtractField } from '../../../common/decorator/response/ExtractField'; export default class ClanRoleDto { + /** + * Unique identifier of the clan role + * + * @example "6650debcaf12345678abcd90" + */ @ExtractField() @Expose() _id: string; + /** + * Human-readable name of the role, must be unique for a clan + * + * @example "My role" + */ @Expose() name: string; + /** + * Type of the role + * + * @example "named" + */ @Expose() clanRoleType: ClanRoleType; + /** + * Permissions associated with this role + * + * @example { "EDIT_SOULHOME": true, "EDIT_CLAN_DATA": true } + */ @Expose() rights: Partial>; } diff --git a/src/clan/role/dto/createClanRole.dto.ts b/src/clan/role/dto/createClanRole.dto.ts index 4f46e6159..c99903230 100644 --- a/src/clan/role/dto/createClanRole.dto.ts +++ b/src/clan/role/dto/createClanRole.dto.ts @@ -4,10 +4,20 @@ import IsRoleRights from '../decorator/validation/IsRoleRights.decorator'; import { CLAN_ROLE_MAX_LENGTH } from '../const/validation'; export class CreateClanRoleDto { + /** + * Human-readable name of the role, must be unique for a clan + * + * @example "My role" + */ @IsString() @MaxLength(CLAN_ROLE_MAX_LENGTH) name: string; + /** + * Permissions associated with this role + * + * @example { "EDIT_SOULHOME": true, "EDIT_CLAN_DATA": true } + */ @IsRoleRights() rights: Partial>; } diff --git a/src/clan/role/dto/setClanRole.dto.ts b/src/clan/role/dto/setClanRole.dto.ts index af398aed2..b3f2cb626 100644 --- a/src/clan/role/dto/setClanRole.dto.ts +++ b/src/clan/role/dto/setClanRole.dto.ts @@ -7,12 +7,16 @@ import { IsMongoId } from 'class-validator'; export default class SetClanRoleDto { /** * _id of player to whom role should be set + * + * @example "6651abcdfe3212345678fabc" */ @IsMongoId() player_id: string | ObjectId; /** * _id of the role to set + * + * @example "6651abcdfe3212345678f999" */ @IsMongoId() role_id: string | ObjectId; diff --git a/src/clan/role/dto/updateClanRole.dto.ts b/src/clan/role/dto/updateClanRole.dto.ts index c3f52f071..7e258caf6 100644 --- a/src/clan/role/dto/updateClanRole.dto.ts +++ b/src/clan/role/dto/updateClanRole.dto.ts @@ -4,14 +4,29 @@ import IsRoleRights from '../decorator/validation/IsRoleRights.decorator'; import { CLAN_ROLE_MAX_LENGTH } from '../const/validation'; export class UpdateClanRoleDto { + /** + * ID of the role to update + * + * @example "6650debcaf12345678abcd90" + */ @IsMongoId() _id: string; + /** + * Updated name of the role (optional) + * + * @example "Strategist" + */ @IsOptional() @IsString() @MaxLength(CLAN_ROLE_MAX_LENGTH) name?: string; + /** + * Permissions associated with this role + * + * @example { "EDIT_SOULHOME": true, "EDIT_CLAN_DATA": true } + */ @IsOptional() @IsRoleRights() rights?: Partial>; diff --git a/src/clanInventory/item/dto/createItem.dto.ts b/src/clanInventory/item/dto/createItem.dto.ts index 62f3d14be..b6c8aa5da 100644 --- a/src/clanInventory/item/dto/createItem.dto.ts +++ b/src/clanInventory/item/dto/createItem.dto.ts @@ -18,42 +18,97 @@ import { Material } from '../enum/material.enum'; @AddType('CreateItemDto') export class CreateItemDto { + /** + * Display name of the item + * + * @example "Sofa_Taakka" + */ @IsString() name: ItemName; + /** + * Weight of the item used for backpack or stock limitations + * + * @example 2 + */ @IsInt() weight: number; + /** + * Recycling category for converting the item into resources + * + * @example "Wood" + */ @IsEnum(Recycling) recycling: Recycling; + /** + * Rarity level of the item, influences value and drop rate + * + * @example "common" + */ @IsEnum(Rarity) rarity: Rarity; + /** + * Unity asset key used for rendering the item in-game + * + * @example "items/crystal_shard" + */ @IsString() unityKey: string; + /** + * List of materials the item is composed of + * + * @example ["puu", "nahka"] + */ @IsArray() - @IsEnum(Material) + @IsEnum(Material, { each: true }) material: Material[]; + /** + * Item's position in a 2D grid [x, y] + * + * @example [3, 5] + */ @IsArray() @ArrayMinSize(2) @ArrayMaxSize(2) location: Array; + /** + * In-game price or market value of the item + * + * @example 150 + */ @IsInt() price: number; + /** + * Marks the item as a piece of furniture (for Soul Home) + * + * @example true + */ @IsBoolean() @IsOptional() isFurniture: boolean; + /** + * ID of the stock where the item is stored + * + * @example "666d99d3e3a12a001234abcd" + */ @IsStockExists() @IsMongoId() @IsOptional() stock_id: string; + /** + * ID of the room where the item is placed + * + * @example "666c88a7f2a98e001298cdef" + */ @IsMongoId() @IsOptional() room_id: string; diff --git a/src/clanInventory/item/dto/item.dto.ts b/src/clanInventory/item/dto/item.dto.ts index 0e432bb57..a7e317110 100644 --- a/src/clanInventory/item/dto/item.dto.ts +++ b/src/clanInventory/item/dto/item.dto.ts @@ -10,49 +10,115 @@ import { Material } from '../enum/material.enum'; @AddType('ItemDto') export class ItemDto { + /** + * Unique identifier of the item + * + * @example "665a1f29c3f4fa0012e7a900" + */ @ExtractField() @Expose() _id: string; + /** + * Name of the item + * + * @example "Sofa_Taakka" + */ @Expose() name: ItemName; + /** + * Weight of the item + * + * @example 1 + */ @Expose() weight: number; + /** + * Recycling type category + * + * @example "Wood" + */ @Expose() recycling: Recycling; + /** + * Item rarity + * + * @example "common" + */ @Expose() rarity: Rarity; + /** + * Materials that compose the item + * + * @example ["puu", "nahka"] + */ @Expose() material: Material[]; + /** + * Unity engine key for rendering + * + * @example "items/mystic_orb" + */ @Expose() unityKey: string; + /** + * Price of the item in in-game currency + * + * @example 500 + */ @Expose() price: number; + /** + * Grid location of the item + * + * @example [1, 4] + */ @Expose() location: Array; + /** + * Whether the item is a piece of furniture + * + * @example false + */ @Expose() isFurniture: boolean; + /** + * ID of the stock storing this item + * + * @example "666d99d3e3a12a001234abcd" + */ @ExtractField() @Expose() stock_id: string; + /** + * Full stock object containing this item + */ @Type(() => StockDto) @Expose() Stock: StockDto; + /** + * ID of the room containing the item + * + * @example "666c88a7f2a98e001298cdef" + */ @ExtractField() @Expose() room_id: string; + /** + * Full room object containing this item + */ @Type(() => RoomDto) @Expose() Room: RoomDto; diff --git a/src/clanInventory/item/dto/moveItem.dto.ts b/src/clanInventory/item/dto/moveItem.dto.ts index c0a1f8c58..3a4f231e7 100644 --- a/src/clanInventory/item/dto/moveItem.dto.ts +++ b/src/clanInventory/item/dto/moveItem.dto.ts @@ -4,13 +4,28 @@ import AddType from '../../../common/base/decorator/AddType.decorator'; @AddType('MoveItemDto') export class MoveItemDto { + /** + * ID of the item to move + * + * @example "665a1f29c3f4fa0012e7a900" + */ @IsMongoId() @IsString() item_id: string; + /** + * Destination type: ROOM or STOCK + * + * @example "Stock" + */ @IsEnum(MoveTo) moveTo: MoveTo; + /** + * Destination ID (room or stock depending on moveTo) + * + * @example "666c88a7f2a98e001298cdef" + */ @ValidateIf((o) => o.move_to === MoveTo.ROOM) @IsMongoId() destination_id: string; diff --git a/src/clanInventory/item/dto/stealItems.dto.ts b/src/clanInventory/item/dto/stealItems.dto.ts index b57978ef9..5af07ac97 100644 --- a/src/clanInventory/item/dto/stealItems.dto.ts +++ b/src/clanInventory/item/dto/stealItems.dto.ts @@ -1,14 +1,29 @@ import { ArrayNotEmpty, IsArray, IsMongoId, IsString } from 'class-validator'; export class StealItemsDto { + /** + * Token authorizing the steal action + * + * @example "aXJj1bE9-TOKEN-8822c" + */ @IsString() steal_token: string; + /** + * IDs of items to be stolen + * + * @example ["665a1f29c3f4fa0012e7a900", "665a1f29c3f4fa0012e7a901"] + */ @IsArray() @ArrayNotEmpty() @IsMongoId({ each: true }) item_ids: string[]; + /** + * ID of the room from which the items are stolen + * + * @example "666c88a7f2a98e001298cdef" + */ @IsMongoId() room_id: string; } diff --git a/src/clanInventory/item/dto/updateItem.dto.ts b/src/clanInventory/item/dto/updateItem.dto.ts index 30dc05915..dfb69a51a 100644 --- a/src/clanInventory/item/dto/updateItem.dto.ts +++ b/src/clanInventory/item/dto/updateItem.dto.ts @@ -9,10 +9,20 @@ import AddType from '../../../common/base/decorator/AddType.decorator'; @AddType('UpdateItemDto') export class UpdateItemDto { + /** + * ID of the item to update + * + * @example "665a1f29c3f4fa0012e7a900" + */ @IsItemExists() @IsMongoId() _id: string; + /** + * Updated location of the item in [x, y] format + * + * @example [2, 3] + */ @IsArray() @ArrayMinSize(2) @ArrayMaxSize(2) diff --git a/src/clanInventory/item/item.controller.ts b/src/clanInventory/item/item.controller.ts index 609d475e9..967a22f77 100644 --- a/src/clanInventory/item/item.controller.ts +++ b/src/clanInventory/item/item.controller.ts @@ -24,6 +24,7 @@ import { APIError } from '../../common/controller/APIError'; import { APIErrorReason } from '../../common/controller/APIErrorReason'; import HasClanRights from '../../clan/role/decorator/guard/HasClanRights'; import { ClanBasicRight } from '../../clan/role/enum/clanBasicRight.enum'; +import ApiResponseDescription from '../../common/swagger/response/ApiResponseDescription'; @Controller('item') export class ItemController { @@ -35,6 +36,20 @@ export class ItemController { @InjectModel(ModelName.PLAYER) private readonly playerModel: Model, ) {} + /** + * Get soulhome data from which items can be stolen + * + * @remarks Based on the provided steal token, which contains the SoulHome _id from which Items can be stolen, the SoulHome data and its Rooms will be returned. + * + * You can see the process flow from [this diagram](https://github.com/Alt-Org/Altzone-Server/tree/dev/doc/img/game_results) + */ + @ApiResponseDescription({ + success: { + dto: SoulHomeDto, + modelName: ModelName.SOULHOME, + }, + errors: [401, 403, 404], + }) @Get('steal') @Authorize({ action: Action.read, subject: SoulHomeDto }) @UseGuards(StealTokenGuard) @@ -62,6 +77,18 @@ export class ItemController { return soulHome; } + /** + * Get item by _id + * + * @remarks Read Item data by its _id field + */ + @ApiResponseDescription({ + success: { + dto: ItemDto, + modelName: ModelName.ITEM, + }, + errors: [400, 404], + }) @Get('/:_id') @Authorize({ action: Action.read, subject: ItemDto }) @UniformResponse(ModelName.ITEM) @@ -69,6 +96,19 @@ export class ItemController { return this.itemService.readOneById(param._id); } + /** + * Update item by _id + * + * @remarks Update item by specified _id + * + * Notice that the player must be in the same clan and it must have a basic right "Edit soul home" + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404], + }) @Put() @Authorize({ action: Action.update, subject: ItemDto }) @HasClanRights([ClanBasicRight.EDIT_SOULHOME]) diff --git a/src/clanInventory/room/dto/ActivateRoom.dto.ts b/src/clanInventory/room/dto/ActivateRoom.dto.ts index 13033449a..fc40f6fa9 100644 --- a/src/clanInventory/room/dto/ActivateRoom.dto.ts +++ b/src/clanInventory/room/dto/ActivateRoom.dto.ts @@ -3,9 +3,19 @@ import AddType from '../../../common/base/decorator/AddType.decorator'; @AddType('ActivateRoomDto') export class ActivateRoomDto { + /** + * List of room IDs to be activated + * + * @example ["666fabc1d2f0e10012aabbcc", "666fabd2d2f0e10012aabbee"] + */ @IsMongoId({ each: true }) room_ids: string[]; + /** + * Duration of activation in seconds + * + * @example 3600 + */ @IsNumber() @IsOptional() durationS: number; diff --git a/src/clanInventory/room/dto/createRoom.dto.ts b/src/clanInventory/room/dto/createRoom.dto.ts index 616c8e9be..76dacaf6f 100644 --- a/src/clanInventory/room/dto/createRoom.dto.ts +++ b/src/clanInventory/room/dto/createRoom.dto.ts @@ -9,19 +9,44 @@ import AddType from '../../../common/base/decorator/AddType.decorator'; @AddType('CreateRoomDto') export class CreateRoomDto { + /** + * Type of floor design used in the room + * + * @example "Wooden" + */ @IsString() floorType: string; + /** + * Type of wall design used in the room + * + * @example "Brick" + */ @IsString() wallType: string; + /** + * Indicates whether the room includes a lift + * + * @example true + */ @IsBoolean() @IsOptional() hasLift: boolean; + /** + * Number of cells (or zones) inside the room + * + * @example 9 + */ @IsNumber() cellCount: number; + /** + * ID of the Soul Home this room belongs to + * + * @example "666abc12d1e2f30012bbccdd" + */ @IsMongoId() soulHome_id: string; } diff --git a/src/clanInventory/room/dto/room.dto.ts b/src/clanInventory/room/dto/room.dto.ts index ed16cc95e..708c61ae5 100644 --- a/src/clanInventory/room/dto/room.dto.ts +++ b/src/clanInventory/room/dto/room.dto.ts @@ -4,28 +4,68 @@ import { ExtractField } from '../../../common/decorator/response/ExtractField'; @AddType('RoomDto') export class RoomDto { + /** + * Unique ID of the room + * + * @example "666fabc1d2f0e10012aabbcc" + */ @ExtractField() @Expose() _id: string; + /** + * Type of flooring used + * + * @example "Marble" + */ @Expose() floorType: string; + /** + * Type of wall styling + * + * @example "Stone" + */ @Expose() wallType: string; + /** + * Whether the room is currently active + * + * @example true + */ @Expose() isActive: boolean; + /** + * Whether the room has a lift feature + * + * @example false + */ @Expose() hasLift: boolean; + /** + * Unix timestamp of when the room will be deactivated + * + * @example 1715950000 + */ @Expose() deactivationTimestamp: number; + /** + * Number of interactive cells in the room + * + * @example 12 + */ @Expose() cellCount: number; + /** + * ID of the parent Soul Home + * + * @example "666abc12d1e2f30012bbccdd" + */ @Expose() soulHome_id: string; } diff --git a/src/clanInventory/room/dto/updateRoom.dto.ts b/src/clanInventory/room/dto/updateRoom.dto.ts index 9c7dec5ed..e43acb554 100644 --- a/src/clanInventory/room/dto/updateRoom.dto.ts +++ b/src/clanInventory/room/dto/updateRoom.dto.ts @@ -9,21 +9,46 @@ import AddType from '../../../common/base/decorator/AddType.decorator'; @AddType('UpdateRoomDto') export class UpdateRoomDto { + /** + * Unique ID of the room to update + * + * @example "666fabc1d2f0e10012aabbcc" + */ @IsMongoId() _id: string; + /** + * Updated floor type + * + * @example "Stone" + */ @IsString() @IsOptional() floorType: string; + /** + * Updated wall type + * + * @example "Painted" + */ @IsString() @IsOptional() wallType: string; + /** + * Update lift availability + * + * @example true + */ @IsBoolean() @IsOptional() hasLift: boolean; + /** + * Updated number of cells in the room + * + * @example 16 + */ @IsNumber() @IsOptional() cellCount: number; diff --git a/src/clanInventory/room/room.controller.ts b/src/clanInventory/room/room.controller.ts index e1a86a045..0d7d4fc65 100644 --- a/src/clanInventory/room/room.controller.ts +++ b/src/clanInventory/room/room.controller.ts @@ -20,6 +20,7 @@ import { AddSearchQuery } from '../../common/interceptor/request/addSearchQuery. import { AddSortQuery } from '../../common/interceptor/request/addSortQuery.interceptor'; import { OffsetPaginate } from '../../common/interceptor/request/offsetPagination.interceptor'; import { IGetAllQuery } from '../../common/interface/IGetAllQuery'; +import ApiResponseDescription from '../../common/swagger/response/ApiResponseDescription'; @Controller('room') export class RoomController { @@ -28,6 +29,22 @@ export class RoomController { private readonly roomHelperService: RoomHelperService, ) {} + /** + * Get Room by _id. + * + * @remarks Get Room by its _id. + * + * If the logged-in user is a Clan member and the Clan does have the requested Room, the Room for this Clan will be returned. + * + * If the logged-in user is not belonging to any Clan, or Room in that Clan with provided _id is not found the 404 error will be returned. + */ + @ApiResponseDescription({ + success: { + dto: RoomDto, + modelName: ModelName.ROOM, + }, + errors: [400, 401, 404], + }) @Get('/:_id') @Authorize({ action: Action.read, subject: RoomDto }) @UniformResponse(ModelName.ROOM) @@ -42,6 +59,25 @@ export class RoomController { }); } + /** + * Get Room all Clan's rooms + * + * @remarks Get all Rooms for the logged-in user. + * + * If the logged-in user is a Clan member, the Rooms for this Clan will be returned. + * + * If the logged-in user is not belonging to any Clan the 404 error will be returned. + * + * If the pagination is required, it can be used, but by default it will return all 30 rooms at once. + */ + @ApiResponseDescription({ + success: { + dto: RoomDto, + modelName: ModelName.ROOM, + returnsArray: true, + }, + errors: [401, 404], + }) @Get() @Authorize({ action: Action.read, subject: RoomDto }) @OffsetPaginate(ModelName.ROOM) @@ -55,6 +91,23 @@ export class RoomController { return this.service.readPlayerClanRooms(user.player_id, query); } + /** + * Update room by _id + * + * @remarks Update Room by its _id specified in the body. + * + * Any Clan member can update any Room, which (SoulHome) belongs to the Clan. + * + * If the logged-in user is a Clan member and the Clan does have the requested Room, the Room for this Clan will be returned. + * + * If the logged-in user is not belonging to any Clan, or Room in that Clan with provided _id is not found the 404 error will be returned. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 404], + }) @Put() @Authorize({ action: Action.update, subject: UpdateRoomDto }) @UniformResponse() @@ -63,6 +116,20 @@ export class RoomController { if (errors) return [null, errors]; } + /** + * Activate room by _id + * + * @remarks Activate the specified Rooms. + * + * If Room _id specified in the room_ids field does not belong to logged-in user's Clan (SoulHome), it will be ignored. + * However, it will return 404 if none of the Room _ids does not belong to the Clan. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 404], + }) @Post('/activate') @UniformResponse(ModelName.ROOM) public async activate( diff --git a/src/clanInventory/soulhome/dto/createSoulHome.dto.ts b/src/clanInventory/soulhome/dto/createSoulHome.dto.ts index 030b99bc3..d4e35ff7a 100644 --- a/src/clanInventory/soulhome/dto/createSoulHome.dto.ts +++ b/src/clanInventory/soulhome/dto/createSoulHome.dto.ts @@ -1,11 +1,23 @@ import { IsMongoId, IsString } from 'class-validator'; import AddType from '../../../common/base/decorator/AddType.decorator'; +import { ApiProperty } from '@nestjs/swagger'; @AddType('CreateSoulHomeDto') export class CreateSoulHomeDto { + /** + * Name of the Soul Home + * + * @example "Sanctuary of Shadows" + */ @IsString() name: string; + /** + * ID of the clan that owns the Soul Home + * + * @example "666abc12d1e2f30012bbccdd" + */ + @ApiProperty({ uniqueItems: true }) @IsMongoId() clan_id: string; } diff --git a/src/clanInventory/soulhome/dto/soulhome.dto.ts b/src/clanInventory/soulhome/dto/soulhome.dto.ts index fb991eac4..17a05be0a 100644 --- a/src/clanInventory/soulhome/dto/soulhome.dto.ts +++ b/src/clanInventory/soulhome/dto/soulhome.dto.ts @@ -3,23 +3,46 @@ import { RoomDto } from '../../room/dto/room.dto'; import { ClanDto } from '../../../clan/dto/clan.dto'; import AddType from '../../../common/base/decorator/AddType.decorator'; import { ExtractField } from '../../../common/decorator/response/ExtractField'; +import { ApiProperty } from '@nestjs/swagger'; @AddType('SoulHomeDto') export class SoulHomeDto { + /** + * Unique identifier for the Soul Home + * + * @example "666fabc1d2f0e10012aabbcc" + */ @ExtractField() @Expose() _id: string; + /** + * Name of the Soul Home + * + * @example "Fortress of Dawn" + */ @Expose() name: string; + /** + * ID of the clan that owns this Soul Home + * + * @example "666abc12d1e2f30012bbccdd" + */ + @ApiProperty({ uniqueItems: true }) @Expose() clan_id: string; + /** + * List of rooms contained in the Soul Home + */ @Type(() => RoomDto) @Expose() Room: RoomDto[]; + /** + * Clan that owns this Soul Home + */ @Type(() => ClanDto) @Expose() Clan: ClanDto; diff --git a/src/clanInventory/soulhome/dto/updateSoulHome.dto.ts b/src/clanInventory/soulhome/dto/updateSoulHome.dto.ts index d3f91581a..c213db7c2 100644 --- a/src/clanInventory/soulhome/dto/updateSoulHome.dto.ts +++ b/src/clanInventory/soulhome/dto/updateSoulHome.dto.ts @@ -3,9 +3,19 @@ import AddType from '../../../common/base/decorator/AddType.decorator'; @AddType('UpdateSoulHomeDto') export class UpdateSoulHomeDto { + /** + * ID of the Soul Home to update + * + * @example "666fabc1d2f0e10012aabbcc" + */ @IsMongoId() _id: string; + /** + * Updated name for the Soul Home + * + * @example "Citadel of Light" + */ @IsString() name?: string; } diff --git a/src/clanInventory/soulhome/soulhome.controller.ts b/src/clanInventory/soulhome/soulhome.controller.ts index 23846772c..8e6747c32 100644 --- a/src/clanInventory/soulhome/soulhome.controller.ts +++ b/src/clanInventory/soulhome/soulhome.controller.ts @@ -13,6 +13,7 @@ import { ModelName } from '../../common/enum/modelName.enum'; import { AddSearchQuery } from '../../common/interceptor/request/addSearchQuery.interceptor'; import { AddSortQuery } from '../../common/interceptor/request/addSortQuery.interceptor'; import { OffsetPaginate } from '../../common/interceptor/request/offsetPagination.interceptor'; +import ApiResponseDescription from '../../common/swagger/response/ApiResponseDescription'; @Controller('soulhome') export class SoulHomeController { @@ -21,6 +22,22 @@ export class SoulHomeController { private readonly helper: SoulHomeHelperService, ) {} + /** + * Get soul home of the logged-in player + * + * @remarks Get SoulHome data for the logged-in user. + * + * If the logged-in user is a Clan member, the SoulHome for this Clan will be returned. + * + * If the logged-in user is not belonging to any Clan the 404 error will be returned. + */ + @ApiResponseDescription({ + success: { + dto: SoulHomeDto, + modelName: ModelName.SOULHOME, + }, + errors: [401, 404], + }) @Get() @Authorize({ action: Action.read, subject: SoulHomeDto }) @OffsetPaginate(ModelName.SOULHOME) diff --git a/src/clanInventory/stock/dto/createStock.dto.ts b/src/clanInventory/stock/dto/createStock.dto.ts index 1bfaa89b0..1e6fc597c 100644 --- a/src/clanInventory/stock/dto/createStock.dto.ts +++ b/src/clanInventory/stock/dto/createStock.dto.ts @@ -4,9 +4,19 @@ import AddType from '../../../common/base/decorator/AddType.decorator'; @AddType('CreateStockDto') export class CreateStockDto { + /** + * Number of item cells available in the stock + * + * @example 20 + */ @IsInt() cellCount: number; + /** + * ID of the clan that owns this stock + * + * @example "666def12e1c2a50014bcaaff" + */ @IsClanExists() @IsMongoId() clan_id: string; diff --git a/src/clanInventory/stock/dto/stock.dto.ts b/src/clanInventory/stock/dto/stock.dto.ts index 743c363a9..8b9e47b55 100644 --- a/src/clanInventory/stock/dto/stock.dto.ts +++ b/src/clanInventory/stock/dto/stock.dto.ts @@ -6,21 +6,42 @@ import { ExtractField } from '../../../common/decorator/response/ExtractField'; @AddType('StockDto') export class StockDto { + /** + * Unique ID of the stock + * + * @example "666fab12d2f0e10012ccbbdd" + */ @ExtractField() @Expose() _id: string; + /** + * Total number of cells in this stock + * + * @example 30 + */ @Expose() cellCount: number; + /** + * ID of the clan associated with this stock + * + * @example "666def12e1c2a50014bcaaff" + */ @ExtractField() @Expose() clan_id: string; + /** + * Clan that owns the stock + */ @Type(() => ClanDto) @Expose() Clan: ClanDto; + /** + * Items currently stored in this stock + */ @Type(() => ItemDto) @Expose() Item: ItemDto[]; diff --git a/src/clanInventory/stock/dto/updateStock.dto.ts b/src/clanInventory/stock/dto/updateStock.dto.ts index 6730bdc1a..9ce4a4df0 100644 --- a/src/clanInventory/stock/dto/updateStock.dto.ts +++ b/src/clanInventory/stock/dto/updateStock.dto.ts @@ -5,14 +5,29 @@ import AddType from '../../../common/base/decorator/AddType.decorator'; @AddType('UpdateStockDto') export class UpdateStockDto { + /** + * ID of the stock to update + * + * @example "666fab12d2f0e10012ccbbdd" + */ @IsStockExists() @IsMongoId() _id: string; + /** + * Updated number of item cells in the stock + * + * @example 25 + */ @IsInt() @IsOptional() cellCount: number; + /** + * Updated clan ID associated with the stock + * + * @example "666def12e1c2a50014bcaaff" + */ @IsClanExists() @IsMongoId() @IsOptional() diff --git a/src/clanInventory/stock/stock.controller.ts b/src/clanInventory/stock/stock.controller.ts index f91ccc711..281e7f2c4 100644 --- a/src/clanInventory/stock/stock.controller.ts +++ b/src/clanInventory/stock/stock.controller.ts @@ -13,11 +13,26 @@ import { AddSearchQuery } from '../../common/interceptor/request/addSearchQuery. import { AddSortQuery } from '../../common/interceptor/request/addSortQuery.interceptor'; import { OffsetPaginate } from '../../common/interceptor/request/offsetPagination.interceptor'; import { IGetAllQuery } from '../../common/interface/IGetAllQuery'; +import ApiResponseDescription from '../../common/swagger/response/ApiResponseDescription'; @Controller('stock') export class StockController { public constructor(private readonly service: StockService) {} + /** + * Get stock by _id + * + * @remarks Read Stock data by its _id field. + * + * Notice that everybody is able to read any Stock data. + */ + @ApiResponseDescription({ + success: { + dto: StockDto, + modelName: ModelName.STOCK, + }, + errors: [400, 401, 404], + }) @Get('/:_id') @Authorize({ action: Action.read, subject: StockDto }) @UniformResponse(ModelName.STOCK) @@ -28,6 +43,19 @@ export class StockController { return this.service.readOneById(param._id, { includeRefs }); } + /** + * Get all stocks + * + * @remarks Read all created Stocks of all Clans. Remember about the pagination + */ + @ApiResponseDescription({ + success: { + dto: StockDto, + modelName: ModelName.STOCK, + returnsArray: true, + }, + errors: [401, 404], + }) @Get() @Authorize({ action: Action.read, subject: StockDto }) @OffsetPaginate(ModelName.STOCK) diff --git a/src/clanShop/clanShop.controller.ts b/src/clanShop/clanShop.controller.ts index 52fee5005..a2a8d46d8 100644 --- a/src/clanShop/clanShop.controller.ts +++ b/src/clanShop/clanShop.controller.ts @@ -12,6 +12,8 @@ import { APIErrorReason } from '../common/controller/APIErrorReason'; import { ItemName } from '../clanInventory/item/enum/itemName.enum'; import HasClanRights from '../clan/role/decorator/guard/HasClanRights'; import { ClanBasicRight } from '../clan/role/enum/clanBasicRight.enum'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; +import ClanShopItemDto from './dto/ClanShopItem.dto'; @Controller('clan-shop') export class ClanShopController { @@ -20,6 +22,21 @@ export class ClanShopController { private readonly service: ClanShopService, ) {} + /** + * Get all available items in clan shop + * + * @remarks Get a list of all items in the clan shop. + * + * Notice that item are rotated on every server restart and every day + */ + @ApiResponseDescription({ + success: { + status: 204, + dto: ClanShopItemDto, + modelName: ModelName.ITEM, + }, + hasAuth: false, + }) @Get('items') @NoAuth() @UniformResponse(ModelName.ITEM) @@ -27,6 +44,23 @@ export class ClanShopController { return this.clanShopScheduler.currentShopItems; } + /** + * Buy an item from clan shop + * + * @remarks Buy an item from a clan shop. + * + * Notice that the item will not be bought right away, but before majority of clan members should vote to buy the item. + * + * There should be also enough coins to buy an item. + * + * Notice that the player must be in the same clan and it must have a basic right "Shop" + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403], + }) @Post('buy') @DetermineClanId() @HasClanRights([ClanBasicRight.SHOP]) diff --git a/src/clanShop/dto/ClanShopItem.dto.ts b/src/clanShop/dto/ClanShopItem.dto.ts new file mode 100644 index 000000000..f445d86c8 --- /dev/null +++ b/src/clanShop/dto/ClanShopItem.dto.ts @@ -0,0 +1,63 @@ +import { ItemName } from '../../clanInventory/item/enum/itemName.enum'; +import { Rarity } from '../../clanInventory/item/enum/rarity.enum'; +import { Recycling } from '../../clanInventory/item/enum/recycling.enum'; +import { Material } from '../../clanInventory/item/enum/material.enum'; +import { Expose } from 'class-transformer'; + +export default class ClanShopItemDto { + /** + * Name of the item available in the clan shop + * + * @example "Sofa_Taakka" + */ + @Expose() + name: ItemName; + + /** + * Weight of the item, used for inventory calculations + * + * @example 3 + */ + @Expose() + weight: number; + + /** + * Price of the item in in-game currency + * + * @example 150 + */ + @Expose() + price: number; + + /** + * Rarity level of the item + * + * @example "common" + */ + @Expose() + rarity: Rarity; + + /** + * Type of recycling behavior for the item + * + * @example "Wood" + */ + @Expose() + recycling: Recycling; + + /** + * Whether the item can be used as furniture in rooms + * + * @example false + */ + @Expose() + isFurniture: boolean; + + /** + * Materials the item is made from + * + * @example ["puu", "paperi"] + */ + @Expose() + material: Material[]; +} diff --git a/src/common/base/decorator/AddType.decorator.ts b/src/common/base/decorator/AddType.decorator.ts index 2db9e6c11..4ef96fbe7 100644 --- a/src/common/base/decorator/AddType.decorator.ts +++ b/src/common/base/decorator/AddType.decorator.ts @@ -15,18 +15,27 @@ export interface ObjectType { * * @param type class objectType field to be added */ -export default function AddType(type: string) { - return function (constructor: T) { - return class extends constructor { - objectType = type; - - constructor(...args: any[]) { - super(...args); - Object.assign(this, { objectType: type }); - } - }; +export default function AddType(type: string): ClassDecorator { + return (target: any) => { + Reflect.defineMetadata('objectType', type, target); + Object.defineProperty(target.prototype, 'objectType', { + value: type, + writable: true, + }); }; } +// export default function AddType(type: string) { +// return function (constructor: T) { +// return class extends constructor { +// objectType = type; +// +// constructor(...args: any[]) { +// super(...args); +// Object.assign(this, { objectType: type }); +// } +// }; +// }; +// } /** * Determines whenever the object is of specified type or not diff --git a/src/common/controller/APIError.ts b/src/common/controller/APIError.ts index 731d00ba1..d46f0d702 100644 --- a/src/common/controller/APIError.ts +++ b/src/common/controller/APIError.ts @@ -47,7 +47,7 @@ type APIErrorArgs = { /** * The class represents an error occurred on controller level * - * The class is used to sent an error to the client side + * The class is used to send an error to the client side * * It extends the Nest HttpException and can be handled by built-in filters (not recommended) * @@ -78,13 +78,51 @@ export class APIError extends HttpException { this.statusCode = validStatus; } + /** + * Name of the error + */ name: string; + + /** + * Error stack + */ stack?: string; + + /** + * Why the error is happened + * @example "NOT_STRING" + */ reason: APIErrorReason; + + /** + * HTTP status code of the error + * @example 400 + */ statusCode: number | null; + + /** + * Message specifies why error happened, for developers "FYI" + * @example "name must be a string" + */ message: string | null; + + /** + * On what field the error happen (if the field is possible to define), mostly found in validation errors + * @example name + */ field: string | null; + + /** + * Value of the field (only if the field is specified), mostly found in validation errors + * @example 1 + */ value: string | null; + + /** + * Any additional data provided. + * For example if the error is thrown by some method and is UNEXPECTED, when this field should contain the thrown error + * @example isString + */ additional: any | null; } diff --git a/src/common/dto/_id.dto.ts b/src/common/dto/_id.dto.ts index 572228454..4860b3979 100644 --- a/src/common/dto/_id.dto.ts +++ b/src/common/dto/_id.dto.ts @@ -14,6 +14,11 @@ import { IsMongoId } from 'class-validator'; *``` */ export class _idDto { + /** + * Mongo _id + * + * @example "663a5d9cde9f1a0012f3b456" + */ @IsMongoId() _id: string; } diff --git a/src/swagger/documentSetup/introSection.ts b/src/common/swagger/documentSetup/introSection.ts similarity index 100% rename from src/swagger/documentSetup/introSection.ts rename to src/common/swagger/documentSetup/introSection.ts diff --git a/src/swagger/documentSetup/swaggerDocumentOptions.ts b/src/common/swagger/documentSetup/swaggerDocumentOptions.ts similarity index 100% rename from src/swagger/documentSetup/swaggerDocumentOptions.ts rename to src/common/swagger/documentSetup/swaggerDocumentOptions.ts diff --git a/src/swagger/documentSetup/swaggerUIOptions.ts b/src/common/swagger/documentSetup/swaggerUIOptions.ts similarity index 100% rename from src/swagger/documentSetup/swaggerUIOptions.ts rename to src/common/swagger/documentSetup/swaggerUIOptions.ts diff --git a/src/common/swagger/response/ApiResponseDescription.ts b/src/common/swagger/response/ApiResponseDescription.ts new file mode 100644 index 000000000..9c3cf5629 --- /dev/null +++ b/src/common/swagger/response/ApiResponseDescription.ts @@ -0,0 +1,57 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ApiStandardErrors, + SupportedErrorCode, +} from './errors/ApiStandardErrors.decorator'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiSuccessResponse, + ApiSuccessResponseOptions, +} from './success/ApiSuccessResponse.decorator'; +import { Type } from '@nestjs/common/interfaces'; + +/** + * Options for describing an endpoint. + */ +type ApiDescriptionOptions = { + /** + * description information of successful response + */ + success: { + /** + * The class of the DTO returned in the `data` field + */ + dto?: Type; + } & ApiSuccessResponseOptions; + /** + * possible errors that may occur on the endpoint + * @default [] + */ + errors?: SupportedErrorCode[]; + /** + * does endpoint have authentication requirement or not + * @default true + */ + hasAuth?: boolean; +}; + +/** + * Adds swagger description for an endpoint. + * + * Notice that it is a helper decorator combining 3: `ApiSuccessResponse`, `ApiStandardErrors` and `ApiBearerAuth`. + */ +export default function ApiResponseDescription({ + success, + errors = [], + hasAuth = true, +}: ApiDescriptionOptions) { + const { dto, ...successOptions } = success; + + const decoratorsToApply = [ + ApiSuccessResponse(dto, successOptions), + ApiStandardErrors(...errors), + ]; + if (hasAuth) decoratorsToApply.push(ApiBearerAuth()); + + return applyDecorators(...decoratorsToApply); +} diff --git a/src/common/swagger/response/errors/ApiStandardErrors.decorator.ts b/src/common/swagger/response/errors/ApiStandardErrors.decorator.ts new file mode 100644 index 000000000..b69f13243 --- /dev/null +++ b/src/common/swagger/response/errors/ApiStandardErrors.decorator.ts @@ -0,0 +1,162 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiResponse, ApiResponseOptions } from '@nestjs/swagger'; +import { APIError } from '../../../controller/APIError'; + +/** + * Supported HTTP error status codes for standard API responses. + */ +export type SupportedErrorCode = 400 | 401 | 403 | 404 | 409; + +/** + * Central record with error status code definitions + */ +const errorDefinitions: Record = { + 400: { + status: 400, + description: + 'Validation error. Some fields are missing, of the wrong type, or failed validation.', + type: APIError, + example: { + statusCode: 400, + errors: [ + { + response: 'NOT_STRING', + status: 400, + message: 'name must be a string', + name: '', + reason: 'NOT_STRING', + field: 'name', + value: '1', + additional: 'isString', + statusCode: 400, + objectType: 'APIError', + }, + ], + }, + }, + 401: { + status: 401, + description: + 'Not authenticated. The Authorization header is missing or the token is expired. ' + + '[More info](https://github.com/Alt-Org/Altzone-Server/wiki/2.-Authentication-and-authorization)', + type: APIError, + example: { + statusCode: 401, + errors: [ + { + response: 'AUTHENTICATION_FAILED', + status: 401, + message: 'Could not authenticate the user', + name: '', + reason: 'AUTHENTICATION_FAILED', + field: 'string', + value: 'string', + additional: 'string', + statusCode: 401, + objectType: 'APIError', + }, + ], + }, + }, + 403: { + status: 403, + description: + 'No permission. The user lacks authorization to perform the requested action. ' + + '[More info](https://github.com/Alt-Org/Altzone-Server/wiki/2.-Authentication-and-authorization)', + type: APIError, + example: { + statusCode: 403, + errors: [ + { + response: 'NOT_AUTHORIZED', + status: 403, + message: + 'The logged-in user has no permission to execute create_request action', + name: '', + reason: 'NOT_AUTHORIZED', + field: 'string', + value: 'string', + additional: 'create_request', + statusCode: 403, + objectType: 'APIError', + }, + ], + }, + }, + 404: { + status: 404, + description: + 'Not found. No matching object was found for the given identifier.', + type: APIError, + example: { + statusCode: 404, + errors: [ + { + response: 'NOT_FOUND', + status: 404, + message: 'Could not find any objects with specified id', + name: '', + reason: 'NOT_FOUND', + field: '_id', + value: '64df3aad42cbaf850a3f891f', + additional: 'string', + statusCode: 404, + objectType: 'APIError', + }, + ], + }, + }, + 409: { + status: 409, + description: 'Conflict. A unique constraint was violated.', + type: APIError, + example: { + statusCode: 409, + errors: [ + { + response: 'NOT_UNIQUE', + status: 409, + message: 'Field "name" with value "John" already exists', + name: '', + reason: 'NOT_UNIQUE', + field: 'name', + value: 'John', + additional: 'string', + statusCode: 409, + objectType: 'APIError', + }, + ], + }, + }, +}; + +/** + * Adds standard error responses to swagger definition using Nest's `@ApiResponse()` decorators + * It supports all common error cases. + * + * Notice that the decorator should be used for controller methods only. + * + * @param statusCodes One or more standard error HTTP status codes. + * + * @example + * ```ts + * @Get + * @ApiStandardErrors(400, 401, 403) + * async myControllerMethod(){ + * return true; + * } + * ``` + * + * @returns A composite NestJS decorator applying appropriate standard error responses. + */ +export function ApiStandardErrors(...statusCodes: SupportedErrorCode[]) { + if (!statusCodes || statusCodes.length === 0) applyDecorators(); + + const uniqueCodes = Array.from(new Set(statusCodes)); + + const decorators = uniqueCodes.map((code) => + ApiResponse(errorDefinitions[code]), + ); + + return applyDecorators(...decorators); +} diff --git a/src/common/swagger/response/success/ApiSuccessResponse.decorator.ts b/src/common/swagger/response/success/ApiSuccessResponse.decorator.ts new file mode 100644 index 000000000..6f8d5a128 --- /dev/null +++ b/src/common/swagger/response/success/ApiSuccessResponse.decorator.ts @@ -0,0 +1,123 @@ +import { applyDecorators, Type } from '@nestjs/common'; +import { + ApiExtraModels, + ApiResponse, + ApiResponseOptions, + getSchemaPath, +} from '@nestjs/swagger'; +import { getArrayMetaDataSchema, getObjectMetaDataSchema } from './metaData'; + +/** + * Additional options for configuring response swagger definition + */ +export type ApiSuccessResponseOptions = ApiResponseOptions & { + /** + * Indicates whether the returned `data` is an array of items. + * If true, Swagger will document the response `data` field as an array of the specified DTO type. + * + * @default false + */ + returnsArray?: boolean; + + /** + * Model name to be used in the data and metadata section of the response. + * + * @default "Object" + * @example "Clan" + */ + modelName?: string; + + /** + * Key name under the `data` object to wrap the returned value. + * If not provided, the modelName is used as the default key. + * + * @example "Clan" + */ + dataKey?: string; + + /** + * When true, adds a `paginationData` object to the Swagger schema definition + * + * Notice that if `returnsArray` is set to true, `paginationData` default option will be set to true by default + * + * @default false + */ + hasPagination?: boolean; +}; + +/** + * Adds a Swagger success response with a wrapped `data` object containing the given DTO. + * + * Notice that it will add status code 200 and description "Operation is successful". + * You can overwrite them in options. + * + * @param dto The class of the DTO returned in the `data` field + * @param options additional options and swagger definition options, which can override any other response definitions + */ +export function ApiSuccessResponse( + dto?: Type, + options?: ApiSuccessResponseOptions, +) { + if (!dto) { + return applyDecorators( + ApiResponse({ + description: 'Successful request, response with no body', + ...options, + }), + ); + } + + const { returnsArray, modelName, dataKey, hasPagination } = options; + + const dataKeyField = dataKey ?? modelName ?? 'Object'; + + const metaData = returnsArray + ? getArrayMetaDataSchema(modelName, dataKey) + : getObjectMetaDataSchema(modelName, dataKey); + + const addPaginationData = + (returnsArray && hasPagination !== false) || hasPagination; + + const paginationData = addPaginationData + ? { + type: 'object', + properties: { + currentPage: { type: 'integer', example: 1 }, + limit: { type: 'integer', example: 20 }, + offset: { type: 'integer', example: 0 }, + itemCount: { type: 'integer', example: 5 }, + pageCount: { type: 'integer', example: 1 }, + }, + } + : undefined; + + return applyDecorators( + ApiExtraModels(dto), + ApiResponse({ + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + [dataKeyField]: returnsArray + ? { + type: 'array', + items: { $ref: getSchemaPath(dto.name) }, + } + : { + $ref: getSchemaPath(dto.name), + }, + }, + }, + metaData, + paginationData, + }, + }, + + status: 200, + description: 'Operation is successful', + ...options, + }), + ); +} diff --git a/src/common/swagger/response/success/metaData.ts b/src/common/swagger/response/success/metaData.ts new file mode 100644 index 000000000..4963a8631 --- /dev/null +++ b/src/common/swagger/response/success/metaData.ts @@ -0,0 +1,67 @@ +/** + * Generates swagger schema definition for objects metadata. + * @param modelName `modelName` example value, if not specified 'Object' will be set + * @param dataKey `dataKey` example value, if not specified the model name will be set + * + * @returns swagger schema definition that can be set for the `metaData` field + */ +export function getObjectMetaDataSchema( + modelName = 'Object', + dataKey = modelName, +) { + return { + type: 'object', + properties: { + dataKey: { + type: 'string', + example: dataKey, + }, + modelName: { + type: 'string', + example: modelName, + }, + dataType: { + type: 'string', + example: 'Object', + }, + dataCount: { + type: 'integer', + example: 1, + }, + }, + }; +} + +/** + * Generates swagger schema definition for arrays metadata. + * @param modelName `modelName` example value, if not specified 'Object' will be set + * @param dataKey `dataKey` example value, if not specified the model name will be set + * + * @returns swagger schema definition that can be set for the `metaData` field + */ +export function getArrayMetaDataSchema( + modelName = 'Object', + dataKey = modelName, +) { + return { + type: 'object', + properties: { + dataKey: { + type: 'string', + example: dataKey, + }, + modelName: { + type: 'string', + example: modelName, + }, + dataType: { + type: 'string', + example: 'Array', + }, + dataCount: { + type: 'number', + example: 3, + }, + }, + }; +} diff --git a/src/swagger/swaggerDocumentBuilder.ts b/src/common/swagger/swaggerDocumentBuilder.ts similarity index 100% rename from src/swagger/swaggerDocumentBuilder.ts rename to src/common/swagger/swaggerDocumentBuilder.ts diff --git a/src/swagger/swaggerSetuper.ts b/src/common/swagger/swaggerInitializer.ts similarity index 51% rename from src/swagger/swaggerSetuper.ts rename to src/common/swagger/swaggerInitializer.ts index b89b8b8d1..2a0985157 100644 --- a/src/swagger/swaggerSetuper.ts +++ b/src/common/swagger/swaggerInitializer.ts @@ -1,6 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { SwaggerModule } from '@nestjs/swagger'; -import { envVars } from '../common/service/envHandler/envVars'; +import { envVars } from '../service/envHandler/envVars'; import { swaggerDocumentOptions } from './documentSetup/swaggerDocumentOptions'; import { swaggerUIOptions } from './documentSetup/swaggerUIOptions'; import { @@ -10,9 +10,10 @@ import { } from './documentSetup/introSection'; import { SwaggerDocumentBuilder } from './swaggerDocumentBuilder'; import { SwaggerTag, swaggerTags } from './tags/tags'; +import { APIError } from '../controller/APIError'; -export default class SwaggerSetuper { - static setupSwaggerFromJSDocs(app: INestApplication) { +export default class SwaggerInitializer { + static initSwaggerFromDecorators(app: INestApplication) { const tagsToAdd: SwaggerTag[] = []; for (const tag in swaggerTags) tagsToAdd.push(swaggerTags[tag]); @@ -22,10 +23,35 @@ export default class SwaggerSetuper { .setVersion(swaggerVersion) .addTags(tagsToAdd) .addBearerAuth() + .addGlobalResponse({ + example: { + statusCode: 500, + errors: [ + { + response: 'UNEXPECTED', + status: 500, + message: 'Unexpected error happen', + name: '', + reason: 'UNEXPECTED', + field: 'string', + value: 'string', + additional: 'string', + statusCode: 500, + objectType: 'APIError', + }, + ], + }, + status: 500, + type: () => APIError, + description: + 'Unexpected error happened. [Please create a new issue here](https://github.com/Alt-Org/Altzone-Server/issues) ' + + 'and specify the endpoint, HTTP method and description if you have any', + }) .build(); const documentFactory = () => SwaggerModule.createDocument(app, config, swaggerDocumentOptions); + SwaggerModule.setup( envVars.SWAGGER_PATH, app, diff --git a/src/swagger/tags/SwaggerTags.decorator.ts b/src/common/swagger/tags/SwaggerTags.decorator.ts similarity index 100% rename from src/swagger/tags/SwaggerTags.decorator.ts rename to src/common/swagger/tags/SwaggerTags.decorator.ts diff --git a/src/swagger/tags/tags.ts b/src/common/swagger/tags/tags.ts similarity index 71% rename from src/swagger/tags/tags.ts rename to src/common/swagger/tags/tags.ts index 6a34dc0c5..de7f045fa 100644 --- a/src/swagger/tags/tags.ts +++ b/src/common/swagger/tags/tags.ts @@ -15,12 +15,15 @@ export type SwaggerTagName = | 'Stock' | 'Item' | 'DailyTasks' - | 'Chat' | 'FleaMarket' + | 'ClanShop' + | 'Chat' | 'Voting' | 'Leaderboard' | 'GameAnalytics' - | 'Box'; + | 'Box' + | 'OnlinePlayers' + | 'GameData'; /** * All swagger tags and their data @@ -33,7 +36,7 @@ export const swaggerTags: Record = { Profile: { name: 'Profile', - description: 'profile', + description: 'profile related functionality', }, Auth: { name: 'Auth', @@ -41,48 +44,54 @@ export const swaggerTags: Record = { }, Player: { name: 'Player', - description: 'player', + description: 'player related functionality', }, CustomCharacter: { name: 'CustomCharacter', - description: 'CustomCharacter', + description: 'CustomCharacter related functionality', }, Clan: { name: 'Clan', - description: 'clan', + description: 'clan related functionality', }, SoulHome: { name: 'SoulHome', - description: 'soulhome', + description: 'soulhome related functionality', }, Room: { name: 'Room', - description: 'room', + description: 'room related functionality', }, Stock: { name: 'Stock', - description: 'stock', + description: 'stock related functionality', }, Item: { name: 'Item', - description: 'item', + description: 'item related functionality', }, DailyTasks: { name: 'DailyTasks', description: 'In-game tasks for Player to do daily, weekly, monthly', }, - Chat: { - name: 'Chat', - description: 'chats and their messages ', - }, FleaMarket: { name: 'FleaMarket', description: "Flea market, place where Clans can sell own items or buy other's.", }, + ClanShop: { + name: 'ClanShop', + description: + 'Clan shop, place where clans can buy items to decorate their soul homes', + }, + + Chat: { + name: 'Chat', + description: 'chats and their messages ', + }, Voting: { name: 'Voting', description: 'In-game votings', @@ -99,6 +108,16 @@ export const swaggerTags: Record = { name: 'Box', description: 'Testing box data', }, + + OnlinePlayers: { + name: 'OnlinePlayers', + description: 'Information about online players', + }, + + GameData: { + name: 'GameData', + description: 'Information about game', + }, }; /** diff --git a/src/dailyTasks/dailyTasks.controller.ts b/src/dailyTasks/dailyTasks.controller.ts index 97dd889b0..e76834c8d 100644 --- a/src/dailyTasks/dailyTasks.controller.ts +++ b/src/dailyTasks/dailyTasks.controller.ts @@ -14,11 +14,11 @@ import { UniformResponse } from '../common/decorator/response/UniformResponse'; import { ModelName } from '../common/enum/modelName.enum'; import { PlayerService } from '../player/player.service'; import { DailyTaskDto } from './dto/dailyTask.dto'; -import { Serialize } from '../common/interceptor/response/Serialize'; import { _idDto } from '../common/dto/_id.dto'; import GameEventEmitter from '../gameEventsEmitter/gameEventEmitter'; import { UpdateUIDailyTaskDto } from './dto/updateUIDailyTask.dto'; import UIDailyTasksService from './uiDailyTasks/uiDailyTasks.service'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; @Controller('dailyTasks') export class DailyTasksController { @@ -29,6 +29,30 @@ export class DailyTasksController { private readonly uiDailyTasksService: UIDailyTasksService, ) {} + /** + * Get player's tasks + * + * @remarks Returns a list of logged-in Player's tasks. + * + * The period query works as follows: + * + * today - all tasks for today (completed and uncomleted) + * + * week - all tasks for the current week (completed and uncomleted) including daily tasks. + * + * month - all tasks for the current month (completed and uncompleted), including weekly and daily tasks. + * + * You can find the json file of player tasks [here](https://github.com/Alt-Org/Altzone-Server/blob/dev/src/playerTasks/playerTasks.json) + */ + @ApiResponseDescription({ + success: { + dto: DailyTaskDto, + modelName: ModelName.DAILY_TASK, + returnsArray: true, + hasPagination: false, + }, + errors: [400, 401, 404], + }) @Get() @UniformResponse(ModelName.DAILY_TASK, DailyTaskDto) async getClanTasks(@LoggedUser() user: User) { @@ -38,14 +62,42 @@ export class DailyTasksController { }); } + /** + * Get a daily task by _id + * + * @remarks Get specific daily task by _id + */ + @ApiResponseDescription({ + success: { + dto: DailyTaskDto, + modelName: ModelName.DAILY_TASK, + }, + errors: [400, 401, 404], + }) @Get('/:_id') @UniformResponse(ModelName.DAILY_TASK, DailyTaskDto) async getTask(@Param() param: _idDto) { return this.dailyTasksService.readOneById(param._id); } + /** + * Reserve a daily task + * + * @remarks Reserve a daily task for the loged-in player. + * + * Reserved task can not be taken by other clan members. + * + * Notice that clan members will get a notification about the task was reserved + */ + @ApiResponseDescription({ + success: { + dto: DailyTaskDto, + modelName: ModelName.DAILY_TASK, + }, + errors: [400, 401, 404], + }) @Put('/reserve/:_id') - @UniformResponse() + @UniformResponse(ModelName.DAILY_TASK, DailyTaskDto) async reserveTask(@Param() param: _idDto, @LoggedUser() user: User) { const clanId = await this.playerService.getPlayerClanId(user.player_id); return this.dailyTasksService.reserveTask( @@ -55,6 +107,17 @@ export class DailyTasksController { ); } + /** + * Un-reserve selected daily task + * + * @remarks Endpoint for un-reserving the task selected by the logged-in player = the task that has the player_id field set to the logged-in player _id. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [401, 404], + }) @Put('/unreserve') @UniformResponse() async unreserveTask(@LoggedUser() user: User) { @@ -64,6 +127,20 @@ export class DailyTasksController { if (errors) return [null, errors]; } + /** + * Update daily task managed by UI + * + * @remarks Endpoint for updating daily tasks, which can be registered only on the client side, such as "press a button". + * + * Notice that although it is possible to make a request to update basically any daily task progress by its _id, + * only daily tasks that are present in the UITaskName enum can be updated via it. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 404], + }) @Put('/uiDailyTask') @UniformResponse() async updateUIDailyTask( @@ -82,6 +159,19 @@ export class DailyTasksController { }); } + /** + * Delete daily task by _id. + * + * @remarks Delete task by specified _id. + * + * Notice that a new task will be generated to replace the old one. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 404], + }) @HttpCode(204) @Delete('/:_id') @UniformResponse(ModelName.DAILY_TASK, DailyTaskDto) diff --git a/src/dailyTasks/dto/dailyTask.dto.ts b/src/dailyTasks/dto/dailyTask.dto.ts index b9f5fe5aa..108fb6a14 100644 --- a/src/dailyTasks/dto/dailyTask.dto.ts +++ b/src/dailyTasks/dto/dailyTask.dto.ts @@ -7,39 +7,94 @@ import { UITaskName } from '../enum/uiTaskName.enum'; @AddType('DailyTaskDto') export class DailyTaskDto { + /** + * Unique identifier of the daily task + * + * @example "665af23e5e982f0013aa334b" + */ @ExtractField() @Expose() _id: string; + /** + * ID of the clan associated with this task + * + * @example "665af23e5e982f0013aa1122" + */ @ExtractField() @Expose() clan_id: string; + /** + * ID of the player assigned to the task + * + * @example "665af23e5e982f0013aa4455" + */ @ExtractField() @Expose() player_id: string; + /** + * Title or brief description of the task + * + * @example {fi: "Lähetä 10 viestiä chatissa"} + */ @Expose() title: TaskTitle; + /** + * Type of task, either server-defined or UI-triggered + * + * @example "write_chat_message" + */ @Expose() type: ServerTaskName | UITaskName; + /** + * Number of points rewarded upon completion + * + * @example 50 + */ @Expose() points: number; + /** + * Amount of coins rewarded for finishing the task + * + * @example 100 + */ @Expose() coins: number; + /** + * Timestamp when the task was started + * + * @example "2025-05-16T14:25:00.000Z" + */ @Expose() startedAt: Date; + /** + * Total amount required to complete the task + * + * @example 10 + */ @Expose() amount: number; + /** + * Amount remaining to complete the task + * + * @example 3 + */ @Expose() amountLeft: number; + /** + * Time limit to finish the task, in minutes + * + * @example 60 + */ @Expose() timeLimitMinutes: number; } diff --git a/src/dailyTasks/dto/updateUIDailyTask.dto.ts b/src/dailyTasks/dto/updateUIDailyTask.dto.ts index d8a0d64a4..d8701416d 100644 --- a/src/dailyTasks/dto/updateUIDailyTask.dto.ts +++ b/src/dailyTasks/dto/updateUIDailyTask.dto.ts @@ -1,6 +1,11 @@ import { IsInt, IsOptional } from 'class-validator'; export class UpdateUIDailyTaskDto { + /** + * Updated amount toward completing the task + * + * @example 7 + */ @IsOptional() @IsInt() amount: number; diff --git a/src/fleaMarket/dto/createFleaMarketItem.dto.ts b/src/fleaMarket/dto/createFleaMarketItem.dto.ts index 1ce2ac16d..00513f827 100644 --- a/src/fleaMarket/dto/createFleaMarketItem.dto.ts +++ b/src/fleaMarket/dto/createFleaMarketItem.dto.ts @@ -16,36 +16,86 @@ import { Material } from '../../clanInventory/item/enum/material.enum'; @AddType('CreateFleaMarketItemDto') export class CreateFleaMarketItemDto { + /** + * Name of the item being listed in the flea market + * + * @example "Sofa_Rakkaus" + */ @IsString() name: ItemName; + /** + * Weight of the item, relevant for carrying capacity + * + * @example 5 + */ @IsInt() weight: number; + /** + * Recycling category of the item + * + * @example "Glass" + */ @IsEnum(Recycling) recycling: Recycling; + /** + * Rarity level of the item + * + * @example "common" + */ @IsEnum(Rarity) rarity: Rarity; + /** + * List of materials used in crafting the item + * + * @example ["tekonahka", "nahka"] + */ @IsArray() @IsEnum(Material) material: Material[]; + /** + * Unique Unity key to identify the asset in the game engine + * + * @example "Assets/Items/MysticLantern" + */ @IsString() unityKey: string; + /** + * Current status of the flea market item + * + * @example "available" + */ @IsEnum(Status) @IsOptional() status: Status = Status.SHIPPING; + /** + * Price of the item in coins + * + * @example 300 + */ @IsInt() price: number; + /** + * Whether the item can be placed as furniture + * + * @example true + */ @IsBoolean() @IsOptional() isFurniture: boolean; + /** + * ID of the clan listing the item + * + * @example "665af23e5e982f0013aa8899" + */ @IsClanExists() clan_id: string; } diff --git a/src/fleaMarket/dto/fleaMarketItem.dto.ts b/src/fleaMarket/dto/fleaMarketItem.dto.ts index a8668e11a..f05540c14 100644 --- a/src/fleaMarket/dto/fleaMarketItem.dto.ts +++ b/src/fleaMarket/dto/fleaMarketItem.dto.ts @@ -9,37 +9,92 @@ import { Material } from '../../clanInventory/item/enum/material.enum'; @AddType('FleaMarketItemDto') export class FleaMarketItemDto { + /** + * Unique identifier of the flea market item + * + * @example "665af23e5e982f0013aa1122" + */ @ExtractField() @Expose() _id: string; + /** + * Name of the item + * + * @example "Sofa_Taakka" + */ @Expose() name: ItemName; + /** + * Weight of the item + * + * @example 5 + */ @Expose() weight: number; + /** + * Recycling category of the item + * + * @example "Landfill" + */ @Expose() recycling: Recycling; + /** + * Rarity level of the item + * + * @example "common" + */ @Expose() rarity: Rarity; + /** + * List of materials used in the item + * + * @example ["polyesteri"] + */ @Expose() material: Material[]; + /** + * Unity engine key for referencing the item + * + * @example "Some key" + */ @Expose() unityKey: string; + /** + * Current status of the item in the flea market + * + * @example "available" + */ @Expose() status: Status; + /** + * Whether the item is a piece of furniture + * + * @example true + */ @Expose() isFurniture: boolean; + /** + * Price of the item in coins + * + * @example 300 + */ @Expose() price: number; + /** + * ID of the clan that owns the item + * + * @example "665af23e5e982f0013aa8899" + */ @ExtractField() @Expose() clan_id: string; diff --git a/src/fleaMarket/dto/itemId.dto.ts b/src/fleaMarket/dto/itemId.dto.ts index 1a158d912..4a756e252 100644 --- a/src/fleaMarket/dto/itemId.dto.ts +++ b/src/fleaMarket/dto/itemId.dto.ts @@ -3,6 +3,11 @@ import AddType from '../../common/base/decorator/AddType.decorator'; @AddType('ItemIdDto') export class ItemIdDto { + /** + * ID of the item to be sold / bought + * + * @example "665af23e5e982f0013aa5566" + */ @IsString() @IsMongoId() item_id: string; diff --git a/src/fleaMarket/fleaMarket.controller.ts b/src/fleaMarket/fleaMarket.controller.ts index e8791bcec..5001d259a 100644 --- a/src/fleaMarket/fleaMarket.controller.ts +++ b/src/fleaMarket/fleaMarket.controller.ts @@ -15,6 +15,7 @@ import { PlayerService } from '../player/player.service'; import { ItemIdDto } from './dto/itemId.dto'; import HasClanRights from '../clan/role/decorator/guard/HasClanRights'; import { ClanBasicRight } from '../clan/role/enum/clanBasicRight.enum'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; @Controller('fleaMarket') export class FleaMarketController { @@ -23,12 +24,37 @@ export class FleaMarketController { private readonly playerService: PlayerService, ) {} + /** + * Get flea market item by _id. + * + * @remarks Get an individual FleaMarketItem by its mongo _id field. + */ + @ApiResponseDescription({ + success: { + dto: FleaMarketItemDto, + modelName: ModelName.FLEA_MARKET_ITEM, + }, + errors: [400, 401, 404], + }) @Get('/:_id') @UniformResponse(ModelName.FLEA_MARKET_ITEM, FleaMarketItemDto) async getOne(@Param() param: _idDto) { return await this.service.readOneById(param._id); } + /** + * Get all flea market items + * + * @remarks Get all FleaMarketItems of the flea market + */ + @ApiResponseDescription({ + success: { + dto: FleaMarketItemDto, + modelName: ModelName.FLEA_MARKET_ITEM, + returnsArray: true, + }, + errors: [400, 401, 404], + }) @Get() @OffsetPaginate(ModelName.FLEA_MARKET_ITEM) @UniformResponse(ModelName.FLEA_MARKET_ITEM, FleaMarketItemDto) @@ -36,6 +62,23 @@ export class FleaMarketController { return await this.service.readMany(query); } + /** + * Sell a clan item on flea market + * + * @remarks Sell an Item on the flea market. + * This will start a voting in the Clan from which Item is being moved to the flea marked. + * Voting min approval percentage is 51. During the voting an Item is in "Shipping" status and can not be bought by other players. + * + * Notice that the player must be in the same clan and it must have a basic right "Shop". + * + * Notice that if a FleaMarketItem has already "Shipping" status 403 will be returned. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404], + }) @Post('sell') @HasClanRights([ClanBasicRight.SHOP]) @UniformResponse() @@ -58,6 +101,24 @@ export class FleaMarketController { ); } + /** + * Buy an item on flea market for your clan + * + * @remarks Buy an Item from the flea market. + * This will start a voting in the Clan for which Item is being purchased. + * Voting duration is 10 min at max and the min approval percentage is 51. + * During the voting an Item is in "Booked" status and can not be bought by other players. + * + * Notice that the player must be in the same clan and it must have a basic right "Shop". + * + * Notice that if a FleaMarketItem has already "Booked" status 403 will be returned. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403, 404], + }) @Post('buy') @HasClanRights([ClanBasicRight.SHOP]) @UniformResponse() diff --git a/src/gameAnalytics/gameAnalytics.controller.ts b/src/gameAnalytics/gameAnalytics.controller.ts index 7ed51d4ed..8b6e24253 100644 --- a/src/gameAnalytics/gameAnalytics.controller.ts +++ b/src/gameAnalytics/gameAnalytics.controller.ts @@ -5,7 +5,6 @@ import { UseFilters, UseInterceptors, } from '@nestjs/common'; -import { _idDto } from '../common/dto/_id.dto'; import { FileInterceptor } from '@nestjs/platform-express'; import { FileValidationFilter } from './FileValidation.filter'; import { UniformResponse } from '../common/decorator/response/UniformResponse'; @@ -17,11 +16,33 @@ import { APIErrorReason } from '../common/controller/APIErrorReason'; import { LogFileService } from './logFile.service'; import { BattleIdHeader } from './decorator/BattleIdHeader.decorator'; import { envVars } from '../common/service/envHandler/envVars'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; @Controller('gameAnalytics') export class GameAnalyticsController { public constructor(private readonly logFileService: LogFileService) {} + /** + * Upload a game analytics log file + * + * @remarks Endpoint uploads a log file and save on the server. + * + * The file will be saved to the folder with name corresponding to the date when it was uploaded, i.e. 1-9-2024. + * + * File name must be unique and therefore its name will contain date, time, Player _id and a random string, i.e. 1-9-2024_14-45-12_667eedc9b3b5bf0f7a840ef1_123456.log + * + * Notice that the request must have a Secret header which holds a password for such requests. + * + * Notice that if the Battle-Id header is not defined the current timestamp will be used instead. + * + * Notice that the request must be in multipart/form-data format, where field name containing the file is "logFile" + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403], + }) @Post('/logFile') @UseFilters(new FileValidationFilter()) @UseInterceptors(FileInterceptor('logFile')) diff --git a/src/gameData/dto/battleResponse.dto.ts b/src/gameData/dto/battleResponse.dto.ts index f9ddc947e..2fd50c556 100644 --- a/src/gameData/dto/battleResponse.dto.ts +++ b/src/gameData/dto/battleResponse.dto.ts @@ -1,5 +1,27 @@ -export type BattleResponseDto = { +import { Expose } from 'class-transformer'; + +export class BattleResponseDto { + /** + * Token required to steal items after a won battle + * + * @example "abc123-steal-token" + */ + @Expose() stealToken: string; + + /** + * Soul home ID where items can be stolen + * + * @example "665af23e5e982f0013aa7777" + */ + @Expose() soulHome_id: string; + + /** + * Rooms _ids where items can be stolen + * + * @example ["665af23e5e982f0013aa8888", "665af23e5e982f0013aa9999"] + */ + @Expose() roomIds: string[]; -}; +} diff --git a/src/gameData/dto/battleResult.dto.ts b/src/gameData/dto/battleResult.dto.ts index 4d009bc28..be133492d 100644 --- a/src/gameData/dto/battleResult.dto.ts +++ b/src/gameData/dto/battleResult.dto.ts @@ -10,21 +10,46 @@ import { import { RequestType } from '../enum/requestType.enum'; export class BattleResultDto { + /** + * Type of the request + * + * @example "result" + */ @IsEnum(RequestType) type: RequestType.RESULT; + /** + * IDs of players in team 1 + * + * @example ["665af23e5e982f0013aa1111", "665af23e5e982f0013aa2222"] + */ @IsArray() @IsMongoId({ each: true }) team1: string[]; + /** + * IDs of players in team 2 + * + * @example ["665af23e5e982f0013aa3333", "665af23e5e982f0013aa4444"] + */ @IsArray() @IsMongoId({ each: true }) team2: string[]; + /** + * Duration of the battle in seconds + * + * @example 120 + */ @IsInt() @IsPositive() duration: number; + /** + * Number of the winning team (1 or 2) + * + * @example 1 + */ @IsInt() @Min(1) @Max(2) diff --git a/src/gameData/dto/game.dto.ts b/src/gameData/dto/game.dto.ts index 3ae82d2c6..8c5689f1d 100644 --- a/src/gameData/dto/game.dto.ts +++ b/src/gameData/dto/game.dto.ts @@ -8,37 +8,77 @@ import { import { Type } from 'class-transformer'; export class GameDto { + /** + * Unique identifier for the game + * + * @example "665af23e5e982f0013aaffff" + */ @IsMongoId() @IsNotEmpty() _id: string; + /** + * Array of player IDs in team 1 + * + * @example ["665af23e5e982f0013aa1111", "665af23e5e982f0013aa2222"] + */ @IsArray() @IsMongoId({ each: true }) @IsNotEmpty() team1: string[]; + /** + * Array of player IDs in team 2 + * + * @example ["665af23e5e982f0013aa3333", "665af23e5e982f0013aa4444"] + */ @IsArray() @IsMongoId({ each: true }) @IsNotEmpty() team2: string[]; + /** + * Clan ID of team 1 + * + * @example "665af23e5e982f0013aacccc" + */ @IsMongoId() @IsNotEmpty() team1Clan: string; + /** + * Clan ID of team 2 + * + * @example "665af23e5e982f0013aadddd" + */ @IsMongoId() @IsNotEmpty() team2Clan: string; + /** + * Winner of the game (1 or 2) + * + * @example 2 + */ @IsEnum([1, 2]) @IsNotEmpty() winner: number; + /** + * Date and time when the game started + * + * @example "2025-05-16T10:00:00.000Z" + */ @IsDate() @Type(() => Date) @IsNotEmpty() startedAt: Date; + /** + * Date and time when the game ended + * + * @example "2025-05-16T10:05:00.000Z" + */ @IsDate() @Type(() => Date) @IsNotEmpty() diff --git a/src/gameData/dto/resultType.dto.ts b/src/gameData/dto/requestType.dto.ts similarity index 74% rename from src/gameData/dto/resultType.dto.ts rename to src/gameData/dto/requestType.dto.ts index f65663712..d21ca092d 100644 --- a/src/gameData/dto/resultType.dto.ts +++ b/src/gameData/dto/requestType.dto.ts @@ -2,6 +2,11 @@ import { IsEnum } from 'class-validator'; import { RequestType } from '../enum/requestType.enum'; export class RequestTypeDto { + /** + * Type of request + * + * @example "result" + */ @IsEnum(RequestType) type: RequestType; } diff --git a/src/gameData/gameData.controller.ts b/src/gameData/gameData.controller.ts index d1eacede5..817036049 100644 --- a/src/gameData/gameData.controller.ts +++ b/src/gameData/gameData.controller.ts @@ -7,15 +7,50 @@ import { APIError } from '../common/controller/APIError'; import { APIErrorReason } from '../common/controller/APIErrorReason'; import { validate } from 'class-validator'; import { RequestType } from './enum/requestType.enum'; -import { RequestTypeDto } from './dto/resultType.dto'; +import { RequestTypeDto } from './dto/requestType.dto'; +import { BattleResultDto } from './dto/battleResult.dto'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; +import { BattleResponseDto } from './dto/battleResponse.dto'; @Controller('gameData') export class GameDataController { constructor(private readonly service: GameDataService) {} + /** + * Inform API about battle + * + * @remarks Endpoint for notifying the API about battle events or any other data. + * + * Notice, that the field type is required and determines the type of the data. + * + * Notice that the type also determines shape of the body. Examples, for each type can be found in request examples section. + * + * ### Type field + * #### result + * + * Result of the battle, all players of the battle should send this data. + * + * Notice that team1 and team2 should hold game's player's _id fields. + * + * As a response for winners an access token will be returned, which can be used when stealing Items from losed Clan's SoulHome. + * Notice that the steal token will expire after some period of time. Losers will get 403 error = they can not get the steal token. + * + * The steal token can be used only by the winner's Clan's members for the loser's Clan Stock. + * + * You can see the process flow from [this diagram](https://github.com/Alt-Org/Altzone-Server/tree/dev/doc/img/game_results) + */ + @ApiResponseDescription({ + success: { + dto: BattleResponseDto, + }, + errors: [400, 401, 403, 404], + }) @Post('battle') @UniformResponse() - async handleBattleResult(@Body() body: any, @LoggedUser() user: User) { + async handleBattleResult( + @Body() body: BattleResultDto, + @LoggedUser() user: User, + ) { const typeDto = new RequestTypeDto(); typeDto.type = body.type; const errors = await validate(typeDto); @@ -27,7 +62,7 @@ export class GameDataController { switch (typeDto.type) { case RequestType.RESULT: - return await this.service.handleResultType(body, user); + return this.service.handleResultType(body, user); default: return new APIError({ reason: APIErrorReason.BAD_REQUEST }); } diff --git a/src/gameData/gameData.service.ts b/src/gameData/gameData.service.ts index 068ed4bb8..e7cf257f6 100644 --- a/src/gameData/gameData.service.ts +++ b/src/gameData/gameData.service.ts @@ -18,6 +18,8 @@ import { APIErrorReason } from '../common/controller/APIErrorReason'; import { RoomService } from '../clanInventory/room/room.service'; import { GameEventsHandler } from '../gameEventsHandler/gameEventsHandler'; import { GameEventType } from '../gameEventsHandler/enum/GameEventType.enum'; +import { IServiceReturn } from '../common/service/basicService/IService'; +import { SEReason } from '../common/service/basicService/SEReason'; @Injectable() export class GameDataService { @@ -48,7 +50,7 @@ export class GameDataService { async handleResultType( battleResult: BattleResultDto, user: User, - ): Promise { + ): Promise> { const currentTime = new Date(); const winningTeam = battleResult.winnerTeam === 1 ? battleResult.team1 : battleResult.team2; @@ -59,11 +61,16 @@ export class GameDataService { ); if (!playerInWinningTeam) - return new APIError({ - reason: APIErrorReason.NOT_AUTHORIZED, - message: - 'Player is not in the winning team and therefore is not allowed to steal', - }); + return [ + null, + [ + new ServiceError({ + reason: SEReason.NOT_ALLOWED, + message: + 'Player is not in the winning team and therefore is not allowed to steal', + }), + ], + ]; this.gameEventsBroker.handleEvent( user.player_id, @@ -73,11 +80,11 @@ export class GameDataService { battleResult.team1[0], battleResult.team2[0], ]); - if (teamIdsErrors) return teamIdsErrors; + if (teamIdsErrors) return [null, teamIdsErrors]; this.createGameIfNotExists(battleResult, teamIds, currentTime); - return await this.generateResponse( + return this.generateResponse( battleResult, teamIds.team1Id, teamIds.team2Id, diff --git a/src/itemMover/itemMover.controller.ts b/src/itemMover/itemMover.controller.ts index 8b47dbf4e..a0a4e46b1 100644 --- a/src/itemMover/itemMover.controller.ts +++ b/src/itemMover/itemMover.controller.ts @@ -8,14 +8,34 @@ import { IdMismatchError } from '../clanInventory/item/errors/playerId.errors'; import { StealTokenGuard } from '../clanInventory/item/guards/StealToken.guard'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { UniformResponse } from '../common/decorator/response/UniformResponse'; -import { _idDto } from '../common/dto/_id.dto'; import { ModelName } from '../common/enum/modelName.enum'; import { StealToken as stealToken } from '../clanInventory/item/type/stealToken.type'; +import SwaggerTags from '../common/swagger/tags/SwaggerTags.decorator'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; +import { ItemDto } from '../clanInventory/item/dto/item.dto'; +@SwaggerTags('Item') @Controller('item') export class ItemMoverController { public constructor(private readonly itemMoverService: ItemMoverService) {} + /** + * Move item + * + * @remarks Move Item from Stock to Room or from Room to Stock, based on the moveTo field, which can have only two values: "Stock" or "Room" + * + * Notice that Clan members can move only own Clan Items and a member is trying to move other Clan's Item the 404 will be returned. + * + * Notice that if an Item need to be moved to a Stock then there is no need to specify the destination_id field, since Clan can have only one Stock. + */ + @ApiResponseDescription({ + success: { + dto: ItemDto, + modelName: ModelName.ITEM, + returnsArray: true, + }, + errors: [400, 401, 404], + }) @Post('/move') @UniformResponse() public async moveItems(@Body() body: MoveItemDto, @LoggedUser() user: User) { @@ -28,6 +48,30 @@ export class ItemMoverController { if (errors) return errors; } + /** + * Steal items from another clan + * + * @remarks Steal Items from the loser Clan's SoulHome. The stolen Items will be automatically added to a specified Room of the winners Clan's SoulHome. + * + * Notice that at first a steal token should be obtained by the winner player(s) from the /gameData/battle POST endpoint with body type "result", + * while informing about game result. + * + * Requests without the steal token or with an expired token will get 403 as a response. + * + * Notice that only found SoulHome Items will be stolen. + * It is possible that some other player already has stoled some of the specified Items, in this case these Items will be ignored since they can not be stolen twice. + * + * You can see the process flow from [this diagram](https://github.com/Alt-Org/Altzone-Server/tree/dev/doc/img/game_results) + */ + @ApiResponseDescription({ + success: { + dto: ItemDto, + modelName: ModelName.ITEM, + returnsArray: true, + description: 'Array of Items, which were stolen', + }, + errors: [400, 401, 404], + }) @Post('steal') @UseGuards(StealTokenGuard) @UniformResponse(ModelName.ITEM) diff --git a/src/leaderboard/dto/clanPosition.dto.ts b/src/leaderboard/dto/clanPosition.dto.ts new file mode 100644 index 000000000..35f5d890e --- /dev/null +++ b/src/leaderboard/dto/clanPosition.dto.ts @@ -0,0 +1,11 @@ +import { Expose } from 'class-transformer'; + +export default class ClanPositionDto { + /** + * Clan's position on the leaderboard + * + * @example 1 + */ + @Expose() + position: number; +} diff --git a/src/leaderboard/dto/leaderboardPlayer.dto.ts b/src/leaderboard/dto/leaderboardPlayer.dto.ts index e0d0c8a2a..587318dab 100644 --- a/src/leaderboard/dto/leaderboardPlayer.dto.ts +++ b/src/leaderboard/dto/leaderboardPlayer.dto.ts @@ -3,6 +3,9 @@ import { PlayerDto } from '../../player/dto/player.dto'; import { ClanLogoDto } from '../../clan/dto/clanLogo.dto'; export class LeaderboardPlayerDto extends PlayerDto { + /** + * Logo of the player's clan shown on the leaderboard + */ @Expose() @Type(() => ClanLogoDto) clanLogo: ClanLogoDto; diff --git a/src/leaderboard/leaderboard.controller.ts b/src/leaderboard/leaderboard.controller.ts index b143a73c1..5ea9831a0 100644 --- a/src/leaderboard/leaderboard.controller.ts +++ b/src/leaderboard/leaderboard.controller.ts @@ -11,6 +11,8 @@ import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { User } from '../auth/user'; import { PlayerService } from '../player/player.service'; import { LeaderboardPlayerDto } from './dto/leaderboardPlayer.dto'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; +import ClanPositionDto from './dto/clanPosition.dto'; @Controller('leaderboard') export class LeaderboardController { @@ -19,26 +21,71 @@ export class LeaderboardController { private readonly playerService: PlayerService, ) {} + /** + * Get top players + * + * @remarks Leaderboard of players. Top Players are defined by the amount of points that he/she has. + * + * Notice that the leaderboards data is updated once every 12 hours. + */ + @ApiResponseDescription({ + success: { + dto: LeaderboardPlayerDto, + modelName: ModelName.PLAYER, + returnsArray: true, + }, + errors: [400, 404], + hasAuth: false, + }) @Get('player') @NoAuth() @UniformResponse(ModelName.PLAYER, LeaderboardPlayerDto) @OffsetPaginate(ModelName.PLAYER) async getPlayerLeaderboard(@GetAllQuery() query: IGetAllQuery) { - return await this.leaderBoardService.getPlayerLeaderboard(query); + return this.leaderBoardService.getPlayerLeaderboard(query); } + /** + * Get top clans + * + * @remarks Leaderboard of clans. Top Clans are defined by the amount of points that each Clan has. + * + * Notice that the leaderboards data is updated once every 12 hours. + */ + @ApiResponseDescription({ + success: { + dto: ClanDto, + modelName: ModelName.CLAN, + returnsArray: true, + }, + errors: [400, 404], + hasAuth: false, + }) @Get('clan') @NoAuth() @UniformResponse(ModelName.CLAN, ClanDto) @OffsetPaginate(ModelName.CLAN) async getClanLeaderboard(@GetAllQuery() query: IGetAllQuery) { - return await this.leaderBoardService.getClanLeaderboard(query); + return this.leaderBoardService.getClanLeaderboard(query); } + /** + * Get logged-in player's clan position on the leaderboard + * + * @remarks Get the logged-in user's clan position on the leaderboard. + * + * Note that if the logged-in user is not in any clan the 404 will be returned + */ + @ApiResponseDescription({ + success: { + dto: ClanPositionDto, + }, + errors: [401, 404], + }) @Get('clan/position') @UniformResponse() async getClanPosition(@LoggedUser() user: User) { const clanId = await this.playerService.getPlayerClanId(user.player_id); - return await this.leaderBoardService.getClanPosition(clanId); + return this.leaderBoardService.getClanPosition(clanId); } } diff --git a/src/main.ts b/src/main.ts index a9dd27787..b7bdb3697 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import { APIError } from './common/controller/APIError'; import { validationToAPIErrors } from './common/exceptionFilter/ValidationExceptionFilter'; import EnvHandler from './common/service/envHandler/envHandler'; import cookieParser from 'cookie-parser'; -import SwaggerSetuper from './swagger/swaggerSetuper'; +import SwaggerInitializer from './common/swagger/swaggerInitializer'; async function bootstrap() { // Validate that all environment variables are added to the .env file @@ -27,7 +27,7 @@ async function bootstrap() { //Let the class-validator use DI system of NestJS useContainer(app.select(AppModule), { fallbackOnErrors: true }); - SwaggerSetuper.setupSwaggerFromJSDocs(app); + SwaggerInitializer.initSwaggerFromDecorators(app); await app.listen(8080); } diff --git a/src/onlinePlayers/dto/onlinePlayer.dto.ts b/src/onlinePlayers/dto/onlinePlayer.dto.ts new file mode 100644 index 000000000..c068eaec9 --- /dev/null +++ b/src/onlinePlayers/dto/onlinePlayer.dto.ts @@ -0,0 +1,19 @@ +import { Expose } from 'class-transformer'; + +export default class OnlinePlayerDto { + /** + * _id of the player + * + * @example "68189c8ce6eda712552911b9" + */ + @Expose() + id: string; + + /** + * name of the player + * + * @example "dragon-slayer" + */ + @Expose() + name: string; +} diff --git a/src/onlinePlayers/onlinePlayers.controller.ts b/src/onlinePlayers/onlinePlayers.controller.ts index 6a0f2c615..f99ea7243 100644 --- a/src/onlinePlayers/onlinePlayers.controller.ts +++ b/src/onlinePlayers/onlinePlayers.controller.ts @@ -3,17 +3,47 @@ import { OnlinePlayersService } from './onlinePlayers.service'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { User } from '../auth/user'; import { UniformResponse } from '../common/decorator/response/UniformResponse'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; +import OnlinePlayerDto from './dto/onlinePlayer.dto'; @Controller('online-players') export class OnlinePlayersController { constructor(private readonly onlinePlayersService: OnlinePlayersService) {} + /** + * Inform the API if player is still online + * + * @remarks The player is considered to be online if he / she has made a request to this endpoint at least once a 5 min. + * + * So it is recommended to make requests to this endpoint every 4-5 min to properly track the players being online, although not often than that. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [401], + }) @Post('ping') @UniformResponse() async ping(@LoggedUser() user: User) { return this.onlinePlayersService.addPlayerOnline(user.player_id); } + /** + * Inform the API if player is still online + * + * @remarks The player is considered to be online if he / she has made a request to this endpoint at least once a 5 min. + * + * So it is recommended to make requests to this endpoint every 4-5 min to properly track the players being online, although not often than that. + */ + @ApiResponseDescription({ + success: { + dto: OnlinePlayerDto, + returnsArray: true, + hasPagination: false, + }, + errors: [401], + }) @Get() @UniformResponse() async getAllOnlinePlayers() { diff --git a/src/player/customCharacter/customCharacter.controller.ts b/src/player/customCharacter/customCharacter.controller.ts index 662eb2758..0cbc56674 100644 --- a/src/player/customCharacter/customCharacter.controller.ts +++ b/src/player/customCharacter/customCharacter.controller.ts @@ -18,11 +18,30 @@ import { UniformResponse } from '../../common/decorator/response/UniformResponse import { IncludeQuery } from '../../common/decorator/param/IncludeQuery.decorator'; import { publicReferences } from './customCharacter.schema'; import { Serialize } from '../../common/interceptor/response/Serialize'; +import ApiResponseDescription from '../../common/swagger/response/ApiResponseDescription'; @Controller('customCharacter') export class CustomCharacterController { public constructor(private readonly service: CustomCharacterService) {} + /** + * Create a custom character + * + * @remarks Create a new CustomCharacter. CustomCharacter represents a character of the Player. + * Player can have many CustomCharacters, CustomCharacter can belong to only one Player. + * + * Notice that the player_id field will be determined based on the logged-in player. + * + * Notice that player can have only one custom character of each type + */ + @ApiResponseDescription({ + success: { + dto: CustomCharacterDto, + modelName: ModelName.CUSTOM_CHARACTER, + status: 201, + }, + errors: [400, 401], + }) @Post() @Authorize({ action: Action.create, subject: CustomCharacterDto }) @UniformResponse(ModelName.CUSTOM_CHARACTER, CustomCharacterDto) @@ -33,6 +52,19 @@ export class CustomCharacterController { return this.service.createOne(body, user.player_id); } + /** + * Get logged-in player chosen battle CustomCharacters + * + * @remarks Get logged-in player chosen CustomCharacters for a battle + */ + @ApiResponseDescription({ + success: { + dto: CustomCharacterDto, + modelName: ModelName.CUSTOM_CHARACTER, + returnsArray: true, + }, + errors: [401, 404], + }) @Get('/battleCharacters') @Authorize({ action: Action.read, subject: CustomCharacterDto }) @UniformResponse(ModelName.CUSTOM_CHARACTER, CustomCharacterDto) @@ -40,6 +72,20 @@ export class CustomCharacterController { return this.service.readPlayerBattleCharacters(user.player_id); } + /** + * Get CustomCharacter by _id + * + * @remarks Read CustomCharacter data by its _id field. + * + * Notice that if the CustomCharacter does not belong to the logged-in Player 404 should be returned. + */ + @ApiResponseDescription({ + success: { + dto: CustomCharacterDto, + modelName: ModelName.CUSTOM_CHARACTER, + }, + errors: [400, 401, 404], + }) @Get('/:_id') @Authorize({ action: Action.read, subject: CustomCharacterDto }) @UniformResponse(ModelName.CUSTOM_CHARACTER, CustomCharacterDto) @@ -54,6 +100,19 @@ export class CustomCharacterController { }); } + /** + * Get all CustomCharacters of the logged-in player + * + * @remarks Read all custom characters. Remember about the pagination + */ + @ApiResponseDescription({ + success: { + dto: CustomCharacterDto, + modelName: ModelName.CUSTOM_CHARACTER, + returnsArray: true, + }, + errors: [401, 404], + }) @Get() @Authorize({ action: Action.read, subject: CustomCharacterDto }) @OffsetPaginate(ModelName.CUSTOM_CHARACTER) @@ -70,6 +129,19 @@ export class CustomCharacterController { }); } + /** + * Update custom character by _id + * + * @remarks Update the CustomCharacter, which _id is specified in the body. + * + * Only the Player, that owns the CustomCharacter can change it. In case the Player does not own the CustomCharacter the 404 is returned. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 404], + }) @Put() @Authorize({ action: Action.update, subject: UpdateCustomCharacterDto }) @UniformResponse(ModelName.CUSTOM_CHARACTER) diff --git a/src/player/customCharacter/dto/createCustomCharacter.dto.ts b/src/player/customCharacter/dto/createCustomCharacter.dto.ts index 307f7bfb0..dcd95b8bb 100644 --- a/src/player/customCharacter/dto/createCustomCharacter.dto.ts +++ b/src/player/customCharacter/dto/createCustomCharacter.dto.ts @@ -4,9 +4,19 @@ import { CharacterId } from '../enum/characterId.enum'; @AddType('CreateCustomCharacterDto') export class CreateCustomCharacterDto { + /** + * Base character ID used as a template + * + * @example "201" + */ @IsEnum(CharacterId) characterId: CharacterId; + /** + * Starting level of the custom character + * + * @example 1 + */ @IsInt() level: number; } diff --git a/src/player/customCharacter/dto/customCharacter.dto.ts b/src/player/customCharacter/dto/customCharacter.dto.ts index 1fbc28193..4b109c968 100644 --- a/src/player/customCharacter/dto/customCharacter.dto.ts +++ b/src/player/customCharacter/dto/customCharacter.dto.ts @@ -6,35 +6,83 @@ import { CharacterId } from '../enum/characterId.enum'; @AddType('CustomCharacterDto') export class CustomCharacterDto { + /** + * Unique ID of the custom character + * + * @example "661b55c4d9d2b21f00a1a4b2" + */ @ExtractField() @Expose() _id: string; + /** + * Base character ID + * + * @example "201" + */ @Expose() characterId: CharacterId; + /** + * Character's defense value + * + * @example 25 + */ @Expose() defence: number; + /** + * Character's total health points + * + * @example 100 + */ @Expose() hp: number; + /** + * Character size value (affects hitbox or visuals) + * + * @example 2 + */ @Expose() size: number; + /** + * Movement speed of the character + * + * @example 5 + */ @Expose() speed: number; + /** + * Attack power of the character + * + * @example 40 + */ @Expose() attack: number; + /** + * Level of the character + * + * @example 3 + */ @Expose() level: number; + /** + * Player ID who owns this custom character + * + * @example "661b55c4d9d2b21f00a1a4a1" + */ @ExtractField() @Expose() player_id: string; + /** + * Player object who owns this custom character + */ @Type(() => PlayerDto) @Expose() Player: PlayerDto; diff --git a/src/player/customCharacter/dto/updateCustomCharacter.dto.ts b/src/player/customCharacter/dto/updateCustomCharacter.dto.ts index 3d1458a30..974ca8552 100644 --- a/src/player/customCharacter/dto/updateCustomCharacter.dto.ts +++ b/src/player/customCharacter/dto/updateCustomCharacter.dto.ts @@ -6,34 +6,74 @@ import { CharacterId } from '../enum/characterId.enum'; export const UpdateCustomCharacterType = 'UpdateCustomCharacterType'; @AddType('UpdateCustomCharacterDto') export class UpdateCustomCharacterDto { + /** + * ID of the custom character to update + * + * @example "661b55c4d9d2b21f00a1a4b2" + */ @IsCustomCharacterExists() @IsMongoId() _id: string; + /** + * New base character ID + * + * @example "202" + */ @IsOptional() @IsEnum(CharacterId) characterId?: CharacterId; + /** + * Updated defense value + * + * @example 30 + */ @IsOptional() @IsInt() defence?: number; + /** + * Updated HP value + * + * @example 120 + */ @IsOptional() @IsInt() hp?: number; + /** + * Updated size value + * + * @example 3 + */ @IsOptional() @IsInt() size?: number; + /** + * Updated attack power + * + * @example 50 + */ @IsOptional() @IsInt() attack?: number; + /** + * Updated speed value + * + * @example 6 + */ @IsOptional() @IsInt() speed?: number; + /** + * Updated character level + * + * @example 5 + */ @IsOptional() @IsInt() level?: number; diff --git a/src/player/dto/avatar.dto.ts b/src/player/dto/avatar.dto.ts index a43a4f276..006ca0fb4 100644 --- a/src/player/dto/avatar.dto.ts +++ b/src/player/dto/avatar.dto.ts @@ -1,33 +1,83 @@ import { Expose } from 'class-transformer'; export class AvatarDto { + /** + * Head variant identifier + * + * @example 1 + */ @Expose() head: number; + /** + * Hairstyle identifier + * + * @example 2 + */ @Expose() hair: number; + /** + * Eye style identifier + * + * @example 3 + */ @Expose() eyes: number; + /** + * Nose style identifier + * + * @example 1 + */ @Expose() nose: number; + /** + * Mouth style identifier + * + * @example 2 + */ @Expose() mouth: number; + /** + * Eyebrows style identifier + * + * @example 1 + */ @Expose() eyebrows: number; + /** + * Clothes identifier + * + * @example 4 + */ @Expose() clothes: number; + /** + * Feet (footwear) identifier + * + * @example 1 + */ @Expose() feet: number; + /** + * Hands (gloves, etc.) identifier + * + * @example 2 + */ @Expose() hands: number; + /** + * Avatar skin color in HEX format + * + * @example "#FAD9B5" + */ @Expose() skinColor: string; } diff --git a/src/player/dto/createPlayer.dto.ts b/src/player/dto/createPlayer.dto.ts index 6e15697d9..6c7009ce7 100644 --- a/src/player/dto/createPlayer.dto.ts +++ b/src/player/dto/createPlayer.dto.ts @@ -13,41 +13,87 @@ import AddType from '../../common/base/decorator/AddType.decorator'; import { Type } from 'class-transformer'; import { ModifyAvatarDto } from './modifyAvatar.dto'; import { IsMongoIdOrNull } from '../../common/decorator/validation/IsMongoIdOrNull.decorator'; +import { ApiProperty } from '@nestjs/swagger'; @AddType('CreatePlayerDto') export class CreatePlayerDto { + /** + * Display name of the player + * + * @example "ShadowKnight" + */ + @ApiProperty({ uniqueItems: true }) @IsString() name: string; + /** + * Backpack capacity (number of item slots) + * + * @example 30 + */ @IsInt() backpackCapacity: number; + /** + * Unique player identifier (e.g., device ID or user ID) + * + * @example "device-uuid-12345" + */ + @ApiProperty({ uniqueItems: true }) @IsString() uniqueIdentifier: string; + /** + * Whether the player confirms being over 13 years old + * + * @example true + */ @IsOptional() @IsBoolean() above13?: boolean; + /** + * Whether the player has parental authorization + * + * @example false + */ @IsOptional() @IsBoolean() parentalAuth?: boolean; + /** + * Battle character IDs linked to the player (max 3) + * + * @example ["60f7c2d9a2d3c7b7e56d01df"] + */ @IsOptional() @IsArray() @ArrayMaxSize(3) @IsMongoIdOrNull({ each: true }) battleCharacter_ids?: string[]; + /** + * ID of the currently selected avatar variant + * + * @example 2 + */ @IsOptional() @IsInt() currentAvatarId?: number; + /** + * Linked profile ID + * + * @example "60f7c2d9a2d3c7b7e56d01df" + */ @IsOptional() @IsProfileExists() @IsMongoId() profile_id?: string; + /** + * Custom avatar setup for this player + */ @IsOptional() @ValidateNested() @Type(() => ModifyAvatarDto) diff --git a/src/player/dto/gameStatistics.dto.ts b/src/player/dto/gameStatistics.dto.ts index c0f0632e3..f1f879a82 100644 --- a/src/player/dto/gameStatistics.dto.ts +++ b/src/player/dto/gameStatistics.dto.ts @@ -3,18 +3,43 @@ import AddType from '../../common/base/decorator/AddType.decorator'; @AddType('GameStatisticsDto') export class GameStatisticsDto { + /** + * Total number of battles played + * + * @example 42 + */ @Expose() playedBattles?: number; + /** + * Total number of battles won + * + * @example 18 + */ @Expose() wonBattles?: number; + /** + * Current amount of diamonds owned + * + * @example 120 + */ @Expose() diamondsAmount?: number; + /** + * Number of votings initiated by this player + * + * @example 3 + */ @Expose() startedVotings?: number; + /** + * Number of votings the player has participated in + * + * @example 7 + */ @Expose() participatedVotings?: number; } diff --git a/src/player/dto/modifyAvatar.dto.ts b/src/player/dto/modifyAvatar.dto.ts index 6ef44e5f2..2f0a34db6 100644 --- a/src/player/dto/modifyAvatar.dto.ts +++ b/src/player/dto/modifyAvatar.dto.ts @@ -1,33 +1,83 @@ import { IsInt, IsString } from 'class-validator'; export class ModifyAvatarDto { + /** + * Head variant ID + * + * @example 1 + */ @IsInt() head: number; + /** + * Hair style ID + * + * @example 3 + */ @IsInt() hair: number; + /** + * Eyes style ID + * + * @example 2 + */ @IsInt() eyes: number; + /** + * Nose style ID + * + * @example 1 + */ @IsInt() nose: number; + /** + * Mouth style ID + * + * @example 2 + */ @IsInt() mouth: number; + /** + * Eyebrows style ID + * + * @example 1 + */ @IsInt() eyebrows: number; + /** + * Clothes set ID + * + * @example 4 + */ @IsInt() clothes: number; + /** + * Feet (footwear) ID + * + * @example 1 + */ @IsInt() feet: number; + /** + * Hands (gloves/accessories) ID + * + * @example 2 + */ @IsInt() hands: number; + /** + * Skin color as a hex string + * + * @example "#FAD9B5" + */ @IsString() skinColor: string; } diff --git a/src/player/dto/player.dto.ts b/src/player/dto/player.dto.ts index ab039ceaf..0ab2996fc 100644 --- a/src/player/dto/player.dto.ts +++ b/src/player/dto/player.dto.ts @@ -9,63 +9,128 @@ import { AvatarDto } from './avatar.dto'; @AddType('PlayerDto') export class PlayerDto { + /** + * Unique player ID + * + * @example "60f7c2d9a2d3c7b7e56d01df" + */ @ExtractField() @Expose() _id: string; + /** + * Player display name + * + * @example "ShadowKnight" + */ @Expose() name: string; + /** + * Total points earned by player + * + * @example 1200 + */ @Expose() points: number; + /** + * Maximum capacity of player's backpack + * + * @example 30 + */ @Expose() backpackCapacity: number; + /** + * Unique identifier (device/user) + * + * @example "device-uuid-12345" + */ @Expose() uniqueIdentifier: string; + /** + * Whether player is over 13 + * + * @example true + */ @Expose() above13?: boolean | null; + /** + * Whether parental authorization was given + * + * @example false + */ @Expose() parentalAuth: boolean | null; + /** + * Game statistics + */ @Type(() => GameStatisticsDto) @Expose() gameStatistics: GameStatisticsDto; + /** + * List of battle character IDs + */ @ExtractField() @Expose() battleCharacter_ids?: string[]; + /** + * ID of the current avatar + */ @Expose() currentAvatarId?: number; + /** + * Linked profile ID + */ @ExtractField() @Expose() profile_id: string; + /** + * Clan ID this player belongs to + */ @ExtractField() @Expose() clan_id: string; + /** + * Player's clan object + */ @Type(() => ClanDto) @Expose() Clan: ClanDto; + /** + * Player's custom battle characters + */ @Type(() => CustomCharacterDto) @Expose() CustomCharacter: CustomCharacterDto[]; + /** + * Player's currently active daily task + */ @Type(() => TaskDto) @Expose() DailyTask?: TaskDto; + /** + * Player's avatar + */ @Type(() => AvatarDto) @Expose() avatar?: AvatarDto; + /** + * ID of the role the player holds in the clan + */ @ExtractField() @Expose() clanRole_id?: string; diff --git a/src/player/dto/updatePlayer.dto.ts b/src/player/dto/updatePlayer.dto.ts index 903f69b25..bdececf28 100644 --- a/src/player/dto/updatePlayer.dto.ts +++ b/src/player/dto/updatePlayer.dto.ts @@ -14,53 +14,109 @@ import AddType from '../../common/base/decorator/AddType.decorator'; import { Type } from 'class-transformer'; import { ModifyAvatarDto } from './modifyAvatar.dto'; import { IsMongoIdOrNull } from '../../common/decorator/validation/IsMongoIdOrNull.decorator'; +import { ApiProperty } from '@nestjs/swagger'; @AddType('UpdatePlayerDto') export class UpdatePlayerDto { + /** + * Player ID to update + * + * @example "60f7c2d9a2d3c7b7e56d01df" + */ @IsPlayerExists() @IsMongoId() _id: string; + /** + * New player name + * + * @example "KnightX" + */ + @ApiProperty({ uniqueItems: true }) @IsString() @IsOptional() name?: string; + /** + * Updated backpack capacity + * + * @example 40 + */ @IsInt() @IsOptional() backpackCapacity?: number; + /** + * New unique identifier + * + * @example "new-device-id-67890" + */ + @ApiProperty({ uniqueItems: true }) @IsString() @IsOptional() uniqueIdentifier?: string; + /** + * Update age confirmation + * + * @example true + */ @IsOptional() @IsBoolean() above13?: boolean; + /** + * Update parental authorization + * + * @example true + */ @IsOptional() @IsBoolean() parentalAuth?: boolean; + /** + * Update battle characters (max 3) + * + * @example ["60f7c2d9a2d3c7b7e56d01df"] + */ @IsOptional() @IsArray() @ArrayMaxSize(3) @IsMongoIdOrNull({ each: true }) battleCharacter_ids?: string[]; + /** + * Update current avatar ID + * + * @example 2 + */ @IsOptional() @IsInt() currentAvatarId?: number; + /** + * New clan assignment + * + * @example "60f7c2d9a2d3c7b7e56d01ab" + */ @IsClanExists() @IsMongoId() @IsOptional() clan_id?: string; + /** + * Clan ID to remove from player + * + * @example "60f7c2d9a2d3c7b7e56d01ab" + */ @IsClanExists() @IsMongoId() @IsOptional() clan_idToDelete?: string; + /** + * Update avatar configuration + */ @IsOptional() @ValidateNested() @Type(() => ModifyAvatarDto) diff --git a/src/player/player.controller.ts b/src/player/player.controller.ts index 97357a5e2..657f0fd6e 100644 --- a/src/player/player.controller.ts +++ b/src/player/player.controller.ts @@ -18,7 +18,6 @@ import { BasicDELETE } from '../common/base/decorator/BasicDELETE.decorator'; import { BasicPUT } from '../common/base/decorator/BasicPUT.decorator'; import { ModelName } from '../common/enum/modelName.enum'; import { NoAuth } from '../auth/decorator/NoAuth.decorator'; -import { CatchCreateUpdateErrors } from '../common/decorator/response/CatchCreateUpdateErrors'; import { Authorize } from '../authorization/decorator/Authorize'; import { Action } from '../authorization/enum/action.enum'; import { OffsetPaginate } from '../common/interceptor/request/offsetPagination.interceptor'; @@ -29,11 +28,30 @@ import { AddSortQuery } from '../common/interceptor/request/addSortQuery.interce import { UniformResponse } from '../common/decorator/response/UniformResponse'; import { publicReferences } from './schemas/player.schema'; import { IncludeQuery } from '../common/decorator/param/IncludeQuery.decorator'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; @Controller('player') export default class PlayerController { public constructor(private readonly service: PlayerService) {} + /** + * 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. + * + * Player is representing an object, which holds data related to game player. This object can be used inside the game for example while joining a Clan. + * 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, + }, + errors: [400, 401, 403, 409], + hasAuth: false, + }) @NoAuth() @Post() @UniformResponse(ModelName.PLAYER, PlayerDto) @@ -41,6 +59,18 @@ export default class PlayerController { return this.service.createOne(body); } + /** + * Get player by _id + * + * @remarks Read Player data by its _id field + */ + @ApiResponseDescription({ + success: { + dto: PlayerDto, + modelName: ModelName.PLAYER, + }, + errors: [400, 401, 404], + }) @Get('/:_id') @UniformResponse(ModelName.PLAYER, PlayerDto) @Authorize({ action: Action.read, subject: PlayerDto }) @@ -51,6 +81,18 @@ export default class PlayerController { return this.service.getPlayerById(param._id, { includeRefs }); } + /** + * Get all players + * + * @remarks Read all created Players. Remember about the pagination + */ + @ApiResponseDescription({ + success: { + dto: PlayerDto, + modelName: ModelName.PLAYER, + }, + errors: [401, 404], + }) @Get() @Authorize({ action: Action.read, subject: PlayerDto }) @OffsetPaginate(ModelName.PLAYER) @@ -61,6 +103,17 @@ export default class PlayerController { return this.service.readAll(query); } + /** + * Update player + * + * @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, + }, + errors: [401, 403, 404, 409], + }) @Put() @HttpCode(204) @Authorize({ action: Action.update, subject: UpdatePlayerDto }) @@ -69,6 +122,26 @@ export default class PlayerController { return this.service.updateOneById(body); } + /** + * 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. + * 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, + * 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: + * CustomCharacters, Clan, except for the Profile data. + * 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, + }, + errors: [400, 401, 403, 404], + }) @Delete('/:_id') @Authorize({ action: Action.delete, subject: PlayerDto }) @BasicDELETE(ModelName.PLAYER) diff --git a/src/profile/dto/createProfile.dto.ts b/src/profile/dto/createProfile.dto.ts index a6c787bbf..d572a710b 100644 --- a/src/profile/dto/createProfile.dto.ts +++ b/src/profile/dto/createProfile.dto.ts @@ -2,15 +2,30 @@ import { IsOptional, IsString, ValidateNested } from 'class-validator'; import { CreatePlayerDto } from '../../player/dto/createPlayer.dto'; import { Type } from 'class-transformer'; import AddType from '../../common/base/decorator/AddType.decorator'; +import { ApiProperty } from '@nestjs/swagger'; @AddType('CreateProfileDto') export class CreateProfileDto { + /** + * Unique username for the profile + * + * @example "soulmaster99" + */ + @ApiProperty({ uniqueItems: true }) @IsString() username: string; + /** + * Password for the profile (should be hashed before storing) + * + * @example "SecureP@ssw0rd!" + */ @IsString() password: string; + /** + * Optional player data to associate with this profile + */ @IsOptional() @ValidateNested() @Type(() => CreatePlayerDto) diff --git a/src/profile/dto/playerProfile.dto.ts b/src/profile/dto/playerProfile.dto.ts index d659df197..84c4bd8bd 100644 --- a/src/profile/dto/playerProfile.dto.ts +++ b/src/profile/dto/playerProfile.dto.ts @@ -5,6 +5,11 @@ import { IsMongoId, IsOptional } from 'class-validator'; @AddType('PlayerProfileDto') export class PlayerProfileDto extends CreatePlayerDto { + /** + * Existing profile ID to associate the player with + * + * @example "662a1b2cd7a64f12e0e1aef9" + */ @IsProfileExists() @IsMongoId() @IsOptional() diff --git a/src/profile/dto/updateProfile.dto.ts b/src/profile/dto/updateProfile.dto.ts index bf28fb570..0d318dfa0 100644 --- a/src/profile/dto/updateProfile.dto.ts +++ b/src/profile/dto/updateProfile.dto.ts @@ -1,17 +1,34 @@ import { IsMongoId, IsOptional, IsString } from 'class-validator'; import { IsProfileExists } from '../decorator/validation/IsProfileExists.decorator'; import AddType from '../../common/base/decorator/AddType.decorator'; +import { ApiProperty } from '@nestjs/swagger'; @AddType('UpdateProfileDto') export class UpdateProfileDto { + /** + * ID of the profile to update + * + * @example "662a1b2cd7a64f12e0e1aef9" + */ @IsProfileExists() @IsMongoId() _id: string; + /** + * Updated username (must be unique) + * + * @example "clanHero77" + */ + @ApiProperty({ uniqueItems: true }) @IsString() @IsOptional() username: string; + /** + * Updated password (will be hashed before storing) + * + * @example "NewSecureP@ssw0rd!" + */ @IsString() @IsOptional() password: string; diff --git a/src/profile/profile.controller.ts b/src/profile/profile.controller.ts index d3cfaa49f..9d54d5144 100644 --- a/src/profile/profile.controller.ts +++ b/src/profile/profile.controller.ts @@ -33,6 +33,7 @@ import { AddSortQuery } from '../common/interceptor/request/addSortQuery.interce import { UniformResponse } from '../common/decorator/response/UniformResponse'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { User } from '../auth/user'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; @Controller('profile') export default class ProfileController { @@ -41,6 +42,23 @@ export default class ProfileController { private readonly playerService: PlayerService, ) {} + /** + * Create a profile + * + * @remarks Create a user profile with Player object associated with it. + * + * Notice, that it is also possible in some edge cases to create a Profile without Player object associated with it, + * however it is not recommended and API expects that for every Profile there is a Player object created. + */ + @ApiResponseDescription({ + success: { + dto: ProfileDto, + modelName: ModelName.PROFILE, + status: 201, + }, + errors: [400, 409], + hasAuth: false, + }) @NoAuth() @Post() @UniformResponse(ModelName.PROFILE, ProfileDto) @@ -68,12 +86,40 @@ export default class ProfileController { return [createdProfile, errors]; } + /** + * Get basic info about logged-in user + * + * @remarks Get basic info of the logged-in user: + * + * - Profile + * - Player + * - Clan + */ + @ApiResponseDescription({ + success: { + dto: ProfileDto, + modelName: ModelName.PROFILE, + }, + errors: [401], + }) @Get('/info') @UniformResponse(ModelName.PROFILE, ProfileDto) async getLoggedUserInfo(@LoggedUser() user: User) { return this.service.getLoggedUserInfo(user.profile_id, user.player_id); } + /** + * Get profile information by _id + * + * @remarks Get profile information by _id + */ + @ApiResponseDescription({ + success: { + dto: ProfileDto, + modelName: ModelName.PROFILE, + }, + errors: [400, 401, 404], + }) @Get('/:_id') @Authorize({ action: Action.read, subject: ProfileDto }) @BasicGET(ModelName.PROFILE, ProfileDto) @@ -82,6 +128,19 @@ export default class ProfileController { return this.service.readOneById(param._id, request['mongoPopulate']); } + /** + * Get all profiles + * + * @remarks Read logged-in user Profile data + */ + @ApiResponseDescription({ + success: { + dto: ProfileDto, + modelName: ModelName.PROFILE, + returnsArray: true, + }, + errors: [401, 404], + }) @Get() @Authorize({ action: Action.read, subject: ProfileDto }) @OffsetPaginate(ModelName.PROFILE) @@ -92,6 +151,17 @@ export default class ProfileController { return this.service.readAll(query); } + /** + * Update profile of logged-in user + * + * @remarks Update logged-in user Profile data. Notice that only fields needed to be updated should be specified. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401], + }) @Put() @Authorize({ action: Action.update, subject: UpdateProfileDto }) @BasicPUT(ModelName.PROFILE) @@ -99,6 +169,24 @@ export default class ProfileController { return this.service.updateOneById(body); } + /** + * Delete profile by _id + * + * @remarks Delete logged-in user's Profile. + * + * Notice, that Profile deletion will lead removing all user data, such as Player and CustomCharacters. + * Since the Player object is assosiated with the Clan, user will be also removed from the Clan. + * + * Notice, that if there was nobody in the Clan with all assosiated objects will be removed. + * However, in case if the user was admin in this Clan and there are no other admins the user must first set at least one admin for this Clan, + * overwise the Profile will not be removed and 403 will be returned. + */ + @ApiResponseDescription({ + success: { + status: 204, + }, + errors: [400, 401, 403], + }) @Delete('/:_id') @HttpCode(204) @Authorize({ action: Action.delete, subject: ProfileDto }) diff --git a/src/voting/dto/addVote.dto.ts b/src/voting/dto/addVote.dto.ts index ccc3bd114..a5adb9bd7 100644 --- a/src/voting/dto/addVote.dto.ts +++ b/src/voting/dto/addVote.dto.ts @@ -4,9 +4,19 @@ import { ItemVoteChoice } from '../enum/choiceType.enum'; @AddType('AddVoteDto') export class AddVoteDto { + /** + * The ID of the voting event the vote is being added to + * + * @example "662f4f1235faaf001ef7b5cb" + */ @IsMongoId() voting_id: string; + /** + * The player's chosen vote option for the item + * + * @example "accept" + */ @IsEnum(ItemVoteChoice) choice: ItemVoteChoice; } diff --git a/src/voting/dto/createVoting.dto.ts b/src/voting/dto/createVoting.dto.ts index ff6e7ea40..479c31ec9 100644 --- a/src/voting/dto/createVoting.dto.ts +++ b/src/voting/dto/createVoting.dto.ts @@ -16,29 +16,60 @@ import { ItemName } from '../../clanInventory/item/enum/itemName.enum'; @AddType('CreateVotingDto') export class CreateVotingDto { + /** + * Organizer information for the voting (player and optional clan) + */ @ValidateNested() @Type(() => Organizer) organizer: Organizer; + /** + * Optional voting end date + * + * @example "2025-05-20T12:00:00Z" + */ @IsDate() @IsOptional() endsOn?: Date; + /** + * The type/category of the voting + * + * @example "selling_item" + */ @IsEnum(VotingType) - type: string; + type: VotingType; + /** + * Optional minimum percentage required for the voting to pass + * + * @example 60 + */ @IsInt() @IsOptional() minPercentage?: number; + /** + * Optional entity ID that the vote is tied to (e.g., item or character) + * + * @example "662f4f1235faaf001ef7b5cd" + */ @IsMongoId() @IsOptional() entity_id?: string; + /** + * Optional item name the voting is associated with + * + * @example "Sofa_Taakka" + */ @IsEnum(ItemName) @IsOptional() entity_name?: ItemName; + /** + * Optional list of votes included in the voting object + */ @IsArray() @IsOptional() votes?: Vote[]; diff --git a/src/voting/dto/organizer.dto.ts b/src/voting/dto/organizer.dto.ts index 46dc5cb31..0325d3901 100644 --- a/src/voting/dto/organizer.dto.ts +++ b/src/voting/dto/organizer.dto.ts @@ -1,9 +1,19 @@ import { IsMongoId, IsOptional } from 'class-validator'; export class Organizer { + /** + * ID of the player who started the vote + * + * @example "662f4f1235faaf001ef7b5aa" + */ @IsMongoId() player_id: string; + /** + * Optional ID of the clan the player belongs to + * + * @example "662f4f1235faaf001ef7b5ab" + */ @IsMongoId() @IsOptional() clan_id: string; diff --git a/src/voting/dto/vote.dto.ts b/src/voting/dto/vote.dto.ts index 8eaf4d298..a87636ca3 100644 --- a/src/voting/dto/vote.dto.ts +++ b/src/voting/dto/vote.dto.ts @@ -2,13 +2,28 @@ import { Expose } from 'class-transformer'; import { ExtractField } from '../../common/decorator/response/ExtractField'; export class VoteDto { + /** + * The choice made by the voter + * + * @example "accept" + */ @Expose() choice: string; + /** + * ID of the player who voted + * + * @example "662f4f1235faaf001ef7b5cc" + */ @ExtractField() @Expose() player_id: string; + /** + * Unique vote ID + * + * @example "662f4f1235faaf001ef7b5cd" + */ @ExtractField() @Expose() _id: string; diff --git a/src/voting/dto/voting.dto.ts b/src/voting/dto/voting.dto.ts index edccc6dbb..a9a8157dc 100644 --- a/src/voting/dto/voting.dto.ts +++ b/src/voting/dto/voting.dto.ts @@ -9,40 +9,92 @@ import { VoteDto } from './vote.dto'; @AddType('VotingDto') export class VotingDto { + /** + * Unique identifier of the voting session + * + * @example "6630ab1234cd5ef001a1b2c3" + */ @ExtractField() @Expose() _id: string; + /** + * Information about the voting organizer, including player and optional clan + */ @Expose() + @Type(() => Organizer) organizer: Organizer; + /** + * Timestamp indicating when the voting officially ended + * + * @example "2025-05-16T14:30:00Z" + */ @Expose() endedAt: Date; + /** + * Timestamp indicating when the voting started + * + * @example "2025-05-10T09:00:00Z" + */ @Expose() startedAt: Date; + /** + * The scheduled time when voting should end (can be used for time limits) + * + * @example "2025-05-15T23:59:59Z" + */ @Expose() endsOn: Date; + /** + * Type of voting being conducted (e.g., item approval, clan decisions) + * + * @example "selling_item" + */ @Expose() type: VotingType; + /** + * Array of player IDs who are participating in the voting + * + * @example ["6630aa9994cd5ef001a1b1c2", "6630aa7774cd5ef001a1b1c3"] + */ @ExtractField() @Expose() player_ids: string[]; + /** + * The minimum percentage of votes required for a decision to pass + * + * @example 60 + */ @Expose() minPercentage: number; + /** + * Array of votes cast in this voting session + */ @Expose() @Type(() => VoteDto) votes: Vote[]; + /** + * ID of the entity being voted on (e.g., item, character) + * + * @example "6630af1234cd5ef001a1b4c5" + */ @ExtractField() @Expose() entity_id: string; + /** + * Name of the item (or other entity) associated with the voting + * + * @example "Sofa_Taakka" + */ @Expose() entity_name: ItemName; } diff --git a/src/voting/schemas/vote.schema.ts b/src/voting/schemas/vote.schema.ts index d948d3aaa..0b5074975 100644 --- a/src/voting/schemas/vote.schema.ts +++ b/src/voting/schemas/vote.schema.ts @@ -5,6 +5,11 @@ import { Choice } from '../type/choice.type'; @Schema() export class Vote { + /** + * The ID of the player who submitted this vote + * + * @example "6630aa9994cd5ef001a1b1c2" + */ @Prop({ type: MongooseSchema.Types.ObjectId, ref: ModelName.PLAYER, @@ -12,6 +17,11 @@ export class Vote { }) player_id: string; + /** + * The choice the player made in the vote (e.g., APPROVE or REJECT) + * + * @example "accept" + */ @Prop({ type: String, required: true, diff --git a/src/voting/voting.controller.ts b/src/voting/voting.controller.ts index 3e9bd0665..9798047ff 100644 --- a/src/voting/voting.controller.ts +++ b/src/voting/voting.controller.ts @@ -5,21 +5,49 @@ import { ModelName } from '../common/enum/modelName.enum'; import { _idDto } from '../common/dto/_id.dto'; import { LoggedUser } from '../common/decorator/param/LoggedUser.decorator'; import { User } from '../auth/user'; -import { Serialize } from '../common/interceptor/response/Serialize'; import { VotingDto } from './dto/voting.dto'; import { AddVoteDto } from './dto/addVote.dto'; import { noPermissionError } from './error/noPermission.error'; +import ApiResponseDescription from '../common/swagger/response/ApiResponseDescription'; @Controller('voting') export class VotingController { constructor(private readonly service: VotingService) {} + /** + * Get all votings + * + * @remarks Get all active votings of the logged user clan. + */ + @ApiResponseDescription({ + success: { + dto: VotingDto, + modelName: ModelName.VOTING, + returnsArray: true, + }, + errors: [401, 404], + }) @Get() @UniformResponse(ModelName.VOTING, VotingDto) async getClanVotings(@LoggedUser() user: User) { return this.service.getClanVotings(user.player_id); } + /** + * Get voting by _id + * + * @remarks Get data of the voting state. + * + * Notice that it can return 403 in case the logged-in player does not have permission to access the voting, + * for example if the player tries to access a voting that is an internal for other Clan. + */ + @ApiResponseDescription({ + success: { + dto: VotingDto, + modelName: ModelName.VOTING, + }, + errors: [400, 401, 403, 404], + }) @Get('/:_id') @UniformResponse(ModelName.VOTING, VotingDto) async getVoting(@Param() param: _idDto, @LoggedUser() user: User) { @@ -32,6 +60,49 @@ export class VotingController { return await this.service.basicService.readOneById(param._id); } + /** + * Send a vote + * + * @remarks Send a vote. + * + * The minimum acceptance percentage referring to the min percentage of a group members that need to accept some option + * for the voting to be concluded. For example Clan members are voting whenever they want to sell an Item or not + * and the min percentage might be 51 => if 51% of all this Clan members accept to sell the Item then the Item can be sold + * and otherwise if the 51% of Clan members reject to sell the Item it will not be sold. + * Same logic can be applied if there are more than 2 options available. + * + * Voting endpoint is served for different kinds of votings. The "type" field is an enum and determines what value the "choice" field may have. + * + * ### selling_item + * + * Player votes whenever he/she wants that an item is going for sale to the flea market. + * The voter must be a member of the Clan from which item is going to sale, otherwise 403 will be returned. + * The min acceptance percentage is 51%. Notice that during the voting process there will be sent different notifications via MQTT: + * + * - new voting started + * - somebody has voted + * - voting ended + * + * ### buying_item + * + * Player votes whenever he/she wants that an item should be bought from the flea market or not. + * The voter must be a member of the Clan for which item is going to be bought, otherwise 403 will be returned. + * The min acceptance percentage is 51%. The time limit for the voting is 10 mins. + * + * Notice that during the voting process there will be sent different notifications via MQTT: + * + * - new voting started + * - somebody has voted + * - voting ended + * - error if time limit expired + */ + @ApiResponseDescription({ + success: { + dto: VotingDto, + modelName: ModelName.VOTING, + }, + errors: [400, 401, 403, 404], + }) @Put() @UniformResponse() async addVote(@Body() body: AddVoteDto, @LoggedUser() user: User) {