diff --git a/package.json b/package.json index c5f3f7d..792135e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "main": "index.js", "scripts": { - "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "dev": "dotenv -e .env.dev -- ts-node-dev --respawn --transpile-only src/index.ts", "test": "echo \"Error: no test specified\" && exit 1", "start": "node dist/index.js", "prebuild": "npm run generate:swagger", @@ -68,6 +68,7 @@ "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^10.0.0", + "dotenv-cli": "^11.0.0", "nodemon": "^3.1.10", "prisma": "^6.11.1", "ts-node": "^10.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b67dd6..ecf7c82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: '@types/uuid': specifier: ^10.0.0 version: 10.0.0 + dotenv-cli: + specifier: ^11.0.0 + version: 11.0.0 nodemon: specifier: ^3.1.10 version: 3.1.10 @@ -1498,6 +1501,14 @@ packages: resolution: {integrity: sha512-l0p4+mIuJIua0mhxGoh4a+iNL9bmeK5DvnSVQa6T0OhrVmaEa1XScX5Etc673FePCJOArq/4Pa2cLGODUWTPOQ==} engines: {node: '>=4'} + dotenv-cli@11.0.0: + resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==} + hasBin: true + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -5641,6 +5652,17 @@ snapshots: dependencies: is-obj: 1.0.1 + dotenv-cli@11.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + minimist: 1.2.8 + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + dotenv@16.6.1: {} dotenv@17.2.3: {} diff --git a/prisma/migrations/20260121113801_add_unread_count_to_chat_participant/migration.sql b/prisma/migrations/20260121113801_add_unread_count_to_chat_participant/migration.sql new file mode 100644 index 0000000..1c77ae3 --- /dev/null +++ b/prisma/migrations/20260121113801_add_unread_count_to_chat_participant/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `ChatParticipant` ADD COLUMN `unreadCount` INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20260121114120_change_camelcase_to_snake_case_at_chat_participant/migration.sql b/prisma/migrations/20260121114120_change_camelcase_to_snake_case_at_chat_participant/migration.sql new file mode 100644 index 0000000..ab03e3e --- /dev/null +++ b/prisma/migrations/20260121114120_change_camelcase_to_snake_case_at_chat_participant/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `unreadCount` on the `ChatParticipant` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE `ChatParticipant` DROP COLUMN `unreadCount`, + ADD COLUMN `unread_count` INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f35ebf8..53f84a3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -515,11 +515,12 @@ model ChatParticipant { room_id Int user_id Int last_read_message_id Int? + unread_count Int @default(0) chatRoom ChatRoom @relation("RoomParticipants", fields: [room_id], references: [room_id], onDelete: Cascade) user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) lastReadMessage ChatMessage? @relation("LastReadMessage", fields: [last_read_message_id], references: [message_id], onDelete: SetNull) - + @@unique([room_id, user_id]) } diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts new file mode 100644 index 0000000..f6718d1 --- /dev/null +++ b/src/chat/controllers/chat.controller.ts @@ -0,0 +1,107 @@ +import { Request, Response } from "express"; +import { chatService } from "../services/chat.service"; +import { + CreateChatRoomRequestDto, + GetChatRoomListRequestDto, + ChatFilterType +} from "../dtos/chat.dto"; + +export const createOrGetChatRoom = async (req: Request, res: Response): Promise => { + if (!req.user) { + res.fail({ + statusCode: 401, + error: "no user", + message: "로그인이 필요합니다.", + }); + return; + } + + try { + const userId = (req.user as { user_id: number }).user_id; + const { partner_id } = req.body as CreateChatRoomRequestDto; + + const result = await chatService.createOrGetChatRoomService(userId, partner_id); + + res.success(result, "채팅방을 성공적으로 생성/반환했습니다."); + } catch (err: any) { + console.error(err); + res.fail({ + error: err.name || "InternalServerError", + message: err.message || "채팅방 생성/반환 중 오류가 발생했습니다.", + statusCode: err.statusCode || 500, + }); + } +}; + +export const getChatRoomDetail = async (req: Request, res: Response): Promise => { + if (!req.user) { + res.fail({ + statusCode: 401, + error: "no user", + message: "로그인이 필요합니다.", + }); + return; + } + + try{ + const userId = (req.user as { user_id: number }).user_id; + + const roomId = Number(req.params.roomId); + const cursor = req.query.cursor ? Number(req.query.cursor) : undefined; + const limit = req.query.limit ? Number(req.query.limit) : 20; + + if (isNaN(roomId)) { + res.fail({ statusCode: 400, error: "BadRequest", message: "올바른 roomId 필요합니다." }); + return; + } + + const result = await chatService.getChatRoomDetailService(roomId, userId, cursor, limit); + + res.success(result, "채팅방 상세를 성공적으로 조회했습니다."); + } catch (err: any) { + console.error(err); + res.fail({ + error: err.name || "InternalServerError", + message: err.message || "채팅방 상세 조회 중 오류가 발생했습니다.", + statusCode: err.statusCode || 500, + }); + } +} + +// == 채팅방 목록 +export const getChatRoomList = async(req: Request, res: Response) => { + if (!req.user) { + res.fail({ + statusCode: 401, + error: "no user", + message: "로그인이 필요합니다.", + }); + return; + } + + try{ + const userId = (req.user as { user_id: number }).user_id; + const { cursor, limit, filter, search } = req.query as unknown as GetChatRoomListRequestDto; + + if (filter && filter !== "all" && filter !== "unread" && filter !== "pinned") { + res.fail({ statusCode: 400, error: "BadRequest", message: "올바른 filter값이 필요합니다." }); + return; + } + + const result = await chatService.getChatRoomListService( + userId, + cursor ? Number(cursor) : undefined, + limit ? Number(limit) : undefined, + filter, + search as string + ); + res.success(result, "채팅방 목록을 성공적으로 조회했습니다."); + } catch (err: any) { + console.error(err); + res.fail({ + error: err.name || "InternalServerError", + message: err.message || "채팅방 목록 조회 중 오류가 발생했습니다.", + statusCode: err.statusCode || 500, + }); + } +} \ No newline at end of file diff --git a/src/chat/dtos/chat.dto.ts b/src/chat/dtos/chat.dto.ts new file mode 100644 index 0000000..4885561 --- /dev/null +++ b/src/chat/dtos/chat.dto.ts @@ -0,0 +1,210 @@ +export type ChatFilterType = "all" | "unread" | "pinned"; + +// == 채팅방 생성 + +export interface CreateChatRoomRequestDto { + partner_id: number; +} + +export interface ChatRoomResponseDto { + room_id: number; + is_new: boolean; +} + +// == 채팅방 상세 + +export interface ChatRoomDto { + room_id: number; + created_at: string; + is_pinned: boolean; +} + +export interface MyChatInfoDto { + user_id: number; + left_at: string | null; +} + +export interface ChatPartnerDto { + user_id: number; + nickname: string; + profile_image_url: string | null; + role: "USER" | "ADMIN"; +} + +export interface BlockStatusDto { + i_blocked_partner: boolean; + partner_blocked_me: boolean; +} + +export interface ChatAttachmentDto { + attachment_id: number; + url: string; + type: "IMAGE" | "FILE"; + original_name: string; + size: number; + created_at: string; +} + +export interface ChatMessageDto { + message_id: number; + sender_id: number; + content: string; + sent_at: string; + attachments: ChatAttachmentDto[]; +} + +export interface ChatPageDto { + has_more: boolean; + total_count: number; +} + +export interface GetChatDetailRequestDto { + room_id: number; + cursor?: number; + limit?: number; +} + +export class ChatRoomDetailResponseDto { + room!: ChatRoomDto; + my!: MyChatInfoDto; + partner!: ChatPartnerDto; + block_status!: BlockStatusDto; + messages!: ChatMessageDto[]; + page!: ChatPageDto; + + static from(params: { + roomDetail: any; + userId: number; + blockInfo: { iBlockedPartner: boolean; partnerBlockedMe: boolean }; + messages: any[]; + hasMore: boolean; + }): ChatRoomDetailResponseDto { + const { roomDetail, userId, blockInfo, messages, hasMore } = params; + + const isUser1Me = roomDetail.user_id1 === userId; + const meInfo = roomDetail.participants?.find((p: any) => p.user_id === userId); + const partnerUser = isUser1Me ? roomDetail.user2 : roomDetail.user1; + + const dto = new ChatRoomDetailResponseDto(); + + dto.room = { + room_id: roomDetail.room_id, + created_at: roomDetail.created_at.toISOString(), + is_pinned: meInfo?.is_pinned ?? false, + }; + + dto.my = { + user_id: userId, + left_at: meInfo?.left_at ? meInfo.left_at.toISOString() : null, + }; + + dto.partner = { + user_id: partnerUser.user_id, + nickname: partnerUser.nickname, + profile_image_url: partnerUser.profileImage?.url ?? null, + role: partnerUser.role === "ADMIN" ? "ADMIN" : "USER", + }; + + dto.block_status = { + i_blocked_partner: blockInfo.iBlockedPartner, + partner_blocked_me: blockInfo.partnerBlockedMe, + }; + + dto.messages = (messages ?? []).map((msg: any) => ({ + message_id: msg.message_id, + sender_id: msg.sender_id, + content: msg.content ?? "", + sent_at: msg.sent_at.toISOString(), + attachments: (msg.attachments ?? []).map((at: any) => ({ + attachment_id: at.attachment_id, + url: at.url, + type: at.type, // "IMAGE" | "FILE" + original_name: at.name, + size: at.size, + created_at: at.created_at.toISOString(), + })), + })); + + dto.page = { + has_more: hasMore, + total_count: dto.messages.length, + }; + + return dto; + } +} + +// == 채팅방 목록 + +export interface ChatRoomListPartnerDto { + user_id: number; + nickname: string; + profile_image_url: string; +} + +export interface ChatRoomListLastMessageDto { + content: string; + sent_at: string; + has_attachments: boolean; +} + +export interface ChatRoomListItemDto{ + room_id: number; + partner: ChatRoomListPartnerDto; + last_message: ChatRoomListLastMessageDto | null; + unread_count: number; + is_pinned: boolean; +} + +export interface GetChatRoomListRequestDto { + filter?: ChatFilterType; + search?: string; + cursor?: number; + limit?: number; +} + +export class ChatRoomListResponseDto { + rooms!: ChatRoomListItemDto[]; + page!: ChatPageDto; + + static from(params: { + userId: number; + roomsInfo: any[]; + totalRoom: number; + hasMore: boolean; + }): ChatRoomListResponseDto { + const { userId, roomsInfo, totalRoom, hasMore } = params; + const dto = new ChatRoomListResponseDto(); + + dto.rooms = roomsInfo.map((p) => { + const room = p.chatRoom; + + const partnerData = room.participants.find((part: any) => part.user_id !== userId); + + const lastMsg = room.lastMessage ? { + content: room.lastMessage.content, + sent_at: room.lastMessage.sent_at, + has_attachments: room.lastMessage.attachments.length > 0 + } : null; + + return { + room_id: room.room_id, + partner: { + user_id: partnerData?.user.user_id || 0, + nickname: partnerData?.user.nickname || "알 수 없는 사용자", + profile_image_url: partnerData?.user.profileImage || null, + }, + last_message: lastMsg, + unread_count: p.unread_count, + is_pinned: p.is_pinned + }; + }); + + dto.page = { + has_more: hasMore, + total_count: totalRoom + }; + + return dto; + } +} \ No newline at end of file diff --git a/src/chat/repositories/chat.repository.ts b/src/chat/repositories/chat.repository.ts new file mode 100644 index 0000000..3edc147 --- /dev/null +++ b/src/chat/repositories/chat.repository.ts @@ -0,0 +1,189 @@ +import prisma from "../../config/prisma"; +import { Prisma } from "@prisma/client"; +import { ChatFilterType } from "../dtos/chat.dto"; + +export class ChatRepository { + async findChatRoomByParticipants(userId1: number, userId2: number) { + return prisma.chatRoom.findFirst({ + where: { + user_id1: userId1, + user_id2: userId2, + }, + }); + } + + async createChatRoom(userId1: number, userId2: number) { + return prisma.chatRoom.create({ + data: { + user_id1: userId1, + user_id2: userId2, + last_message_id: null, + }, + }); + } + + async findRoomDetailWithParticipant(roomId: number) { + return prisma.chatRoom.findUnique({ + where: { room_id: roomId }, + include: { + user1: true, + user2: true, + participants: true, + }, + }); + } + + async findMessagesByRoomId(roomId: number, cursor?: number, limit: number = 20, userId?: number) { + const leftInfo = await prisma.chatParticipant.findFirst({ + where: { + room_id: roomId, + user_id: userId, + }, + select: { left_at: true } + }); + + const leftAt = leftInfo?.left_at; + const hasCursor = cursor !== undefined && cursor !== null && cursor !== 0; + + const whereConditions: any = { + room_id: roomId, + } + + // 채팅방을 나갔으면 그 이후의 메세지만 조회 + if (leftAt) { + whereConditions.created_at = { gt: leftAt }; + } + + const [messages, totalCount] = await Promise.all([ + // 메시지 목록 조회 + prisma.chatMessage.findMany({ + where: whereConditions, + take: limit + 1, + ...(hasCursor + ? { + skip: 1, + cursor: { message_id: cursor }, + } + : {}), + orderBy: { message_id: "asc" }, + include: { attachments: true }, + }), + // 해당 방의 전체 메시지 개수 조회 + prisma.chatMessage.count({ + where: whereConditions, + }), + ]); + + return { + messages, + totalCount, + }; + } + + async blockStatus(myId: number, partnerId: number) { + const blocks = await prisma.userBlock.findMany({ + where: { + OR: [ + { blocker_id: myId, blocked_id: partnerId }, + { blocker_id: partnerId, blocked_id: myId }, + ], + }, + }); + + return { + iBlockedPartner: blocks.some((b) => b.blocker_id === myId), + partnerBlockedMe: blocks.some((b) => b.blocker_id === partnerId), + }; + } + + // == 채팅방 목록 조회 + async findRoomListByUserId( + userId: number, + options: { + cursor?: number; + limit: number; + filter: ChatFilterType; + search?: string; + } + ) { + const { cursor, limit, filter, search } = options; + + const where: Prisma.ChatParticipantWhereInput = { + user_id: userId, + left_at: null, // 나가지 않은 방 + }; + + if (filter === "pinned") { + where.is_pinned = true; + } + + else if (filter === "unread") { + where.unread_count = { gt: 0 }; + } + + if (search && search.trim().length > 0) { + where.chatRoom = { + participants: { + some: { + user_id: { not: userId }, + user: { + nickname: { + contains: search.trim(), + }, + }, + }, + }, + }; + } + + const hasCursor = cursor !== undefined && cursor !== null && cursor !== 0; + + const [rooms, totalRoom] = await Promise.all([ + prisma.chatParticipant.findMany({ + where, + + include: { + chatRoom: { + include: { + lastMessage: { + include: { + attachments: true, + }, + }, + participants: { + include: { + user: true, + }, + }, + }, + }, + lastReadMessage: true, + }, + + orderBy: { + chatRoom: { + last_message_id: "desc", // 마지막 메세지 최신 순 + }, + }, + + take: limit + 1, + + ...(hasCursor + ? { + skip: 1, + cursor: { + room_id_user_id: { + room_id: cursor, + user_id: userId, + }, + }, + } + : {}), + }), + prisma.chatParticipant.count({ where }) + ]); + return {rooms, totalRoom} + }; +} + + diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts new file mode 100644 index 0000000..81f634c --- /dev/null +++ b/src/chat/routes/chat.route.ts @@ -0,0 +1,352 @@ +import { Router } from "express"; +import { createOrGetChatRoom, getChatRoomDetail, getChatRoomList } from "../controllers/chat.controller"; +import { authenticateJwt } from "../../config/passport"; + +const router = Router(); + +/** + * @swagger + * tags: + * - name: Chat + * description: 채팅 관련 API + */ + +/** + * @swagger + * /api/chat/rooms: + * post: + * summary: 채팅방 생성 또는 반환 + * description: 상대방과의 1:1 채팅방을 생성하거나 이미 존재하는 채팅방을 반환합니다. + * tags: [Chat] + * security: + * - jwt: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - partner_id + * properties: + * partner_id: + * type: integer + * example: 34 + * responses: + * 200: + * description: 채팅방 생성/반환 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 채팅방을 성공적으로 생성/반환했습니다. + * data: + * type: object + * properties: + * room_id: + * type: integer + * example: 35 + * is_new: + * type: boolean + * example: true + * description: 이미 존재하는 채팅방인 경우 false, 새로 생성된 채팅방인 경우 true + * statusCode: + * type: integer + * example: 200 + * 401: + * description: 인증 실패 (토큰 없음/만료/유효하지 않음) + */ + +router.post("/rooms", authenticateJwt, createOrGetChatRoom); + +/** + * @swagger + * /api/chat/rooms/{roomId}: + * get: + * summary: 채팅방 상세 조회 + * description: > + * 채팅방 상세 정보(상대 정보/차단 상태/메시지 목록/페이지 정보)를 조회합니다. + * 메시지는 오래된 순(ASC)으로 반환되며, cursor 기반으로 과거 메시지를 추가로 불러올 수 있습니다. + * tags: [Chat] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: roomId + * required: true + * schema: + * type: integer + * description: 채팅방 ID + * example: 2 + * - in: query + * name: cursor + * required: false + * schema: + * type: integer + * description: 이번 응답에서 가장 오래된 message_id (첫 요청은 생략) + * example: 70 + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * default: 20 + * description: 가져올 메시지 개수 (기본값 20) + * example: 20 + * responses: + * 200: + * description: 채팅방 상세 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 채팅방 상세를 성공적으로 조회했습니다. + * data: + * type: object + * properties: + * room: + * type: object + * properties: + * room_id: + * type: integer + * example: 2 + * created_at: + * type: string + * format: date-time + * example: 2025-08-21T12:26:42.522Z + * is_pinned: + * type: boolean + * example: true + * my: + * type: object + * properties: + * user_id: + * type: integer + * example: 45 + * left_at: + * type: string + * format: date-time + * example: 2026-01-20T10:00:00.000Z + * partner: + * type: object + * properties: + * user_id: + * type: integer + * example: 67 + * nickname: + * type: string + * example: 달팽이 + * profile_image_url: + * type: string + * example: https://...png + * role: + * type: string + * example: USER + * block_status: + * type: object + * properties: + * i_blocked_partner: + * type: boolean + * example: true + * partner_blocked_me: + * type: boolean + * example: false + * messages: + * type: array + * items: + * type: object + * properties: + * message_id: + * type: integer + * example: 56 + * sender_id: + * type: integer + * example: 45 + * content: + * type: string + * example: 혹시 이 사진이랑 파일도 프롬프트에 사용할 수 있나요? + * sent_at: + * type: string + * format: date-time + * example: 2025-08-21T12:26:42.522Z + * attachments: + * type: array + * items: + * type: object + * properties: + * attachment_id: + * type: integer + * example: 23 + * url: + * type: string + * example: https://...png + * type: + * type: string + * enum: [IMAGE, FILE] + * example: IMAGE + * original_name: + * type: string + * example: picture.png + * size: + * type: integer + * example: 27187 + * created_at: + * type: string + * format: date-time + * example: 2025-08-21T12:26:42.522Z + * page: + * type: object + * properties: + * has_more: + * type: boolean + * example: false + * total_count: + * type: integer + * example: 2 + * statusCode: + * type: integer + * example: 200 + * 401: + * description: 인증 실패 (토큰 없음/만료/유효하지 않음) + * 404: + * description: 채팅방을 찾을 수 없음 + */ + + +router.get("/rooms/:roomId", authenticateJwt, getChatRoomDetail); + +/** + * @swagger + * /api/chat/rooms: + * get: + * summary: 채팅방 목록 조회 + * description: > + * 내 채팅방 목록을 조회합니다. + * filter(전체/안읽음/고정), search(상대 닉네임 검색), cursor 기반 페이징을 지원합니다. + * tags: [Chat] + * security: + * - jwt: [] + * parameters: + * - in: query + * name: filter + * required: false + * schema: + * type: string + * enum: [all, unread, pinned] + * default: all + * description: 조회 필터 (기본값 all) + * example: unread + * - in: query + * name: search + * required: false + * schema: + * type: string + * description: 상대방 닉네임 검색 키워드 (기본값 없음) + * example: 달팽이 + * - in: query + * name: cursor + * required: false + * schema: + * type: integer + * description: 마지막으로 조회된 room_id (첫 요청 생략 가능) + * example: 70 + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * default: 20 + * description: 가져올 채팅방 개수 (기본값 20) + * example: 20 + * responses: + * 200: + * description: 채팅방 목록 조회 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 채팅방 목록을 성공적으로 조회했습니다. + * data: + * type: object + * properties: + * rooms: + * type: array + * items: + * type: object + * properties: + * room_id: + * type: integer + * example: 12 + * partner: + * type: object + * properties: + * user_id: + * type: integer + * example: 67 + * nickname: + * type: string + * example: 달팽이 + * profile_image_url: + * type: string + * example: https://...png + * last_message: + * type: object + * nullable: true + * properties: + * content: + * type: string + * nullable: true + * example: 안녕하세요 너무 신기하네요 + * sent_at: + * type: string + * format: date-time + * nullable: true + * example: 2025-10-18T10:15:00Z + * has_attachments: + * type: boolean + * example: false + * attachment_summary: + * type: object + * nullable: true + * properties: + * image_count: + * type: integer + * example: 2 + * file_count: + * type: integer + * example: 0 + * unread_count: + * type: integer + * example: 123 + * is_pinned: + * type: boolean + * example: false + * page: + * type: object + * properties: + * has_more: + * type: boolean + * example: false + * total_count: + * type: integer + * example: 3 + * statusCode: + * type: integer + * example: 200 + * 400: + * description: 잘못된 요청 (유효하지 않은 filter 값) + * 401: + * description: 인증 실패 (토큰 없음/만료/유효하지 않음) + */ + +router.get("/rooms", authenticateJwt, getChatRoomList); +export default router; \ No newline at end of file diff --git a/src/chat/services/chat.service.ts b/src/chat/services/chat.service.ts new file mode 100644 index 0000000..af7a4e6 --- /dev/null +++ b/src/chat/services/chat.service.ts @@ -0,0 +1,100 @@ +import { ChatRepository } from "../repositories/chat.repository"; +import { AppError } from "../../errors/AppError"; +import { + ChatRoomResponseDto, + ChatRoomDetailResponseDto, + ChatRoomListResponseDto, + ChatFilterType, +} from "../dtos/chat.dto"; + +export class ChatService { + constructor(private readonly chatRepo: ChatRepository) {} + + // == 채팅방 생성 + async createOrGetChatRoomService( + userId: number, partnerId: number + ): Promise { + const userId1 = Math.min(userId, partnerId); + const userId2 = Math.max(userId, partnerId); + + const existingRoom = await this.chatRepo.findChatRoomByParticipants(userId1, userId2); + + if (existingRoom) { // 존재하는 채팅방이 있으면 반환 + return { + room_id: existingRoom.room_id, + is_new: false, + }; + } + + // 없으면 새로 생성 + const newRoom = await this.chatRepo.createChatRoom(userId1, userId2); + return { + room_id: newRoom.room_id, + is_new: true, + }; + } + + // == 채팅방 상세 조회 + async getChatRoomDetailService( + roomId: number, userId: number, cursor?: number, limit: number = 20 + ):Promise { + const roomDetail = await this.chatRepo.findRoomDetailWithParticipant(roomId); + if (!roomDetail) { + throw new AppError("채팅방을 찾을 수 없습니다.", 404, "NotFoundError"); + } + + // my, partner 구분 + const isUser1Me = roomDetail.user_id1 === userId; + const me = isUser1Me ? roomDetail.user1 : roomDetail.user2; + const partner = isUser1Me ? roomDetail.user2 : roomDetail.user1; + + const myId = me.user_id; + const partnerId = partner.user_id; + + // 차단 정보 및 메세지 목록 + const [blockInfo, messageInfo] = await Promise.all([ + this.chatRepo.blockStatus(myId, partnerId), + this.chatRepo.findMessagesByRoomId(roomId, cursor, limit, myId), + ]); + + // 페이지네이션 + const hasMore = messageInfo.messages.length > limit; + const messages = hasMore ? messageInfo.messages.slice(0, limit) : messageInfo.messages; + + return ChatRoomDetailResponseDto.from({ + roomDetail, + userId, + blockInfo, + messages, + hasMore, + }); + } + + // == 채팅방 목록 조회 + async getChatRoomListService( + userId: number, cursor?: number, limit: number = 20, filter: ChatFilterType = "all", search?: string + ):Promise { + const roomList = await this.chatRepo.findRoomListByUserId( + userId, { + cursor, + limit, + filter, + search + }); + + + // 페이지네이션 + const hasMore = roomList.rooms.length > limit; + const roomsInfo = hasMore ? roomList.rooms.slice(0, limit) : roomList.rooms; + + return ChatRoomListResponseDto.from({ + userId, + roomsInfo, + totalRoom: roomList.totalRoom, + hasMore, + }); + } +} + +export const chatService = new ChatService(new ChatRepository()); + diff --git a/src/index.ts b/src/index.ts index d276791..9bfcf1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import "dotenv/config"; import express, { ErrorRequestHandler } from "express"; import { responseHandler } from "./middlewares/responseHandler"; import { errorHandler } from "./middlewares/errorHandler"; @@ -31,6 +30,7 @@ import adminMemberRouter from "./members/routes/admin-member.route"; import signupRouter from "./signup/routes/signup.route" import signinRouter from "./signin/routes/signin.route"; import passwordRouter from "./password/routes/password.route"; +import chatRouter from "./chat/routes/chat.route"; import morgan = require('morgan'); const PORT = 3000; const app = express(); @@ -147,6 +147,9 @@ app.use( purchaseRouter ); +// 채팅 라우터 +app.use("/api/chat", chatRouter); + // 프롬프트 다운로드 라우터 app.use("/api/prompts", promptDownloadRouter);