From 19357391ea0cc261a688bb99176b945c14610012 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 20 Aug 2026 02:51:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(user):=20=EB=A6=AC=EB=B7=B0=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EA=B0=80=EB=8A=A5=20=EC=A3=BC=EB=AC=B8=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EB=AA=A9=EB=A1=9D(myReviewableOrderItems)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit figma 마이페이지 리뷰 탭(MyPage_Review_Tab) '리뷰 남기기' 목록 대응. 기존에는 myOrders.hasReviewableItem + myOrder.canWriteReview 조합으로만 접근 가능해 아이템 플랫 목록·탭 카운트를 구할 수 없었다. - SDL user-review.graphql에 myReviewableOrderItems(input): MyReviewableOrderItemConnection! 추가 (totalCount = '리뷰 남기기 N' 탭 카운트) - 판정 조건은 기존 canWriteReview/findReviewableOrderIds와 동일: 픽업 완료(PICKED_UP) + 활성 리뷰 미존재(soft-delete 리뷰는 재작성 가능). 삭제된 주문 제외(nested soft-delete 가드 명시) - 카드 필드: orderItemId(writeReview 입력용)·productId·케이크명 스냅샷· 대표 이미지·매장명·지역 표기(buildRegionLabel 재사용)·pickedUpAt - 정렬은 픽업 최신순(picked_up_at desc, id desc). offset 페이지네이션 - OrderRepository.listReviewableOrderItems 추가, UserReviewService에서 조합 회귀 테스트 6건: 픽업 최신순·카운트, 카드 매핑, 활성 리뷰 제외·soft-delete 리뷰 재포함, 비픽업·삭제 주문·타인 주문 제외, offset/hasMore, resolver 통합 1건(작성 후 목록에서 빠짐). --- .../order/repositories/order.repository.ts | 62 ++++++++ .../inputs/my-reviewable-order-items.input.ts | 3 + .../resolvers/user-review-query.resolver.ts | 11 ++ .../resolvers/user-review.resolver.spec.ts | 26 ++++ .../user/services/user-review.service.spec.ts | 138 ++++++++++++++++++ .../user/services/user-review.service.ts | 32 ++++ .../user/types/user-review-output.type.ts | 16 ++ src/features/user/user-review.graphql | 29 ++++ 8 files changed, 317 insertions(+) create mode 100644 src/features/user/dto/inputs/my-reviewable-order-items.input.ts diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index 81d9d43..7dab92c 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -174,6 +174,68 @@ export class OrderRepository { return new Set(rows.map((r) => r.order_id.toString())); } + /** + * 리뷰 작성 가능한 주문 아이템 페이지(마이페이지 '리뷰 남기기' 탭). + * 조건은 canWriteReview/findReviewableOrderIds와 동일: 픽업 완료 + 활성 리뷰 미존재 + * (soft-delete된 리뷰는 재작성 가능으로 취급). 픽업 최신순 정렬. + */ + async listReviewableOrderItems(args: { + accountId: bigint; + offset: number; + limit: number; + }) { + const where = { + deleted_at: null, + order: { + account_id: args.accountId, + status: OrderStatus.PICKED_UP, + // soft-delete extension은 nested relation filter에 deleted_at을 주입하지 + // 않으므로 삭제된 주문의 아이템이 노출되지 않게 명시한다 + deleted_at: null, + }, + OR: [ + { review: { is: null } }, + { review: { is: { deleted_at: { not: null } } } }, + ], + }; + + const [items, totalCount] = await this.prisma.$transaction([ + this.prisma.orderItem.findMany({ + where, + orderBy: [{ order: { picked_up_at: 'desc' } }, { id: 'desc' }], + skip: args.offset, + take: args.limit, + select: { + id: true, + product_id: true, + product_name_snapshot: true, + order: { select: { picked_up_at: true } }, + product: { + select: { + images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + take: 1, + select: { image_url: true }, + }, + }, + }, + store: { + select: { + store_name: true, + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + }, + }, + }, + }), + this.prisma.orderItem.count({ where }), + ]); + + return { items, totalCount }; + } + async findOrderDetailByAccount(args: { orderId: bigint; accountId: bigint }) { return this.prisma.order.findFirst({ where: { diff --git a/src/features/user/dto/inputs/my-reviewable-order-items.input.ts b/src/features/user/dto/inputs/my-reviewable-order-items.input.ts new file mode 100644 index 0000000..32f24dc --- /dev/null +++ b/src/features/user/dto/inputs/my-reviewable-order-items.input.ts @@ -0,0 +1,3 @@ +import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; + +export class MyReviewableOrderItemsInput extends UserPaginationInput {} diff --git a/src/features/user/resolvers/user-review-query.resolver.ts b/src/features/user/resolvers/user-review-query.resolver.ts index 60c9181..f08a03e 100644 --- a/src/features/user/resolvers/user-review-query.resolver.ts +++ b/src/features/user/resolvers/user-review-query.resolver.ts @@ -1,9 +1,11 @@ import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; +import { MyReviewableOrderItemsInput } from '@/features/user/dto/inputs/my-reviewable-order-items.input'; import { MyReviewsInput } from '@/features/user/dto/inputs/my-reviews.input'; import { UserReviewService } from '@/features/user/services/user-review.service'; import type { + MyReviewableOrderItemConnection, MyReviewConnection, MyReviewOrNull, } from '@/features/user/types/user-review-output.type'; @@ -28,6 +30,15 @@ export class UserReviewQueryResolver { return this.reviewService.myReviews(accountId, input); } + @Query('myReviewableOrderItems') + myReviewableOrderItems( + @CurrentUser() user: JwtUser, + @Args('input') input?: MyReviewableOrderItemsInput, + ): Promise { + const accountId = parseAccountId(user); + return this.reviewService.myReviewableOrderItems(accountId, input); + } + @Query('myReviewForOrderItem') myReviewForOrderItem( @CurrentUser() user: JwtUser, diff --git a/src/features/user/resolvers/user-review.resolver.spec.ts b/src/features/user/resolvers/user-review.resolver.spec.ts index 8d6c3a2..a60d9c2 100644 --- a/src/features/user/resolvers/user-review.resolver.spec.ts +++ b/src/features/user/resolvers/user-review.resolver.spec.ts @@ -1,5 +1,6 @@ import type { PrismaClient } from '@prisma/client'; +import { OrderRepository } from '@/features/order'; import { ReviewRepository } from '@/features/user/repositories/review.repository'; import { UserReviewMutationResolver } from '@/features/user/resolvers/user-review-mutation.resolver'; import { UserReviewQueryResolver } from '@/features/user/resolvers/user-review-query.resolver'; @@ -36,6 +37,7 @@ describe('User Review Resolvers (real DB)', () => { UserReviewMutationResolver, UserReviewService, ReviewRepository, + OrderRepository, { provide: S3Service, useValue: s3Service }, ], }); @@ -112,4 +114,28 @@ describe('User Review Resolvers (real DB)', () => { expect(result.totalCount).toBe(1); expect(result.items[0].rating).toBe(4); }); + + it('Query.myReviewableOrderItems: 작성 가능 아이템이 조회되고 작성 후엔 빠진다', async () => { + const ctx = await setupReviewableItem(); + + const before = await queryResolver.myReviewableOrderItems({ + accountId: ctx.accountId.toString(), + }); + expect(before.totalCount).toBe(1); + expect(before.items[0].orderItemId).toBe(ctx.orderItemId.toString()); + + await mutationResolver.writeReview( + { accountId: ctx.accountId.toString() }, + { + orderItemId: ctx.orderItemId.toString(), + rating: 5, + content: VALID_CONTENT, + }, + ); + + const after = await queryResolver.myReviewableOrderItems({ + accountId: ctx.accountId.toString(), + }); + expect(after.totalCount).toBe(0); + }); }); diff --git a/src/features/user/services/user-review.service.spec.ts b/src/features/user/services/user-review.service.spec.ts index d0da04c..78d1b00 100644 --- a/src/features/user/services/user-review.service.spec.ts +++ b/src/features/user/services/user-review.service.spec.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import type { PrismaClient } from '@prisma/client'; +import { OrderRepository } from '@/features/order'; import { ReviewRepository } from '@/features/user/repositories/review.repository'; import { UserReviewService } from '@/features/user/services/user-review.service'; import { S3Service } from '@/global/storage/s3.service'; @@ -15,6 +16,7 @@ import { createOrder, createOrderItem, createProduct, + createReview, createStore, createUserProfile, } from '@/test/factories'; @@ -36,6 +38,7 @@ describe('UserReviewService (real DB)', () => { providers: [ UserReviewService, ReviewRepository, + OrderRepository, { provide: S3Service, useValue: s3Service }, ], }); @@ -536,4 +539,139 @@ describe('UserReviewService (real DB)', () => { ); }); }); + + describe('myReviewableOrderItems', () => { + /** 특정 계정의 픽업 완료 주문 아이템 생성. */ + async function pickUpItem( + accountId: bigint, + args?: { + productName?: string; + pickedUpAt?: Date; + orderStatus?: 'PICKED_UP' | 'CONFIRMED'; + orderDeletedAt?: Date | null; + }, + ): Promise { + const order = await createOrder(prisma, { + account_id: accountId, + status: args?.orderStatus ?? 'PICKED_UP', + }); + if (args?.pickedUpAt || args?.orderDeletedAt) { + await prisma.order.update({ + where: { id: order.id }, + data: { + ...(args.pickedUpAt ? { picked_up_at: args.pickedUpAt } : {}), + ...(args.orderDeletedAt ? { deleted_at: args.orderDeletedAt } : {}), + }, + }); + } + const item = await createOrderItem(prisma, { + order_id: order.id, + product_name_snapshot: args?.productName ?? '리뷰 대상 케이크', + }); + return item.id; + } + + it('픽업 완료 + 리뷰 미작성 아이템을 픽업 최신순으로 반환한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await pickUpItem(account.id, { + productName: '먼저 픽업', + pickedUpAt: new Date('2026-08-01T10:00:00Z'), + }); + await pickUpItem(account.id, { + productName: '나중 픽업', + pickedUpAt: new Date('2026-08-10T10:00:00Z'), + }); + + const result = await service.myReviewableOrderItems(account.id); + + expect(result.items.map((i) => i.productName)).toEqual([ + '나중 픽업', + '먼저 픽업', + ]); + expect(result.totalCount).toBe(2); + expect(result.hasMore).toBe(false); + }); + + it('카드 필드(이미지·매장명·지역명·픽업시각·orderItemId)를 매핑한다', async () => { + const setup = await setupReviewableOrderItem(); + await prisma.productImage.create({ + data: { + product_id: setup.productId, + image_url: 'https://img/review-target.png', + }, + }); + await prisma.store.update({ + where: { id: setup.storeId }, + data: { address_city: '인천', address_neighborhood: '청라동' }, + }); + + const [item] = (await service.myReviewableOrderItems(setup.accountId)) + .items; + + expect(item).toMatchObject({ + orderItemId: setup.orderItemId.toString(), + productName: '상품R 스냅샷', + productImageUrl: 'https://img/review-target.png', + storeName: '매장R', + regionLabel: '인천 청라동', + }); + }); + + it('활성 리뷰가 있으면 제외하고, soft-delete된 리뷰면 다시 포함한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const reviewed = await pickUpItem(account.id, { productName: '작성됨' }); + const review = await createReview(prisma, { order_item_id: reviewed }); + const rewritable = await pickUpItem(account.id, { + productName: '재작성 가능', + }); + const deletedReview = await createReview(prisma, { + order_item_id: rewritable, + }); + await prisma.review.update({ + where: { id: deletedReview.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.myReviewableOrderItems(account.id); + + expect(result.items.map((i) => i.productName)).toEqual(['재작성 가능']); + expect(review.deleted_at).toBeNull(); + }); + + it('픽업 완료가 아닌 주문·삭제된 주문·타인 주문은 제외한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await pickUpItem(account.id, { orderStatus: 'CONFIRMED' }); + await pickUpItem(account.id, { orderDeletedAt: new Date() }); + const other = await createAccount(prisma, { account_type: 'USER' }); + await pickUpItem(other.id); + + const result = await service.myReviewableOrderItems(account.id); + + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + }); + + it('offset/limit 페이지네이션과 hasMore를 계산한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + for (let i = 0; i < 3; i += 1) { + await pickUpItem(account.id, { + pickedUpAt: new Date(`2026-08-0${i + 1}T10:00:00Z`), + }); + } + + const firstPage = await service.myReviewableOrderItems(account.id, { + offset: 0, + limit: 2, + }); + const secondPage = await service.myReviewableOrderItems(account.id, { + offset: 2, + limit: 2, + }); + + expect(firstPage.items).toHaveLength(2); + expect(firstPage.hasMore).toBe(true); + expect(secondPage.items).toHaveLength(1); + expect(secondPage.hasMore).toBe(false); + }); + }); }); diff --git a/src/features/user/services/user-review.service.ts b/src/features/user/services/user-review.service.ts index 84c9c6e..0c04336 100644 --- a/src/features/user/services/user-review.service.ts +++ b/src/features/user/services/user-review.service.ts @@ -7,13 +7,17 @@ import { import { OrderStatus, ReviewMediaType } from '@prisma/client'; import { parseId } from '@/common/utils/id-parser'; +import { OrderRepository } from '@/features/order'; +import { buildRegionLabel } from '@/features/store'; import { USER_REVIEW_ERRORS } from '@/features/user/constants/user-review-error-messages'; import type { CreateReviewMediaUploadUrlInput } from '@/features/user/dto/inputs/create-review-media-upload-url.input'; +import type { MyReviewableOrderItemsInput } from '@/features/user/dto/inputs/my-reviewable-order-items.input'; import type { MyReviewsInput } from '@/features/user/dto/inputs/my-reviews.input'; import type { WriteReviewInput } from '@/features/user/dto/inputs/write-review.input'; import { ReviewRepository } from '@/features/user/repositories/review.repository'; import type { MyReview, + MyReviewableOrderItemConnection, MyReviewConnection, MyReviewOrNull, ReviewMediaUploadUrl, @@ -51,9 +55,37 @@ const MAX_VIDEO_COUNT = 1; export class UserReviewService { constructor( private readonly reviewRepo: ReviewRepository, + private readonly orderRepo: OrderRepository, private readonly s3Service: S3Service, ) {} + /** 리뷰 작성 가능한 주문 아이템 목록(마이페이지 '리뷰 남기기' 탭). 픽업 최신순. */ + async myReviewableOrderItems( + accountId: bigint, + input?: MyReviewableOrderItemsInput, + ): Promise { + const offset = input?.offset ?? 0; + const limit = input?.limit ?? 20; + + const { items, totalCount } = await this.orderRepo.listReviewableOrderItems( + { accountId, offset, limit }, + ); + + return { + items: items.map((item) => ({ + orderItemId: item.id.toString(), + productId: item.product_id.toString(), + productName: item.product_name_snapshot, + productImageUrl: item.product?.images?.[0]?.image_url ?? null, + storeName: item.store?.store_name ?? '매장 정보 없음', + regionLabel: item.store ? buildRegionLabel(item.store) : null, + pickedUpAt: item.order?.picked_up_at ?? null, + })), + totalCount, + hasMore: offset + limit < totalCount, + }; + } + async writeReview( accountId: bigint, input: WriteReviewInput, diff --git a/src/features/user/types/user-review-output.type.ts b/src/features/user/types/user-review-output.type.ts index e7dce65..899800c 100644 --- a/src/features/user/types/user-review-output.type.ts +++ b/src/features/user/types/user-review-output.type.ts @@ -24,6 +24,22 @@ export interface MyReviewConnection { hasMore: boolean; } +export interface MyReviewableOrderItem { + orderItemId: string; + productId: string; + productName: string; + productImageUrl: string | null; + storeName: string; + regionLabel: string | null; + pickedUpAt: Date | null; +} + +export interface MyReviewableOrderItemConnection { + items: MyReviewableOrderItem[]; + totalCount: number; + hasMore: boolean; +} + export interface MyReviewOrNull { review: MyReview | null; canWrite: boolean; diff --git a/src/features/user/user-review.graphql b/src/features/user/user-review.graphql index 3ec6e08..fe25bca 100644 --- a/src/features/user/user-review.graphql +++ b/src/features/user/user-review.graphql @@ -1,10 +1,39 @@ extend type Query { """내가 작성한 리뷰 목록""" myReviews(input: MyReviewsInput): MyReviewConnection! + """리뷰 작성 가능한 주문 아이템 목록(마이페이지 '리뷰 남기기' 탭). 픽업 완료 + 활성 리뷰 미존재.""" + myReviewableOrderItems(input: MyReviewableOrderItemsInput): MyReviewableOrderItemConnection! """주문 아이템에 대한 리뷰 작성 가능 여부 및 기존 리뷰""" myReviewForOrderItem(orderItemId: ID!): MyReviewOrNull! } +input MyReviewableOrderItemsInput { + offset: Int = 0 + limit: Int = 20 +} + +type MyReviewableOrderItemConnection { + items: [MyReviewableOrderItem!]! + """'리뷰 남기기 N' 탭 카운트""" + totalCount: Int! + hasMore: Boolean! +} + +"""리뷰 작성 가능한 주문 아이템 카드. writeReview 입력에 orderItemId를 그대로 사용한다.""" +type MyReviewableOrderItem { + orderItemId: ID! + productId: ID! + """케이크명(주문 시점 스냅샷)""" + productName: String! + """대표 상품 이미지. 없으면 null.""" + productImageUrl: String + storeName: String! + """매장 위치 표기(예: 인천 청라동). 없으면 null.""" + regionLabel: String + """픽업 완료 시각(최신 픽업 순 정렬 기준).""" + pickedUpAt: DateTime +} + extend type Mutation { """리뷰 미디어 업로드용 Presigned URL 발급""" createReviewMediaUploadUrl(input: CreateReviewMediaUploadUrlInput!): ReviewMediaUploadUrl!