diff --git a/prisma/migrations/20260104064222_add_notification_type_admin_message/migration.sql b/prisma/migrations/20260104064222_add_notification_type_admin_message/migration.sql new file mode 100644 index 0000000..34a977f --- /dev/null +++ b/prisma/migrations/20260104064222_add_notification_type_admin_message/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `Notification` MODIFY `type` ENUM('FOLLOW', 'NEW_PROMPT', 'INQUIRY', 'ANNOUNCEMENT', 'REPORT', 'ADMIN_MESSAGE') NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1fbd32c..8c6fd8c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -501,6 +501,7 @@ enum NotificationType { INQUIRY ANNOUNCEMENT REPORT + ADMIN_MESSAGE } enum Payment_provider { diff --git a/src/messages/services/message.service.ts b/src/messages/services/message.service.ts index 4fefa1c..00c59a5 100644 --- a/src/messages/services/message.service.ts +++ b/src/messages/services/message.service.ts @@ -2,6 +2,7 @@ import { Service } from "typedi"; import { CreateMessageDto } from "../dtos/message.dto"; import { MessageRepository } from "../repositories/message.repository"; import { AppError } from "../../errors/AppError"; +import eventBus from "../../config/eventBus"; @Service() export class MessageService { @@ -114,6 +115,9 @@ async sendMessage(currentUserId: number, data: CreateMessageDto) { const message = await this.messageRepository.createMessage(data); + // 새 관리자 메세지 알림 이벤트 발생 + eventBus.emit('adminMessage.created', data.sender_id, data.receiver_id, data.body); + return { message: "메시지 전송 성공", message_id: message.message_id, diff --git a/src/notifications/listeners/notification.listener.ts b/src/notifications/listeners/notification.listener.ts index 11ebd1e..bd47d26 100644 --- a/src/notifications/listeners/notification.listener.ts +++ b/src/notifications/listeners/notification.listener.ts @@ -5,6 +5,7 @@ import { createFollowNotification, createInquiryNotification, createPromptNotification, + createAdminMessageNtification } from "../services/notification.service"; @@ -52,4 +53,15 @@ eventBus.on('prompt.created', async (prompterId: number, promptId: number) => { } catch (err) { console.error("[알림 리스너 오류]: 새로운 프롬프트 업로드 알림 생성 실패", err); } -}); \ No newline at end of file +}); + + +// 관리자 메세지 알림 리스너 +eventBus.on('adminMessage.created', async( adminId: number, userId: number, content: string) => { + try { + await createAdminMessageNtification(adminId, userId, content); + } catch (err) { + console.error("[알림 리스너 오류]: 관리자 메세지 알림 생성 실패", err); + } + +}) \ No newline at end of file diff --git a/src/notifications/repositories/notification.repository.ts b/src/notifications/repositories/notification.repository.ts index 2fc4c36..464bf08 100644 --- a/src/notifications/repositories/notification.repository.ts +++ b/src/notifications/repositories/notification.repository.ts @@ -100,7 +100,6 @@ export const createNotification = async ({ }); }; -// ==========알림 목록 조회========== // ==========알림 목록 조회========== export const findNotificationsByUserId = async ( userId: number, @@ -131,9 +130,9 @@ export const findNotificationsByUserId = async ( }), }); - // 2. FOLLOW / NEW_PROMPT 알림에 대해 profileImage 조회 + // profileImage 조회 const actorIdsToFetch = notifications - .filter(n => (n.type === 'FOLLOW' || n.type === 'NEW_PROMPT') && n.actor) + .filter(n => (n.type === 'FOLLOW' || n.type === 'NEW_PROMPT' || n.type === 'ADMIN_MESSAGE') && n.actor) .map(n => n.actor!.user_id); let actorProfilesMap: Record = {}; @@ -160,7 +159,7 @@ export const findNotificationsByUserId = async ( ? { ...n.actor, profileImage: - n.type === 'FOLLOW' || n.type === 'NEW_PROMPT' + n.type === 'FOLLOW' || n.type === 'NEW_PROMPT' || n.type === 'ADMIN_MESSAGE' ? actorProfilesMap[n.actor.user_id] ?? null : null, } diff --git a/src/notifications/routes/notification.route.ts b/src/notifications/routes/notification.route.ts index 5071924..e5ae0b9 100644 --- a/src/notifications/routes/notification.route.ts +++ b/src/notifications/routes/notification.route.ts @@ -71,18 +71,25 @@ router.get('/me', authenticateJwt, getNotificationList); // 알림 목록 조회 * description: | * - 커서 기반 페이지네이션(cursor-based-pagination) 사용. * - `cursor`는 이전 요청에서 받은 마지막 데이터의 ID를 의미하며, 이를 기준으로 이후 데이터를 조회. + * * - 첫 요청 시에는 `cursor`를 생략하여 최신 데이터부터 조회. + * * - `has_more` 속성으로 더 불러올 데이터가 있는지 미리 확인 가능. * * - type 종류: - * - FOLLOW: 누가 나를 팔로우 했을 때 - * - NEW_PROMPT: 알림 설정한 프롬프터가 새 프롬프트를 올렸을 때 - * - INQUIRY: 나에게 문의사항이 도착했을 때 - * - ANNOUNCEMENT: 공지사항이 등록되었을 때 - * - REPORT: 내 신고가 접수되었을 때 + * - `FOLLOW`: 누가 나를 팔로우 했을 때 + * + * - `NEW_PROMPT`: 알림 설정한 프롬프터가 새 프롬프트를 올렸을 때 + * + * - `INQUIRY`: 나에게 문의사항이 도착했을 때 + * + * - `ANNOUNCEMENT`: 공지사항이 등록되었을 때 * - * - actor 필드는 알림을 유발한 사용자를 뜻하며, 타입이 REPORT, ANNOUNCEMENT일 때에만 null입니다. - * - profile_image 필드는 타입이 FOLLOW, NEW_PROMPT일 경우에만 반환됩니다. + * - `REPORT`: 내 신고가 접수되었을 때 + * + * - `ADMIN_MESSAGE`: 관리자 메시지가 도착했을 때 + * + * - `actor` 필드는 알림을 유발한 사용자를 뜻하며, 타입이 `REPORT`, `ANNOUNCEMENT`일 때에는 null입니다. * tags: [Notifications] * security: @@ -117,28 +124,31 @@ router.get('/me', authenticateJwt, getNotificationList); // 알림 목록 조회 * has_more: false * notifications: * - notification_id: 600 - * content: 신고가 접수되었습니다. + * content: "신고가 접수되었습니다." * type: REPORT * created_at: "2025-10-26T16:58:29.743Z" * link_url: null * actor: null * - notification_id: 599 - * content: 신고가 접수되었습니다. - * type: REPORT + * content: "`홍길동`님이 새 프롬프트를 업로드하셨습니다." + * type: NEW_PROMPT * created_at: "2025-10-26T16:57:04.162Z" - * link_url: null - * actor: null + * link_url: /profile/10 + * actor: + * user_id: 10 + * nickname: "홍길동" + * profile_image: "https://promptplace-s3.s3.ap-northeast-2.amazonaws.com/profile-images/1a2b3c4d-5678-90ab-cdef-1234567890ab_1755892991870.png" * - notification_id: 598 - * content: 신고가 접수되었습니다. - * type: REPORT + * content: "새로운 공지사항이 등록되었습니다." + * type: ANNOUNCEMENT * created_at: "2025-10-26T13:38:26.906Z" * link_url: null * actor: null * - notification_id: 489 - * content: "‘또도도잉’님이 회원님을 팔로우합니다." - * type: FOLLOW + * content: "프롬프트에 새로운 문의가 도착했습니다." + * type: INQUIRY * created_at: "2025-08-21T12:26:45.288Z" - * link_url: "/profile/33" + * link_url: "/inquiries/2" * actor: * user_id: 33 * nickname: "또도도잉" @@ -152,6 +162,15 @@ router.get('/me', authenticateJwt, getNotificationList); // 알림 목록 조회 * user_id: 33 * nickname: "또도도잉" * profile_image: "https://promptplace-s3.s3.ap-northeast-2.amazonaws.com/profile-images/3b137096-7915-408d-ad94-b70e5aa53107_1755892991870.png" + * - notification_id: 487 + * content: "안녕하세요. 프롬프트 플레이스 관리자입니다. 회원님의 원활한 서비스 이용을 위해 공지사항을 확인해주세요." + * type: ADMIN_MESSAGE + * created_at: "2025-08-21T12:26:42.522Z" + * link_url: null + * actor: + * user_id: 45 + * nickname: "관리자" + * profile_image: "https://promptplace-s3.s3.ap-northeast-2.amazonaws.com/profile-images/3b137096-7915-408d-ad94-b70e5aa53107_1755892991870.png" * statusCode: 200 * 401: * description: 인증 실패 diff --git a/src/notifications/services/notification.service.ts b/src/notifications/services/notification.service.ts index fcc06b6..8d44cc4 100644 --- a/src/notifications/services/notification.service.ts +++ b/src/notifications/services/notification.service.ts @@ -235,4 +235,16 @@ export const getNotificationHasNewStatusService = async ( const hasNew = latestNotificationTime > lastNotificationCheckTime; return { hasNew }; -}; \ No newline at end of file +}; + +// 관리자 메세지 알림 +export const createAdminMessageNtification = async( adminId: number, userId: number, content: string) => { + + return createNotificationService({ + userId, + type: NotificationType.ADMIN_MESSAGE, + content: content, + linkUrl: null, + actorId: adminId, + }); +} \ No newline at end of file