From 9c0e360a7daf36d6080a22c7cd523dec7f95223c Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Sat, 24 Jan 2026 00:26:48 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=B0=A8=EB=8B=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 29 +++++++++++++++++++++++- src/chat/repositories/chat.repository.ts | 11 ++++++++- src/chat/routes/chat.route.ts | 4 +++- src/chat/services/chat.service.ts | 11 +++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index f6718d1..ec460cb 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -104,4 +104,31 @@ export const getChatRoomList = async(req: Request, res: Response) => { statusCode: err.statusCode || 500, }); } -} \ No newline at end of file +} + +// == 상대방 차단 +export const blockUser = async(req: Request, res: Response) => { + if (!req.user) { + res.fail({ + statusCode: 401, + error: "no user", + message: "로그인이 필요합니다.", + }); + return; + } + try { + const blockerId = (req.user as { user_id: number }).user_id; + const { blocked_user_id } = req.body as { blocked_user_id: number }; + + await chatService.blockUserService(blockerId, blocked_user_id); + + res.success(null, "상대방을 성공적으로 차단했습니다."); + } catch (err: any) { + console.error(err); + res.fail({ + error: err.error || "InternalServerError", + message: err.message || "상대방 차단 중 오류가 발생했습니다.", + statusCode: err.statusCode || 500, + }); + } +}; \ No newline at end of file diff --git a/src/chat/repositories/chat.repository.ts b/src/chat/repositories/chat.repository.ts index 5d1966e..4f964a6 100644 --- a/src/chat/repositories/chat.repository.ts +++ b/src/chat/repositories/chat.repository.ts @@ -194,6 +194,15 @@ export class ChatRepository { ]); return {rooms, totalRoom} }; -} + // == 상대방 차단 + async blockUser(blockerId: number, blockedId: number) { + return prisma.userBlock.create({ + data: { + blocker_id: blockerId, + blocked_id: blockedId, + }, + }); + } +} diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index 5cfe2ad..f1d740c 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, getChatRoomDetail, getChatRoomList } from "../controllers/chat.controller"; +import { createOrGetChatRoom, getChatRoomDetail, getChatRoomList, blockUser } from "../controllers/chat.controller"; import { authenticateJwt } from "../../config/passport"; const router = Router(); @@ -350,4 +350,6 @@ router.get("/rooms/:roomId", authenticateJwt, getChatRoomDetail); */ router.get("/rooms", authenticateJwt, getChatRoomList); + +router.post("/block", authenticateJwt, blockUser); 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 af7a4e6..bca8c11 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -94,6 +94,17 @@ export class ChatService { hasMore, }); } + + // == 상대방 차단 + async blockUserService( + blockerId: number, blockedId: number + ): Promise { + const blockStatus = await this.chatRepo.blockStatus(blockerId, blockedId); + if (blockStatus.iBlockedPartner) { + throw new AppError("이미 차단한 사용자입니다.", 400, "BadRequest"); + } + await this.chatRepo.blockUser(blockerId, blockedId); + } } export const chatService = new ChatService(new ChatRepository()); From 617b93d2b7dc38a5a165510eb5de458f38b9d5ae Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Sat, 24 Jan 2026 00:28:02 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20AppError=EB=A5=BC=20=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=95=84=EB=93=9C=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index ec460cb..7ad917d 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -26,7 +26,7 @@ export const createOrGetChatRoom = async (req: Request, res: Response): Promise< } catch (err: any) { console.error(err); res.fail({ - error: err.name || "InternalServerError", + error: err.error || "InternalServerError", message: err.message || "채팅방 생성/반환 중 오류가 발생했습니다.", statusCode: err.statusCode || 500, }); @@ -61,7 +61,7 @@ export const getChatRoomDetail = async (req: Request, res: Response): Promise { } catch (err: any) { console.error(err); res.fail({ - error: err.name || "InternalServerError", + error: err.error || "InternalServerError", message: err.message || "채팅방 목록 조회 중 오류가 발생했습니다.", statusCode: err.statusCode || 500, }); From 82f13a2366ce779e013bba1daa78b3413f5d90f5 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Sat, 24 Jan 2026 00:37:19 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index 7ad917d..3033a07 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -120,6 +120,16 @@ export const blockUser = async(req: Request, res: Response) => { const blockerId = (req.user as { user_id: number }).user_id; const { blocked_user_id } = req.body as { blocked_user_id: number }; + if (!blocked_user_id || isNaN(blocked_user_id)) { + res.fail({ statusCode: 400, error: "BadRequest", message: "올바른 blocked_user_id가 필요합니다." }); + return; + } + + if (blockerId === blocked_user_id) { + res.fail({ statusCode: 400, error: "BadRequest", message: "자기 자신을 차단할 수 없습니다." }); + return; + } + await chatService.blockUserService(blockerId, blocked_user_id); res.success(null, "상대방을 성공적으로 차단했습니다."); From e72a691676d00b1391f63436a9113b48a3a69c6f Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Sat, 24 Jan 2026 00:37:37 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20swagger=20=EB=AC=B8=EC=84=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/routes/chat.route.ts | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index f1d740c..ab614ab 100644 --- a/src/chat/routes/chat.route.ts +++ b/src/chat/routes/chat.route.ts @@ -350,6 +350,50 @@ router.get("/rooms/:roomId", authenticateJwt, getChatRoomDetail); */ router.get("/rooms", authenticateJwt, getChatRoomList); +/** + * @swagger + * /api/chat/block: + * post: + * summary: 사용자 차단 + * description: > + * 상대방을 차단합니다. + * tags: [Chat] + * security: + * - jwt: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - blocked_user_id + * properties: + * blocked_user_id: + * type: integer + * example: 5 + * responses: + * 200: + * description: 차단 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 상대방을 성공적으로 차단했습니다. + * data: + * nullable: true + * example: null + * statusCode: + * type: integer + * example: 200 + * 400: + * description: 잘못된 요청 + * 401: + * description: 인증 실패 (토큰 없음/만료/유효하지 않음) + */ router.post("/block", authenticateJwt, blockUser); export default router; \ No newline at end of file From 7f8caafc5f4d0237881ef05b092d9cc10f5ea92e Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Sat, 24 Jan 2026 01:13:36 +0900 Subject: [PATCH 5/8] =?UTF-8?q?chore:=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=8B=9C=20unread=20?= =?UTF-8?q?=EC=B9=BC=EB=9F=BC=20=EB=A6=AC=EC=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/repositories/chat.repository.ts | 18 ++++++++++++++++++ src/chat/services/chat.service.ts | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/chat/repositories/chat.repository.ts b/src/chat/repositories/chat.repository.ts index 4f964a6..e91d849 100644 --- a/src/chat/repositories/chat.repository.ts +++ b/src/chat/repositories/chat.repository.ts @@ -29,6 +29,7 @@ export class ChatRepository { }); } + // == 채팅방 상세 조회 (참여자 정보 포함) async findRoomDetailWithParticipant(roomId: number) { return prisma.chatRoom.findUnique({ where: { room_id: roomId }, @@ -40,6 +41,23 @@ export class ChatRepository { }); } + // == 안읽은 메세지 초기화 + async resetUnreadCount(roomId: number, userId: number, lastMessageId?: number | null) { + return prisma.chatParticipant.update({ + where: { + room_id_user_id: { + room_id: roomId, + user_id: userId, + }, + }, + data: { + unread_count: 0, + last_read_message_id: lastMessageId, + }, + }); + } + + // == 메시지 목록 조회 async findMessagesByRoomId(roomId: number, cursor?: number, limit: number = 20, userId?: number) { const leftInfo = await prisma.chatParticipant.findFirst({ where: { diff --git a/src/chat/services/chat.service.ts b/src/chat/services/chat.service.ts index bca8c11..950ef08 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -51,10 +51,13 @@ export class ChatService { const myId = me.user_id; const partnerId = partner.user_id; - // 차단 정보 및 메세지 목록 + const updateReadStatus = !cursor ? this.chatRepo.resetUnreadCount(roomId, myId, roomDetail.last_message_id) : Promise.resolve(); + + // 1. 차단 정보 조회 2. 메세지 목록 조회 3. 안읽은 메세지 초기화 const [blockInfo, messageInfo] = await Promise.all([ this.chatRepo.blockStatus(myId, partnerId), this.chatRepo.findMessagesByRoomId(roomId, cursor, limit, myId), + updateReadStatus ]); // 페이지네이션 From 3e03c34aae26d6f8b5963d351fd76e8a9be33059 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Sat, 24 Jan 2026 02:01:23 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=82=98=EA=B0=80=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 34 ++++++++++++++++- src/chat/repositories/chat.repository.ts | 15 ++++++++ src/chat/routes/chat.route.ts | 47 +++++++++++++++++++++++- src/chat/services/chat.service.ts | 11 ++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index 3033a07..42d6b69 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -129,7 +129,7 @@ export const blockUser = async(req: Request, res: Response) => { res.fail({ statusCode: 400, error: "BadRequest", message: "자기 자신을 차단할 수 없습니다." }); return; } - + await chatService.blockUserService(blockerId, blocked_user_id); res.success(null, "상대방을 성공적으로 차단했습니다."); @@ -141,4 +141,36 @@ export const blockUser = async(req: Request, res: Response) => { statusCode: err.statusCode || 500, }); } +}; + +// == 채팅방 나가기 +export const leaveChatRoom = 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 roomId = Number(req.params.roomId); + console.log("🍀roomId:", roomId); + + if (isNaN(roomId)) { + res.fail({ statusCode: 400, error: "BadRequest", message: "올바른 roomId가 필요합니다." }); + return; + } + await chatService.leaveChatRoomService(roomId, userId); + + res.success(null, "채팅방을 성공적으로 나갔습니다."); + } catch (err: any) { + console.error(err); + res.fail({ + error: err.error || "InternalServerError", + message: err.message || "채팅방 나가기 중 오류가 발생했습니다.", + statusCode: err.statusCode || 500, + }); + } }; \ No newline at end of file diff --git a/src/chat/repositories/chat.repository.ts b/src/chat/repositories/chat.repository.ts index e91d849..ded791d 100644 --- a/src/chat/repositories/chat.repository.ts +++ b/src/chat/repositories/chat.repository.ts @@ -222,5 +222,20 @@ export class ChatRepository { }, }); } + + // == 채팅방 나가기 + async leaveChatRoom(roomId: number, userId: number) { + return prisma.chatParticipant.update({ + where: { + room_id_user_id: { + room_id: roomId, + user_id: userId, + }, + }, + data: { + left_at: new Date(), + }, + }); + }; } diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index ab614ab..02ec70e 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, getChatRoomDetail, getChatRoomList, blockUser } from "../controllers/chat.controller"; +import { createOrGetChatRoom, getChatRoomDetail, getChatRoomList, blockUser, leaveChatRoom, } from "../controllers/chat.controller"; import { authenticateJwt } from "../../config/passport"; const router = Router(); @@ -396,4 +396,49 @@ router.get("/rooms", authenticateJwt, getChatRoomList); */ router.post("/block", authenticateJwt, blockUser); +/** + * @swagger + * /api/chat/rooms/{roomId}/leave: + * patch: + * summary: 채팅방 나가기 + * description: > + * 채팅방을 나갑니다.
+ * 채팅방을 나가면 채팅방 목록 조회 리스트에서 제외됩니다.
+ * 나갔더라도 채팅방은 유지되며 계속적으로 수신이 가능합니다. 다시 입장할 수 있지만 나가기 전 메시지들은 볼 수 없습니다. + * + * tags: [Chat] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: roomId + * required: true + * schema: + * type: integer + * description: 채팅방 ID + * example: 2 + * responses: + * 200: + * description: 채팅방 나가기 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 채팅방을 성공적으로 나갔습니다. + * data: + * nullable: true + * example: null + * statusCode: + * type: integer + * example: 200 + * 400: + * description: 잘못된 요청 + * 401: + * description: 인증 실패 (토큰 없음/만료/유효하지 않음) + */ + +router.patch("/rooms/:roomId/leave", authenticateJwt, leaveChatRoom); 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 950ef08..7c60408 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -108,6 +108,17 @@ export class ChatService { } await this.chatRepo.blockUser(blockerId, blockedId); } + + // == 채팅방 나가기 + async leaveChatRoomService( + roomId: number, userId: number + ): Promise { + const roomDetail = await this.chatRepo.findRoomDetailWithParticipant(roomId); + if (!roomDetail) { + throw new AppError("채팅방을 찾을 수 없습니다.", 404, "NotFoundError"); + } + await this.chatRepo.leaveChatRoom(roomId, userId); + } } export const chatService = new ChatService(new ChatRepository()); From 71b4b013a18f4136b8e99c0010d987129ab16da2 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Sat, 24 Jan 2026 03:56:47 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20presign=20url=20=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 35 +++++++++ src/chat/routes/chat.route.ts | 100 +++++++++++++++++++++++- src/chat/services/chat.service.ts | 17 ++++ src/middlewares/s3.util.ts | 25 ++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/middlewares/s3.util.ts diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index 42d6b69..d604c5b 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -173,4 +173,39 @@ export const leaveChatRoom = async(req: Request, res: Response) => { statusCode: err.statusCode || 500, }); } +}; +// == S3 presigned URL 발급 +export const getPresignedUrl = async(req: Request, res: Response) => { + if (!req.user) { + res.fail({ + statusCode: 401, + error: "no user", + message: "로그인이 필요합니다.", + }); + return; + } + try { + const rawFiles = req.body.files; + + if (!rawFiles || !Array.isArray(rawFiles) || rawFiles.length === 0) { + res.fail({ statusCode: 400, error: "BadRequest", message: "업로드할 파일 정보가 필요합니다." }); + return; + } + + const files = rawFiles.map((file: any) => ({ + fileName: file.name, + contentType: file.content_type + })); + + const result = await chatService.getPresignedUrlService(files); + + res.success(result, "presign을 성공적으로 발급했습니다."); + } catch (err: any) { + console.error(err); + res.fail({ + error: err.error || "InternalServerError", + message: err.message || "presigned URL 생성 중 오류가 발생했습니다.", + statusCode: err.statusCode || 500, + }); + } }; \ No newline at end of file diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index 02ec70e..5c4cf62 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, getChatRoomDetail, getChatRoomList, blockUser, leaveChatRoom, } from "../controllers/chat.controller"; +import { createOrGetChatRoom, getChatRoomDetail, getChatRoomList, blockUser, leaveChatRoom, getPresignedUrl} from "../controllers/chat.controller"; import { authenticateJwt } from "../../config/passport"; const router = Router(); @@ -441,4 +441,102 @@ router.post("/block", authenticateJwt, blockUser); */ router.patch("/rooms/:roomId/leave", authenticateJwt, leaveChatRoom); +/** + * @swagger + * /api/chat/presigned-url: + * post: + * summary: Presigned URL 발급 + * description: > + * 파일 업로드를 위한 presigned url을 발급합니다.

