diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index d252967..81d9d43 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -142,6 +142,38 @@ export class OrderRepository { }); } + /** + * 주어진 orderId 중 PICKED_UP 상태이며 active 리뷰가 미작성인 OrderItem을 + * 1건 이상 가진 order의 ID 집합을 반환한다. + * + * 주의: list 매핑에서 order별 개별 쿼리(N+1) 회피용. 단일 IN 쿼리로 처리. + */ + async findReviewableOrderIds(args: { + accountId: bigint; + orderIds: bigint[]; + }): Promise> { + if (args.orderIds.length === 0) return new Set(); + + const rows = await this.prisma.orderItem.findMany({ + where: { + order_id: { in: args.orderIds }, + deleted_at: null, + order: { + account_id: args.accountId, + status: OrderStatus.PICKED_UP, + }, + OR: [ + { review: { is: null } }, + { review: { is: { deleted_at: { not: null } } } }, + ], + }, + select: { order_id: true }, + distinct: ['order_id'], + }); + + return new Set(rows.map((r) => r.order_id.toString())); + } + async findOrderDetailByAccount(args: { orderId: bigint; accountId: bigint }) { return this.prisma.order.findFirst({ where: { diff --git a/src/features/user/services/user-order.service.spec.ts b/src/features/user/services/user-order.service.spec.ts index 5029e14..6cf9019 100644 --- a/src/features/user/services/user-order.service.spec.ts +++ b/src/features/user/services/user-order.service.spec.ts @@ -171,6 +171,158 @@ describe('UserOrderService (real DB)', () => { service.listMyOrders(account.id, { limit: 51 }), ).rejects.toThrow(BadRequestException); }); + + it('주문이 0건이면 빈 connection을 반환한다', async () => { + const account = await setupUser(); + + const result = await service.listMyOrders(account.id); + + expect(result.totalCount).toBe(0); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + }); + + // ─── hasReviewableItem ─── + describe('hasReviewableItem', () => { + async function createPickedUpOrderWithItem(accountId: bigint) { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: accountId, + status: 'PICKED_UP', + }); + const item = await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + return { store, product, order, item }; + } + + async function createReview(args: { + orderItemId: bigint; + accountId: bigint; + storeId: bigint; + productId: bigint; + deletedAt?: Date | null; + }) { + return prisma.review.create({ + data: { + order_item_id: args.orderItemId, + account_id: args.accountId, + store_id: args.storeId, + product_id: args.productId, + rating: 5, + content: '리뷰 더미 텍스트입니다. 만족합니다.', + deleted_at: args.deletedAt ?? null, + }, + }); + } + + it('PICKED_UP + 리뷰 미작성 item이 1건이면 true', async () => { + const account = await setupUser(); + await createPickedUpOrderWithItem(account.id); + + const result = await service.listMyOrders(account.id); + + expect(result.items).toHaveLength(1); + expect(result.items[0].hasReviewableItem).toBe(true); + }); + + it('PICKED_UP + 모든 item에 active 리뷰가 있으면 false', async () => { + const account = await setupUser(); + const { item, store, product } = await createPickedUpOrderWithItem( + account.id, + ); + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('PICKED_UP + 모든 item의 리뷰가 soft-delete면 true', async () => { + const account = await setupUser(); + const { item, store, product } = await createPickedUpOrderWithItem( + account.id, + ); + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + deletedAt: new Date(), + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(true); + }); + + it('CONFIRMED 등 비-PICKED_UP 상태는 false', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: account.id, + status: 'CONFIRMED', + }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].status).toBe('CONFIRMED'); + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('CANCELED 상태는 false', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: account.id, + status: 'CANCELED', + }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].status).toBe('CANCELED'); + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('PICKED_UP + 일부 item에만 리뷰 미작성이면 true (혼합 케이스)', async () => { + const account = await setupUser(); + const { item, store, product, order } = + await createPickedUpOrderWithItem(account.id); + // item1에는 리뷰가 있고, item2에는 없음 + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + }); + const product2 = await createProduct(prisma, { store_id: store.id }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product2.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(true); + }); + }); }); // ─── getMyOrder ─── diff --git a/src/features/user/services/user-order.service.ts b/src/features/user/services/user-order.service.ts index dc7cf25..594165c 100644 --- a/src/features/user/services/user-order.service.ts +++ b/src/features/user/services/user-order.service.ts @@ -51,6 +51,13 @@ export class UserOrderService { const hasMore = orders.length > limit; const sliced = hasMore ? orders.slice(0, limit) : orders; + // N+1 회피: PICKED_UP + 미작성 리뷰가 있는 order id 집합을 단일 IN 쿼리로 조회 + const reviewableOrderIds = + await this.orderRepository.findReviewableOrderIds({ + accountId, + orderIds: sliced.map((o) => o.id), + }); + return { items: sliced.map((order) => { const firstItem = order.items[0]; @@ -69,6 +76,7 @@ export class UserOrderService { additionalItemCount: Math.max(0, itemCount - 1), totalPrice: order.total_price, storeName: firstItem?.store?.store_name ?? '매장 정보 없음', + hasReviewableItem: reviewableOrderIds.has(order.id.toString()), }; }), totalCount, diff --git a/src/features/user/types/user-order-output.type.ts b/src/features/user/types/user-order-output.type.ts index 4013c1f..76ee5db 100644 --- a/src/features/user/types/user-order-output.type.ts +++ b/src/features/user/types/user-order-output.type.ts @@ -11,6 +11,7 @@ export interface MyOrderSummary { additionalItemCount: number; totalPrice: number; storeName: string; + hasReviewableItem: boolean; } export interface MyOrderConnection { diff --git a/src/features/user/user-order.graphql b/src/features/user/user-order.graphql index 0ed313a..41025f7 100644 --- a/src/features/user/user-order.graphql +++ b/src/features/user/user-order.graphql @@ -37,6 +37,8 @@ type MyOrderSummary { totalPrice: Int! """매장명""" storeName: String! + """주문 내 OrderItem 중 리뷰 작성 가능한 것이 1건이라도 있는지 (PICKED_UP + 활성 리뷰 미존재)""" + hasReviewableItem: Boolean! } """주문 상세"""