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
2 changes: 2 additions & 0 deletions src/features/store/constants/store-review.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** 매장 리뷰 목록 기본 페이지 크기. */
export const DEFAULT_STORE_REVIEWS_LIMIT = 20;
16 changes: 16 additions & 0 deletions src/features/store/dto/inputs/store-reviews.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';

export class StoreReviewsInput {
@IsString()
storeId!: string;

@IsOptional()
@IsString()
cursor?: string;

@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
117 changes: 117 additions & 0 deletions src/features/store/repositories/store-review.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Injectable } from '@nestjs/common';
import { Prisma, type ReviewMediaType } from '@prisma/client';

import { PrismaService } from '@/prisma';

export interface StoreReviewMediaRow {
media_type: ReviewMediaType;
media_url: string;
thumbnail_url: string | null;
sort_order: number;
}

/** 매장 공개 리뷰 조회 결과 row. storeReviews 매퍼 입력. */
export interface StoreReviewRow {
id: bigint;
rating: Prisma.Decimal;
content: string | null;
created_at: Date;
account: {
user_profile: { nickname: string; deleted_at: Date | null } | null;
};
order_item: { product_name_snapshot: string };
media: StoreReviewMediaRow[];
}

/**
* 매장 공개 리뷰 조회 전용 repository.
*
* user feature의 ReviewRepository(본인 리뷰 작성/관리)와 책임이 분리된다.
* 같은 review 테이블을 읽지만 "매장의 공개 리뷰 목록 + 좋아요 집계"는 매장 조회 유스케이스.
*/
@Injectable()
export class StoreReviewRepository {
constructor(private readonly prisma: PrismaService) {}

/** 매장 공개 리뷰 목록(최신순, 커서 id desc). soft-delete 제외. */
async listStoreReviews(args: {
storeId: bigint;
limit: number;
cursor?: bigint;
}): Promise<StoreReviewRow[]> {
return this.prisma.review.findMany({
where: {
store_id: args.storeId,
deleted_at: null,
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict storeReviews to active stores

When a store is soft-deleted or marked inactive, storeDetail hides it via findStoreDetailById (is_active: true, deleted_at: null), but this new public review query only filters reviews by store_id. Anyone who still knows that store ID can call storeReviews and receive the review content/media and totalCount; add the same active-store relation filter or an existence check, and mirror it in countStoreReviews.

Useful? React with 👍 / 👎.

// storeDetail과 동일하게 비활성/삭제 매장의 리뷰는 노출하지 않는다
store: { is_active: true, deleted_at: null },
...(args.cursor ? { id: { lt: args.cursor } } : {}),
},
select: {
id: true,
rating: true,
content: true,
created_at: true,
account: {
// soft-delete extension은 nested relation에 deleted_at을 주입하지 않으므로
// deleted_at을 함께 읽어 탈퇴 작성자 닉네임은 매퍼에서 익명화한다
select: {
user_profile: { select: { nickname: true, deleted_at: true } },
},
},
Comment on lines +55 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide nicknames for deleted review authors

When a user deletes their account after writing a review, softDeleteAccount marks userProfile.deleted_at and overwrites the nickname to deleted_<accountId> (src/features/user/repositories/user.repository.ts), but this nested select still reads the profile without checking deleted_at (the soft-delete extension only patches the root review.findMany). As a result, the public storeReviews response exposes authorNickname: "deleted_123" for deleted users instead of anonymizing it; select deleted_at and map deleted profiles to null or filter the relation.

Useful? React with 👍 / 👎.

order_item: { select: { product_name_snapshot: true } },
media: {
where: { deleted_at: null },
orderBy: { sort_order: 'asc' },
select: {
media_type: true,
media_url: true,
thumbnail_url: true,
sort_order: true,
},
},
},
orderBy: { id: 'desc' },
take: args.limit + 1,
});
}

/** 매장 활성 리뷰 수(후기 탭 카운트). 비활성/삭제 매장은 0. */
async countStoreReviews(storeId: bigint): Promise<number> {
return this.prisma.review.count({
where: {
store_id: storeId,
deleted_at: null,
store: { is_active: true, deleted_at: null },
},
});
}

/** 리뷰별 좋아요 수. */
async aggregateLikeCounts(reviewIds: bigint[]): Promise<Map<bigint, number>> {
if (reviewIds.length === 0) return new Map();
const rows = await this.prisma.reviewLike.groupBy({
by: ['review_id'],
where: { review_id: { in: reviewIds }, deleted_at: null },
_count: { _all: true },
});
return new Map(rows.map((r) => [r.review_id, r._count._all]));
}

/** 로그인 사용자가 좋아요한 review_id 집합(string). */
async findLikedReviewIds(args: {
reviewIds: bigint[];
accountId: bigint;
}): Promise<Set<string>> {
if (args.reviewIds.length === 0) return new Set();
const rows = await this.prisma.reviewLike.findMany({
where: {
review_id: { in: args.reviewIds },
account_id: args.accountId,
deleted_at: null,
},
select: { review_id: true },
});
return new Set(rows.map((r) => r.review_id.toString()));
}
}
85 changes: 85 additions & 0 deletions src/features/store/resolvers/store-review-query.resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { PrismaClient } from '@prisma/client';

