Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/features/notification/constants/notification-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { OrderStatus } from '@prisma/client';

/** 주문 상태별 알림 제목. 매핑이 없는 상태는 알림을 만들지 않는다. */
export const ORDER_STATUS_NOTIFICATION_TITLES: Partial<
Record<OrderStatus, string>
> = {
[OrderStatus.CONFIRMED]: '주문이 확정되었습니다',
[OrderStatus.MADE]: '주문이 제작 완료되었습니다',
[OrderStatus.PICKED_UP]: '주문이 픽업 처리되었습니다',
};

/** 주문 상태별 알림 본문. 주문번호를 앞에 붙여 조립한다. */
export const ORDER_STATUS_NOTIFICATION_BODIES: Partial<
Record<OrderStatus, string>
> = {
[OrderStatus.CONFIRMED]: '주문이 확정되었습니다.',
[OrderStatus.MADE]: '주문의 상품 제작이 완료되었습니다.',
[OrderStatus.PICKED_UP]: '주문이 픽업 완료 처리되었습니다.',
};

export const REVIEW_LIKED_NOTIFICATION = {
title: '리뷰에 좋아요가 추가되었습니다',
body: '회원님의 리뷰를 다른 사용자가 좋아합니다.',
} as const;
6 changes: 6 additions & 0 deletions src/features/notification/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// 알림 내용(문구·이벤트 매핑)의 단일 소스 — order·user repository가 소비한다.
export {
buildOrderStatusNotification,
buildReviewLikedNotification,
type NotificationPayload,
} from '@/features/notification/services/notification-payloads.helper';
Original file line number Diff line number Diff line change
@@ -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: '회원님의 리뷰를 다른 사용자가 좋아합니다.',
});
});
});
});
64 changes: 64 additions & 0 deletions src/features/notification/services/notification-payloads.helper.ts
Original file line number Diff line number Diff line change
@@ -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, NotificationEvent>
> = {
[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,
};
}
57 changes: 7 additions & 50 deletions src/features/order/repositories/order.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -141,7 +140,7 @@ export class OrderRepository {
/**
* SUBMITTED 주문 생성. Order + OrderItem + 옵션 스냅샷 + 상태 히스토리를
* 트랜잭션으로 원자 생성한다. SUBMITTED는 알림 미발송
* (알림은 판매자 상태 변경부터 — orderStatusToNotificationEvent 규칙).
* (알림은 판매자 상태 변경부터 — buildOrderStatusNotification 규칙).
* capacityGuard가 있으면 capacity 행을 FOR UPDATE로 잠근 뒤 점유를
* 재집계해, 동시 주문이 마지막 잔여를 함께 차지하는 race를 차단한다.
* capacity 초과면 null을 반환한다(호출부가 도메인 에러로 변환).
Expand Down Expand Up @@ -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,
},
});
}
Expand All @@ -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} 주문 상태가 변경되었습니다.`;
}
}
8 changes: 3 additions & 5 deletions src/features/user/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
},
});

Expand Down
Loading