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(), }, });