+ * **업로드 프로세스:**
+ * 1) 본 API를 호출하여 파일별 url 과 key 를 받습니다.
+ * 2) 받은 url 로 PUT 요청을 보내 실제 파일을 업로드합니다.
+ * 3) 업로드가 모두 성공하면, 채팅 메시지 전송 API 호출 시 서버로부터 받은 key 값들을 함께 보냅니다. + * tags: [Chat] + * security: + * - jwt: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - files + * properties: + * files: + * type: array + * items: + * type: object + * required: + * - name + * - content_type + * properties: + * name: + * type: string + * example: cat.jpg + * content_type: + * type: string + * example: image/jpg + * example: + * files: + * - name: cat.jpg + * content_type: image/jpg + * - name: dog.png + * content_type: image/png + * - name: info.pdf + * content_type: application/pdf + * responses: + * 200: + * description: presign 발급 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: presign을 성공적으로 발급했습니다. + * data: + * type: object + * properties: + * attachments: + * type: array + * items: + * type: object + * properties: + * name: + * type: string + * example: cat.jpg + * url: + * type: string + * example: https://s3.aws.com/bucket/random-key-1?signature=... + * key: + * type: string + * example: uploads/random-key-1.jpg + * statusCode: + * type: integer + * example: 200 + * example: + * message: presign을 성공적으로 발급했습니다. + * data: + * attachments: + * - name: cat.jpg + * url: https://s3.aws.com/bucket/random-key-1?signature=... + * key: uploads/random-key-1.jpg + * - name: dog.jpg + * url: https://s3.aws.com/bucket/random-key-2?signature=... + * key: uploads/random-key-2.png + * - name: info.pdf + * url: https://s3.aws.com/bucket/random-key-3?signature=... + * key: uploads/random-key-3.pdf + * statusCode: 200 + * 400: + * description: 잘못된 요청 + * 401: + * description: 인증 실패 (토큰 없음/만료/유효하지 않음) + */ + +router.post("/presigned-url", authenticateJwt, getPresignedUrl); + 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 7c60408..d97b3c9 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -6,6 +6,7 @@ import { ChatRoomListResponseDto, ChatFilterType, } from "../dtos/chat.dto"; +import { getPresignedUrl } from "../../middlewares/s3.util"; export class ChatService { constructor(private readonly chatRepo: ChatRepository) {} @@ -119,6 +120,22 @@ export class ChatService { } await this.chatRepo.leaveChatRoom(roomId, userId); } + + // == S3 presigned URL 발급 + async getPresignedUrlService(files: { fileName: string; contentType: string}[]) { + const attatchments = await Promise.all( + files.map(async (f) => { + const {url, key} = await getPresignedUrl(f.fileName, f.contentType); + + return { + name: f.fileName, + url: url, + key: key, + }; + }) + ); + return { attatchments }; + } } export const chatService = new ChatService(new ChatRepository()); diff --git a/src/middlewares/s3.util.ts b/src/middlewares/s3.util.ts new file mode 100644 index 0000000..de6c03b --- /dev/null +++ b/src/middlewares/s3.util.ts @@ -0,0 +1,25 @@ +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { v4 as uuidv4 } from "uuid"; + +export const getPresignedUrl = async (fileName: string, contentType: string) => { + const fileExtension = fileName.split('.').pop(); + const key = `uploads/${uuidv4()}_${fileExtension}`; + + const s3 = new S3Client({ + region: process.env.S3_REGION, + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID!, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, + }, + }); + + const command = new PutObjectCommand({ + Bucket: process.env.S3_BUCKET, + Key: key, + ContentType: contentType, + }); + + const url = await getSignedUrl(s3, command, { expiresIn: 1800 }); + return { url, key }; +} \ No newline at end of file From 0d39ebf88fabf7cf3dbb12d3c822eae822ed39c0 Mon Sep 17 00:00:00 2001 From: Arin0303 Date: Sat, 24 Jan 2026 04:37:36 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=ED=86=A0=EA=B8=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/chat/controllers/chat.controller.ts | 34 +++++++++++++- src/chat/dtos/chat.dto.ts | 5 +++ src/chat/repositories/chat.repository.ts | 16 +++++++ src/chat/routes/chat.route.ts | 57 +++++++++++++++++++++++- src/chat/services/chat.service.ts | 15 +++++++ 5 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index d604c5b..9bd6b35 100644 --- a/src/chat/controllers/chat.controller.ts +++ b/src/chat/controllers/chat.controller.ts @@ -207,5 +207,35 @@ export const getPresignedUrl = async(req: Request, res: Response) => { message: err.message || "presigned URL 생성 중 오류가 발생했습니다.", statusCode: err.statusCode || 500, }); - } -}; \ No newline at end of file + } +}; + +// == 채팅방 고정 토글 +export const togglePinChatRoom = 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 roomId = Number(req.params.roomId); + if (isNaN(roomId)) { + res.fail({ statusCode: 400, error: "BadRequest", message: "올바른 roomId가 필요합니다." }); + return; + } + const isPinned = await chatService.togglePinChatRoomService(roomId, userId); + res.success(isPinned, "채팅방 고정을 성공적으로 토글했습니다."); + } + catch (err: any) { + console.error(err); + res.fail({ + error: err.error || "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 4885561..b3adeaa 100644 --- a/src/chat/dtos/chat.dto.ts +++ b/src/chat/dtos/chat.dto.ts @@ -207,4 +207,9 @@ export class ChatRoomListResponseDto { return dto; } +} + +// == 채팅방 고정 토글 +export interface TogglePinResponseDto { + is_pinned: boolean; } \ No newline at end of file diff --git a/src/chat/repositories/chat.repository.ts b/src/chat/repositories/chat.repository.ts index ded791d..f032afd 100644 --- a/src/chat/repositories/chat.repository.ts +++ b/src/chat/repositories/chat.repository.ts @@ -237,5 +237,21 @@ export class ChatRepository { }, }); }; + + + // == 채팅방 고정 토글 + async togglePinChatRoom(roomId: number, userId: number, isPinned: boolean) { + return prisma.chatParticipant.update({ + where: { + room_id_user_id: { + room_id: roomId, + user_id: userId, + }, + }, + data: { + is_pinned: !isPinned, // 토글 + }, + }); + } } diff --git a/src/chat/routes/chat.route.ts b/src/chat/routes/chat.route.ts index 5c4cf62..49c7a3b 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, getChatRoomDetail, getChatRoomList, blockUser, leaveChatRoom, getPresignedUrl} from "../controllers/chat.controller"; +import { createOrGetChatRoom, getChatRoomDetail, getChatRoomList, blockUser, leaveChatRoom, getPresignedUrl, togglePinChatRoom} from "../controllers/chat.controller"; import { authenticateJwt } from "../../config/passport"; const router = Router(); @@ -539,4 +539,59 @@ router.patch("/rooms/:roomId/leave", authenticateJwt, leaveChatRoom); router.post("/presigned-url", authenticateJwt, getPresignedUrl); +/** + * @swagger + * /api/chat/rooms/{roomId}/pin: + * patch: + * summary: 채팅방 고정 토글 + * description: > + * 채팅방 고정을 토글합니다.

