diff --git a/src/chat/controllers/chat.controller.ts b/src/chat/controllers/chat.controller.ts index f6718d1..9bd6b35 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, }); } -} \ 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 }; + + 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, "상대방을 성공적으로 차단했습니다."); + } catch (err: any) { + console.error(err); + res.fail({ + error: err.error || "InternalServerError", + message: err.message || "상대방 차단 중 오류가 발생했습니다.", + 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, + }); + } +}; +// == 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, + }); + } +}; + +// == 채팅방 고정 토글 +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 5d1966e..f032afd 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: { @@ -194,6 +212,46 @@ export class ChatRepository { ]); return {rooms, totalRoom} }; -} + // == 상대방 차단 + async blockUser(blockerId: number, blockedId: number) { + return prisma.userBlock.create({ + data: { + blocker_id: blockerId, + blocked_id: blockedId, + }, + }); + } + + // == 채팅방 나가기 + 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(), + }, + }); + }; + + + // == 채팅방 고정 토글 + 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 5cfe2ad..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 } from "../controllers/chat.controller"; +import { createOrGetChatRoom, getChatRoomDetail, getChatRoomList, blockUser, leaveChatRoom, getPresignedUrl, togglePinChatRoom} from "../controllers/chat.controller"; import { authenticateJwt } from "../../config/passport"; const router = Router(); @@ -350,4 +350,248 @@ 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); +/** + * @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); +/** + * @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); + +/** + * @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 af7a4e6..214d134 100644 --- a/src/chat/services/chat.service.ts +++ b/src/chat/services/chat.service.ts @@ -5,7 +5,9 @@ import { ChatRoomDetailResponseDto, ChatRoomListResponseDto, ChatFilterType, + TogglePinResponseDto, } from "../dtos/chat.dto"; +import { getPresignedUrl } from "../../middlewares/s3.util"; export class ChatService { constructor(private readonly chatRepo: ChatRepository) {} @@ -51,10 +53,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 ]); // 페이지네이션 @@ -94,6 +99,58 @@ 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); + } + + // == 채팅방 나가기 + 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); + } + + // == 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 }; + } + + // == 채팅방 고정 토글 + 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()); 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