diff --git a/packages/backend/src/auth/google/googleAuth.service.spec.ts b/packages/backend/src/auth/google/googleAuth.service.spec.ts index 3d3f49be..1e714846 100644 --- a/packages/backend/src/auth/google/googleAuth.service.spec.ts +++ b/packages/backend/src/auth/google/googleAuth.service.spec.ts @@ -15,7 +15,7 @@ describe('GoogleAuthService 테스트', () => { }; test('oauthId와 type에 맞는 유저가 있으면 해당 객체를 반환한다.', async () => { - const user: User = { + const user: Partial = { id: 1, role: Role.USER, type: OauthType.GOOGLE, diff --git a/packages/backend/src/auth/session.module.ts b/packages/backend/src/auth/session.module.ts index 07c36bba..ddd58c70 100644 --- a/packages/backend/src/auth/session.module.ts +++ b/packages/backend/src/auth/session.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { MemoryStore } from 'express-session'; -export const MEMORY_STORE = 'memoryStore'; +export const MEMORY_STORE = Symbol('memoryStore'); @Module({ providers: [ diff --git a/packages/backend/src/auth/session/webSocketSession.guard..ts b/packages/backend/src/auth/session/webSocketSession.guard.ts similarity index 96% rename from packages/backend/src/auth/session/webSocketSession.guard..ts rename to packages/backend/src/auth/session/webSocketSession.guard.ts index a19ff956..f2c766a3 100644 --- a/packages/backend/src/auth/session/webSocketSession.guard..ts +++ b/packages/backend/src/auth/session/webSocketSession.guard.ts @@ -15,7 +15,7 @@ export interface SessionSocket extends Socket { session?: User; } -interface PassportSession extends SessionData { +export interface PassportSession extends SessionData { passport: { user: User }; } diff --git a/packages/backend/src/auth/session/websocketSession.service.ts b/packages/backend/src/auth/session/websocketSession.service.ts new file mode 100644 index 00000000..10af73c2 --- /dev/null +++ b/packages/backend/src/auth/session/websocketSession.service.ts @@ -0,0 +1,25 @@ +import { MemoryStore } from 'express-session'; +import { Socket } from 'socket.io'; +import { websocketCookieParse } from '@/auth/session/cookieParser'; +import { PassportSession } from '@/auth/session/webSocketSession.guard'; + +export class WebsocketSessionService { + constructor(private readonly sessionStore: MemoryStore) {} + + async getAuthenticatedUser(socket: Socket) { + const cookieValue = websocketCookieParse(socket); + const session = await this.getSession(cookieValue); + return session ? session.passport.user : undefined; + } + + private getSession(cookieValue: string) { + return new Promise((resolve) => { + this.sessionStore.get(cookieValue, (err: Error, session) => { + if (err || !session) { + resolve(undefined); + } + resolve(session as PassportSession); + }); + }); + } +} diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts index 4e2c9371..d295f0c9 100644 --- a/packages/backend/src/chat/chat.controller.ts +++ b/packages/backend/src/chat/chat.controller.ts @@ -1,13 +1,22 @@ -import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiBadRequestResponse, ApiOkResponse, ApiOperation, } from '@nestjs/swagger'; import SessionGuard from '@/auth/session/session.guard'; +import { ChatGateway } from '@/chat/chat.gateway'; import { ChatService } from '@/chat/chat.service'; import { ToggleLikeApi } from '@/chat/decorator/like.decorator'; -import { ChatScrollRequest } from '@/chat/dto/chat.request'; +import { ChatScrollQuery } from '@/chat/dto/chat.request'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; import { LikeRequest } from '@/chat/dto/like.request'; import { LikeService } from '@/chat/like.service'; @@ -19,6 +28,7 @@ export class ChatController { constructor( private readonly chatService: ChatService, private readonly likeService: LikeService, + private readonly chatGateWay: ChatGateway, ) {} @ApiOperation({ @@ -38,18 +48,20 @@ export class ChatController { }, }) @Get() - async findChatList(@Query() request: ChatScrollRequest) { - return await this.chatService.scrollNextChat( - request.stockId, - request.latestChatId, - request.pageSize, - ); + async findChatList( + @Query() request: ChatScrollQuery, + @Req() req: Express.Request, + ) { + const user = req.user as User; + return await this.chatService.scrollNextChat(request, user?.id); } @UseGuards(SessionGuard) @ToggleLikeApi() @Post('like') async toggleChatLike(@Body() request: LikeRequest, @GetUser() user: User) { - return await this.likeService.toggleLike(user.id, request.chatId); + const result = await this.likeService.toggleLike(user.id, request.chatId); + this.chatGateWay.broadcastLike(result); + return result; } } diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index f5fc099e..77db725d 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -7,14 +7,19 @@ import { WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; +import { MemoryStore } from 'express-session'; import { Server, Socket } from 'socket.io'; import { Logger } from 'winston'; import { SessionSocket, WebSocketSessionGuard, -} from '@/auth/session/webSocketSession.guard.'; +} from '@/auth/session/webSocketSession.guard'; +import { WebsocketSessionService } from '@/auth/session/websocketSession.service'; +import { MEMORY_STORE } from '@/auth/session.module'; import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; +import { ChatScrollQuery, isChatScrollQuery } from '@/chat/dto/chat.request'; +import { LikeResponse } from '@/chat/dto/like.response'; import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter'; import { StockService } from '@/stock/stock.service'; @@ -35,11 +40,16 @@ interface chatResponse { export class ChatGateway implements OnGatewayConnection { @WebSocketServer() server: Server; + websocketSessionService: WebsocketSessionService; + constructor( @Inject('winston') private readonly logger: Logger, private readonly stockService: StockService, private readonly chatService: ChatService, - ) {} + @Inject(MEMORY_STORE) sessionStore: MemoryStore, + ) { + this.websocketSessionService = new WebsocketSessionService(sessionStore); + } @UseGuards(WebSocketSessionGuard) @SubscribeMessage('chat') @@ -65,25 +75,50 @@ export class ChatGateway implements OnGatewayConnection { this.server.to(room).emit('chat', this.toResponse(savedChat)); } + async broadcastLike(response: LikeResponse) { + this.server.to(response.stockId).emit('like', response); + } + async handleConnection(client: Socket) { - const room = client.handshake.query.stockId; - if ( - !this.isString(room) || - !(await this.stockService.checkStockExist(room)) - ) { - client.emit('error', 'Invalid stockId'); - this.logger.warn(`client connected with invalid stockId: ${room}`); + try { + const user = + await this.websocketSessionService.getAuthenticatedUser(client); + const { stockId, pageSize } = await this.getChatScrollQuery(client); + await this.validateExistStock(stockId); + client.join(stockId); + const messages = await this.chatService.scrollFirstChat( + { + stockId, + pageSize, + }, + user?.id, + ); + this.logger.info(`client joined room ${stockId}`); + client.emit('chat', messages); + } catch (e) { + const error = e as Error; + this.logger.warn(error.message); + client.emit('error', error.message); client.disconnect(); - return; } - client.join(room); - const messages = await this.chatService.scrollFirstChat(room); - this.logger.info(`client joined room ${room}`); - client.emit('chat', messages); } - private isString(value: string | string[] | undefined): value is string { - return typeof value === 'string'; + private async validateExistStock(stockId: string): Promise { + if (!(await this.stockService.checkStockExist(stockId))) { + throw new Error(`Stock does not exist: ${stockId}`); + } + } + + private async getChatScrollQuery(client: Socket): Promise { + const query = client.handshake.query; + if (!isChatScrollQuery(query)) { + throw new Error('Invalid chat scroll query'); + } + return { + stockId: query.stockId, + latestChatId: query.latestChatId ? Number(query.latestChatId) : undefined, + pageSize: query.pageSize ? Number(query.pageSize) : undefined, + }; } private toResponse(chat: Chat): chatResponse { diff --git a/packages/backend/src/chat/chat.service.spec.ts b/packages/backend/src/chat/chat.service.spec.ts index 7d181061..24c77fae 100644 --- a/packages/backend/src/chat/chat.service.spec.ts +++ b/packages/backend/src/chat/chat.service.spec.ts @@ -8,7 +8,11 @@ describe('ChatService 테스트', () => { const chatService = new ChatService(dataSource as DataSource); await expect(() => - chatService.scrollNextChat('A005930', 1, 101), + chatService.scrollNextChat({ + stockId: 'A005930', + latestChatId: 1, + pageSize: 101, + }), ).rejects.toThrow('pageSize should be less than 100'); }); @@ -17,7 +21,7 @@ describe('ChatService 테스트', () => { const chatService = new ChatService(dataSource as DataSource); await expect(() => - chatService.scrollFirstChat('A005930', 101), + chatService.scrollFirstChat({ stockId: 'A005930', pageSize: 101 }), ).rejects.toThrow('pageSize should be less than 100'); }); }); diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index ef262e7b..569a44ef 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; +import { ChatScrollQuery } from '@/chat/dto/chat.request'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; export interface ChatMessage { @@ -22,24 +23,21 @@ export class ChatService { }); } - async scrollFirstChat(stockId: string, scrollSize?: number) { - this.validatePageSize(scrollSize); - const result = await this.findFirstChatScroll(stockId, scrollSize); - return await this.toScrollResponse(result, scrollSize); + async scrollFirstChat(chatScrollQuery: ChatScrollQuery, userId?: number) { + this.validatePageSize(chatScrollQuery); + const result = await this.findFirstChatScroll(chatScrollQuery, userId); + return await this.toScrollResponse(result, chatScrollQuery.pageSize); } - async scrollNextChat( - stockId: string, - latestChatId?: number, - pageSize?: number, - ) { - this.validatePageSize(pageSize); - const result = await this.findChatScroll(stockId, latestChatId, pageSize); - return await this.toScrollResponse(result, pageSize); + async scrollNextChat(chatScrollQuery: ChatScrollQuery, userId?: number) { + this.validatePageSize(chatScrollQuery); + const result = await this.findChatScroll(chatScrollQuery, userId); + return await this.toScrollResponse(result, chatScrollQuery.pageSize); } - private validatePageSize(scrollSize?: number) { - if (scrollSize && scrollSize > 100) { + private validatePageSize(chatScrollQuery: ChatScrollQuery) { + const { pageSize } = chatScrollQuery; + if (pageSize && pageSize > 100) { throw new BadRequestException('pageSize should be less than 100'); } } @@ -54,45 +52,54 @@ export class ChatService { } private async findChatScroll( - stockId: string, - latestChatId?: number, - pageSize?: number, + chatScrollQuery: ChatScrollQuery, + userId?: number, ) { - if (!latestChatId) { - return await this.findFirstChatScroll(stockId, pageSize); + if (!chatScrollQuery.latestChatId) { + return await this.findFirstChatScroll(chatScrollQuery, userId); } else { - return await this.findNextChatScroll(stockId, latestChatId, pageSize); + return await this.findNextChatScroll(chatScrollQuery); } } - private async findFirstChatScroll(stockId: string, pageSize?: number) { + private async findFirstChatScroll( + chatScrollQuery: ChatScrollQuery, + userId?: number, + ) { const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - if (!pageSize) { - pageSize = DEFAULT_PAGE_SIZE; + if (!chatScrollQuery.pageSize) { + chatScrollQuery.pageSize = DEFAULT_PAGE_SIZE; } + const { stockId, pageSize } = chatScrollQuery; return queryBuilder + .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { + userId, + }) .where('chat.stock_id = :stockId', { stockId }) .orderBy('chat.id', 'DESC') - .limit(pageSize + 1) + .take(pageSize + 1) .getMany(); } private async findNextChatScroll( - stockId: string, - latestChatId: number, - pageSize?: number, + chatScrollQuery: ChatScrollQuery, + userId?: number, ) { const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - if (!pageSize) { - pageSize = DEFAULT_PAGE_SIZE; + if (!chatScrollQuery.pageSize) { + chatScrollQuery.pageSize = DEFAULT_PAGE_SIZE; } + const { stockId, latestChatId, pageSize } = chatScrollQuery; return queryBuilder + .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { + userId, + }) .where('chat.stock_id = :stockId and chat.id < :latestChatId', { stockId, latestChatId, }) .orderBy('chat.id', 'DESC') - .limit(pageSize + 1) + .take(pageSize + 1) .getMany(); } } diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts index f3260509..0c68943b 100644 --- a/packages/backend/src/chat/dto/chat.request.ts +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -1,13 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNumber, IsOptional, IsString } from 'class-validator'; -export class ChatScrollRequest { +export class ChatScrollQuery { @ApiProperty({ description: '종목 주식 id(종목방 id)', example: 'A005930', }) @IsString() - readonly stockId: string; + stockId: string; @ApiProperty({ description: '최신 채팅 id', @@ -16,7 +16,7 @@ export class ChatScrollRequest { }) @IsOptional() @IsNumber() - readonly latestChatId?: number; + latestChatId?: number; @ApiProperty({ description: '페이지 크기', @@ -26,5 +26,24 @@ export class ChatScrollRequest { }) @IsOptional() @IsNumber() - readonly pageSize?: number; + pageSize?: number; +} + +export function isChatScrollQuery(object: unknown): object is ChatScrollQuery { + if (typeof object !== 'object' || object === null) { + return false; + } + + if (!('stockId' in object) || typeof object.stockId !== 'string') { + return false; + } + + if ( + 'latestChatId' in object && + !Number.isInteger(Number(object.latestChatId)) + ) { + return false; + } + + return !('pageSize' in object && !Number.isInteger(Number(object.pageSize))); } diff --git a/packages/backend/src/chat/dto/chat.response.ts b/packages/backend/src/chat/dto/chat.response.ts index 48aacb0d..803b81e6 100644 --- a/packages/backend/src/chat/dto/chat.response.ts +++ b/packages/backend/src/chat/dto/chat.response.ts @@ -7,6 +7,7 @@ interface ChatResponse { likeCount: number; message: string; type: string; + liked: boolean; createdAt: Date; } @@ -25,6 +26,7 @@ export class ChatScrollResponse { likeCount: 0, message: '안녕하세요', type: ChatType.NORMAL, + isLiked: true, createdAt: new Date(), }, ], @@ -38,6 +40,7 @@ export class ChatScrollResponse { message: chat.message, type: chat.type, createdAt: chat.date!.createdAt, + liked: !!(chat.likes && chat.likes.length > 0), })); this.hasMore = hasMore; } diff --git a/packages/backend/src/chat/dto/like.response.ts b/packages/backend/src/chat/dto/like.response.ts index 94fafde6..5c6ff7fa 100644 --- a/packages/backend/src/chat/dto/like.response.ts +++ b/packages/backend/src/chat/dto/like.response.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Chat } from '@/chat/domain/chat.entity'; export class LikeResponse { @ApiProperty({ @@ -8,6 +9,13 @@ export class LikeResponse { }) chatId: number; + @ApiProperty({ + type: 'string', + description: '참여 중인 좀목 id', + example: 'A005930', + }) + stockId: string; + @ApiProperty({ type: Number, description: '채팅의 좋아요 수', @@ -28,4 +36,34 @@ export class LikeResponse { example: '2021-08-01T00:00:00', }) date: Date; + + static createLikeResponse(chat: Chat): LikeResponse { + if (!isStockId(chat.stock.id)) { + throw new Error(`Stock id is undefined: ${chat.id}`); + } + return { + stockId: chat.stock.id, + chatId: chat.id, + likeCount: chat.likeCount, + message: 'like chat', + date: chat.date.updatedAt, + }; + } + + static createUnlikeResponse(chat: Chat): LikeResponse { + if (!isStockId(chat.stock.id)) { + throw new Error(`Stock id is undefined: ${chat.id}`); + } + return { + stockId: chat.stock.id, + chatId: chat.id, + likeCount: chat.likeCount, + message: 'like cancel', + date: chat.date.updatedAt, + }; + } } + +function isStockId(stockId?: string): stockId is string { + return stockId !== undefined; +} \ No newline at end of file diff --git a/packages/backend/src/chat/like.service.spec.ts b/packages/backend/src/chat/like.service.spec.ts new file mode 100644 index 00000000..1099b7bd --- /dev/null +++ b/packages/backend/src/chat/like.service.spec.ts @@ -0,0 +1,68 @@ +import { createDataSourceMock } from '@/user/user.service.spec'; +import { DataSource } from 'typeorm'; +import { LikeService } from '@/chat/like.service'; +import { Chat } from '@/chat/domain/chat.entity'; +import { Stock } from '@/stock/domain/stock.entity'; +import { User } from '@/user/domain/user.entity'; +import { Like } from '@/chat/domain/like.entity'; + +function createChat(): Chat { + return { + stock: new Stock(), + user: new User(), + id: 1, + likeCount: 1, + message: '안녕하세요', + type: 'NORMAL', + date: { + createdAt: new Date(), + updatedAt: new Date(), + }, + }; +} + +describe('LikeService 테스트', () => { + test('존재하지 않는 채팅을 좋아요를 시도하면 예외가 발생한다.', () => { + const managerMock = { + findOne: jest.fn().mockResolvedValue(null), + }; + const datasource = createDataSourceMock(managerMock); + const likeService = new LikeService(datasource as DataSource); + + expect(likeService.toggleLike(1, 1)).rejects.toThrow('Chat not found'); + }); + + test('특정 채팅에 좋아요를 한다.', async () => { + const chat = createChat(); + const managerMock = { + findOne: jest + .fn() + .mockResolvedValueOnce(chat) + .mockResolvedValueOnce(null), + save: jest.fn(), + }; + const datasource = createDataSourceMock(managerMock); + const likeService = new LikeService(datasource as DataSource); + + const response = await likeService.toggleLike(1, 1); + + expect(response.likeCount).toBe(2); + }); + + test('특정 채팅에 좋아요를 취소한다.', async () => { + const chat = createChat(); + const managerMock = { + findOne: jest + .fn() + .mockResolvedValueOnce(chat) + .mockResolvedValueOnce(new Like()), + remove: jest.fn(), + }; + const datasource = createDataSourceMock(managerMock); + const likeService = new LikeService(datasource as DataSource); + + const response = await likeService.toggleLike(1, 1); + + expect(response.likeCount).toBe(0); + }); +}); \ No newline at end of file diff --git a/packages/backend/src/chat/like.service.ts b/packages/backend/src/chat/like.service.ts index a58a0104..a1495905 100644 --- a/packages/backend/src/chat/like.service.ts +++ b/packages/backend/src/chat/like.service.ts @@ -11,12 +11,21 @@ export class LikeService { async toggleLike(userId: number, chatId: number) { return await this.dataSource.transaction(async (manager) => { const chat = await this.findChat(chatId, manager); + const like = await manager.findOne(Like, { + where: { user: { id: userId }, chat: { id: chatId } }, + }); + if (like) { + return await this.deleteLike(manager, chat, like); + } return await this.saveLike(manager, chat, userId); }); } private async findChat(chatId: number, manager: EntityManager) { - const chat = await manager.findOne(Chat, { where: { id: chatId } }); + const chat = await manager.findOne(Chat, { + where: { id: chatId }, + relations: ['stock'], + }); if (!chat) { throw new BadRequestException('Chat not found'); } @@ -36,11 +45,16 @@ export class LikeService { }), manager.save(Chat, chat), ]); - return { - likeCount: chat.likeCount, - message: 'like chat', - chatId: chat.id, - date: chat.date.updatedAt, - }; + return LikeResponse.createLikeResponse(chat); + } + + private async deleteLike( + manager: EntityManager, + chat: Chat, + like: Like, + ): Promise { + chat.likeCount -= 1; + await Promise.all([manager.remove(like), manager.save(Chat, chat)]); + return LikeResponse.createUnlikeResponse(chat); } } diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 0be16e68..935fea99 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -19,6 +19,10 @@ async function bootstrap() { transformOptions: { enableImplicitConversion: true }, }), ); + app.enableCors({ + origin: true, + credentials: true, + }); useSwagger(app); app.use(passport.initialize()); app.use(passport.session()); diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 3645b33e..429e8812 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -13,10 +13,10 @@ import { UserStock } from '@/stock/domain/userStock.entity'; @Entity() export class Stock { @PrimaryColumn({ name: 'stock_id' }) - id?: string; + id: string; @Column({ name: 'stock_name' }) - name?: string; + name: string; @Column({ default: 0 }) views: number = 0; @@ -25,14 +25,14 @@ export class Stock { isTrading: boolean = true; @Column({ name: 'group_code' }) - groupCode?: string; - - @OneToMany(() => Like, (like) => like.chat) - likes?: Like[]; + groupCode: string; @Column(() => DateEmbedded, { prefix: '' }) date?: DateEmbedded; + @OneToMany(() => Like, (like) => like.chat) + likes?: Like[]; + @OneToMany(() => UserStock, (userStock) => userStock.stock) userStocks?: UserStock[]; diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index ed85f3c1..5aaf6707 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -164,7 +164,6 @@ export class StockController { }) @Get() async searchStock(@Query() request: StockSearchRequest) { - console.log(request.name); return await this.stockService.searchStock(request.name); }