+ * `isPinned`:
+ * - false → 토글 결과 = 고정 해제
+ * - true → 토글 결과 = 고정 + * tags: [Chat] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: roomId + * required: true + * schema: + * type: integer + * description: 채팅방 ID + * example: 2 + * responses: + * 200: + * description: 채팅방 고정 토글 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: 채팅방 고정을 성공적으로 토글했습니다. + * data: + * type: object + * properties: + * isPinned: + * type: boolean + * example: true + * statusCode: + * type: integer + * example: 200 + * example: + * message: 채팅방 고정을 성공적으로 토글했습니다. + * data: + * isPinned: true + * statusCode: 200 + * 400: + * description: 잘못된 요청 + * 401: + * description: 인증 실패 (토큰 없음/만료/유효하지 않음) + * 404: + * description: 채팅방을 찾을 수 없음 + */ +router.patch("/rooms/:roomId/pin", authenticateJwt, togglePinChatRoom); + 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 d97b3c9..214d134 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -5,6 +5,7 @@ import { ChatRoomDetailResponseDto, ChatRoomListResponseDto, ChatFilterType, + TogglePinResponseDto, } from "../dtos/chat.dto"; import { getPresignedUrl } from "../../middlewares/s3.util"; @@ -136,6 +137,20 @@ export class ChatService { ); return { attatchments }; } + + // == 채팅방 고정 토글 + async togglePinChatRoomService( + roomId: number, userId: number + ): Promise { + const roomDetail = await this.chatRepo.findRoomDetailWithParticipant(roomId); + if (!roomDetail) { + throw new AppError("채팅방을 찾을 수 없습니다.", 404, "NotFoundError"); + } + + const isPinned = roomDetail.participants.some((p) => p.user_id === userId && p.is_pinned); // 현재 고정 상태 + const togglePinned = (await this.chatRepo.togglePinChatRoom(roomId, userId, isPinned)).is_pinned + return {is_pinned: togglePinned}; + } } export const chatService = new ChatService(new ChatRepository());