diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ff183b7..bec96d2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -229,7 +229,7 @@ model ProjectPost { id Int @id @default(autoincrement()) user_id Int title String - content String @db.Text + content String @db.LongText thumbnail_url String? role String start_date DateTime diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index 3e6dcb2..8aef61e 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -22,7 +22,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { // 유저 소켓 접속 async handleConnection(client: Socket) { - const userId = +client.handshake.query.userId; + const userId = Number(client.handshake.query.userId); client.data.userId = userId; // userId 넘버로 저장 // 유저 온라인 -> DB에 저장 @@ -90,33 +90,15 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { console.log(`User ${userId2} is not connected.`); } - // 알람 기능 + // 알림 const sender = await this.chatService.getSenderProfile(userId1); - const message = `${sender.nickname}님과의 개인 채팅방이 생성되었습니다.`; - // 알람 DB에 저장 - const createdNotification = - await this.notificationService.createNotification( - userId2, - userId1, - 'privateChat', - message - ); - - // 전송할 알림 데이터 객체 - const notificationData = { - notificationId: createdNotification.notificationId, // 포함된 notificationId - type: 'privateChat', - message, - senderNickname: sender.nickname, - senderProfileUrl: sender.profileUrl, - }; - - // SSE를 통해 실시간 알림 전송 - this.notificationService.sendRealTimeNotification( + await this.chatService.handleChatNotices( + sender, userId2, - notificationData + 'privateChat', + message ); // 클라이언트에 채널id 전달 @@ -173,31 +155,16 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { console.log('모든 유저가 오프라인 상태입니다.'); } + // 알림 const sender = await this.chatService.getSenderProfile(userId); const message = `${sender.nickname}님이 단체 채팅방을 생성했습니다.`; groupMemberIds.forEach(async memberId => { - const createdNotification = - await this.notificationService.createNotification( - memberId, - userId, - 'groupChat', - message - ); - - // 전송할 알림 데이터 객체 - const notificationData = { - notificationId: createdNotification.notificationId, // 포함된 notificationId - type: 'groupChat', - message, - senderNickname: sender.nickname, - senderProfileUrl: sender.profileUrl, - }; - - // SSE를 통해 실시간 알림 전송 - this.notificationService.sendRealTimeNotification( + await this.chatService.handleChatNotices( + sender, memberId, - notificationData + 'groupChat', + message ); }); @@ -247,7 +214,8 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { // 클라이언트에 채널 객체 전달 client.emit('channelJoined', channel); - this.server.to(channelId.toString()).emit('broadcastChannelJoined'); + console.log(client.id); + client.broadcast.to(channelId.toString()).emit('broadcastChannelJoined'); } // 메세지 송수신 @@ -303,7 +271,6 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { date, readCount: messageData.read_count, }; - console.log(sendData); // 오프라인 유저들에게 알람 const offlineUsers = await this.chatService.getChannelOfflineUsers( @@ -314,23 +281,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { const message = '새로운 메세지가 있습니다.'; offlineUsers.forEach(async id => { - const createdNotification = - await this.notificationService.createNotification( - id, - userId, - 'groupChat', - message - ); - - const notificationData = { - notificationId: createdNotification.notificationId, // 포함된 notificationId - type: 'groupChat', - message, - senderNickname: user.nickname, - senderProfileUrl: user.profileUrl, - }; - - this.notificationService.sendRealTimeNotification(id, notificationData); + await this.chatService.handleChatNotices(user, id, 'message', message); }); } @@ -353,7 +304,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { // DB에서 유저 삭제 + 채널 탈퇴 메세지 DB 저장 후 반환 const leaveMessage = await this.chatService.deleteUser(userId, channelId); - this.server.to(channelId.toString()).emit('message', leaveMessage); + client.broadcast.to(channelId.toString()).emit('message', leaveMessage); } // 메세지 실시간 읽음처리 @@ -369,6 +320,6 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { const { userId, channelId, messageId } = data; await this.chatService.increaseReadCount(messageId); await this.chatService.setLastMessageId(userId, channelId, messageId); - this.server.to(data.channelId.toString()).emit('readCounted', messageId); + this.server.to(data.channelId.toString()).emit('readCounted'); } } diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 54cf5ca..3dbad05 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -4,12 +4,14 @@ import { GetMessageDto } from './dto/getMessage.dto'; import { SearchMessageDto } from './dto/serchMessage.dto'; import { S3Service } from '@src/s3/s3.service'; import * as fileType from 'file-type'; +import { NotificationsService } from '@src/modules/notification/notification.service'; @Injectable() export class ChatService { constructor( private readonly prisma: PrismaService, - private readonly s3: S3Service + private readonly s3: S3Service, + private readonly notificationsService: NotificationsService ) {} // 온라인 유저 DB에 저장 @@ -314,7 +316,7 @@ export class ChatService { const result = await this.prisma.message.findMany({ orderBy: { // 커서값이 없다면(초기요청) direction 상관없이 desc 정렬 - id: cursor ? (direction == 'forward' ? 'asc' : 'desc') : 'desc', + id: cursor || direction === 'backward' ? 'desc' : 'asc', }, where: cursor ? { @@ -343,21 +345,16 @@ export class ChatService { }); // 메세지 데이터 양식화 - const data = await this.getMessageObj(result); + const messages = await this.getMessageObj(result); - // 메세지 데이터, 메세지 id순 오름차순 정렬 - const messages = - !cursor || direction == 'backward' ? data.reverse() : data; - - // 커서 const cursors = direction == 'backward' - ? { prev: data[0] ? data[0].messageId : null } - : { - next: data[data.length - 1] - ? data[data.length - 1].messageId - : null, - }; + ? messages[messages.length - 1] + ? messages[messages.length - 1].messageId + : null + : messages[0] + ? messages[0].messageId + : null; // 응답 메세지 const message = { @@ -366,7 +363,7 @@ export class ChatService { }; // 응답데이터 {메세지데이터, 커서, 응답메세지} - return { messages, cursors, message }; + return { messages, cursor: cursors, message }; } catch (err) { return err.message; } @@ -392,7 +389,7 @@ export class ChatService { select: { id: true }, }); direction = 'backward'; - cursor = res.id; + cursor = res.id + 1; } // 키워드에 해당하는 메세지id 검색 @@ -409,8 +406,10 @@ export class ChatService { }); if (!keywordMessage) { - const message = { code: 404, text: '메세지를 찾을 수 없습니다' }; - return { message }; + throw new HttpException( + '메세지를 찾을 수 없습니다', + HttpStatus.NOT_FOUND + ); } // 키워드 메세지 커서 설정 @@ -438,23 +437,16 @@ export class ChatService { const messages = await this.getMessageById(ids); // 무한 스크롤용 커서 데이터 - const cursors = { - // backward 무한스크롤 요청 커서 - prev: forwardIds.length ? forwardIds[0] : null, - // forward 무한스크롤 요청 커서 - next: backwordIds.length ? backwordIds[backwordIds.length - 1] : null, - // 검색 메세지 아이디 커서 - search, - }; + const cursors = search; const message = { code: 200, message: '데이터 패칭 성공', }; - return { messages, cursors, message }; + return { messages, cursor: cursors, message }; } catch (err) { - return err; + throw err; } } @@ -510,8 +502,8 @@ export class ChatService { user_id: userId, }, }); - const userData = this.getSenderProfile(userId); - const nickname = (await userData).nickname; + const userData = await this.getSenderProfile(userId); + const nickname = userData.nickname; const data = { type: 'exit', @@ -524,6 +516,16 @@ export class ChatService { data, }); + const lastMessage = await this.getLastMessageId(userId, channelId); + + await this.prisma.message.updateMany({ + where: { + id: { lt: lastMessage?.last_message_id || 0 }, + channel_id: channelId, + }, + data: { read_count: { decrement: 1 } }, + }); + return { userId, type: msg.type, @@ -566,14 +568,18 @@ export class ChatService { }; } - async increaseReadCount(messageId) { + async increaseReadCount(messageId: number) { await this.prisma.message.update({ where: { id: messageId }, data: { read_count: { increment: 1 } }, }); } - async setLastMessageId(userId, channelId, lastMessageId) { + async setLastMessageId( + userId: number, + channelId: number, + lastMessageId: number + ) { const exist = await this.prisma.last_message_status.findFirst({ where: { user_id: userId, channel_id: channelId }, }); @@ -595,7 +601,7 @@ export class ChatService { } // 라스트 메세지 id 조회 - async getLastMessageId(userId, channelId) { + async getLastMessageId(userId: number, channelId: number) { const lastMessageId = await this.prisma.last_message_status.findFirst({ where: { user_id: userId, @@ -647,4 +653,36 @@ export class ChatService { return result; } + + // 알림 생성 및 전송 + async handleChatNotices( + sender, + targetUserId: number, + type: string, + message: string + ) { + // 알림 생성 및 DB에 저장 + const createdNotification = + await this.notificationsService.createNotification( + targetUserId, + sender.userId, + type, + message + ); + + // 전송할 알림 데이터 객체 + const notificationData = { + notificationId: createdNotification.notificationId, // 포함된 notificationId + type: 'privateChat', + message, + senderNickname: sender.nickname, + senderProfileUrl: sender.profileUrl, + }; + + // SSE를 통해 실시간 알림 전송 + this.notificationsService.sendRealTimeNotification( + targetUserId, + notificationData + ); + } } diff --git a/src/feed/feed.service.ts b/src/feed/feed.service.ts index 6fa260e..5529928 100644 --- a/src/feed/feed.service.ts +++ b/src/feed/feed.service.ts @@ -46,8 +46,8 @@ export class FeedService { const result = await this.prisma.feedPost.findMany({ orderBy: { id: 'desc' }, where: { - ...(cursor ? { id: { lt: cursor } } : {}), // cursor 조건 추가 (옵셔널) ...(feedTagIds ? { id: { in: feedTagIds } } : {}), // 태그 조건 추가 (옵셔널) + ...(cursor ? { id: { lt: cursor } } : {}), // cursor 조건 추가 (옵셔널) }, take: limit, diff --git a/src/modules/notification/notification.controller.ts b/src/modules/notification/notification.controller.ts index b077c79..3121b6f 100644 --- a/src/modules/notification/notification.controller.ts +++ b/src/modules/notification/notification.controller.ts @@ -44,7 +44,6 @@ export class NotificationsController { } console.log(`✅ SSE 연결 성공 - 사용자 ${userId}`); - req.on('close', () => { console.log(`❌ 사용자 ${userId}와의 SSE 연결 종료`); }); diff --git a/src/modules/notification/notification.service.ts b/src/modules/notification/notification.service.ts index 3f68c75..3ac6ce2 100644 --- a/src/modules/notification/notification.service.ts +++ b/src/modules/notification/notification.service.ts @@ -25,9 +25,6 @@ export class NotificationsService { message, }, }); - - console.log('✅ 알림 생성 완료:', createdNotification); - return { notificationId: createdNotification.id, // `id`를 `notificationId`로 변경 ...createdNotification, @@ -39,8 +36,6 @@ export class NotificationsService { } async getUnreadNotifications(userId: number) { - console.log(`🔍 [getUnreadNotifications] 시작 - userId: ${userId}`); - // 1. 읽지 않은 알림 조회 const unreadNotifications = await this.prisma.notification.findMany({ where: { @@ -60,15 +55,10 @@ export class NotificationsService { }, }); - console.log( - '📥 [getUnreadNotifications] DB 조회 결과:', - unreadNotifications - ); - // 2. 데이터를 변환하여 반환 const transformedNotifications = unreadNotifications.map(notification => { const transformedNotification = { - notificationId: notification.id, // + notificationId: notification.id, userId: notification.userId, senderId: notification.senderId, type: notification.type, @@ -80,18 +70,8 @@ export class NotificationsService { profileUrl: notification.sender.profile_url, // `profile_url` -> `profileUrl` }, }; - - console.log( - '🔧 [getUnreadNotifications] 변환된 알림:', - transformedNotification - ); return transformedNotification; }); - - console.log('📤 [getUnreadNotifications] 최종 반환 데이터:', { - notifications: transformedNotifications, - }); - return { notifications: transformedNotifications, }; diff --git a/src/modules/project/dto/CreateProject.dto.ts b/src/modules/project/dto/CreateProject.dto.ts index 1f7682f..6278ceb 100644 --- a/src/modules/project/dto/CreateProject.dto.ts +++ b/src/modules/project/dto/CreateProject.dto.ts @@ -14,7 +14,6 @@ export class CreateProjectDto { title: string; @IsString() - @Length(1, 500) content: string; @IsString() diff --git a/src/modules/project/project.service.ts b/src/modules/project/project.service.ts index 83e0295..b2ad196 100644 --- a/src/modules/project/project.service.ts +++ b/src/modules/project/project.service.ts @@ -130,108 +130,112 @@ export class ProjectService { } async createProject(createProjectDto: CreateProjectDto, userId: number) { - const { - title, - content, - role, - hub_type, - start_date, - duration, - work_type, - recruiting, - skills, - detail_roles, - } = createProjectDto; - - const thumbnailUrl = await this.getThumbnailUrl(content); - // 프로젝트 생성 - const project = await this.prisma.projectPost.create({ - data: { + try { + const { title, content, role, hub_type, - start_date: new Date(start_date), + start_date, duration, work_type, recruiting, - thumbnail_url: thumbnailUrl, - user_id: userId, // userId를 사용하여 사용자 식별 - }, - }); + skills, + detail_roles, + } = createProjectDto; - // 태그 저장 (skills) - const tags = []; - for (const skill of skills) { - const tag = await this.prisma.projectTag.upsert({ - where: { name: skill }, - create: { name: skill }, - update: {}, - }); - - await this.prisma.projectPostTag.create({ + const thumbnailUrl = await this.getThumbnailUrl(content); + // 프로젝트 생성 + const project = await this.prisma.projectPost.create({ data: { - post_id: project.id, - tag_id: tag.id, + title, + content, + role, + hub_type, + start_date: new Date(start_date), + duration, + work_type, + recruiting, + thumbnail_url: thumbnailUrl, + user_id: userId, // userId를 사용하여 사용자 식별 }, }); - tags.push(tag.name); // 생성된 태그 추가 - } + // 태그 저장 (skills) + const tags = []; + for (const skill of skills) { + const tag = await this.prisma.projectTag.upsert({ + where: { name: skill }, + create: { name: skill }, + update: {}, + }); - const roleMapping: Record = { - Programmer: 1, - Artist: 2, - Designer: 3, - }; + await this.prisma.projectPostTag.create({ + data: { + post_id: project.id, + tag_id: tag.id, + }, + }); - // 매핑된 role_id 가져오기 - const saveRoleId = roleMapping[role]; + tags.push(tag.name); // 생성된 태그 추가 + } - // 모집단위 저장 (detail_roles) - const roles = []; - for (const detail_role of detail_roles) { - const role = await this.prisma.detailRole.upsert({ - where: { name: detail_role }, - create: { name: detail_role, role_id: saveRoleId }, - update: {}, - }); + const roleMapping: Record = { + Programmer: 1, + Artist: 2, + Designer: 3, + }; - await this.prisma.projectDetailRole.create({ - data: { - post_id: project.id, - detail_role_id: role.id, - }, - }); + // 매핑된 role_id 가져오기 + const saveRoleId = roleMapping[role]; - roles.push(role.name); // 생성된 모집단위 추가 - } + // 모집단위 저장 (detail_roles) + const roles = []; + for (const detail_role of detail_roles) { + const role = await this.prisma.detailRole.upsert({ + where: { name: detail_role }, + create: { name: detail_role, role_id: saveRoleId }, + update: {}, + }); - // 결과 반환 - return { - message: { - code: 201, - text: '프로젝트 생성에 성공했습니다', - }, - project: { - projectId: project.id, - title: project.title, - content: project.content, - thumbnailUrl: project.thumbnail_url, - role: project.role, - hubType: project.hub_type, - startDate: project.start_date, - duration: project.duration, - workType: project.work_type, - status: project.recruiting ? 'OPEN' : 'CLOSED', - viewCount: project.view, - applyCount: 0, - bookmarkCount: 0, - createdAt: project.created_at, - skills: tags, - detailRoles: roles, - }, - }; + await this.prisma.projectDetailRole.create({ + data: { + post_id: project.id, + detail_role_id: role.id, + }, + }); + + roles.push(role.name); // 생성된 모집단위 추가 + } + + // 결과 반환 + return { + message: { + code: 201, + text: '프로젝트 생성에 성공했습니다', + }, + project: { + projectId: project.id, + title: project.title, + content: project.content, + thumbnailUrl: project.thumbnail_url, + role: project.role, + hubType: project.hub_type, + startDate: project.start_date, + duration: project.duration, + workType: project.work_type, + status: project.recruiting ? 'OPEN' : 'CLOSED', + viewCount: project.view, + applyCount: 0, + bookmarkCount: 0, + createdAt: project.created_at, + skills: tags, + detailRoles: roles, + }, + }; + } catch (err) { + console.log(err); + } } async getPopularProjectsThisWeek() { @@ -317,90 +321,94 @@ export class ProjectService { } async getProjectDetail(userId: number, numProjectId: number) { - // 조회수 증가 - await this.prisma.projectPost.update({ - where: { id: numProjectId }, - data: { - view: { - increment: 1, // view 값을 1 증가 + try { + // 조회수 증가 + await this.prisma.projectPost.update({ + where: { id: numProjectId }, + data: { + view: { + increment: 1, // view 값을 1 증가 + }, }, - }, - }); + }); - // 프로젝트 상세 정보 조회 - const project = await this.prisma.projectPost.findUnique({ - where: { id: numProjectId }, - include: { - Tags: { - select: { - tag: { - select: { name: true }, + // 프로젝트 상세 정보 조회 + const project = await this.prisma.projectPost.findUnique({ + where: { id: numProjectId }, + include: { + Tags: { + select: { + tag: { + select: { name: true }, + }, }, }, - }, - Details: { - select: { - detail_role: { - select: { name: true }, + Details: { + select: { + detail_role: { + select: { name: true }, + }, }, }, - }, - user: { - select: { - id: true, - name: true, - nickname: true, - profile_url: true, - introduce: true, - role: true, + user: { + select: { + id: true, + name: true, + nickname: true, + profile_url: true, + introduce: true, + role: true, + }, + }, + Applications: { + select: { id: true }, // 지원 데이터를 가져옴 }, }, - Applications: { - select: { id: true }, // 지원 데이터를 가져옴 - }, - }, - }); + }); - if (!project) { - throw new NotFoundException('프로젝트를 찾을 수 없습니다.'); - } + if (!project) { + throw new NotFoundException('프로젝트를 찾을 수 없습니다.'); + } - // 사용자가 작성자인지 여부 확인 - const isOwnConnectionHub = project.user.id === userId; + // 사용자가 작성자인지 여부 확인 + const isOwnConnectionHub = project.user.id === userId; - // 데이터 반환 - return { - message: { - code: 200, - text: '프로젝트 상세 조회에 성공했습니다', - }, - project: { - projectId: project.id, - title: project.title, - content: project.content, - role: project.role, - hubType: project.hub_type, - startDate: project.start_date, - duration: project.duration, - workType: project.work_type, - status: project.recruiting ? 'OPEN' : 'CLOSED', - skills: project.Tags.map(t => t.tag.name), - detailRoles: project.Details.map(d => d.detail_role.name), - viewCount: project.view, // 이미 증가된 view 값을 사용 - bookmarkCount: project.saved_count, - applyCount: project.Applications.length, - createdAt: project.created_at, - manager: { - userId: project.user.id, - name: project.user.name, - nickname: project.user.nickname, - role: project.user.role.name, - profileUrl: project.user.profile_url, - introduce: project.user.introduce ? project.user.introduce : null, + // 데이터 반환 + return { + message: { + code: 200, + text: '프로젝트 상세 조회에 성공했습니다', }, - }, - isOwnConnectionHub, - }; + project: { + projectId: project.id, + title: project.title, + content: project.content, + role: project.role, + hubType: project.hub_type, + startDate: project.start_date, + duration: project.duration, + workType: project.work_type, + status: project.recruiting ? 'OPEN' : 'CLOSED', + skills: project.Tags.map(t => t.tag.name), + detailRoles: project.Details.map(d => d.detail_role.name), + viewCount: project.view, // 이미 증가된 view 값을 사용 + bookmarkCount: project.saved_count, + applyCount: project.Applications.length, + createdAt: project.created_at, + manager: { + userId: project.user.id, + name: project.user.name, + nickname: project.user.nickname, + role: project.user.role.name, + profileUrl: project.user.profile_url, + introduce: project.user.introduce ? project.user.introduce : null, + }, + }, + isOwnConnectionHub, + }; + } catch (err) { + console.log(err); + } } async applyToProject(userId: number, projectId: number) {