From 5ea7ece27aee134db87893464f6a8ba03d5243af Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 12:29:38 +0900 Subject: [PATCH 01/29] =?UTF-8?q?feat:=20chat=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=9E=AC=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 0 src/chat/dtos/chat.dto.ts | 0 src/chat/repositories/chat.repository.ts | 0 src/chat/routes/chat.route.ts | 0 src/chat/services/chat.service.ts | 0 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/chat/controllers/chat.controller.ts create mode 100644 src/chat/dtos/chat.dto.ts create mode 100644 src/chat/repositories/chat.repository.ts create mode 100644 src/chat/routes/chat.route.ts create mode 100644 src/chat/services/chat.service.ts diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/chat/dtos/chat.dto.ts b/src/chat/dtos/chat.dto.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/chat/repositories/chat.repository.ts b/src/chat/repositories/chat.repository.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/chat/services/chat.service.ts b/src/chat/services/chat.service.ts new file mode 100644 index 0000000..e69de29 From 83b3971f038c4c6a2dab3b60ba7f54eaa0b7fb64 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 12:57:11 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1/=EB=B0=98=ED=99=98=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=84=B0=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/routes/chat.route.ts | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index e69de29..c0f740c 100644 --- a/src/chat/routes/chat.route.ts +++ b/src/chat/routes/chat.route.ts @@ -0,0 +1,59 @@ +import { Router } from "express"; +import { createOrGetChatRoom } from "../controllers/chat.controller"; +import { authenticateJwt } from "../../config/passport"; + +/** + * @swagger + * tags: + * - name: Chat + * description: 채팅 관련 API + */ + +/** + * @swagger + * /api/chat/rooms: + * post: + * summary: 채팅방 생성 또는 반환 + * description: + * 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 + */ +router.post("/api/chat/rooms", authenticateJwt, createOrGetChatRoom); From ce13456ee6b455393dcc1b36f662619637ef8697 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 12:59:42 +0900 Subject: [PATCH 03/29] =?UTF-8?q?add:=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/routes/chat.route.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index c0f740c..b4243ae 100644 --- a/src/chat/routes/chat.route.ts +++ b/src/chat/routes/chat.route.ts @@ -2,6 +2,8 @@ import { Router } from "express"; import { createOrGetChatRoom } from "../controllers/chat.controller"; import { authenticateJwt } from "../../config/passport"; + +const router = Router(); /** * @swagger * tags: From a3a8a3250a43b9a891e8fe9d582d9d56c10e98f7 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 12:59:53 +0900 Subject: [PATCH 04/29] =?UTF-8?q?add:=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/routes/chat.route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index b4243ae..242b64f 100644 --- a/src/chat/routes/chat.route.ts +++ b/src/chat/routes/chat.route.ts @@ -2,7 +2,6 @@ import { Router } from "express"; import { createOrGetChatRoom } from "../controllers/chat.controller"; import { authenticateJwt } from "../../config/passport"; - const router = Router(); /** * @swagger From 37aaa289929b2067e1a85ce9c98f7621370c006c Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 18:10:46 +0900 Subject: [PATCH 05/29] =?UTF-8?q?feat:=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index e69de29..aa216e6 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -0,0 +1,35 @@ +import { Request, Response } from "express"; +import { + createOrGetChatRoomService, +} from "../services/chat.service"; + +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 partnerId = (req.body as { partner_id: number }).partner_id; + + const result = await createOrGetChatRoomService(userId, partnerId); + 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 From 69148862e54ffdcd37289e408206a43ab0c88fcf Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 18:22:29 +0900 Subject: [PATCH 06/29] =?UTF-8?q?fix:=20controller=EC=97=90=20dto=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index aa216e6..addc93c 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -3,6 +3,8 @@ import { createOrGetChatRoomService, } from "../services/chat.service"; +import { CreateChatRoomRequestDto } from "../dtos/chat.dto"; + export const createOrGetChatRoom = async ( req: Request, res: Response ):Promise => { @@ -17,8 +19,7 @@ export const createOrGetChatRoom = async ( try { const userId = (req.user as { user_id: number }).user_id; - const partnerId = (req.body as { partner_id: number }).partner_id; - + const { partnerId } = req.body as CreateChatRoomRequestDto; const result = await createOrGetChatRoomService(userId, partnerId); res.success( {...result}, From 1b4cb15d874337fa2fbb5ba996cfed98c79b7b04 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 18:24:17 +0900 Subject: [PATCH 07/29] =?UTF-8?q?dto=20=EB=B3=80=EC=88=98=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 2 +- src/chat/dtos/chat.dto.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index addc93c..b8e8776 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -19,7 +19,7 @@ export const createOrGetChatRoom = async ( try { const userId = (req.user as { user_id: number }).user_id; - const { partnerId } = req.body as CreateChatRoomRequestDto; + const { partner_id } = req.body as CreateChatRoomRequestDto; const result = await createOrGetChatRoomService(userId, partnerId); res.success( {...result}, diff --git a/src/chat/dtos/chat.dto.ts b/src/chat/dtos/chat.dto.ts index e69de29..e16600e 100644 --- a/src/chat/dtos/chat.dto.ts +++ b/src/chat/dtos/chat.dto.ts @@ -0,0 +1,8 @@ +export interface CreateChatRoomRequestDto { + partner_id: number; +} + +export interface ChatRoomResponseDto { + room_id: number; + is_new: boolean; +} \ No newline at end of file From 1ad8e88a7283fd92c8d261536e733cad0247a640 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 18:25:50 +0900 Subject: [PATCH 08/29] =?UTF-8?q?controller=EC=97=90=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index b8e8776..56fc895 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -3,7 +3,10 @@ import { createOrGetChatRoomService, } from "../services/chat.service"; -import { CreateChatRoomRequestDto } from "../dtos/chat.dto"; +import { + CreateChatRoomRequestDto, + ChatRoomResponseDto +} from "../dtos/chat.dto"; export const createOrGetChatRoom = async ( req: Request, res: Response @@ -20,7 +23,7 @@ export const createOrGetChatRoom = async ( try { const userId = (req.user as { user_id: number }).user_id; const { partner_id } = req.body as CreateChatRoomRequestDto; - const result = await createOrGetChatRoomService(userId, partnerId); + const result: ChatRoomResponseDto = await createOrGetChatRoomService(userId, partnerId); res.success( {...result}, "채팅방을 성공적으로 생성/반환했습니다.", From ecc3ab654b8dac80d2f931b915000ff23d902ca8 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 18:40:40 +0900 Subject: [PATCH 09/29] =?UTF-8?q?feat:=20repository=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/repositories/chat.repository.ts | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/chat/repositories/chat.repository.ts b/src/chat/repositories/chat.repository.ts index e69de29..d90a0ab 100644 --- a/src/chat/repositories/chat.repository.ts +++ b/src/chat/repositories/chat.repository.ts @@ -0,0 +1,28 @@ +import { PrismaClient } from "@prisma/client"; +const prisma = new PrismaClient(); + +export const findChatRoomByParticipants = async ( + user_id1: number, user_id2: number +) => { + const existChatRoom = await prisma.chatRoom.findFirst({ + where: { + user_id1 : user_id1, + user_id2: user_id2 + }, + }); + return existChatRoom; +} + +export const createChatRoom = async ( + user_id1: number, user_id2: number +) => { + const newChatRoom = await prisma.chatRoom.create({ + data: { + user_id1 : user_id1, + user_id2: user_id2, + last_message_id: null, + }, + }); + return newChatRoom; +} + From aa237500ec3949927d4f09e2c0595f5be979399e Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 18:56:03 +0900 Subject: [PATCH 10/29] =?UTF-8?q?feat:=20controller=20=EB=B3=80=EC=88=98?= =?UTF-8?q?=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index 56fc895..9214f0b 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -23,7 +23,7 @@ export const createOrGetChatRoom = async ( try { const userId = (req.user as { user_id: number }).user_id; const { partner_id } = req.body as CreateChatRoomRequestDto; - const result: ChatRoomResponseDto = await createOrGetChatRoomService(userId, partnerId); + const result: ChatRoomResponseDto = await createOrGetChatRoomService(userId, partner_id); res.success( {...result}, "채팅방을 성공적으로 생성/반환했습니다.", From 3a8693eeb60ebe6127a596bccefb4be7b46a3e6a Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 18:56:21 +0900 Subject: [PATCH 11/29] =?UTF-8?q?feat:=20service=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/services/chat.service.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/chat/services/chat.service.ts b/src/chat/services/chat.service.ts index e69de29..b31d1ce 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -0,0 +1,31 @@ +import { + findChatRoomByParticipants, + createChatRoom, +} from "../repositories/chat.repository"; + +import { + ChatRoomResponseDto +} from "../dtos/chat.dto"; + +export const createOrGetChatRoomService = async ( + userId: number, partnerId: number +): Promise => { + const userId1 = Math.min(userId, partnerId); + const userId2 = Math.max(userId, partnerId); + + const existingRoom = await findChatRoomByParticipants(userId1, userId2); + + if (existingRoom) { // 존재하는 채팅방이 있으면 반환 + return { + room_id: existingRoom.room_id, + is_new: false, + }; + } + + // 없으면 새로 생성 + const newRoom = await createChatRoom(userId1, userId2); + return { + room_id: newRoom.room_id, + is_new: true, + }; +} \ No newline at end of file From 2f57957c829cf60daa766ca66e40993be4dafa42 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 22:00:07 +0900 Subject: [PATCH 12/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=84=B0=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index d276791..6ae8714 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,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 +148,9 @@ app.use( purchaseRouter ); +// 채팅 라우터 +app.use("/api/chat", chatRouter); + // 프롬프트 다운로드 라우터 app.use("/api/prompts", promptDownloadRouter); From b044064ceaf23d639bb54a47a13149ca853ad76c Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 22:01:17 +0900 Subject: [PATCH 13/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20swagger=20=EB=AC=B8=EB=B2=95=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/routes/chat.route.ts | 57 ++--------------------------------- 1 file changed, 3 insertions(+), 54 deletions(-) diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index 242b64f..5b239c6 100644 --- a/src/chat/routes/chat.route.ts +++ b/src/chat/routes/chat.route.ts @@ -3,58 +3,7 @@ import { createOrGetChatRoom } 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: - * 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 - */ -router.post("/api/chat/rooms", authenticateJwt, createOrGetChatRoom); +router.post("/api/chat/rooms", createOrGetChatRoom); + +export default router; \ No newline at end of file From ecfdd16f0a7814d4bac08f589416f79c0261127d Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 22:04:29 +0900 Subject: [PATCH 14/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20swagger=20=EB=AC=B8=EB=B2=95=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/routes/chat.route.ts | 56 ++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index 5b239c6..6f517f6 100644 --- a/src/chat/routes/chat.route.ts +++ b/src/chat/routes/chat.route.ts @@ -4,6 +4,60 @@ import { authenticateJwt } from "../../config/passport"; const router = Router(); -router.post("/api/chat/rooms", createOrGetChatRoom); +/** + * @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 + */ + +router.post("/rooms", authenticateJwt, createOrGetChatRoom); export default router; \ No newline at end of file From 3aa7e4cb7435b2484a0fbaf03d5922e9dd13fd5a Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 22:26:44 +0900 Subject: [PATCH 15/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20dto=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/dtos/chat.dto.ts | 61 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/chat/dtos/chat.dto.ts b/src/chat/dtos/chat.dto.ts index e16600e..5f65cdd 100644 --- a/src/chat/dtos/chat.dto.ts +++ b/src/chat/dtos/chat.dto.ts @@ -5,4 +5,65 @@ export interface CreateChatRoomRequestDto { export interface ChatRoomResponseDto { room_id: number; is_new: boolean; +} + +export interface GetChatDetailRequestDto { + room_id: number; + cursor?: number; + limit?: number; +} + +export interface ChatRoomDetailResponseDto { + room: ChatRoomDto; + my: MyChatInfoDto; + parter: ChatPartnerDto; + block_status: BlockStatusDto; + messages: ChatMessageDto[]; + page: ChatPageDto; +} + +export interface ChatRoomDto { + room_id: number; + created_at: string; + is_pinned: boolean; +} + +export interface MyChatInfoDto { + user_id: number; + last_read_message_id: number | null; + 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 ChatMessageDto { + message_id: number; + sender_id: number; + content: string; + sent_at: string; + attachments: ChatAttachmentDto[]; +} + +export interface ChatAttachmentDto { + attachment_id: number; + url: string; + type: "IMAGE" | "FILE"; + original_name: string; + size: number; + created_at: string; +} + +export interface ChatPageDto { + has_more: boolean; + total_message: number; } \ No newline at end of file From 5875e02e27b37e08b8dc5dccb06fe150876aa2bf Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Mon, 19 Jan 2026 23:21:36 +0900 Subject: [PATCH 16/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20repository=EB=A5=BC=20class=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/repositories/chat.repository.ts | 47 +++++++++++++----------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/chat/repositories/chat.repository.ts b/src/chat/repositories/chat.repository.ts index d90a0ab..4b8e7cc 100644 --- a/src/chat/repositories/chat.repository.ts +++ b/src/chat/repositories/chat.repository.ts @@ -1,28 +1,31 @@ +// repositories/chat.repository.ts import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient(); -export const findChatRoomByParticipants = async ( - user_id1: number, user_id2: number -) => { - const existChatRoom = await prisma.chatRoom.findFirst({ - where: { - user_id1 : user_id1, - user_id2: user_id2 - }, +export class ChatRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findChatRoomByParticipants( + user_id1: number, + user_id2: number + ) { + return this.prisma.chatRoom.findFirst({ + where: { + user_id1, + user_id2, + }, }); - return existChatRoom; -} + } -export const createChatRoom = async ( - user_id1: number, user_id2: number -) => { - const newChatRoom = await prisma.chatRoom.create({ - data: { - user_id1 : user_id1, - user_id2: user_id2, - last_message_id: null, - }, + async createChatRoom( + user_id1: number, + user_id2: number + ) { + return this.prisma.chatRoom.create({ + data: { + user_id1, + user_id2, + last_message_id: null, + }, }); - return newChatRoom; + } } - From cffb09c06eba137937bf99cc5bec501ed2e4c586 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Tue, 20 Jan 2026 00:34:52 +0900 Subject: [PATCH 17/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20repository=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/repositories/chat.repository.ts | 68 ++++++++++++++++++++---- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/src/chat/repositories/chat.repository.ts b/src/chat/repositories/chat.repository.ts index 4b8e7cc..e1ad772 100644 --- a/src/chat/repositories/chat.repository.ts +++ b/src/chat/repositories/chat.repository.ts @@ -1,31 +1,81 @@ -// repositories/chat.repository.ts import { PrismaClient } from "@prisma/client"; export class ChatRepository { constructor(private readonly prisma: PrismaClient) {} async findChatRoomByParticipants( - user_id1: number, - user_id2: number + userId1: number, + userId2: number ) { return this.prisma.chatRoom.findFirst({ where: { - user_id1, - user_id2, + user_id1: userId1, + user_id2: userId2, }, }); } async createChatRoom( - user_id1: number, - user_id2: number + userId1: number, + userId2: number ) { return this.prisma.chatRoom.create({ data: { - user_id1, - user_id2, + user_id1: userId1, + user_id2: userId2, last_message_id: null, }, }); } + + // 채팅방 기본 정보 및 참여자 정보 조회 + async findRoomDetailWithParticipant(roomId: number) { + return this.prisma.chatRoom.findUnique({ + where: { room_id: roomId }, + include: { + user1: true, + user2: true, + participants: true, + }, + }); + } + + // 메시지 및 첨부파일 조회 (Paging 처리) + async findMessagesByRoomId(room_id: number, cursor?: number, limit: number = 20) { + return this.prisma.chatMessage.findMany({ + where: { + room_id: room_id, + }, + take: limit + 1, + + ...(cursor && { + skip: 1, + cursor: { message_id : cursor }, + }), + + orderBy: { + message_id: 'asc' + }, + include: { + attachments: true, + }, + }); + } + + async blockStatus(myId: number, partnerId: number) { + const blocks = await this.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), + }; + }; } + From 170947c22ec2351069701387da47a5a80c0f9224 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Tue, 20 Jan 2026 13:15:19 +0900 Subject: [PATCH 18/29] =?UTF-8?q?fix:=20=EA=B8=B0=EC=A1=B4=20service?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=EC=9D=84=20class=20=ED=98=95=ED=83=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/services/chat.service.ts | 42 ++++++++++++++++--------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/chat/services/chat.service.ts b/src/chat/services/chat.service.ts index b31d1ce..99a02e5 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -1,31 +1,33 @@ -import { - findChatRoomByParticipants, - createChatRoom, -} from "../repositories/chat.repository"; +import { Service } from "typedi"; +import { ChatRepository } from "../repositories/chat.repository"; import { ChatRoomResponseDto } from "../dtos/chat.dto"; -export const createOrGetChatRoomService = async ( +@Service() +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); + ): 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 existingRoom = await findChatRoomByParticipants(userId1, userId2); - - if (existingRoom) { // 존재하는 채팅방이 있으면 반환 + // 없으면 새로 생성 + const newRoom = await this.chatRepo.createChatRoom(userId1, userId2); return { - room_id: existingRoom.room_id, - is_new: false, + room_id: newRoom.room_id, + is_new: true, }; } - - // 없으면 새로 생성 - const newRoom = await createChatRoom(userId1, userId2); - return { - room_id: newRoom.room_id, - is_new: true, - }; } \ No newline at end of file From 7032b9d9aff45a455fad171533328bbed25fd6ce Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Tue, 20 Jan 2026 20:23:06 +0900 Subject: [PATCH 19/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C,=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/services/chat.service.ts | 47 ++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src/chat/services/chat.service.ts b/src/chat/services/chat.service.ts index 99a02e5..9569afe 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -1,13 +1,13 @@ -import { Service } from "typedi"; import { ChatRepository } from "../repositories/chat.repository"; import { - ChatRoomResponseDto + ChatRoomResponseDto, + ChatRoomDetailResponseDto, } from "../dtos/chat.dto"; -@Service() export class ChatService { constructor(private readonly chatRepo: ChatRepository) {} + async createOrGetChatRoomService( userId: number, partnerId: number ): Promise { @@ -30,4 +30,43 @@ export class ChatService { is_new: true, }; } -} \ No newline at end of file + + // 채팅방 상세 조회 + async getChatRoomDetailService( + roomId: number, userId: number, cursor: number = 0, limit: number = 20 + ):Promise { + const roomDetail = await this.chatRepo.findRoomDetailWithParticipant(roomId); + if (!roomDetail) { + throw new Error("채팅방을 찾을 수 없습니다."); + } + + // 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) + ]); + + // 페이지네이션 + const hasMore = messageInfo.length > limit; + const messages = hasMore ? messageInfo.slice(0, limit) : messageInfo; + + return ChatRoomDetailResponseDto.from({ + roomDetail, + userId, + blockInfo, + messages, + hasMore, + }); + } +} + +export const chatService = new ChatService(new ChatRepository()); + From 4eb691db2b15c5297fc1a4ad0bbe27120ff1c1e8 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Wed, 21 Jan 2026 23:22:29 +0900 Subject: [PATCH 20/29] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=EC=9E=90=20=EC=8A=A4=ED=82=A4=EB=A7=88=EC=97=90=20unr?= =?UTF-8?q?ead=5Fcount=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 2 ++ .../migration.sql | 9 +++++++++ 2 files changed, 11 insertions(+) create mode 100644 prisma/migrations/20260121113801_add_unread_count_to_chat_participant/migration.sql create mode 100644 prisma/migrations/20260121114120_change_camelcase_to_snake_case_at_chat_participant/migration.sql 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; From 534c7f4fe6720e8498f41fce6fa16634eac843ac Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Wed, 21 Jan 2026 23:23:07 +0900 Subject: [PATCH 21/29] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=EC=9E=90=20=EC=8A=A4=ED=82=A4=EB=A7=88=EC=97=90=20unr?= =?UTF-8?q?ead=5Fcount=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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]) } From 91b73e4dffeadf6ccb208dafc2a885bfe899cb42 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Wed, 21 Jan 2026 23:23:48 +0900 Subject: [PATCH 22/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C,?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=201=EC=B0=A8=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 22 ++ src/chat/controllers/chat.controller.ts | 115 ++++++--- src/chat/dtos/chat.dto.ts | 200 ++++++++++++++-- src/chat/repositories/chat.repository.ts | 200 +++++++++++----- src/chat/routes/chat.route.ts | 284 ++++++++++++++++++++++- src/chat/services/chat.service.ts | 40 +++- 7 files changed, 749 insertions(+), 113 deletions(-) diff --git a/package.json b/package.json index c5f3f7d..b1b4912 100644 --- a/package.json +++ b/package.json @@ -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/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index 9214f0b..2261df7 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -1,16 +1,12 @@ import { Request, Response } from "express"; -import { - createOrGetChatRoomService, -} from "../services/chat.service"; - +import { chatService } from "../services/chat.service"; import { - CreateChatRoomRequestDto, - ChatRoomResponseDto + CreateChatRoomRequestDto, + GetChatRoomListRequestDto, + ChatFilterType } from "../dtos/chat.dto"; -export const createOrGetChatRoom = async ( - req: Request, res: Response -):Promise => { +export const createOrGetChatRoom = async (req: Request, res: Response): Promise => { if (!req.user) { res.fail({ statusCode: 401, @@ -19,21 +15,88 @@ export const createOrGetChatRoom = async ( }); return; } - - try { - const userId = (req.user as { user_id: number }).user_id; - const { partner_id } = req.body as CreateChatRoomRequestDto; - const result: ChatRoomResponseDto = await 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, - }); + + 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.query.room_id); + 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: "올바른 room_id가 필요합니다." }); + return; } -}; \ No newline at end of file + + const result = await chatService.getChatRoomDetailService(roomId, userId, cursor, limit); + + res.success(result, result.message); + } 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; + + const result = await chatService.getChatRoomListService( + userId, + cursor ? Number(cursor) : undefined, + limit ? Number(limit) : undefined, + filter, + search as string + ); + res.success(result, result.message); + } 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 index 5f65cdd..5c6731f 100644 --- a/src/chat/dtos/chat.dto.ts +++ b/src/chat/dtos/chat.dto.ts @@ -1,3 +1,7 @@ +export type ChatFilterType = "all" | "unread" | "pinned"; + +// == 채팅방 생성 + export interface CreateChatRoomRequestDto { partner_id: number; } @@ -7,20 +11,7 @@ export interface ChatRoomResponseDto { is_new: boolean; } -export interface GetChatDetailRequestDto { - room_id: number; - cursor?: number; - limit?: number; -} - -export interface ChatRoomDetailResponseDto { - room: ChatRoomDto; - my: MyChatInfoDto; - parter: ChatPartnerDto; - block_status: BlockStatusDto; - messages: ChatMessageDto[]; - page: ChatPageDto; -} +// == 채팅방 상세 export interface ChatRoomDto { room_id: number; @@ -30,7 +21,6 @@ export interface ChatRoomDto { export interface MyChatInfoDto { user_id: number; - last_read_message_id: number | null; left_at: string | null; } @@ -46,24 +36,184 @@ export interface BlockStatusDto { partner_blocked_me: boolean; } -export interface ChatMessageDto { - message_id: number; - sender_id: number; - content: string; - sent_at: string; - attachments: ChatAttachmentDto[]; -} - export interface ChatAttachmentDto { attachment_id: number; url: string; - type: "IMAGE" | "FILE"; + 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_message: number; + total_count: number; +} + +export interface GetChatDetailRequestDto { + room_id: number; + cursor?: number; + limit?: number; +} + +export class ChatRoomDetailResponseDto { + message!: string; + 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.message = "채팅방 상세를 성공적으로 조회했습니다."; + + 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 { + message!: string; + 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.message = "채팅방 목록을 성공적으로 조회했습니다."; + + dto.rooms = roomsInfo.map((p) => { + const room = p.chatRoom; + + // 1. partners 배열에서 '나'가 아닌 첫 번째 상대방 '하나'만 찾습니다. (find 사용) + const partnerData = room.participants.find((part: any) => part.user_id !== userId); + + // 2. 마지막 메시지 가공 (배열이 아닌 단일 객체 혹은 null) + 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: partners (X) -> 아래처럼 객체로 바로 넣어줍니다. + partner: { + user_id: partnerData?.user.user_id || 0, + nickname: partnerData?.user.nickname || "알 수 없는 사용자", + profile_image_url: partnerData?.user.userstatus || null, + }, + last_message: lastMsg, + unread_count: p.unreadCount, + 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 index e1ad772..f0407f8 100644 --- a/src/chat/repositories/chat.repository.ts +++ b/src/chat/repositories/chat.repository.ts @@ -1,13 +1,10 @@ -import { PrismaClient } from "@prisma/client"; +import prisma from "../../config/prisma"; +import { Prisma } from "@prisma/client"; +import { ChatFilterType } from "../dtos/chat.dto"; export class ChatRepository { - constructor(private readonly prisma: PrismaClient) {} - - async findChatRoomByParticipants( - userId1: number, - userId2: number - ) { - return this.prisma.chatRoom.findFirst({ + async findChatRoomByParticipants(userId1: number, userId2: number) { + return prisma.chatRoom.findFirst({ where: { user_id1: userId1, user_id2: userId2, @@ -15,11 +12,8 @@ export class ChatRepository { }); } - async createChatRoom( - userId1: number, - userId2: number - ) { - return this.prisma.chatRoom.create({ + async createChatRoom(userId1: number, userId2: number) { + return prisma.chatRoom.create({ data: { user_id1: userId1, user_id2: userId2, @@ -28,54 +22,150 @@ export class ChatRepository { }); } - // 채팅방 기본 정보 및 참여자 정보 조회 - async findRoomDetailWithParticipant(roomId: number) { - return this.prisma.chatRoom.findUnique({ + async findRoomDetailWithParticipant(roomId: number) { + return prisma.chatRoom.findUnique({ where: { room_id: roomId }, include: { - user1: true, + user1: true, user2: true, - participants: true, + participants: true, }, }); } - // 메시지 및 첨부파일 조회 (Paging 처리) - async findMessagesByRoomId(room_id: number, cursor?: number, limit: number = 20) { - return this.prisma.chatMessage.findMany({ - where: { - room_id: room_id, - }, - take: limit + 1, - - ...(cursor && { - skip: 1, - cursor: { message_id : cursor }, - }), - - orderBy: { - message_id: 'asc' - }, - include: { - attachments: true, - }, - }); - } - - async blockStatus(myId: number, partnerId: number) { - const blocks = await this.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 findMessagesByRoomId(roomId: number, cursor?: number, limit: number = 20) { + const hasCursor = cursor !== undefined && cursor !== null && cursor !== 0; + + const [messages, totalCount] = await Promise.all([ + // 메시지 목록 조회 + prisma.chatMessage.findMany({ + where: { room_id: roomId }, + take: limit + 1, + ...(hasCursor + ? { + skip: 1, + cursor: { message_id: cursor }, + } + : {}), + orderBy: { message_id: "asc" }, + include: { attachments: true }, + }), + // 해당 방의 전체 메시지 개수 조회 + prisma.chatMessage.count({ + where: { room_id: roomId }, + }), + ]); + + 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 index 6f517f6..844b3e7 100644 --- a/src/chat/routes/chat.route.ts +++ b/src/chat/routes/chat.route.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { createOrGetChatRoom } from "../controllers/chat.controller"; +import { createOrGetChatRoom, getChatRoomDetail, getChatRoomList } from "../controllers/chat.controller"; import { authenticateJwt } from "../../config/passport"; const router = Router(); @@ -56,8 +56,290 @@ const router = Router(); * statusCode: * type: integer * example: 200 + * 401: + * description: 인증 실패 (토큰 없음/만료/유효하지 않음) */ router.post("/rooms", authenticateJwt, createOrGetChatRoom); +/** + * @swagger + * /api/chat/rooms/{room-id}: + * get: + * summary: 채팅방 상세 조회 + * description: > + * 채팅방 상세 정보(상대 정보/차단 상태/메시지 목록/페이지 정보)를 조회합니다. + * 메시지는 오래된 순(ASC)으로 반환되며, cursor 기반으로 과거 메시지를 추가로 불러올 수 있습니다. + * tags: [Chat] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: room_id + * 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: 채팅방 상세를 성공적으로 조회했습니다. + * 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 + * nullable: true + * 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 + * nullable: true + * 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 + * description: 오래된 순(ASC) + * items: + * type: object + * properties: + * message_id: + * type: integer + * example: 56 + * sender_id: + * type: integer + * example: 45 + * content: + * type: string + * nullable: true + * 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 + * 401: + * description: 인증 실패 (토큰 없음/만료/유효하지 않음) + * 404: + * description: 채팅방을 찾을 수 없음 + */ + +router.get("/rooms/:room-id", 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: 채팅방 목록을 성공적으로 조회했습니다. + * 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 + * nullable: true + * 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: 0 + * is_pinned: + * type: boolean + * example: true + * page: + * type: object + * properties: + * has_more: + * type: boolean + * example: false + * total_count: + * type: integer + * example: 3 + * statusCode: + * type: integer + * example: 200 + * 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 index 9569afe..b33ec63 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -1,13 +1,16 @@ 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 { @@ -31,13 +34,13 @@ export class ChatService { }; } - // 채팅방 상세 조회 + // == 채팅방 상세 조회 async getChatRoomDetailService( - roomId: number, userId: number, cursor: number = 0, limit: number = 20 + roomId: number, userId: number, cursor?: number, limit: number = 20 ):Promise { const roomDetail = await this.chatRepo.findRoomDetailWithParticipant(roomId); if (!roomDetail) { - throw new Error("채팅방을 찾을 수 없습니다."); + throw new AppError("채팅방을 찾을 수 없습니다.", 404, "NotFoundError"); } // my, partner 구분 @@ -55,8 +58,8 @@ export class ChatService { ]); // 페이지네이션 - const hasMore = messageInfo.length > limit; - const messages = hasMore ? messageInfo.slice(0, limit) : messageInfo; + const hasMore = messageInfo.messages.length > limit; + const messages = hasMore ? messageInfo.messages.slice(0, limit) : messageInfo.messages; return ChatRoomDetailResponseDto.from({ roomDetail, @@ -66,6 +69,31 @@ export class ChatService { 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()); From 3381aab58400712af5a68021fb82a969af9aed00 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Thu, 22 Jan 2026 12:30:24 +0900 Subject: [PATCH 23/29] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EC=8B=9C=20.env.dev=EB=A5=BC=20=EB=B0=94=EB=9D=BC?= =?UTF-8?q?=EB=B3=B4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index b1b4912..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", diff --git a/src/index.ts b/src/index.ts index 6ae8714..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"; From 10c464e5d0953ae1dd5c7f0f64cab816d4e95462 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Thu, 22 Jan 2026 13:07:32 +0900 Subject: [PATCH 24/29] =?UTF-8?q?fix:=20swagger=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/routes/chat.route.ts | 329 +++++++++++++++++----------------- 1 file changed, 169 insertions(+), 160 deletions(-) diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index 844b3e7..4c1f4fa 100644 --- a/src/chat/routes/chat.route.ts +++ b/src/chat/routes/chat.route.ts @@ -64,7 +64,7 @@ router.post("/rooms", authenticateJwt, createOrGetChatRoom); /** * @swagger - * /api/chat/rooms/{room-id}: + * /api/chat/rooms/{roomId}: * get: * summary: 채팅방 상세 조회 * description: > @@ -75,7 +75,7 @@ router.post("/rooms", authenticateJwt, createOrGetChatRoom); * - jwt: [] * parameters: * - in: path - * name: room_id + * name: roomId * required: true * schema: * type: integer @@ -107,116 +107,120 @@ router.post("/rooms", authenticateJwt, createOrGetChatRoom); * message: * type: string * example: 채팅방 상세를 성공적으로 조회했습니다. - * 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 - * nullable: true - * 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 - * nullable: true - * 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 - * description: 오래된 순(ASC) - * items: - * type: object - * properties: - * message_id: - * type: integer - * example: 56 - * sender_id: - * type: integer - * example: 45 - * content: - * type: string - * nullable: true - * 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: + * data: * type: object * properties: - * has_more: - * type: boolean - * example: false - * total_count: - * type: integer - * example: 2 + * 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 + * nullable: true + * 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 + * nullable: true + * 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 + * description: 오래된 순(ASC) + * items: + * type: object + * properties: + * message_id: + * type: integer + * example: 56 + * sender_id: + * type: integer + * example: 45 + * content: + * type: string + * nullable: true + * 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 * 401: * description: 인증 실패 (토큰 없음/만료/유효하지 않음) * 404: * description: 채팅방을 찾을 수 없음 */ -router.get("/rooms/:room-id", authenticateJwt, getChatRoomDetail); + +router.get("/rooms/:roomId", authenticateJwt, getChatRoomDetail); /** * @swagger @@ -272,73 +276,78 @@ router.get("/rooms/:room-id", authenticateJwt, getChatRoomDetail); * message: * type: string * example: 채팅방 목록을 성공적으로 조회했습니다. - * rooms: - * type: array - * items: - * type: object - * properties: - * room_id: - * type: integer - * example: 12 - * partner: + * data: + * type: object + * properties: + * rooms: + * type: array + * items: * type: object * properties: - * user_id: + * room_id: * type: integer - * example: 67 - * nickname: - * type: string - * example: 달팽이 - * profile_image_url: - * type: string - * nullable: true - * 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: + * example: 12 + * partner: * type: object - * nullable: true * properties: - * image_count: + * user_id: * type: integer - * example: 2 - * file_count: - * type: integer - * example: 0 - * unread_count: - * type: integer - * example: 0 - * is_pinned: - * type: boolean - * example: true - * page: - * type: object - * properties: - * has_more: - * type: boolean - * example: false - * total_count: - * type: integer - * example: 3 + * example: 67 + * nickname: + * type: string + * example: 달팽이 + * profile_image_url: + * type: string + * nullable: true + * 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: 0 + * is_pinned: + * type: boolean + * example: true + * page: + * type: object + * properties: + * has_more: + * type: boolean + * example: false + * total_count: + * type: integer + * example: 3 * statusCode: * type: integer * example: 200 * 401: * description: 인증 실패 (토큰 없음/만료/유효하지 않음) + * 400: + * description: 잘못된 요청 (유효하지 않은 filter 값) */ router.get("/rooms", authenticateJwt, getChatRoomList); From bf492439b66b9432fcc904c6deafa09e5e8e9bc2 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Thu, 22 Jan 2026 13:08:22 +0900 Subject: [PATCH 25/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C,?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=202=EC=B0=A8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 13 +++++++++---- src/chat/dtos/chat.dto.ts | 6 ------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index 2261df7..f6718d1 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -46,18 +46,18 @@ export const getChatRoomDetail = async (req: Request, res: Response): Promise { 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, @@ -90,7 +95,7 @@ export const getChatRoomList = async(req: Request, res: Response) => { filter, search as string ); - res.success(result, result.message); + res.success(result, "채팅방 목록을 성공적으로 조회했습니다."); } catch (err: any) { console.error(err); res.fail({ diff --git a/src/chat/dtos/chat.dto.ts b/src/chat/dtos/chat.dto.ts index 5c6731f..1a2d467 100644 --- a/src/chat/dtos/chat.dto.ts +++ b/src/chat/dtos/chat.dto.ts @@ -65,7 +65,6 @@ export interface GetChatDetailRequestDto { } export class ChatRoomDetailResponseDto { - message!: string; room!: ChatRoomDto; my!: MyChatInfoDto; partner!: ChatPartnerDto; @@ -88,8 +87,6 @@ export class ChatRoomDetailResponseDto { const dto = new ChatRoomDetailResponseDto(); - dto.message = "채팅방 상세를 성공적으로 조회했습니다."; - dto.room = { room_id: roomDetail.room_id, created_at: roomDetail.created_at.toISOString(), @@ -167,7 +164,6 @@ export interface GetChatRoomListRequestDto { } export class ChatRoomListResponseDto { - message!: string; rooms!: ChatRoomListItemDto[]; page!: ChatPageDto; @@ -180,8 +176,6 @@ export class ChatRoomListResponseDto { const { userId, roomsInfo, totalRoom, hasMore } = params; const dto = new ChatRoomListResponseDto(); - dto.message = "채팅방 목록을 성공적으로 조회했습니다."; - dto.rooms = roomsInfo.map((p) => { const room = p.chatRoom; From bed3ff780790af37476c1c04002aa731b0454501 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Fri, 23 Jan 2026 03:55:52 +0900 Subject: [PATCH 26/29] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=82=98=EA=B0=84=20=EC=8B=9C=EA=B0=84=20=EC=9D=B4=ED=9B=84?= =?UTF-8?q?=EC=9D=98=20=EB=A9=94=EC=84=B8=EC=A7=80=EB=93=A4=EB=A7=8C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/repositories/chat.repository.ts | 24 +++++++++++++++++++++--- src/chat/services/chat.service.ts | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/chat/repositories/chat.repository.ts b/src/chat/repositories/chat.repository.ts index f0407f8..3edc147 100644 --- a/src/chat/repositories/chat.repository.ts +++ b/src/chat/repositories/chat.repository.ts @@ -33,13 +33,31 @@ export class ChatRepository { }); } - async findMessagesByRoomId(roomId: number, cursor?: number, limit: number = 20) { + 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: { room_id: roomId }, + where: whereConditions, take: limit + 1, ...(hasCursor ? { @@ -52,7 +70,7 @@ export class ChatRepository { }), // 해당 방의 전체 메시지 개수 조회 prisma.chatMessage.count({ - where: { room_id: roomId }, + where: whereConditions, }), ]); diff --git a/src/chat/services/chat.service.ts b/src/chat/services/chat.service.ts index b33ec63..af7a4e6 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -54,7 +54,7 @@ export class ChatService { // 차단 정보 및 메세지 목록 const [blockInfo, messageInfo] = await Promise.all([ this.chatRepo.blockStatus(myId, partnerId), - this.chatRepo.findMessagesByRoomId(roomId, cursor, limit) + this.chatRepo.findMessagesByRoomId(roomId, cursor, limit, myId), ]); // 페이지네이션 From 47a92732782e633565f71dc3a6b508a321d4c581 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Fri, 23 Jan 2026 04:20:07 +0900 Subject: [PATCH 27/29] =?UTF-8?q?fix:=20swagger=EC=97=90=20statusCode=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/routes/chat.route.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index 4c1f4fa..81f634c 100644 --- a/src/chat/routes/chat.route.ts +++ b/src/chat/routes/chat.route.ts @@ -132,7 +132,6 @@ router.post("/rooms", authenticateJwt, createOrGetChatRoom); * left_at: * type: string * format: date-time - * nullable: true * example: 2026-01-20T10:00:00.000Z * partner: * type: object @@ -145,7 +144,6 @@ router.post("/rooms", authenticateJwt, createOrGetChatRoom); * example: 달팽이 * profile_image_url: * type: string - * nullable: true * example: https://...png * role: * type: string @@ -161,7 +159,6 @@ router.post("/rooms", authenticateJwt, createOrGetChatRoom); * example: false * messages: * type: array - * description: 오래된 순(ASC) * items: * type: object * properties: @@ -173,7 +170,6 @@ router.post("/rooms", authenticateJwt, createOrGetChatRoom); * example: 45 * content: * type: string - * nullable: true * example: 혹시 이 사진이랑 파일도 프롬프트에 사용할 수 있나요? * sent_at: * type: string @@ -213,6 +209,9 @@ router.post("/rooms", authenticateJwt, createOrGetChatRoom); * total_count: * type: integer * example: 2 + * statusCode: + * type: integer + * example: 200 * 401: * description: 인증 실패 (토큰 없음/만료/유효하지 않음) * 404: @@ -298,7 +297,6 @@ router.get("/rooms/:roomId", authenticateJwt, getChatRoomDetail); * example: 달팽이 * profile_image_url: * type: string - * nullable: true * example: https://...png * last_message: * type: object @@ -328,10 +326,10 @@ router.get("/rooms/:roomId", authenticateJwt, getChatRoomDetail); * example: 0 * unread_count: * type: integer - * example: 0 + * example: 123 * is_pinned: * type: boolean - * example: true + * example: false * page: * type: object * properties: @@ -344,10 +342,10 @@ router.get("/rooms/:roomId", authenticateJwt, getChatRoomDetail); * statusCode: * type: integer * example: 200 - * 401: - * description: 인증 실패 (토큰 없음/만료/유효하지 않음) * 400: * description: 잘못된 요청 (유효하지 않은 filter 값) + * 401: + * description: 인증 실패 (토큰 없음/만료/유효하지 않음) */ router.get("/rooms", authenticateJwt, getChatRoomList); From a44036b8663ee32a480a3b5e3efff19a4c653d3b Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Fri, 23 Jan 2026 04:27:01 +0900 Subject: [PATCH 28/29] =?UTF-8?q?fix:=20profile=5Fimage=5Furl=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/dtos/chat.dto.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/chat/dtos/chat.dto.ts b/src/chat/dtos/chat.dto.ts index 1a2d467..dd5dad6 100644 --- a/src/chat/dtos/chat.dto.ts +++ b/src/chat/dtos/chat.dto.ts @@ -179,10 +179,8 @@ export class ChatRoomListResponseDto { dto.rooms = roomsInfo.map((p) => { const room = p.chatRoom; - // 1. partners 배열에서 '나'가 아닌 첫 번째 상대방 '하나'만 찾습니다. (find 사용) const partnerData = room.participants.find((part: any) => part.user_id !== userId); - // 2. 마지막 메시지 가공 (배열이 아닌 단일 객체 혹은 null) const lastMsg = room.lastMessage ? { content: room.lastMessage.content, sent_at: room.lastMessage.sent_at, @@ -191,11 +189,10 @@ export class ChatRoomListResponseDto { return { room_id: room.room_id, - // partner: partners (X) -> 아래처럼 객체로 바로 넣어줍니다. partner: { user_id: partnerData?.user.user_id || 0, nickname: partnerData?.user.nickname || "알 수 없는 사용자", - profile_image_url: partnerData?.user.userstatus || null, + profile_image_url: partnerData?.user.profileImage || null, }, last_message: lastMsg, unread_count: p.unreadCount, From 13835ae709cb4b33b0646c9d6686c4c31ddbe835 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Fri, 23 Jan 2026 04:31:59 +0900 Subject: [PATCH 29/29] =?UTF-8?q?feat:=20unread=5Fcount=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=9D=91=EB=8B=B5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/dtos/chat.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chat/dtos/chat.dto.ts b/src/chat/dtos/chat.dto.ts index dd5dad6..4885561 100644 --- a/src/chat/dtos/chat.dto.ts +++ b/src/chat/dtos/chat.dto.ts @@ -195,7 +195,7 @@ export class ChatRoomListResponseDto { profile_image_url: partnerData?.user.profileImage || null, }, last_message: lastMsg, - unread_count: p.unreadCount, + unread_count: p.unread_count, is_pinned: p.is_pinned }; });