diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index 81d9d43..b3f6bf6 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -41,6 +41,21 @@ export interface OngoingOrderRow { }[]; } +/** 리뷰 작성 가능 주문 아이템 row. UserReviewService 매핑 입력. */ +export interface ReviewableOrderItemRow { + id: bigint; + product_id: bigint; + product_name_snapshot: string; + order: { picked_up_at: Date | null } | null; + product: { images: { image_url: string }[] } | null; + store: { + store_name: string; + address_city: string | null; + address_neighborhood: string | null; + region: { name: string } | null; + } | null; +} + @Injectable() export class OrderRepository { constructor(private readonly prisma: PrismaService) {} @@ -174,6 +189,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; + }): Promise<{ items: ReviewableOrderItemRow[]; totalCount: 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..f5f1a8e 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,149 @@ 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 pickedUpAt = new Date('2026-08-15T05:00:00.000Z'); + const orderItem = await prisma.orderItem.findUniqueOrThrow({ + where: { id: setup.orderItemId }, + }); + await prisma.order.update({ + where: { id: orderItem.order_id }, + data: { picked_up_at: pickedUpAt }, + }); + + const [item] = (await service.myReviewableOrderItems(setup.accountId)) + .items; + + expect(item).toMatchObject({ + orderItemId: setup.orderItemId.toString(), + productId: setup.productId.toString(), + productName: '상품R 스냅샷', + productImageUrl: 'https://img/review-target.png', + storeName: '매장R', + regionLabel: '인천 청라동', + pickedUpAt, + }); + }); + + 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!