diff --git a/prisma/migrations/20250809081437_add_chat_message_read/migration.sql b/prisma/migrations/20250809081437_add_chat_message_read/migration.sql new file mode 100644 index 0000000..6f08f82 --- /dev/null +++ b/prisma/migrations/20250809081437_add_chat_message_read/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE `chat_message_reads` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `messageId` BIGINT NOT NULL, + `accountId` BIGINT NOT NULL, + `read` BOOLEAN NOT NULL DEFAULT false, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `chat_message_reads` ADD CONSTRAINT `chat_message_reads_messageId_fkey` FOREIGN KEY (`messageId`) REFERENCES `chat_messages`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `chat_message_reads` ADD CONSTRAINT `chat_message_reads_accountId_fkey` FOREIGN KEY (`accountId`) REFERENCES `accounts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 52e9f77..8936d7c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,7 @@ model Account { userCategories UserCategory[] follows Follow[] userBadges UserBadge[] + chatMessageReads ChatMessageRead[] @@unique([provider, oauthId]) @@map("accounts") @@ -235,10 +236,23 @@ model ChatMessage { chatroom Chatroom @relation(fields: [chatroomId], references: [id]) sender User @relation(fields: [senderId], references: [id]) + chatMessageReads ChatMessageRead[] @@map("chat_messages") } +model ChatMessageRead { + id BigInt @id @default(autoincrement()) + messageId BigInt + accountId BigInt + read Boolean @default(false) + + message ChatMessage @relation(fields: [messageId], references: [id]) + account Account @relation(fields: [accountId], references: [id]) + + @@map("chat_message_reads") +} + model Follow { id BigInt @id @default(autoincrement()) artistId BigInt @map("artist_id") diff --git a/src/chat/controller/chatroom.controller.js b/src/chat/controller/chatroom.controller.js index 240e895..8a9ecb2 100644 --- a/src/chat/controller/chatroom.controller.js +++ b/src/chat/controller/chatroom.controller.js @@ -27,7 +27,8 @@ export const createChatroom = async (req, res, next) => { export const getChatroom = async (req, res, next) => { try { const dto = new GetChatroomDto({ - consumerId: BigInt(req.user.userId) + consumerId: BigInt(req.user.userId), + accountId: BigInt(req.user.accountId), }); const chatrooms = await ChatroomService.getChatroomsByUserId(dto); diff --git a/src/chat/dto/chatroom.dto.js b/src/chat/dto/chatroom.dto.js index 61f6c84..84841b2 100644 --- a/src/chat/dto/chatroom.dto.js +++ b/src/chat/dto/chatroom.dto.js @@ -7,11 +7,26 @@ export class CreateChatroomDto { } export class GetChatroomDto { - constructor({ consumerId }) { + constructor({ consumerId, accountId }) { this.consumerId = BigInt(consumerId); + this.accountId = BigInt(accountId); } } +export class ChatroomListResponseDto { + constructor(room, unreadCount = 0) { + this.chatroom_id = room.id; + this.artist_id = room.artist.id; + this.artist_nickname = room.artist.nickname; + this.artist_profile_image = room.artist.profileImage; + this.request_id = room.request.id; + this.request_title = room.request.commission.title; + this.last_message = room.chatMessages[0]?.content || null; + this.last_message_time = room.chatMessages[0]?.createdAt || null; + this.has_unread = unreadCount; + } +} + export class DeleteChatroomDto { constructor({ chatroomIds, userType, userId }) { this.chatroomIds = chatroomIds.map(id => BigInt(id)); diff --git a/src/chat/repository/chat.repository.js b/src/chat/repository/chat.repository.js index 644a089..1132868 100644 --- a/src/chat/repository/chat.repository.js +++ b/src/chat/repository/chat.repository.js @@ -46,4 +46,50 @@ export const ChatRepository = { } }); }, + + async markAsRead(accountId, messageId) { + return await prisma.chatMessageRead.upsert({ + where: { + messageId_accountId: { + messageId: BigInt(messageId), + accountId: BigInt(accountId), + }, + }, + update: { read: true }, + create: { + messageId: BigInt(messageId), + accountId: BigInt(accountId), + read: true, + }, + }); + }, + + async isMessageRead(accountId, messageId) { + const record = await prisma.chatMessageRead.findUnique({ + where: { + messageId_accountId: { + messageId: BigInt(messageId), + accountId: BigInt(accountId), + }, + }, + }); + return record?.read || false; + }, + + async countUnreadMessages(chatroomId, accountId) { + const count = await prisma.chatMessage.count({ + where: { + chatroomId: BigInt(chatroomId), + NOT: { + chatMessageReads: { + some: { + accountId: BigInt(accountId), + read: true, + }, + }, + }, + }, + }); + return count; + }, }; \ No newline at end of file diff --git a/src/chat/repository/chatroom.repository.js b/src/chat/repository/chatroom.repository.js index d344949..3d66e4b 100644 --- a/src/chat/repository/chatroom.repository.js +++ b/src/chat/repository/chatroom.repository.js @@ -23,11 +23,35 @@ export const ChatroomRepository = { }, async findChatroomsByUser(consumerId) { - return await prisma.chatroom.findMany({ - where: { - consumerId: consumerId, - hiddenConsumer: false, + return prisma.chatroom.findMany({ + where: { consumerId }, + include: { + artist: { + select: { + id: true, + nickname: true, + profileImage: true, + } + }, + request: { + select: { + id: true, + commission: { + select: { + title: true + } + } + } + }, + chatMessages: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { + content: true, + createdAt: true + } } + } }); }, diff --git a/src/chat/service/chat.service.js b/src/chat/service/chat.service.js index bf98dc0..e04aef7 100644 --- a/src/chat/service/chat.service.js +++ b/src/chat/service/chat.service.js @@ -25,4 +25,12 @@ export const ChatService = { const messages = await ChatRepository.searchByKeyword(dto.keyword, chatroomIds); return messages; }, + + async markMessageAsRead(accountId, messageId) { + return await ChatRepository.markAsRead(accountId, messageId); + }, + + async getUnreadCount(chatroomId, accountId) { + return await ChatRepository.countUnreadMessages(chatroomId, accountId); + } }; \ No newline at end of file diff --git a/src/chat/service/chatroom.service.js b/src/chat/service/chatroom.service.js index 5bf20d3..c62feca 100644 --- a/src/chat/service/chatroom.service.js +++ b/src/chat/service/chatroom.service.js @@ -1,10 +1,12 @@ import { ChatroomRepository } from "../repository/chatroom.repository.js"; +import { ChatRepository } from "../repository/chat.repository.js"; import { UserRepository } from "../../user/repository/user.repository.js"; import { RequestRepository } from "../../request/repository/request.repository.js"; import { UserNotFoundError } from "../../common/errors/user.errors.js"; import { ArtistNotFoundError } from "../../common/errors/artist.errors.js"; import { RequestNotFoundError } from "../../common/errors/request.errors.js"; import { ChatroomNotFoundError } from "../../common/errors/chat.errors.js"; +import { ChatroomListResponseDto } from "../dto/chatroom.dto.js"; export const ChatroomService = { async createChatroom(dto) { @@ -52,8 +54,15 @@ export const ChatroomService = { } const chatrooms = await ChatroomRepository.findChatroomsByUser(dto.consumerId); - - return chatrooms; + console.log(dto.accountId) + + const result = []; + for (const room of chatrooms) { + const unreadCount = await ChatRepository.countUnreadMessages(room.id, dto.accountId); + result.push(new ChatroomListResponseDto(room, unreadCount)); + } + + return result; }, async softDeleteChatroomsByUser(dto) { diff --git a/src/chat/socket/socket.js b/src/chat/socket/socket.js index 2356784..3d37e47 100644 --- a/src/chat/socket/socket.js +++ b/src/chat/socket/socket.js @@ -2,6 +2,8 @@ import { uploadToS3 } from "../../s3.upload.js"; import { Server } from "socket.io"; import { stringifyWithBigInt } from "../../bigintJson.js"; import { PrismaClient } from '@prisma/client'; +import { verifyJwt } from '../../jwt.config.js'; +import { ChatRepository } from "../repository/chat.repository.js"; const prisma = new PrismaClient(); @@ -10,13 +12,61 @@ export default function setupSocket(server) { cors: { origin: "*", methods: ["GET", "POST"] }, }); + io.use((socket, next) => { + try { + const token = socket.handshake.auth?.token; + if (!token) { + return next(new Error("Authentication error: Token missing")); + } + const user = verifyJwt(token); + socket.user = user; // userId, role 등 저장 + next(); + } catch (err) { + next(new Error("Authentication error: Invalid token")); + } + }); + io.on("connection", (socket) => { console.log("User connected:", socket.id); // 채팅방 join - socket.on("join", (chatroomId) => { + socket.on("join", async (chatroomId) => { socket.join(chatroomId); - console.log(`User ${socket.id} joined chatroom ${chatroomId}`); + console.log(`User ${socket.user.userId} joined chatroom ${chatroomId}`); + + try { + console.log(socket.user) + const accountId = BigInt(socket.user.accountId); + + // 해당 채팅방의 모든 미확인 메시지에 대해 읽음 처리 + const unreadMessages = await prisma.chatMessage.findMany({ + where: { + chatroomId: BigInt(chatroomId), + NOT: { + chatMessageReads: { + some: { + accountId: BigInt(accountId), + read: true, + }, + }, + }, + }, + }); + + // 읽음 상태 기록 업데이트 + await Promise.all( + unreadMessages.map((msg) => + ChatRepository.markAsRead(accountId, msg.id) + ) + ); + + // 필요하면 클라이언트에 읽음 처리 완료 알림 전송 + socket.emit("read messages success", { chatroomId }); + + } catch (err) { + console.error("Error marking messages as read:", err); + socket.emit("error", { message: "읽음 처리 중 오류가 발생했습니다." }); + } }); // 메시지 수신 diff --git a/src/common/swagger/chat.json b/src/common/swagger/chat.json index e483125..6fb579b 100644 --- a/src/common/swagger/chat.json +++ b/src/common/swagger/chat.json @@ -107,12 +107,15 @@ "items": { "type": "object", "properties": { - "id": { "type": "integer", "example": 10 }, - "consumerId": { "type": "integer", "example": 1 }, - "artistId": { "type": "integer", "example": 2 }, - "requestId": { "type": "integer", "example": 3 }, - "createdAt": { "type": "string", "format": "date-time" }, - "updatedAt": { "type": "string", "format": "date-time" } + "chatroom_id": { "type": "string", "example": "2" }, + "artist_id": { "type": "string", "example": "1" }, + "artist_nickname": { "type": "string", "example": "artist_one" }, + "artist_profile_image": { "type": "string", "example": "https://example.com/artist1.png" }, + "request_id": { "type": "string", "example": "1" }, + "request_title": { "type": "string", "example": "테스트 커미션 글" }, + "last_message": { "type": ["string", "null"], "example": null }, + "last_message_time": { "type": ["string", "null"], "format": "date-time", "example": null }, + "has_unread": { "type": "integer", "example": 0 } } } }