diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index bf8288a9..8b2be06b 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WinstonModule } from 'nest-winston'; import { AuthModule } from '@/auth/auth.module'; -import { logger } from '@/configs/logger.config'; +import { SessionModule } from '@/auth/session.module'; +import { ChatModule } from '@/chat/chat.module'; import { typeormDevelopConfig, typeormProductConfig, } from '@/configs/devTypeormConfig'; +import { logger } from '@/configs/logger.config'; import { StockModule } from '@/stock/stock.module'; import { UserModule } from '@/user/user.module'; @@ -23,6 +25,8 @@ import { UserModule } from '@/user/user.module'; ), WinstonModule.forRoot(logger), AuthModule, + ChatModule, + SessionModule, ], controllers: [], providers: [], diff --git a/packages/backend/src/auth/auth.module.ts b/packages/backend/src/auth/auth.module.ts index ae2d0647..f513d613 100644 --- a/packages/backend/src/auth/auth.module.ts +++ b/packages/backend/src/auth/auth.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; -import { GoogleAuthController } from '@/auth/googleAuth.controller'; -import { GoogleAuthService } from '@/auth/googleAuth.service'; -import { GoogleStrategy } from '@/auth/passport/google.strategy'; -import { SessionSerializer } from '@/auth/passport/session.serializer'; +import { GoogleAuthController } from '@/auth/google/googleAuth.controller'; +import { GoogleAuthService } from '@/auth/google/googleAuth.service'; +import { GoogleStrategy } from '@/auth/google/strategy/google.strategy'; +import { SessionSerializer } from '@/auth/session/session.serializer'; import { UserModule } from '@/user/user.module'; @Module({ diff --git a/packages/backend/src/auth/googleAuth.controller.ts b/packages/backend/src/auth/google/googleAuth.controller.ts similarity index 71% rename from packages/backend/src/auth/googleAuth.controller.ts rename to packages/backend/src/auth/google/googleAuth.controller.ts index 1e159782..1d460748 100644 --- a/packages/backend/src/auth/googleAuth.controller.ts +++ b/packages/backend/src/auth/google/googleAuth.controller.ts @@ -1,8 +1,7 @@ -import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { Request } from 'express'; -import { GoogleAuthGuard } from '@/auth/guard/google.guard'; -import { User } from '@/user/domain/user.entity'; +import { Request, Response } from 'express'; +import { GoogleAuthGuard } from '@/auth/google/guard/google.guard'; @ApiTags('Auth') @Controller('auth/google') @@ -21,9 +20,8 @@ export class GoogleAuthController { @Get('/redirect') @UseGuards(GoogleAuthGuard) - async handleRedirect(@Req() request: Request) { - const user = request.user as User; - return { nickname: user.nickname, email: user.email }; + async handleRedirect(@Res() response: Response) { + response.redirect('/'); } @ApiOperation({ diff --git a/packages/backend/src/auth/googleAuth.service.spec.ts b/packages/backend/src/auth/google/googleAuth.service.spec.ts similarity index 95% rename from packages/backend/src/auth/googleAuth.service.spec.ts rename to packages/backend/src/auth/google/googleAuth.service.spec.ts index 9b90c9e1..3d3f49be 100644 --- a/packages/backend/src/auth/googleAuth.service.spec.ts +++ b/packages/backend/src/auth/google/googleAuth.service.spec.ts @@ -1,5 +1,5 @@ -import { GoogleAuthService } from '@/auth/googleAuth.service'; -import { OauthUserInfo } from '@/auth/passport/google.strategy'; +import { GoogleAuthService } from '@/auth/google/googleAuth.service'; +import { OauthUserInfo } from '@/auth/google/strategy/google.strategy'; import { OauthType } from '@/user/domain/ouathType'; import { Role } from '@/user/domain/role'; import { User } from '@/user/domain/user.entity'; diff --git a/packages/backend/src/auth/googleAuth.service.ts b/packages/backend/src/auth/google/googleAuth.service.ts similarity index 93% rename from packages/backend/src/auth/googleAuth.service.ts rename to packages/backend/src/auth/google/googleAuth.service.ts index d19be403..cd80f18f 100644 --- a/packages/backend/src/auth/googleAuth.service.ts +++ b/packages/backend/src/auth/google/googleAuth.service.ts @@ -1,5 +1,5 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { OauthUserInfo } from '@/auth/passport/google.strategy'; +import { OauthUserInfo } from '@/auth/google/strategy/google.strategy'; import { UserService } from '@/user/user.service'; @Injectable() diff --git a/packages/backend/src/auth/guard/google.guard.ts b/packages/backend/src/auth/google/guard/google.guard.ts similarity index 99% rename from packages/backend/src/auth/guard/google.guard.ts rename to packages/backend/src/auth/google/guard/google.guard.ts index bfb85f36..58b47875 100644 --- a/packages/backend/src/auth/guard/google.guard.ts +++ b/packages/backend/src/auth/google/guard/google.guard.ts @@ -12,4 +12,4 @@ export class GoogleAuthGuard extends AuthGuard('google') { await super.logIn(request); return isActivate; } -} \ No newline at end of file +} diff --git a/packages/backend/src/auth/passport/google.strategy.ts b/packages/backend/src/auth/google/strategy/google.strategy.ts similarity index 95% rename from packages/backend/src/auth/passport/google.strategy.ts rename to packages/backend/src/auth/google/strategy/google.strategy.ts index 3851330c..b87b7945 100644 --- a/packages/backend/src/auth/passport/google.strategy.ts +++ b/packages/backend/src/auth/google/strategy/google.strategy.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20'; -import { GoogleAuthService } from '@/auth/googleAuth.service'; +import { GoogleAuthService } from '@/auth/google/googleAuth.service'; import { OauthType } from '@/user/domain/ouathType'; import { Logger } from 'winston'; diff --git a/packages/backend/src/auth/session.module.ts b/packages/backend/src/auth/session.module.ts new file mode 100644 index 00000000..07c36bba --- /dev/null +++ b/packages/backend/src/auth/session.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { MemoryStore } from 'express-session'; + +export const MEMORY_STORE = 'memoryStore'; + +@Module({ + providers: [ + { + provide: MEMORY_STORE, + useFactory: () => { + return new MemoryStore(); + }, + }, + ], + exports: [MEMORY_STORE], +}) +export class SessionModule {} diff --git a/packages/backend/src/auth/session/cookieParser.ts b/packages/backend/src/auth/session/cookieParser.ts new file mode 100644 index 00000000..9ba73e67 --- /dev/null +++ b/packages/backend/src/auth/session/cookieParser.ts @@ -0,0 +1,33 @@ +import * as crypto from 'node:crypto'; +import { WsException } from '@nestjs/websockets'; +import * as cookie from 'cookie'; +import { Socket } from 'socket.io'; +import { sessionConfig } from '@/configs/session.config'; + +const DEFAULT_SESSION_ID = 'connect.sid'; + +export const websocketCookieParse = (socket: Socket) => { + if (!socket.request.headers.cookie) { + throw new WsException('not found cookie'); + } + const cookies = cookie.parse(socket.request.headers.cookie); + const sid = cookies[sessionConfig.name || DEFAULT_SESSION_ID]; + return getSessionIdFromCookie(sid); +}; + +const getSessionIdFromCookie = (cookieValue: string) => { + if (cookieValue.startsWith('s:')) { + const [id, signature] = cookieValue.slice(2).split('.'); + const expectedSignature = crypto + .createHmac('sha256', sessionConfig.secret) + .update(id) + .digest('base64') + .replace(/=+$/, ''); + + if (expectedSignature === signature) { + return id; + } + throw new WsException('Invalid cookie signature'); + } + throw new WsException('Invalid cookie format'); +}; diff --git a/packages/backend/src/auth/session/session.guard.ts b/packages/backend/src/auth/session/session.guard.ts new file mode 100644 index 00000000..cc9e6be5 --- /dev/null +++ b/packages/backend/src/auth/session/session.guard.ts @@ -0,0 +1,10 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Request } from 'express'; + +@Injectable() +export default class SessionGuard implements CanActivate { + async canActivate(context: ExecutionContext): Promise { + const socket: Request = context.switchToHttp().getRequest(); + return !!socket.user; + } +} diff --git a/packages/backend/src/auth/passport/session.serializer.ts b/packages/backend/src/auth/session/session.serializer.ts similarity index 100% rename from packages/backend/src/auth/passport/session.serializer.ts rename to packages/backend/src/auth/session/session.serializer.ts diff --git a/packages/backend/src/auth/session/webSocketSession.guard..ts b/packages/backend/src/auth/session/webSocketSession.guard..ts new file mode 100644 index 00000000..a19ff956 --- /dev/null +++ b/packages/backend/src/auth/session/webSocketSession.guard..ts @@ -0,0 +1,45 @@ +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, +} from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import { MemoryStore, SessionData } from 'express-session'; +import { Socket } from 'socket.io'; +import { websocketCookieParse } from '@/auth/session/cookieParser'; +import { MEMORY_STORE } from '@/auth/session.module'; +import { User } from '@/user/domain/user.entity'; + +export interface SessionSocket extends Socket { + session?: User; +} + +interface PassportSession extends SessionData { + passport: { user: User }; +} + +@Injectable() +export class WebSocketSessionGuard implements CanActivate { + constructor( + @Inject(MEMORY_STORE) private readonly sessionStore: MemoryStore, + ) {} + async canActivate(context: ExecutionContext): Promise { + const socket: SessionSocket = context.switchToHttp().getRequest(); + const cookieValue = websocketCookieParse(socket); + const session = await this.getSession(cookieValue); + socket.session = session.passport.user; + return true; + } + + private getSession(cookieValue: string) { + return new Promise((resolve, reject) => { + this.sessionStore.get(cookieValue, (err: Error, session) => { + if (err || !session) { + reject(new WsException('forbidden chat')); + } + resolve(session as PassportSession); + }); + }); + } +} diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts new file mode 100644 index 00000000..73b867cd --- /dev/null +++ b/packages/backend/src/chat/chat.gateway.ts @@ -0,0 +1,90 @@ +import { Inject, UseFilters, UseGuards } from '@nestjs/common'; +import { + ConnectedSocket, + MessageBody, + OnGatewayConnection, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { Logger } from 'winston'; +import { + SessionSocket, + WebSocketSessionGuard, +} from '@/auth/session/webSocketSession.guard.'; +import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter'; +import { StockService } from '@/stock/stock.service'; +import { ChatService } from '@/chat/chat.service'; +import { Chat } from '@/chat/domain/chat.entity'; + +interface chatMessage { + room: string; + content: string; +} + +interface chatResponse { + likeCount: number; + message: string; + type: string; + createdAt: Date; +} + +@WebSocketGateway({ namespace: 'chat' }) +@UseFilters(WebSocketExceptionFilter) +export class ChatGateway implements OnGatewayConnection { + @WebSocketServer() + server: Server; + constructor( + @Inject('winston') private readonly logger: Logger, + private readonly stockService: StockService, + private readonly chatService: ChatService, + ) {} + + @UseGuards(WebSocketSessionGuard) + @SubscribeMessage('chat') + async handleConnectStock( + @MessageBody() message: chatMessage, + @ConnectedSocket() client: SessionSocket, + ) { + const { room, content } = message; + if (!client.rooms.has(room)) { + client.emit('error', 'You are not in the room'); + this.logger.warn(`client is not in the room ${room}`); + return; + } + if (!client.session || !client.session.id) { + client.emit('error', 'Invalid session'); + this.logger.warn('client session is invalid'); + return; + } + const savedChat = await this.chatService.saveChat(client.session.id, { + stockId: room, + message: content, + }); + this.server.to(room).emit('chat', this.toResponse(savedChat)); + } + + async handleConnection(client: Socket) { + const room = client.handshake.query.stockId; + if (!room || !(await this.stockService.checkStockExist(room as string))) { + client.emit('error', 'Invalid stockId'); + this.logger.warn(`client connected with invalid stockId: ${room}`); + client.disconnect(); + return; + } + if (room) { + client.join(room); + this.logger.info(`client joined room ${room}`); + } + } + + private toResponse(chat: Chat): chatResponse { + return { + likeCount: chat.likeCount, + message: chat.message, + type: chat.type, + createdAt: chat.date?.createdAt || new Date(), + }; + } +} diff --git a/packages/backend/src/chat/chat.module.ts b/packages/backend/src/chat/chat.module.ts new file mode 100644 index 00000000..345a0f76 --- /dev/null +++ b/packages/backend/src/chat/chat.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SessionModule } from '@/auth/session.module'; +import { ChatGateway } from '@/chat/chat.gateway'; +import { Chat } from '@/chat/domain/chat.entity'; +import { StockModule } from '@/stock/stock.module'; +import { ChatService } from '@/chat/chat.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Chat]), StockModule, SessionModule], + providers: [ChatGateway, ChatService], +}) +export class ChatModule {} diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts new file mode 100644 index 00000000..c6fabff2 --- /dev/null +++ b/packages/backend/src/chat/chat.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; + +interface ChatMessage { + message: string; + stockId: string; +} + +@Injectable() +export class ChatService { + constructor(private readonly dataSource: DataSource) {} + + async saveChat(userId: number, chatMessage: ChatMessage) { + return this.dataSource.manager.save(Chat, { + user: { id: userId }, + stock: { id: chatMessage.stockId }, + message: chatMessage.message, + }); + } +} \ No newline at end of file diff --git a/packages/backend/src/chat/domain/chat.entity.ts b/packages/backend/src/chat/domain/chat.entity.ts new file mode 100644 index 00000000..e937d898 --- /dev/null +++ b/packages/backend/src/chat/domain/chat.entity.ts @@ -0,0 +1,29 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { ChatType } from '@/chat/domain/chatType.enum'; +import { DateEmbedded } from '@/common/dateEmbedded.entity'; +import { Stock } from '@/stock/domain/stock.entity'; +import { User } from '@/user/domain/user.entity'; + +@Entity() +export class Chat { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, (user) => user.id) + user: User; + + @ManyToOne(() => Stock, (stock) => stock.id) + stock: Stock; + + @Column() + message: string; + + @Column({ type: 'enum', enum: ChatType, default: ChatType.NORMAL }) + type: ChatType = ChatType.NORMAL; + + @Column({ name: 'like_count', default: 0 }) + likeCount: number = 0; + + @Column(() => DateEmbedded, { prefix: '' }) + date?: DateEmbedded; +} diff --git a/packages/backend/src/chat/domain/chatType.enum.ts b/packages/backend/src/chat/domain/chatType.enum.ts new file mode 100644 index 00000000..c078a2e9 --- /dev/null +++ b/packages/backend/src/chat/domain/chatType.enum.ts @@ -0,0 +1,6 @@ +export const ChatType = { + NORMAL: 'NORMAL', + BROADCAST: 'BROADCAST', +}; + +export type ChatType = (typeof ChatType)[keyof typeof ChatType]; diff --git a/packages/backend/src/common/decorator/user.decorator.ts b/packages/backend/src/common/decorator/user.decorator.ts new file mode 100644 index 00000000..017ea845 --- /dev/null +++ b/packages/backend/src/common/decorator/user.decorator.ts @@ -0,0 +1,17 @@ +import { + createParamDecorator, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { User } from '@/user/domain/user.entity'; + +export const GetUser = createParamDecorator( + (data: unknown, ctx: ExecutionContext): User => { + const request: Request = ctx.switchToHttp().getRequest(); + if (!request.user) { + throw new UnauthorizedException('Unauthorized'); + } + return request.user as User; + }, +); diff --git a/packages/backend/src/configs/devTypeormConfig.ts b/packages/backend/src/configs/devTypeormConfig.ts index 0672e4a6..41ceff73 100644 --- a/packages/backend/src/configs/devTypeormConfig.ts +++ b/packages/backend/src/configs/devTypeormConfig.ts @@ -22,4 +22,4 @@ export const typeormDevelopConfig: TypeOrmModuleOptions = { database: process.env.DB_NAME, entities: [__dirname + '/../**/*.entity.{js,ts}'], logging: true, -}; \ No newline at end of file +}; diff --git a/packages/backend/src/configs/session.config.ts b/packages/backend/src/configs/session.config.ts index c4899db3..65c49b96 100644 --- a/packages/backend/src/configs/session.config.ts +++ b/packages/backend/src/configs/session.config.ts @@ -7,6 +7,7 @@ export const sessionConfig = { secret: process.env.COOKIE_SECRET || randomUUID().toString(), resave: false, saveUninitialized: false, + name: process.env.COOKIE_NAME, cookie: { maxAge: Number(process.env.COOKIE_MAX_AGE), }, diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 1012be4f..311bcdb9 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -3,12 +3,14 @@ import { NestFactory } from '@nestjs/core'; import * as session from 'express-session'; import * as passport from 'passport'; import { AppModule } from './app.module'; +import { MEMORY_STORE } from '@/auth/session.module'; import { sessionConfig } from '@/configs/session.config'; import { useSwagger } from '@/configs/swagger.config'; async function bootstrap() { const app = await NestFactory.create(AppModule); - app.use(session(sessionConfig)); + const store = app.get(MEMORY_STORE); + app.use(session({ ...sessionConfig, store })); app.useGlobalPipes(new ValidationPipe({ transform: true })); useSwagger(app); app.use(passport.initialize()); diff --git a/packages/backend/src/middlewares/filter/webSocketException.filter.ts b/packages/backend/src/middlewares/filter/webSocketException.filter.ts new file mode 100644 index 00000000..b1662fcb --- /dev/null +++ b/packages/backend/src/middlewares/filter/webSocketException.filter.ts @@ -0,0 +1,24 @@ +import { + ArgumentsHost, + Catch, + Inject, + WsExceptionFilter, +} from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import { Socket } from 'socket.io'; +import { Logger } from 'winston'; + +@Catch(WsException) +export class WebSocketExceptionFilter implements WsExceptionFilter { + constructor(@Inject('winston') private readonly logger: Logger) {} + catch(exception: WsException, host: ArgumentsHost) { + const client = host.switchToWs().getClient(); + const data = host.switchToWs().getData(); + const errorMessage = exception.message; + client.emit('error', { + message: errorMessage, + data, + }); + this.logger.warn(`error occurred: ${errorMessage}`); + } +} diff --git a/packages/backend/src/stock/dto/userStock.request.ts b/packages/backend/src/stock/dto/userStock.request.ts index 4d3e4184..992f6465 100644 --- a/packages/backend/src/stock/dto/userStock.request.ts +++ b/packages/backend/src/stock/dto/userStock.request.ts @@ -2,15 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsInt, IsString } from 'class-validator'; -export class UserStockCreateRequest { - @ApiProperty({ - example: 1, - description: '사용자 id', - }) - @IsInt() - @Transform(({ value }) => parseInt(value)) - userId: number; - +export class UserStockRequest { @ApiProperty({ example: 'A005930', description: '주식 종목 id', @@ -20,14 +12,6 @@ export class UserStockCreateRequest { } export class UserStockDeleteRequest { - @ApiProperty({ - example: 1, - description: '사용자 id', - }) - @IsInt() - @Transform(({ value }) => parseInt(value)) - userId: number; - @ApiProperty({ example: 1, description: '유저 소유 주식 id', diff --git a/packages/backend/src/stock/dto/userStock.response.ts b/packages/backend/src/stock/dto/userStock.response.ts index 74950729..5e3c12fa 100644 --- a/packages/backend/src/stock/dto/userStock.response.ts +++ b/packages/backend/src/stock/dto/userStock.response.ts @@ -22,3 +22,19 @@ export class UserStockResponse { this.date = new Date(); } } + +export class UserStockOwnerResponse { + @ApiProperty({ description: '사용자 주식 소유 여부', example: true }) + isOwner: boolean; + + @ApiProperty({ + description: '응답 date', + example: new Date(), + }) + date: Date; + + constructor(isOwner: boolean) { + this.isOwner = isOwner; + this.date = new Date(); + } +} diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index e76065fb..042353a3 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -7,8 +7,16 @@ import { Param, Post, Query, + Req, + UseGuards, } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { + ApiCookieAuth, + ApiOkResponse, + ApiOperation, + ApiParam, +} from '@nestjs/swagger'; +import { Request } from 'express'; import { ApiGetStocks, LimitQuery } from './decorator/stock.decorator'; import { ApiGetStockData } from './decorator/stockData.decorator'; import { StockDetailResponse } from './dto/stockDetail.response'; @@ -21,13 +29,20 @@ import { StockDataYearlyService, } from './stockData.service'; import { StockDetailService } from './stockDetail.service'; +import SessionGuard from '@/auth/session/session.guard'; +import { GetUser } from '@/common/decorator/user.decorator'; +import { sessionConfig } from '@/configs/session.config'; import { StockViewsResponse } from '@/stock/dto/stock.Response'; import { StockViewRequest } from '@/stock/dto/stockView.request'; import { - UserStockCreateRequest, UserStockDeleteRequest, + UserStockRequest, } from '@/stock/dto/userStock.request'; -import { UserStockResponse } from '@/stock/dto/userStock.response'; +import { + UserStockOwnerResponse, + UserStockResponse, +} from '@/stock/dto/userStock.response'; +import { User } from '@/user/domain/user.entity'; @Controller('stock') export class StockController { @@ -62,6 +77,7 @@ export class StockController { } @Post('/user') + @ApiCookieAuth(sessionConfig.name) @ApiOperation({ summary: '유저 소유 주식 추가 API', description: '유저가 소유 주식을 추가한다.', @@ -70,12 +86,14 @@ export class StockController { description: '유저 소유 주식 추가 성공', type: UserStockResponse, }) + @UseGuards(SessionGuard) async createUserStock( - @Body() request: UserStockCreateRequest, + @Body() requestBody: UserStockRequest, + @GetUser() user: User, ): Promise { const stock = await this.stockService.createUserStock( - request.userId, - request.stockId, + user.id, + requestBody.stockId, ); return new UserStockResponse( Number(stock.identifiers[0].id), @@ -96,19 +114,42 @@ export class StockController { date: new Date(), }, }) + @UseGuards(SessionGuard) async deleteUserStock( @Body() request: UserStockDeleteRequest, + @GetUser() user: User, ): Promise { - await this.stockService.deleteUserStock( - Number(request.userId), - request.userStockId, - ); + await this.stockService.deleteUserStock(user.id, request.userStockId); return new UserStockResponse( request.userStockId, '사용자 소유 주식을 삭제했습니다.', ); } + @ApiOperation({ + summary: '유저 소유 주식 확인 API', + description: '유저가 소유 주식을 확인한다.', + }) + @ApiOkResponse({ + description: '유저 소유 확인', + type: UserStockOwnerResponse, + }) + @Get('user/ownership') + async checkOwnership( + @Body() body: UserStockRequest, + @Req() request: Request, + ) { + const user = request.user as User; + if (!user) { + return new UserStockOwnerResponse(false); + } + const result = await this.stockService.isUserStockOwner( + body.stockId, + user.id, + ); + return new UserStockOwnerResponse(result); + } + @Get(':stockId/minutely') @ApiGetStockData('주식 분 단위 데이터 조회 API', '분') async getStockDataMinutely( diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 4ccdd0ad..13df81b0 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -50,5 +50,6 @@ import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; StockDataMonthlyService, StockDetailService, ], + exports: [StockService], }) export class StockModule {} diff --git a/packages/backend/src/stock/stock.service.spec.ts b/packages/backend/src/stock/stock.service.spec.ts index 7935f271..33244070 100644 --- a/packages/backend/src/stock/stock.service.spec.ts +++ b/packages/backend/src/stock/stock.service.spec.ts @@ -256,6 +256,28 @@ describe('StockService 테스트', () => { ]); }); + test('소유 주식인지 확인한다.', async () => { + const managerMock = { + exists: jest.fn().mockResolvedValue(true), + }; + const dataSource = createDataSourceMock(managerMock); + const stockService = new StockService(dataSource as DataSource, logger); + + const result = await stockService.isUserStockOwner(stockId, userId); + + expect(result).toBe(true); + expect(managerMock.exists).toHaveBeenCalled(); + }); + + test('인증된 유저가 아니면 소유 주식은 항상 false를 반환한다.', async () => { + const dataSource = createDataSourceMock({}); + const stockService = new StockService(dataSource as DataSource, logger); + + const result = await stockService.isUserStockOwner(stockId); + + expect(result).toBe(false); + }); + test('주식 하락률 기준 상위 데이터를 반환한다.', async () => { const limit = 20; // QueryBuilder Mock diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index e8fb5842..1f23e48f 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -24,7 +24,6 @@ export class StockService { }); } - // 유저 존재는 인증에서 확인가능해서 생략 async createUserStock(userId: number, stockId: string) { return await this.datasource.transaction(async (manager) => { await this.validateStockExists(stockId, manager); @@ -36,6 +35,26 @@ export class StockService { }); } + async isUserStockOwner(stockId: string, userId?: number) { + return await this.datasource.transaction(async (manager) => { + if (!userId) { + return false; + } + return await manager.exists(UserStock, { + where: { + user: { id: userId }, + stock: { id: stockId }, + }, + }); + }); + } + + async checkStockExist(stockId: string) { + return await this.datasource.manager.exists(Stock, { + where: { id: stockId }, + }); + } + async deleteUserStock(userId: number, userStockId: number) { await this.datasource.transaction(async (manager) => { const userStock = await manager.findOne(UserStock, { diff --git a/packages/backend/src/user/domain/user.entity.ts b/packages/backend/src/user/domain/user.entity.ts index de6f0007..26cdb345 100644 --- a/packages/backend/src/user/domain/user.entity.ts +++ b/packages/backend/src/user/domain/user.entity.ts @@ -14,13 +14,13 @@ import { Role } from '@/user/domain/role'; @Entity({ name: 'users' }) export class User { @PrimaryGeneratedColumn() - id?: number; + id: number; @Column({ length: 50 }) - nickname?: string; + nickname: string; @Column({ length: 50 }) - email?: string; + email: string; @Column({ length: 5, default: Role.USER }) role: Role = Role.USER; @@ -29,14 +29,14 @@ export class User { type: OauthType = OauthType.LOCAL; @Column('decimal', { name: 'oauth_id' }) - oauthId?: string; + oauthId: string; @Column({ name: 'is_light', default: true }) isLight: boolean = true; @Column(() => DateEmbedded, { prefix: '' }) - date?: DateEmbedded; + date: DateEmbedded; @OneToMany(() => UserStock, (userStock) => userStock.user) - userStocks?: UserStock[]; + userStocks: UserStock[]; } diff --git a/packages/backend/src/user/dto/userTheme.response.ts b/packages/backend/src/user/dto/userTheme.response.ts new file mode 100644 index 00000000..71869900 --- /dev/null +++ b/packages/backend/src/user/dto/userTheme.response.ts @@ -0,0 +1,17 @@ +import { Transform } from 'class-transformer'; +import { IsInt, IsString, IsBoolean, IsDateString } from 'class-validator'; + +export class UpdateUserThemeResponse { + @IsInt() + id: number; + + @IsString() + nickname: string; + + @IsBoolean() + isLight: boolean; + + @IsDateString() + @Transform(({ value }) => value.toISOString()) + updatedAt: Date; +} diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts new file mode 100644 index 00000000..748ae2b7 --- /dev/null +++ b/packages/backend/src/user/user.controller.ts @@ -0,0 +1,72 @@ +import { + Controller, + Patch, + Param, + Body, + HttpCode, + HttpStatus, + Get, +} from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { UpdateUserThemeResponse } from './dto/userTheme.response'; +import { UserService } from './user.service'; + +@Controller('user') +export class UserController { + constructor(private readonly userService: UserService) {} + + @Patch(':id/theme') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '유저 테마 변경 API', + description: '유저 테마를 라이트모드인지 다크모드인지 변경합니다.', + }) + @ApiParam({ name: 'id', type: Number, description: 'User ID' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + isLight: { + type: 'boolean', + description: 'true: light mode, false: dark mode', + example: true, + }, + }, + required: ['isLight'], + }, + }) + @ApiResponse({ status: 200, description: 'User theme updated successfully' }) + @ApiResponse({ status: 400, description: 'isLight property is required' }) + @ApiResponse({ status: 404, description: 'User not found' }) + async updateTheme( + @Param('id') id: number, + @Body('isLight') isLight?: boolean, + ): Promise { + const updatedUser = await this.userService.updateUserTheme(id, isLight); + + return { + id: updatedUser.id!, + isLight: updatedUser.isLight!, + nickname: updatedUser.nickname!, + updatedAt: updatedUser.date!.updatedAt!, + }; + } + + @Get(':id/theme') + @ApiOperation({ + summary: 'Get user theme mode', + description: + 'Retrieve the current theme mode (light or dark) for a specific user', + }) + @ApiParam({ name: 'id', type: Number, description: 'User ID' }) + @ApiResponse({ + status: 200, + description: 'User theme retrieved successfully', + schema: { type: 'boolean' }, + }) + @ApiResponse({ status: 404, description: 'User not found' }) + async getTheme(@Param('id') id: number) { + const isLight = await this.userService.getUserTheme(id); + return { isLight }; + } +} diff --git a/packages/backend/src/user/user.module.ts b/packages/backend/src/user/user.module.ts index 2a684173..bad61255 100644 --- a/packages/backend/src/user/user.module.ts +++ b/packages/backend/src/user/user.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserController } from './user.controller'; import { User } from '@/user/domain/user.entity'; import { UserService } from '@/user/user.service'; @@ -7,5 +8,6 @@ import { UserService } from '@/user/user.service'; imports: [TypeOrmModule.forFeature([User])], providers: [UserService], exports: [UserService], + controllers: [UserController], }) export class UserModule {} diff --git a/packages/backend/src/user/user.service.spec.ts b/packages/backend/src/user/user.service.spec.ts index 3cc57f11..a0a3f19d 100644 --- a/packages/backend/src/user/user.service.spec.ts +++ b/packages/backend/src/user/user.service.spec.ts @@ -1,14 +1,23 @@ +/* eslint-disable max-lines-per-function */ +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; +import { User } from './domain/user.entity'; import { OauthType } from '@/user/domain/ouathType'; import { UserService } from '@/user/user.service'; export function createDataSourceMock( - managerMock: Partial, + managerMock?: Partial, ): Partial { + const defaultManagerMock: Partial = { + findOne: jest.fn(), + save: jest.fn(), + exists: jest.fn(), + }; + return { getRepository: managerMock.getRepository, transaction: jest.fn().mockImplementation(async (work) => { - return work(managerMock); + return work({ ...defaultManagerMock, ...managerMock }); }), }; } @@ -48,4 +57,89 @@ describe('UserService 테스트', () => { async () => await userService.register(registerRequest), ).rejects.toThrow('user already exists'); }); + + test('유저 테마를 업데이트한다', async () => { + const userId = 1; + const isLight = false; + const mockUser = { id: userId, isLight: true }; + + const managerMock = { + findOne: jest.fn().mockResolvedValue(mockUser), + save: jest.fn().mockResolvedValue({ ...mockUser, isLight }), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + const result = await userService.updateUserTheme(userId, isLight); + + expect(dataSource.transaction).toHaveBeenCalled(); + expect(managerMock.findOne).toHaveBeenCalledWith(User, { + where: { id: userId }, + }); + expect(managerMock.save).toHaveBeenCalledWith({ ...mockUser, isLight }); + expect(result.isLight).toBe(isLight); + }); + + test('isLight가 제공되지 않으면 BadRequestException을 발생시킨다', async () => { + const managerMock = { + findOne: jest.fn(), + save: jest.fn(), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + await expect(userService.updateUserTheme(1)).rejects.toThrow( + BadRequestException, + ); + }); + + test('유저가 존재하지 않으면 NotFoundException을 발생시킨다', async () => { + const userId = 1; + const isLight = true; + + const managerMock = { + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn(), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + await expect(userService.updateUserTheme(userId, isLight)).rejects.toThrow( + NotFoundException, + ); + }); + + test('유저 테마를 가져온다', async () => { + const userId = 1; + const isLight = true; + const mockUser = { id: userId, isLight }; + + const managerMock = { + findOne: jest.fn().mockResolvedValue(mockUser), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + const result = await userService.getUserTheme(userId); + + expect(managerMock.findOne).toHaveBeenCalledWith(User, { + where: { id: userId }, + select: ['isLight'], + }); + expect(result).toBe(isLight); + }); + + test('유저가 존재하지 않을 경우 NotFoundException을 발생시킨다', async () => { + const userId = 1; + + const managerMock = { + findOne: jest.fn().mockResolvedValue(null), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + await expect(userService.getUserTheme(userId)).rejects.toThrow( + NotFoundException, + ); + }); }); diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index 215d4bcc..c1eebd52 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -1,4 +1,8 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { OauthType } from './domain/ouathType'; import { User } from './domain/user.entity'; @@ -9,10 +13,10 @@ type RegisterRequest = Required< @Injectable() export class UserService { - constructor(private readonly dataSources: DataSource) {} + constructor(private readonly dataSource: DataSource) {} async register({ nickname, email, type, oauthId }: RegisterRequest) { - return await this.dataSources.transaction(async (manager) => { + return await this.dataSource.transaction(async (manager) => { await this.validateUserExists(type, oauthId, manager); return await manager.save(User, { nickname, @@ -24,7 +28,7 @@ export class UserService { } async findUserByOauthIdAndType(oauthId: string, type: OauthType) { - return await this.dataSources.manager.findOne(User, { + return await this.dataSource.manager.findOne(User, { where: { oauthId, type }, }); } @@ -38,4 +42,34 @@ export class UserService { throw new BadRequestException('user already exists'); } } + + async updateUserTheme(userId: number, isLight?: boolean): Promise { + return await this.dataSource.transaction(async (manager) => { + if (isLight === undefined) { + throw new BadRequestException('isLight property is required'); + } + + const user = await manager.findOne(User, { where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + user.isLight = isLight; + return await manager.save(user); + }); + } + + async getUserTheme(userId: number): Promise { + const user = await this.dataSource.manager.findOne(User, { + where: { id: userId }, + select: ['isLight'], + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return user.isLight; + } }