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
77 changes: 77 additions & 0 deletions src/features/order/repositories/order.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input';

export class MyReviewableOrderItemsInput extends UserPaginationInput {}
11 changes: 11 additions & 0 deletions src/features/user/resolvers/user-review-query.resolver.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -28,6 +30,15 @@ export class UserReviewQueryResolver {
return this.reviewService.myReviews(accountId, input);
}

@Query('myReviewableOrderItems')
myReviewableOrderItems(
@CurrentUser() user: JwtUser,
@Args('input') input?: MyReviewableOrderItemsInput,
): Promise<MyReviewableOrderItemConnection> {
const accountId = parseAccountId(user);
return this.reviewService.myReviewableOrderItems(accountId, input);
}

@Query('myReviewForOrderItem')
myReviewForOrderItem(
@CurrentUser() user: JwtUser,
Expand Down
26 changes: 26 additions & 0 deletions src/features/user/resolvers/user-review.resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -36,6 +37,7 @@ describe('User Review Resolvers (real DB)', () => {
UserReviewMutationResolver,
UserReviewService,
ReviewRepository,
OrderRepository,
{ provide: S3Service, useValue: s3Service },
],
});
Expand Down Expand Up @@ -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);
});
});
148 changes: 148 additions & 0 deletions src/features/user/services/user-review.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,6 +16,7 @@ import {
createOrder,
createOrderItem,
createProduct,
createReview,
createStore,
createUserProfile,
} from '@/test/factories';
Expand All @@ -36,6 +38,7 @@ describe('UserReviewService (real DB)', () => {
providers: [
UserReviewService,
ReviewRepository,
OrderRepository,
{ provide: S3Service, useValue: s3Service },
],
});
Expand Down Expand Up @@ -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<bigint> {
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,
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
});
});
});
32 changes: 32 additions & 0 deletions src/features/user/services/user-review.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<MyReviewableOrderItemConnection> {
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,
Expand Down
Loading
Loading