Skip to content

Commit

Permalink
Feature/#92 - 채팅 좋아요 취소 기능 구현 (#197)
Browse files Browse the repository at this point in the history
  • Loading branch information
xjfcnfw3 authored Nov 20, 2024
2 parents f0c72df + c2f20d0 commit 8f16568
Show file tree
Hide file tree
Showing 16 changed files with 306 additions and 78 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('GoogleAuthService 테스트', () => {
};

test('oauthId와 type에 맞는 유저가 있으면 해당 객체를 반환한다.', async () => {
const user: User = {
const user: Partial<User> = {
id: 1,
role: Role.USER,
type: OauthType.GOOGLE,
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/auth/session.module.ts
Original file line number Diff line number Diff line change
@@ -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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface SessionSocket extends Socket {
session?: User;
}

interface PassportSession extends SessionData {
export interface PassportSession extends SessionData {
passport: { user: User };
}

Expand Down
25 changes: 25 additions & 0 deletions packages/backend/src/auth/session/websocketSession.service.ts
Original file line number Diff line number Diff line change
@@ -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<PassportSession | undefined>((resolve) => {
this.sessionStore.get(cookieValue, (err: Error, session) => {
if (err || !session) {
resolve(undefined);
}
resolve(session as PassportSession);
});
});
}
}
30 changes: 21 additions & 9 deletions packages/backend/src/chat/chat.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +28,7 @@ export class ChatController {
constructor(
private readonly chatService: ChatService,
private readonly likeService: LikeService,
private readonly chatGateWay: ChatGateway,
) {}

@ApiOperation({
Expand All @@ -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;
}
}
67 changes: 51 additions & 16 deletions packages/backend/src/chat/chat.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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')
Expand All @@ -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<void> {
if (!(await this.stockService.checkStockExist(stockId))) {
throw new Error(`Stock does not exist: ${stockId}`);
}
}

private async getChatScrollQuery(client: Socket): Promise<ChatScrollQuery> {
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 {
Expand Down
8 changes: 6 additions & 2 deletions packages/backend/src/chat/chat.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -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');
});
});
67 changes: 37 additions & 30 deletions packages/backend/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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');
}
}
Expand All @@ -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();
}
}
Loading

0 comments on commit 8f16568

Please sign in to comment.