import { StoreReviewRepository } from '@/features/store/repositories/store-review.repository';
import { StoreReviewQueryResolver } from '@/features/store/resolvers/store-review-query.resolver';
import { StoreReviewService } from '@/features/store/services/store-review.service';
import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client';
import { closeTruncateConnection, truncateAll } from '@/test/db/truncate';
import {
createAccount,
createOrder,
createOrderItem,
createReview,
createStore,
} from '@/test/factories';
import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder';

/**
* Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증.
* 분기/집계 세부 검증은 service.spec.ts에서 담당.
*/
describe('Store Review Query Resolver (real DB)', () => {
let resolver: StoreReviewQueryResolver;
let prisma: PrismaClient;

beforeAll(async () => {
const { module, prisma: p } = await createTestingModuleWithRealDb({
providers: [
StoreReviewQueryResolver,
StoreReviewService,
StoreReviewRepository,
],
});
resolver = module.get(StoreReviewQueryResolver);
prisma = p;
});

afterAll(async () => {
await closeTruncateConnection();
await disconnectTestPrismaClient();
});

beforeEach(async () => {
await truncateAll();
});

async function makeReview(storeId: bigint) {
const account = await createAccount(prisma, { account_type: 'USER' });
const order = await createOrder(prisma, { account_id: account.id });
const orderItem = await createOrderItem(prisma, {
order_id: order.id,
store_id: storeId,
});
return createReview(prisma, { order_item_id: orderItem.id, rating: 5 });
}

it('storeReviews: 비로그인 사용자에게 목록·totalCount를 반환한다', async () => {
const store = await createStore(prisma);
await makeReview(store.id);

const result = await resolver.storeReviews(
{ storeId: store.id.toString() },
undefined,
);

expect(result.totalCount).toBe(1);
expect(result.items[0].isLiked).toBe(false);
});

it('storeReviews: 로그인 사용자(JwtUser)의 좋아요 여부를 채운다', async () => {
const store = await createStore(prisma);
const review = await makeReview(store.id);
const liker = await createAccount(prisma, { account_type: 'USER' });
await prisma.reviewLike.create({
data: { review_id: review.id, account_id: liker.id },
});

const result = await resolver.storeReviews(
{ storeId: store.id.toString() },
{ accountId: liker.id.toString() },
);

expect(result.items[0].isLiked).toBe(true);
expect(result.items[0].likeCount).toBe(1);
});
});
31 changes: 31 additions & 0 deletions src/features/store/resolvers/store-review-query.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { UseGuards } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';

import { StoreReviewsInput } from '@/features/store/dto/inputs/store-reviews.input';
import { StoreReviewService } from '@/features/store/services/store-review.service';
import type { StoreReviewConnection } from '@/features/store/types/store-review-output.type';
import {
CurrentUser,
OptionalJwtAuthGuard,
parseAccountId,
type JwtUser,
} from '@/global/auth';

