From 3a008996fa05b199455aed906546b4752fb9b258 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Fri, 28 Aug 2026 01:45:50 +0900 Subject: [PATCH] =?UTF-8?q?refactor(notification):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=C2=B7=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=EC=9D=84=20notification=20feature=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #203 단기 단계. 주문 상태 알림의 문구·이벤트 매핑이 order.repository private 메서드에, 리뷰 좋아요 알림 문구가 user.repository 인라인에 박혀 있어 알림 정책 변경이 데이터 계층 수정으로 번지는 구조였다. 알림 생성 경로는 이 2곳이 전부다. - src/features/notification 신설(순수 함수 + 상수만, DI·module 없음): buildOrderStatusNotification / buildReviewLikedNotification이 type·event·문구 payload의 단일 소스. cross-feature 배럴로 노출. - order.repository: private 3메서드 삭제 → payload 빌더 호출 + 저장 위임만. CANCELED의 도달 불가 문구 분기는 통합 빌더에서 제거(동작 동일 — event 매핑이 없어 원래도 알림 미생성). - user.repository: 리뷰 좋아요 인라인 문구 → 빌더 조합. - 원자성은 동기 same-tx 유지(사용자 확정). 이벤트+outbox 전환은 푸시 등 외부 채널 도입 시 재검토. 회귀: 기존 알림 검증 spec(order.repository.spec의 CONFIRMED 생성·CANCELED 미생성, user-engagement.spec의 좋아요 알림) 무변경 green. 신규 notification-payloads.helper.spec 4케이스(상태별 payload·비대상 상태 null· 좋아요 payload). 전체 validate green (192 suites / 1,660 tests). --- .../constants/notification-messages.ts | 24 +++++++ src/features/notification/index.ts | 6 ++ .../notification-payloads.helper.spec.ts | 62 ++++++++++++++++++ .../services/notification-payloads.helper.ts | 64 +++++++++++++++++++ .../order/repositories/order.repository.ts | 57 ++--------------- .../user/repositories/user.repository.ts | 8 +-- 6 files changed, 166 insertions(+), 55 deletions(-) create mode 100644 src/features/notification/constants/notification-messages.ts create mode 100644 src/features/notification/index.ts create mode 100644 src/features/notification/services/notification-payloads.helper.spec.ts create mode 100644 src/features/notification/services/notification-payloads.helper.ts diff --git a/src/features/notification/constants/notification-messages.ts b/src/features/notification/constants/notification-messages.ts new file mode 100644 index 0000000..1993ae1 --- /dev/null +++ b/src/features/notification/constants/notification-messages.ts @@ -0,0 +1,24 @@ +import { OrderStatus } from '@prisma/client'; + +/** 주문 상태별 알림 제목. 매핑이 없는 상태는 알림을 만들지 않는다. */ +export const ORDER_STATUS_NOTIFICATION_TITLES: Partial< + Record +> = { + [OrderStatus.CONFIRMED]: '주문이 확정되었습니다', + [OrderStatus.MADE]: '주문이 제작 완료되었습니다', + [OrderStatus.PICKED_UP]: '주문이 픽업 처리되었습니다', +}; + +/** 주문 상태별 알림 본문. 주문번호를 앞에 붙여 조립한다. */ +export const ORDER_STATUS_NOTIFICATION_BODIES: Partial< + Record +> = { + [OrderStatus.CONFIRMED]: '주문이 확정되었습니다.', + [OrderStatus.MADE]: '주문의 상품 제작이 완료되었습니다.', + [OrderStatus.PICKED_UP]: '주문이 픽업 완료 처리되었습니다.', +}; + +export const REVIEW_LIKED_NOTIFICATION = { + title: '리뷰에 좋아요가 추가되었습니다', + body: '회원님의 리뷰를 다른 사용자가 좋아합니다.', +} as const; diff --git a/src/features/notification/index.ts b/src/features/notification/index.ts new file mode 100644 index 0000000..8c0b6a4 --- /dev/null +++ b/src/features/notification/index.ts @@ -0,0 +1,6 @@ +// 알림 내용(문구·이벤트 매핑)의 단일 소스 — order·user repository가 소비한다. +export { + buildOrderStatusNotification, + buildReviewLikedNotification, + type NotificationPayload, +} from '@/features/notification/services/notification-payloads.helper'; diff --git a/src/features/notification/services/notification-payloads.helper.spec.ts b/src/features/notification/services/notification-payloads.helper.spec.ts new file mode 100644 index 0000000..b3593bb --- /dev/null +++ b/src/features/notification/services/notification-payloads.helper.spec.ts @@ -0,0 +1,62 @@ +import { + NotificationEvent, + NotificationType, + OrderStatus, +} from '@prisma/client'; + +import { + buildOrderStatusNotification, + buildReviewLikedNotification, +} from '@/features/notification/services/notification-payloads.helper'; + +describe('notification-payloads.helper', () => { + describe('buildOrderStatusNotification', () => { + it('CONFIRMED는 주문번호가 붙은 확정 알림 payload를 만든다', () => { + expect( + buildOrderStatusNotification('ORD-1', OrderStatus.CONFIRMED), + ).toEqual({ + type: NotificationType.ORDER_STATUS, + event: NotificationEvent.ORDER_CONFIRMED, + title: '주문이 확정되었습니다', + body: 'ORD-1 주문이 확정되었습니다.', + }); + }); + + it('MADE·PICKED_UP도 상태별 이벤트·문구로 매핑된다', () => { + expect(buildOrderStatusNotification('ORD-2', OrderStatus.MADE)).toEqual({ + type: NotificationType.ORDER_STATUS, + event: NotificationEvent.ORDER_MADE, + title: '주문이 제작 완료되었습니다', + body: 'ORD-2 주문의 상품 제작이 완료되었습니다.', + }); + expect( + buildOrderStatusNotification('ORD-3', OrderStatus.PICKED_UP), + ).toEqual({ + type: NotificationType.ORDER_STATUS, + event: NotificationEvent.ORDER_PICKED_UP, + title: '주문이 픽업 처리되었습니다', + body: 'ORD-3 주문이 픽업 완료 처리되었습니다.', + }); + }); + + it('알림 대상이 아닌 상태(CANCELED·SUBMITTED)는 null을 반환한다', () => { + expect( + buildOrderStatusNotification('ORD-4', OrderStatus.CANCELED), + ).toBeNull(); + expect( + buildOrderStatusNotification('ORD-5', OrderStatus.SUBMITTED), + ).toBeNull(); + }); + }); + + describe('buildReviewLikedNotification', () => { + it('리뷰 좋아요 알림 payload를 만든다', () => { + expect(buildReviewLikedNotification()).toEqual({ + type: NotificationType.REVIEW_LIKE, + event: NotificationEvent.REVIEW_LIKED, + title: '리뷰에 좋아요가 추가되었습니다', + body: '회원님의 리뷰를 다른 사용자가 좋아합니다.', + }); + }); + }); +}); diff --git a/src/features/notification/services/notification-payloads.helper.ts b/src/features/notification/services/notification-payloads.helper.ts new file mode 100644 index 0000000..6136482 --- /dev/null +++ b/src/features/notification/services/notification-payloads.helper.ts @@ -0,0 +1,64 @@ +import { + NotificationEvent, + NotificationType, + OrderStatus, +} from '@prisma/client'; + +import { + ORDER_STATUS_NOTIFICATION_BODIES, + ORDER_STATUS_NOTIFICATION_TITLES, + REVIEW_LIKED_NOTIFICATION, +} from '@/features/notification/constants/notification-messages'; + +/** + * 알림 내용(type·event·문구)의 단일 소스 (이슈 #203). + * "무엇을 알릴지"는 여기서, "언제 어떤 row로 저장할지"는 각 repository가 + * 트랜잭션 안에서 담당한다 — 문구·채널 정책이 바뀌어도 데이터 계층은 불변. + * DI-free 순수 함수만 둔다. + */ + +export interface NotificationPayload { + type: NotificationType; + event: NotificationEvent; + title: string; + body: string; +} + +/** 주문 상태 → 알림 이벤트. 알림 대상이 아닌 상태(CANCELED 등)는 null. */ +const ORDER_STATUS_NOTIFICATION_EVENTS: Partial< + Record +> = { + [OrderStatus.CONFIRMED]: NotificationEvent.ORDER_CONFIRMED, + [OrderStatus.MADE]: NotificationEvent.ORDER_MADE, + [OrderStatus.PICKED_UP]: NotificationEvent.ORDER_PICKED_UP, +}; + +/** + * 주문 상태 변경 알림 payload. 알림 대상이 아닌 상태면 null을 반환하고, + * 호출부는 그 경우 알림을 생성하지 않는다(CANCELED는 정책상 알림 없음). + */ +export function buildOrderStatusNotification( + orderNumber: string, + toStatus: OrderStatus, +): NotificationPayload | null { + const event = ORDER_STATUS_NOTIFICATION_EVENTS[toStatus]; + const title = ORDER_STATUS_NOTIFICATION_TITLES[toStatus]; + const body = ORDER_STATUS_NOTIFICATION_BODIES[toStatus]; + if (!event || !title || !body) return null; + return { + type: NotificationType.ORDER_STATUS, + event, + title, + body: `${orderNumber} ${body}`, + }; +} + +/** 리뷰 최초 좋아요 알림 payload(복원 좋아요는 호출부에서 알림 생략). */ +export function buildReviewLikedNotification(): NotificationPayload { + return { + type: NotificationType.REVIEW_LIKE, + event: NotificationEvent.REVIEW_LIKED, + title: REVIEW_LIKED_NOTIFICATION.title, + body: REVIEW_LIKED_NOTIFICATION.body, + }; +} diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index ccd0c41..1c4cbc4 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -2,13 +2,12 @@ import { Injectable } from '@nestjs/common'; import { AuditActionType, AuditTargetType, - NotificationEvent, - NotificationType, OrderStatus, Prisma, type AccountType, } from '@prisma/client'; +import { buildOrderStatusNotification } from '@/features/notification'; import { activeWhere, PrismaService } from '@/prisma'; export interface MyOrderRow { @@ -141,7 +140,7 @@ export class OrderRepository { /** * SUBMITTED 주문 생성. Order + OrderItem + 옵션 스냅샷 + 상태 히스토리를 * 트랜잭션으로 원자 생성한다. SUBMITTED는 알림 미발송 - * (알림은 판매자 상태 변경부터 — orderStatusToNotificationEvent 규칙). + * (알림은 판매자 상태 변경부터 — buildOrderStatusNotification 규칙). * capacityGuard가 있으면 capacity 행을 FOR UPDATE로 잠근 뒤 점유를 * 재집계해, 동시 주문이 마지막 잔여를 함께 차지하는 race를 차단한다. * capacity 초과면 null을 반환한다(호출부가 도메인 에러로 변환). @@ -672,21 +671,17 @@ export class OrderRepository { }, }); - const notificationEvent = this.orderStatusToNotificationEvent( + // 알림 내용은 notification feature가 단일 소스 — 여기는 저장 위임만 한다 + const notification = buildOrderStatusNotification( + updatedOrder.order_number, args.toStatus, ); - if (notificationEvent) { + if (notification) { await tx.notification.create({ data: { account_id: order.account_id, - type: NotificationType.ORDER_STATUS, - title: this.notificationTitleByOrderStatus(args.toStatus), - body: this.notificationBodyByOrderStatus( - updatedOrder.order_number, - args.toStatus, - ), - event: notificationEvent, order_id: order.id, + ...notification, }, }); } @@ -713,42 +708,4 @@ export class OrderRepository { return updatedOrder; }); } - - private orderStatusToNotificationEvent( - status: OrderStatus, - ): NotificationEvent | null { - if (status === OrderStatus.CONFIRMED) - return NotificationEvent.ORDER_CONFIRMED; - if (status === OrderStatus.MADE) return NotificationEvent.ORDER_MADE; - if (status === OrderStatus.PICKED_UP) - return NotificationEvent.ORDER_PICKED_UP; - return null; - } - - private notificationTitleByOrderStatus(status: OrderStatus): string { - if (status === OrderStatus.CONFIRMED) return '주문이 확정되었습니다'; - if (status === OrderStatus.MADE) return '주문이 제작 완료되었습니다'; - if (status === OrderStatus.PICKED_UP) return '주문이 픽업 처리되었습니다'; - if (status === OrderStatus.CANCELED) return '주문이 취소되었습니다'; - return '주문 상태가 변경되었습니다'; - } - - private notificationBodyByOrderStatus( - orderNumber: string, - status: OrderStatus, - ): string { - if (status === OrderStatus.CONFIRMED) { - return `${orderNumber} 주문이 확정되었습니다.`; - } - if (status === OrderStatus.MADE) { - return `${orderNumber} 주문의 상품 제작이 완료되었습니다.`; - } - if (status === OrderStatus.PICKED_UP) { - return `${orderNumber} 주문이 픽업 완료 처리되었습니다.`; - } - if (status === OrderStatus.CANCELED) { - return `${orderNumber} 주문이 취소되었습니다.`; - } - return `${orderNumber} 주문 상태가 변경되었습니다.`; - } } diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index f42f6a5..a2a266a 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -3,12 +3,12 @@ import { AccountType, CustomDraftStatus, IdentityProvider, - NotificationEvent, NotificationType, Prisma, } from '@prisma/client'; import { buildWithdrawnProviderSubject } from '@/common/utils/withdrawn-identity'; +import { buildReviewLikedNotification } from '@/features/notification'; import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; export interface UserAccountIdentity { @@ -679,16 +679,14 @@ export class UserRepository { }, }); + // 알림 내용은 notification feature가 단일 소스 — 여기는 저장 위임만 한다 await tx.notification.create({ data: { account_id: review.account_id, - type: NotificationType.REVIEW_LIKE, - event: NotificationEvent.REVIEW_LIKED, - title: '리뷰에 좋아요가 추가되었습니다', - body: '회원님의 리뷰를 다른 사용자가 좋아합니다.', review_id: review.id, store_id: review.store_id, product_id: review.product_id, + ...buildReviewLikedNotification(), }, });