diff --git a/package-lock.json b/package-lock.json index c982cdd..3d236c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,13 +14,15 @@ "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.0.0", + "@nestjs/mapped-types": "^2.0.6", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.0.0", + "@nestjs/swagger": "^8.1.0", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.0.0", "bcryptjs": "^2.4.3", - "class-transformer": "^0.1.0-beta.10", + "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.4.7", @@ -32,6 +34,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.7.1", + "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.20" }, "devDependencies": { @@ -1607,6 +1610,11 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", + "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==" + }, "node_modules/@nestjs-modules/ioredis": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@nestjs-modules/ioredis/-/ioredis-1.2.4.tgz", @@ -1774,6 +1782,25 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.6.tgz", + "integrity": "sha512-84ze+CPfp1OWdpRi1/lOu59hOhTz38eVzJvRKrg9ykRFwDz+XleKfMsG0gUqNZYFa6v53XYzeD+xItt8uDW7NQ==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/passport": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", @@ -1847,6 +1874,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/swagger": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-8.1.0.tgz", + "integrity": "sha512-8hzH+r/31XshzXHC9vww4T0xjDAxMzvOaT1xAOvvY1LtXTWyNRCUP2iQsCYJOnnMrR+vydWjvRZiuB3hdvaHxA==", + "dependencies": { + "@microsoft/tsdoc": "^0.15.0", + "@nestjs/mapped-types": "2.0.6", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.3.0", + "swagger-ui-dist": "5.18.2" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/terminus": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.2.0.tgz", @@ -2063,6 +2122,12 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3104,7 +3169,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -3748,9 +3812,9 @@ "license": "MIT" }, "node_modules/class-transformer": { - "version": "0.1.0-beta.10", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.1.0-beta.10.tgz", - "integrity": "sha512-Cq9Xd+0/k4vOHr/c1cCRwWIXqS6V+hFJ5d/delZS4931fch7pLSgv7/PP6iJb0Ka0IO0tSmV+GY3MnvUN1U42A==" + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" }, "node_modules/class-validator": { "version": "0.14.1", @@ -6892,7 +6956,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -11760,6 +11823,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.2.tgz", + "integrity": "sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", diff --git a/package.json b/package.json index 77d9e5c..b7b5fd0 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,15 @@ "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.0.0", + "@nestjs/mapped-types": "^2.0.6", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.0.0", + "@nestjs/swagger": "^8.1.0", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.0.0", "bcryptjs": "^2.4.3", - "class-transformer": "^0.1.0-beta.10", + "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.4.7", @@ -43,6 +45,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.7.1", + "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.20" }, "devDependencies": { diff --git a/src/app.controller.ts b/src/app.controller.ts index cce879e..f53aef9 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; +import { ApiTags } from '@nestjs/swagger'; +@ApiTags('healthCheck') @Controller() export class AppController { constructor(private readonly appService: AppService) {} diff --git a/src/app.module.ts b/src/app.module.ts index 08e5b02..7dd9f30 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,12 +6,14 @@ import { env } from 'process'; import { UserModule } from 'src/user/user.module'; import { AuthModule } from './auth/auth.module'; import { RedisModule } from './redis/redis.module'; +import { GameRoomModule } from './gameRoom/gameRoom.module'; @Module({ imports: [ UserModule, AuthModule, RedisModule, + GameRoomModule, TypeOrmModule.forRoot({ type: 'mysql', host: process.env.MYSQL_HOST || 'mysql', diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 20771c3..3b73b8c 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -13,10 +13,14 @@ import { Response } from 'express'; import { Request } from 'express'; import { JwtService } from '@nestjs/jwt'; import { UserService } from '../user/user.service'; -import LoginUserDto from './auth.dto'; -import { JwtAuthGuard } from './auth.guard'; +import LoginUserDto from './dto/auth.dto'; +import { RedisAuthGuard } from './auth.guard'; import { RedisService } from 'src/redis/redis.service'; + +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +@ApiTags('auth') + @Controller('auth') export class AuthController { constructor( @@ -38,7 +42,7 @@ export class AuthController { throw new HttpException('Invalid credentials', HttpStatus.UNAUTHORIZED); } - const payload = { userEmail: user.userEmail, sub: user.id }; + const payload = { userEmail: user.userEmail, userId: user.id }; const accessToken = this.jwtService.sign(payload, { expiresIn: '2h' }); const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' }); await this.redisService.set(`access:${user.userEmail}`, accessToken, 3600); @@ -60,7 +64,7 @@ export class AuthController { }; } - @UseGuards(JwtAuthGuard) + @UseGuards(RedisAuthGuard) @Get('profile') getProfile() { return { message: 'This is a protected route' }; diff --git a/src/auth/auth.guard.ts b/src/auth/auth.guard.ts index 2155290..9593486 100644 --- a/src/auth/auth.guard.ts +++ b/src/auth/auth.guard.ts @@ -2,4 +2,4 @@ import { Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; @Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') {} +export class RedisAuthGuard extends AuthGuard('jwt') {} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 81b449d..dd143ba 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,7 +5,7 @@ import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; import { UserService } from '../user/user.service'; -import { User } from '../user/user.entity'; +import { User } from '../user/entities/user.entity'; import { RedisModule } from 'src/redis/redis.module'; @Module({ diff --git a/src/auth/auth.dto.ts b/src/auth/dto/auth.dto.ts similarity index 100% rename from src/auth/auth.dto.ts rename to src/auth/dto/auth.dto.ts diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 191b660..53f8321 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -1,18 +1,38 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, ExtractJwt } from 'passport-jwt'; +import { RedisService } from 'src/redis/redis.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor() { + constructor(private readonly redisService: RedisService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: process.env.JWT_SECRET, // 환경변수로 관리 추천 + secretOrKey: process.env.JWT_SECRET, // 환경변수로 관리 }); } async validate(payload: any) { - return { userId: payload.sub, userEmail: payload.userEmail }; + const { userEmail, userId, exp } = payload; + + if (!userEmail || !userId) { + throw new UnauthorizedException('Invalid token payload'); + } + + // Redis에서 토큰 확인 + const storedToken = await this.redisService.get(`access:${userEmail}`); + if (!storedToken) { + throw new UnauthorizedException('Token not found in Redis'); + } + + // 만료 시간 확인 + const currentTime = Math.floor(Date.now() / 1000); + if (exp && currentTime > exp) { + throw new UnauthorizedException('Token has expired'); + } + + // 유효한 유저 정보 반환 + return { userEmail, userId }; } } diff --git a/src/filters/http-exception.filter.ts b/src/filters/http-exception.filter.ts new file mode 100644 index 0000000..6175d03 --- /dev/null +++ b/src/filters/http-exception.filter.ts @@ -0,0 +1,22 @@ + +import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'; +import { Response } from 'express'; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const status = exception.getStatus(); + const exceptionResponse: any = exception.getResponse(); + + const errorResponse = { + statusCode: status, + timestamp: new Date().toISOString(), + path: ctx.getRequest().url, + ...(typeof exceptionResponse === 'string' ? { message: exceptionResponse } : exceptionResponse), + }; + + response.status(status).json(errorResponse); + } +} diff --git a/src/gameRoom/dto/createGameRoom.dto.ts b/src/gameRoom/dto/createGameRoom.dto.ts new file mode 100644 index 0000000..70c508b --- /dev/null +++ b/src/gameRoom/dto/createGameRoom.dto.ts @@ -0,0 +1 @@ +export class CreateGameRoomDto {} diff --git a/src/gameRoom/dto/gameRoom.dto.ts b/src/gameRoom/dto/gameRoom.dto.ts new file mode 100644 index 0000000..71ce3ed --- /dev/null +++ b/src/gameRoom/dto/gameRoom.dto.ts @@ -0,0 +1,8 @@ + +import { IsString, IsNotEmpty } from 'class-validator'; + +export class GameroomDto { + @IsString() + @IsNotEmpty() + name: string; +} diff --git a/src/gameRoom/dto/updateGameRoom.dto.ts b/src/gameRoom/dto/updateGameRoom.dto.ts new file mode 100644 index 0000000..88ccac0 --- /dev/null +++ b/src/gameRoom/dto/updateGameRoom.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateGameRoomDto } from './createGameRoom.dto'; + +export class UpdateGameRoomDto extends PartialType(CreateGameRoomDto) {} diff --git a/src/gameRoom/entities/gameRoom.entity.ts b/src/gameRoom/entities/gameRoom.entity.ts new file mode 100644 index 0000000..6447152 --- /dev/null +++ b/src/gameRoom/entities/gameRoom.entity.ts @@ -0,0 +1,24 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +@Entity() +export class GameRoom { + @PrimaryGeneratedColumn() + id: number; + + @Column() + roomName: string; + + @Column() + maxPlayers: number; + + @Column({ default: 0 }) // 초기값 설정 + currentCount: number; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/gameRoom/entities/gameRoomUser.entity.ts b/src/gameRoom/entities/gameRoomUser.entity.ts new file mode 100644 index 0000000..ecfa46f --- /dev/null +++ b/src/gameRoom/entities/gameRoomUser.entity.ts @@ -0,0 +1,21 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +@Entity() +export class GameRoomUser { + @PrimaryGeneratedColumn() + id: number; + + @Column() + roomId: number; + + @Column() // 필수 입력값으로 설정 + userId: number; + + @CreateDateColumn() + joinedAt: Date; +} diff --git a/src/gameRoom/gameRoom.controller.ts b/src/gameRoom/gameRoom.controller.ts new file mode 100644 index 0000000..b4494f9 --- /dev/null +++ b/src/gameRoom/gameRoom.controller.ts @@ -0,0 +1,51 @@ +import { + Controller, + Post, + Get, + Delete, + Body, + Param, + UseGuards, + Req, +} from '@nestjs/common'; +import { GameRoomService } from './gameRoom.service'; +import { RedisAuthGuard } from 'src/auth/auth.guard'; + + +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +@ApiTags('gameRoom') + +@Controller('gameRoom') +@UseGuards(RedisAuthGuard) // 컨트롤러 전체에 Guard 적용 +export class GameRoomController { + constructor(private readonly gameRoomService: GameRoomService) {} + + // 방 생성과 동시에 참가 + @Post('create') + async createRoom(@Body() body: { roomName: string }, @Req() req: any) { + const { roomName } = body; + const userId = await req.user.userId; // JWT에서 추출한 userId 사용 + + return await this.gameRoomService.createRoom(roomName, userId); + } + + // 방 참가 + @Post('join/:roomId') + async joinRoom(@Param('roomId') roomId: number, @Req() req: any) { + const userId = req.user.userId; // JWT에서 추출한 userId 사용 + return await this.gameRoomService.joinRoom(roomId, userId); + } + + // 방 나가기 + @Delete('leave/:roomId') + async leaveRoom(@Param('roomId') roomId: number, @Req() req: any) { + const userId = req.user.userId; // JWT에서 추출한 userId 사용 + return await this.gameRoomService.leaveRoom(roomId, userId); + } + + // 방 상태 조회 + @Get(':roomId') + async getRoomStatus(@Param('roomId') roomId: number) { + return await this.gameRoomService.getRoomStatus(roomId); + } +} diff --git a/src/gameRoom/gameRoom.module.ts b/src/gameRoom/gameRoom.module.ts new file mode 100644 index 0000000..6fa4543 --- /dev/null +++ b/src/gameRoom/gameRoom.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GameRoomController } from './gameRoom.controller'; +import { GameRoomService } from './gameRoom.service'; +import { GameRoom } from './entities/gameRoom.entity'; +import { GameRoomUser } from './entities/gameRoomUser.entity'; +import { RedisModule } from 'src/redis/redis.module'; // RedisModule 추가 + +@Module({ + imports: [ + TypeOrmModule.forFeature([GameRoom, GameRoomUser]), + RedisModule, // RedisModule 가져오기 + ], + controllers: [GameRoomController], + providers: [GameRoomService], +}) +export class GameRoomModule {} diff --git a/src/gameRoom/gameRoom.service.ts b/src/gameRoom/gameRoom.service.ts new file mode 100644 index 0000000..9788402 --- /dev/null +++ b/src/gameRoom/gameRoom.service.ts @@ -0,0 +1,134 @@ +// gameRoom.service.ts +import { + Injectable, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { GameRoom } from './entities/gameRoom.entity'; +import { GameRoomUser } from './entities/gameRoomUser.entity'; + +@Injectable() +export class GameRoomService { + constructor( + @InjectRepository(GameRoom) + private readonly gameRoomRepository: Repository, + @InjectRepository(GameRoomUser) + private readonly gameRoomUserRepository: Repository, + ) {} + + // 방 생성과 동시에 유저 참가 + async createRoom(roomName: string, userId: number) { + // 1. 유저가 이미 어떤 방에 속해 있는지 확인 + const existingMembership = await this.gameRoomUserRepository.findOne({ + where: { userId }, + }); + if (existingMembership) { + throw new BadRequestException( + `User ${userId} is already in room ${existingMembership.roomId}. Please leave that room first.`, + ); + } + + // 2. 방 생성 + const newRoom = this.gameRoomRepository.create({ + roomName, + maxPlayers: 2, // 기본값: 2명 + currentCount: 1, // 방 생성과 동시에 1명 참가 + }); + const room = await this.gameRoomRepository.save(newRoom); + + // 3. 유저를 방에 추가 + const newUser = this.gameRoomUserRepository.create({ + roomId: room.id, + userId, + }); + await this.gameRoomUserRepository.save(newUser); + + return { room, user: newUser }; + } + + // 방 참가 + async joinRoom(roomId: number, userId: number) { + // 1. 유저가 이미 다른 방에 속해 있는지 확인 + // - 이미 같은 방에 있다면 "이미 참여 중" 예외를 던지고 + // - 다른 방에 있다면 "다른 방에 참여 중" 예외를 던짐 + const existingMembership = await this.gameRoomUserRepository.findOne({ + where: { userId }, + }); + if (existingMembership) { + if (existingMembership.roomId === roomId) { + throw new BadRequestException( + `User ${userId} already joined this room.`, + ); + } else { + throw new BadRequestException( + `User ${userId} is already in a different room (${existingMembership.roomId}). Leave that room first.`, + ); + } + } + + // 2. 방 존재 여부와 정원 확인 + const room = await this.gameRoomRepository.findOne({ + where: { id: roomId }, + }); + if (!room) { + throw new NotFoundException('Room not found'); + } + if (room.currentCount >= room.maxPlayers) { + throw new BadRequestException('Room is full'); + } + + // 3. 유저를 방에 추가 + const newUser = this.gameRoomUserRepository.create({ roomId, userId }); + room.currentCount += 1; + await this.gameRoomRepository.save(room); + + return await this.gameRoomUserRepository.save(newUser); + } + + // 방에서 나가기 + async leaveRoom(roomId: number, userId: number) { + const room = await this.gameRoomRepository.findOne({ + where: { id: roomId }, + }); + if (!room) { + throw new NotFoundException('Room not found'); + } + + const user = await this.gameRoomUserRepository.findOne({ + where: { roomId, userId }, + }); + if (!user) { + throw new BadRequestException('User not in the room'); + } + + // 유저 삭제 + await this.gameRoomUserRepository.remove(user); + room.currentCount -= 1; // 현재 인원 감소 + + // 방에 더 이상 유저가 없으면 방 삭제 + if (room.currentCount === 0) { + await this.gameRoomRepository.remove(room); + return { + message: `User ${userId} left room ${roomId}. Room has been deleted as it's empty.`, + }; + } + + await this.gameRoomRepository.save(room); + return { message: `User ${userId} left room ${roomId}` }; + } + + // 방 상태 조회 + async getRoomStatus(roomId: number) { + const room = await this.gameRoomRepository.findOne({ + where: { id: roomId }, + }); + if (!room) { + throw new NotFoundException('Room not found'); + } + + const users = await this.gameRoomUserRepository.find({ where: { roomId } }); + return { room, users }; + } +} diff --git a/src/main.ts b/src/main.ts index 89e5663..60c0913 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import * as cookieParser from 'cookie-parser'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -10,8 +11,15 @@ async function bootstrap() { app.useGlobalPipes(new ValidationPipe()); // 라우트 디버깅 활성화 - const server = app.getHttpAdapter(); - // console.log('Routes:', server.getHttpServer()._events.request._router.stack); + const config = new DocumentBuilder() + .setTitle('API Documentation') + .setDescription('The API description') + .setVersion('1.0') + .addTag('auth') // 원하는 태그를 추가 + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/src/user/user.dto.ts b/src/user/dto/user.dto.ts similarity index 72% rename from src/user/user.dto.ts rename to src/user/dto/user.dto.ts index 33591fd..f8922f7 100644 --- a/src/user/user.dto.ts +++ b/src/user/dto/user.dto.ts @@ -1,13 +1,16 @@ -import { IsString, IsEmail, Matches } from 'class-validator'; +import { IsString, IsEmail, Matches, IsNotEmpty } from 'class-validator'; export default class SingupUserDto { @IsEmail({}, { message: '이메일 형식이 틀렸습니다.' }) + @IsNotEmpty() userEmail: string; @IsString() - readonly userNickname: string; + @IsNotEmpty() + userNickname: string; @IsString() + @IsNotEmpty() @Matches(/^(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { message: '비밀번호는 알파벳, 숫자, 특수문자를 포함하여 8글자 이상 작성해주세요', diff --git a/src/user/user.entity.ts b/src/user/entities/user.entity.ts similarity index 100% rename from src/user/user.entity.ts rename to src/user/entities/user.entity.ts diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 2532718..c115575 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -1,8 +1,10 @@ import { Body, Controller, Post, BadRequestException } from '@nestjs/common'; import { UserService } from './user.service'; -import SingupUserDto from './user.dto'; +import SingupUserDto from './dto/user.dto'; import { validateOrReject } from 'class-validator'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +@ApiTags('user') @Controller('user') export class UserController { constructor(private readonly userService: UserService) {} diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 105f745..ab1e5c0 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from './user.entity'; +import { User } from './entities/user.entity'; import { UserService } from './user.service'; import { UserController } from './user.controller'; import { AuthModule } from 'src/auth/auth.module'; diff --git a/src/user/user.service.ts b/src/user/user.service.ts index c295cd6..1e82513 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -1,7 +1,7 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { User } from './user.entity'; +import { User } from './entities/user.entity'; import * as bcrypt from 'bcryptjs'; @Injectable()