/**
* 매장 공개 리뷰 조회 resolver. 비로그인도 접근 가능한 public query.
* 옵셔널 인증으로 로그인 시에만 isLiked를 채운다.
*/
@Resolver('Query')
export class StoreReviewQueryResolver {
constructor(private readonly storeReviewService: StoreReviewService) {}

@Query('storeReviews')
@UseGuards(OptionalJwtAuthGuard)
storeReviews(
@Args('input') input: StoreReviewsInput,
@CurrentUser() user: JwtUser | undefined,
): Promise<StoreReviewConnection> {
const accountId = user ? parseAccountId(user) : undefined;
return this.storeReviewService.storeReviews(input, accountId);
}
}
77 changes: 77 additions & 0 deletions src/features/store/services/store-review-mappers.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Prisma } from '@prisma/client';

import type { StoreReviewRow } from '@/features/store/repositories/store-review.repository';
import { toStoreReview } from '@/features/store/services/store-review-mappers.helper';

function makeRow(o: Partial<StoreReviewRow> = {}): StoreReviewRow {
return {
id: 1n,
rating: new Prisma.Decimal('4.5'),
content: '맛있어요',
created_at: new Date('2026-01-01T00:00:00.000Z'),
account: { user_profile: { nickname: '구매자1', deleted_at: null } },
order_item: { product_name_snapshot: '레터링 케이크' },
media: [
{
media_type: 'IMAGE',
media_url: 'a.png',
thumbnail_url: 't.png',
sort_order: 0,
},
],
...o,
};
}

describe('toStoreReview', () => {
it('row를 StoreReview로 매핑한다(rating number·media·productName·author)', () => {
const result = toStoreReview(makeRow(), 3, true);
expect(result).toEqual({
id: '1',
rating: 4.5,
content: '맛있어요',
media: [
{
mediaType: 'IMAGE',
mediaUrl: 'a.png',
thumbnailUrl: 't.png',
sortOrder: 0,
},
],
likeCount: 3,
isLiked: true,
authorNickname: '구매자1',
productName: '레터링 케이크',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
});
});

it('user_profile이 없으면 authorNickname은 null', () => {
const result = toStoreReview(
makeRow({ account: { user_profile: null } }),
0,
false,
);
expect(result.authorNickname).toBeNull();
});

it('탈퇴(soft-delete)한 작성자의 닉네임은 익명화한다(null)', () => {
const result = toStoreReview(
makeRow({
account: {
user_profile: { nickname: 'deleted_123', deleted_at: new Date() },
},
}),
0,
false,
);
expect(result.authorNickname).toBeNull();
});

it('media가 없으면 빈 배열, likeCount/isLiked 인자를 반영', () => {
const result = toStoreReview(makeRow({ media: [] }), 0, false);
expect(result.media).toEqual([]);
expect(result.likeCount).toBe(0);
expect(result.isLiked).toBe(false);
});
});
29 changes: 29 additions & 0 deletions src/features/store/services/store-review-mappers.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { StoreReviewRow } from '@/features/store/repositories/store-review.repository';
import type { StoreReview } from '@/features/store/types/store-review-output.type';

export function toStoreReview(
row: StoreReviewRow,
likeCount: number,
isLiked: boolean,
): StoreReview {
return {
id: row.id.toString(),
rating: Number(row.rating),
content: row.content,
media: row.media.map((m) => ({
mediaType: m.media_type,
mediaUrl: m.media_url,
thumbnailUrl: m.thumbnail_url,
sortOrder: m.sort_order,
})),
likeCount,
isLiked,
// 탈퇴(soft-delete)한 작성자는 nickname이 deleted_<id>로 덮어써지므로 익명화한다
authorNickname:
row.account.user_profile && row.account.user_profile.deleted_at === null
? row.account.user_profile.nickname
: null,
productName: row.order_item.product_name_snapshot,
createdAt: row.created_at,
};
}
Loading
Loading