From 3f737eeb5a067395b9a83485352bf040bde47cb7 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 03:46:24 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(store):=20=EA=B5=AC=EB=A7=A4=EC=9E=90?= =?UTF-8?q?=20=EB=A7=A4=EC=9E=A5=20=ED=9B=84=EA=B8=B0=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C(storeReviews)=EC=99=80=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=A7=91=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storeReviews(input): 매장 공개 리뷰 목록(최신순·커서), totalCount(후기 탭 카운트), media·작성자 닉네임·연결 상품명(주문 스냅샷) - 리뷰 좋아요 수 집계 + isLiked(OptionalJwtAuthGuard, 비로그인 false) - 매장 공개 리뷰 조회를 store feature가 소유(StoreReviewRepository 신설). user의 ReviewRepository(본인 리뷰 작성/관리)와 책임 분리, cross-feature 경계 준수 - 단위(mapper)·통합(service/resolver) 테스트 11건 --- .../store/constants/store-review.constants.ts | 2 + .../store/dto/inputs/store-reviews.input.ts | 16 ++ .../repositories/store-review.repository.ts | 105 +++++++++++ .../store-review-query.resolver.spec.ts | 85 +++++++++ .../resolvers/store-review-query.resolver.ts | 31 ++++ .../store-review-mappers.helper.spec.ts | 64 +++++++ .../services/store-review-mappers.helper.ts | 25 +++ .../services/store-review.service.spec.ts | 172 ++++++++++++++++++ .../store/services/store-review.service.ts | 58 ++++++ src/features/store/store-reviews.graphql | 45 +++++ src/features/store/store.module.ts | 6 + .../store/types/store-review-output.type.ts | 30 +++ 12 files changed, 639 insertions(+) create mode 100644 src/features/store/constants/store-review.constants.ts create mode 100644 src/features/store/dto/inputs/store-reviews.input.ts create mode 100644 src/features/store/repositories/store-review.repository.ts create mode 100644 src/features/store/resolvers/store-review-query.resolver.spec.ts create mode 100644 src/features/store/resolvers/store-review-query.resolver.ts create mode 100644 src/features/store/services/store-review-mappers.helper.spec.ts create mode 100644 src/features/store/services/store-review-mappers.helper.ts create mode 100644 src/features/store/services/store-review.service.spec.ts create mode 100644 src/features/store/services/store-review.service.ts create mode 100644 src/features/store/store-reviews.graphql create mode 100644 src/features/store/types/store-review-output.type.ts diff --git a/src/features/store/constants/store-review.constants.ts b/src/features/store/constants/store-review.constants.ts new file mode 100644 index 0000000..5b0f1fe --- /dev/null +++ b/src/features/store/constants/store-review.constants.ts @@ -0,0 +1,2 @@ +/** 매장 리뷰 목록 기본 페이지 크기. */ +export const DEFAULT_STORE_REVIEWS_LIMIT = 20; diff --git a/src/features/store/dto/inputs/store-reviews.input.ts b/src/features/store/dto/inputs/store-reviews.input.ts new file mode 100644 index 0000000..81d2e49 --- /dev/null +++ b/src/features/store/dto/inputs/store-reviews.input.ts @@ -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; +} diff --git a/src/features/store/repositories/store-review.repository.ts b/src/features/store/repositories/store-review.repository.ts new file mode 100644 index 0000000..e7b3321 --- /dev/null +++ b/src/features/store/repositories/store-review.repository.ts @@ -0,0 +1,105 @@ +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 } | 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 { + return this.prisma.review.findMany({ + where: { + store_id: args.storeId, + deleted_at: null, + ...(args.cursor ? { id: { lt: args.cursor } } : {}), + }, + select: { + id: true, + rating: true, + content: true, + created_at: true, + account: { + select: { user_profile: { select: { nickname: true } } }, + }, + 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, + }); + } + + /** 매장 활성 리뷰 수(후기 탭 카운트). */ + async countStoreReviews(storeId: bigint): Promise { + return this.prisma.review.count({ + where: { store_id: storeId, deleted_at: null }, + }); + } + + /** 리뷰별 좋아요 수. */ + async aggregateLikeCounts(reviewIds: bigint[]): Promise> { + 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> { + 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())); + } +} diff --git a/src/features/store/resolvers/store-review-query.resolver.spec.ts b/src/features/store/resolvers/store-review-query.resolver.spec.ts new file mode 100644 index 0000000..d662594 --- /dev/null +++ b/src/features/store/resolvers/store-review-query.resolver.spec.ts @@ -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); + }); +}); diff --git a/src/features/store/resolvers/store-review-query.resolver.ts b/src/features/store/resolvers/store-review-query.resolver.ts new file mode 100644 index 0000000..799014c --- /dev/null +++ b/src/features/store/resolvers/store-review-query.resolver.ts @@ -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 { + const accountId = user ? parseAccountId(user) : undefined; + return this.storeReviewService.storeReviews(input, accountId); + } +} diff --git a/src/features/store/services/store-review-mappers.helper.spec.ts b/src/features/store/services/store-review-mappers.helper.spec.ts new file mode 100644 index 0000000..70bceee --- /dev/null +++ b/src/features/store/services/store-review-mappers.helper.spec.ts @@ -0,0 +1,64 @@ +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 { + 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' } }, + 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('media가 없으면 빈 배열, likeCount/isLiked 인자를 반영', () => { + const result = toStoreReview(makeRow({ media: [] }), 0, false); + expect(result.media).toEqual([]); + expect(result.likeCount).toBe(0); + expect(result.isLiked).toBe(false); + }); +}); diff --git a/src/features/store/services/store-review-mappers.helper.ts b/src/features/store/services/store-review-mappers.helper.ts new file mode 100644 index 0000000..8f96024 --- /dev/null +++ b/src/features/store/services/store-review-mappers.helper.ts @@ -0,0 +1,25 @@ +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, + authorNickname: row.account.user_profile?.nickname ?? null, + productName: row.order_item.product_name_snapshot, + createdAt: row.created_at, + }; +} diff --git a/src/features/store/services/store-review.service.spec.ts b/src/features/store/services/store-review.service.spec.ts new file mode 100644 index 0000000..17b468a --- /dev/null +++ b/src/features/store/services/store-review.service.spec.ts @@ -0,0 +1,172 @@ +import type { PrismaClient } from '@prisma/client'; + +import { StoreReviewRepository } from '@/features/store/repositories/store-review.repository'; +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'; + +describe('StoreReviewService (real DB)', () => { + let service: StoreReviewService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [StoreReviewService, StoreReviewRepository], + }); + service = module.get(StoreReviewService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function makeReview( + storeId: bigint, + opts: { rating?: number; nickname?: string; productName?: string } = {}, + ) { + const account = await createAccount(prisma, { account_type: 'USER' }); + if (opts.nickname !== undefined) { + await prisma.userProfile.create({ + data: { account_id: account.id, nickname: opts.nickname }, + }); + } + const order = await createOrder(prisma, { account_id: account.id }); + const orderItem = await createOrderItem(prisma, { + order_id: order.id, + store_id: storeId, + product_name_snapshot: opts.productName ?? '케이크', + }); + return createReview(prisma, { + order_item_id: orderItem.id, + rating: opts.rating ?? 5, + }); + } + + it('리뷰가 없으면 빈 목록과 totalCount 0', async () => { + const store = await createStore(prisma); + const result = await service.storeReviews({ storeId: store.id.toString() }); + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it('작성자 닉네임·연결 상품명·평점·미디어를 반환한다', async () => { + const store = await createStore(prisma); + const review = await makeReview(store.id, { + rating: 4, + nickname: '구매자1', + productName: '레터링 케이크', + }); + await prisma.reviewMedia.create({ + data: { + review_id: review.id, + media_type: 'IMAGE', + media_url: 'a.png', + thumbnail_url: 't.png', + sort_order: 0, + }, + }); + + const result = await service.storeReviews({ storeId: store.id.toString() }); + + expect(result.totalCount).toBe(1); + expect(result.items[0]).toMatchObject({ + rating: 4, + authorNickname: '구매자1', + productName: '레터링 케이크', + }); + expect(result.items[0].media).toEqual([ + { + mediaType: 'IMAGE', + mediaUrl: 'a.png', + thumbnailUrl: 't.png', + sortOrder: 0, + }, + ]); + }); + + it('user_profile이 없으면 authorNickname은 null', async () => { + const store = await createStore(prisma); + await makeReview(store.id, {}); + const result = await service.storeReviews({ storeId: store.id.toString() }); + expect(result.items[0].authorNickname).toBeNull(); + }); + + it('좋아요 수를 집계하고 isLiked는 로그인 사용자 기준(비로그인 false)', async () => { + const store = await createStore(prisma); + const review = await makeReview(store.id, {}); + const liker1 = await createAccount(prisma, { account_type: 'USER' }); + const liker2 = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewLike.create({ + data: { review_id: review.id, account_id: liker1.id }, + }); + await prisma.reviewLike.create({ + data: { review_id: review.id, account_id: liker2.id }, + }); + + const anon = await service.storeReviews({ storeId: store.id.toString() }); + expect(anon.items[0].likeCount).toBe(2); + expect(anon.items[0].isLiked).toBe(false); + + const loggedIn = await service.storeReviews( + { storeId: store.id.toString() }, + liker1.id, + ); + expect(loggedIn.items[0].isLiked).toBe(true); + }); + + it('soft-delete된 리뷰는 목록·카운트에서 제외한다', async () => { + const store = await createStore(prisma); + const review = await makeReview(store.id, {}); + await prisma.review.update({ + where: { id: review.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.storeReviews({ storeId: store.id.toString() }); + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + }); + + it('최신순(id desc) + 커서 페이지네이션을 처리한다', async () => { + const store = await createStore(prisma); + const r1 = await makeReview(store.id, {}); + const r2 = await makeReview(store.id, {}); + const r3 = await makeReview(store.id, {}); + + const first = await service.storeReviews({ + storeId: store.id.toString(), + limit: 2, + }); + expect(first.items.map((r) => r.id)).toEqual([ + r3.id.toString(), + r2.id.toString(), + ]); + expect(first.hasMore).toBe(true); + expect(first.totalCount).toBe(3); + + const second = await service.storeReviews({ + storeId: store.id.toString(), + limit: 2, + cursor: first.nextCursor ?? undefined, + }); + expect(second.items.map((r) => r.id)).toEqual([r1.id.toString()]); + expect(second.hasMore).toBe(false); + expect(second.nextCursor).toBeNull(); + }); +}); diff --git a/src/features/store/services/store-review.service.ts b/src/features/store/services/store-review.service.ts new file mode 100644 index 0000000..874f1ca --- /dev/null +++ b/src/features/store/services/store-review.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { DEFAULT_STORE_REVIEWS_LIMIT } from '@/features/store/constants/store-review.constants'; +import type { StoreReviewsInput } from '@/features/store/dto/inputs/store-reviews.input'; +import { StoreReviewRepository } from '@/features/store/repositories/store-review.repository'; +import { toStoreReview } from '@/features/store/services/store-review-mappers.helper'; +import type { StoreReviewConnection } from '@/features/store/types/store-review-output.type'; + +@Injectable() +export class StoreReviewService { + constructor(private readonly repo: StoreReviewRepository) {} + + /** + * 매장 공개 리뷰 목록(커서). soft-delete 제외, 최신순. + * 좋아요 수는 집계, isLiked는 로그인 사용자에 한해 채운다(비로그인 false). + */ + async storeReviews( + input: StoreReviewsInput, + accountId?: bigint, + ): Promise { + const storeId = parseId(input.storeId); + const limit = input.limit ?? DEFAULT_STORE_REVIEWS_LIMIT; + + const [rows, totalCount] = await Promise.all([ + this.repo.listStoreReviews({ + storeId, + limit, + cursor: input.cursor ? parseId(input.cursor) : undefined, + }), + this.repo.countStoreReviews(storeId), + ]); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const reviewIds = page.map((row) => row.id); + + const [likeCounts, likedIds] = await Promise.all([ + this.repo.aggregateLikeCounts(reviewIds), + accountId + ? this.repo.findLikedReviewIds({ reviewIds, accountId }) + : Promise.resolve(new Set()), + ]); + + return { + items: page.map((row) => + toStoreReview( + row, + likeCounts.get(row.id) ?? 0, + likedIds.has(row.id.toString()), + ), + ), + totalCount, + hasMore, + nextCursor: hasMore ? page[page.length - 1].id.toString() : null, + }; + } +} diff --git a/src/features/store/store-reviews.graphql b/src/features/store/store-reviews.graphql new file mode 100644 index 0000000..99f956a --- /dev/null +++ b/src/features/store/store-reviews.graphql @@ -0,0 +1,45 @@ +extend type Query { + """매장 공개 리뷰 목록(최신순, 커서 기반). 비로그인 접근 가능.""" + storeReviews(input: StoreReviewsInput!): StoreReviewConnection! +} + +input StoreReviewsInput { + storeId: ID! + """이전 페이지 마지막 리뷰 id(이후부터 조회).""" + cursor: ID + limit: Int = 20 +} + +"""매장 리뷰 목록(커서 기반).""" +type StoreReviewConnection { + items: [StoreReview!]! + """전체 리뷰 수(후기 탭 카운트). 0이면 빈 상태.""" + totalCount: Int! + hasMore: Boolean! + nextCursor: ID +} + +"""매장 공개 리뷰.""" +type StoreReview { + id: ID! + """평점(0.0~5.0).""" + rating: Float! + content: String + media: [StoreReviewMedia!]! + likeCount: Int! + """로그인 사용자의 좋아요 여부(비로그인 시 false).""" + isLiked: Boolean! + """작성자 닉네임.""" + authorNickname: String + """연결 상품명(주문 시점 스냅샷).""" + productName: String! + createdAt: DateTime! +} + +"""매장 리뷰 첨부 미디어.""" +type StoreReviewMedia { + mediaType: ReviewMediaType! + mediaUrl: String! + thumbnailUrl: String + sortOrder: Int! +} diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts index e025622..9e808f2 100644 --- a/src/features/store/store.module.ts +++ b/src/features/store/store.module.ts @@ -1,24 +1,30 @@ import { Module } from '@nestjs/common'; +import { StoreReviewRepository } from '@/features/store/repositories/store-review.repository'; import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; import { StoreDetailQueryResolver } from '@/features/store/resolvers/store-detail-query.resolver'; import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; +import { StoreReviewQueryResolver } from '@/features/store/resolvers/store-review-query.resolver'; import { StoreWishlistMutationResolver } from '@/features/store/resolvers/store-wishlist-mutation.resolver'; import { StoreDetailService } from '@/features/store/services/store-detail.service'; import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StoreReviewService } from '@/features/store/services/store-review.service'; import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; @Module({ providers: [ StoreRepository, + StoreReviewRepository, StoreWishlistRepository, StoreListingService, StoreWishlistService, StoreDetailService, + StoreReviewService, StoreQueryResolver, StoreWishlistMutationResolver, StoreDetailQueryResolver, + StoreReviewQueryResolver, ], exports: [StoreRepository], }) diff --git a/src/features/store/types/store-review-output.type.ts b/src/features/store/types/store-review-output.type.ts new file mode 100644 index 0000000..cce6582 --- /dev/null +++ b/src/features/store/types/store-review-output.type.ts @@ -0,0 +1,30 @@ +/** + * storeReviews resolver 반환용 도메인 출력 타입. + * SDL(store-reviews.graphql)의 타입과 필드 일치. + */ + +export interface StoreReviewMedia { + mediaType: 'IMAGE' | 'VIDEO'; + mediaUrl: string; + thumbnailUrl: string | null; + sortOrder: number; +} + +export interface StoreReview { + id: string; + rating: number; + content: string | null; + media: StoreReviewMedia[]; + likeCount: number; + isLiked: boolean; + authorNickname: string | null; + productName: string; + createdAt: Date; +} + +export interface StoreReviewConnection { + items: StoreReview[]; + totalCount: number; + hasMore: boolean; + nextCursor: string | null; +} From 847f8acdb0308e3acca4dbfe7df38059775b350c Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 03:56:05 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(store):=20storeReviews=EB=A5=BC=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20=EB=A7=A4=EC=9E=A5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20(storeDetail=EA=B3=BC=20=EC=9D=BC=EA=B4=80?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비활성/삭제 매장의 store_id를 알면 storeReviews로 리뷰 내용·미디어·totalCount가 노출되던 문제를 수정한다. listStoreReviews·countStoreReviews where에 store active/deleted 술어를 추가해 storeDetail의 가드와 일치시킨다. (Codex P2 반영) --- .../store/repositories/store-review.repository.ts | 10 ++++++++-- .../store/services/store-review.service.spec.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/features/store/repositories/store-review.repository.ts b/src/features/store/repositories/store-review.repository.ts index e7b3321..658dccd 100644 --- a/src/features/store/repositories/store-review.repository.ts +++ b/src/features/store/repositories/store-review.repository.ts @@ -41,6 +41,8 @@ export class StoreReviewRepository { where: { store_id: args.storeId, deleted_at: null, + // storeDetail과 동일하게 비활성/삭제 매장의 리뷰는 노출하지 않는다 + store: { is_active: true, deleted_at: null }, ...(args.cursor ? { id: { lt: args.cursor } } : {}), }, select: { @@ -68,10 +70,14 @@ export class StoreReviewRepository { }); } - /** 매장 활성 리뷰 수(후기 탭 카운트). */ + /** 매장 활성 리뷰 수(후기 탭 카운트). 비활성/삭제 매장은 0. */ async countStoreReviews(storeId: bigint): Promise { return this.prisma.review.count({ - where: { store_id: storeId, deleted_at: null }, + where: { + store_id: storeId, + deleted_at: null, + store: { is_active: true, deleted_at: null }, + }, }); } diff --git a/src/features/store/services/store-review.service.spec.ts b/src/features/store/services/store-review.service.spec.ts index 17b468a..43f4dc0 100644 --- a/src/features/store/services/store-review.service.spec.ts +++ b/src/features/store/services/store-review.service.spec.ts @@ -169,4 +169,14 @@ describe('StoreReviewService (real DB)', () => { expect(second.hasMore).toBe(false); expect(second.nextCursor).toBeNull(); }); + + it('비활성/삭제 매장의 리뷰는 목록·카운트에서 제외한다', async () => { + const store = await createStore(prisma, { is_active: false }); + await makeReview(store.id, {}); + + const result = await service.storeReviews({ storeId: store.id.toString() }); + + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + }); }); From fa88dc1ccb4d9f52023c347690aea1fea237e4c6 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 04:08:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(store):=20storeReviews=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=83=88=ED=87=B4=20=EC=9E=91=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=9D=B5=EB=AA=85=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탈퇴(soft-delete) 시 nickname이 deleted_로 덮어써지는데, soft-delete extension은 nested user_profile에 deleted_at을 주입하지 않아 storeReviews가 deleted_를 그대로 노출했다. user_profile.deleted_at을 함께 select하고 매퍼에서 탈퇴 프로필은 authorNickname=null로 익명화한다. (Codex P2 :55 반영) --- .../repositories/store-review.repository.ts | 10 +++++++-- .../store-review-mappers.helper.spec.ts | 15 ++++++++++++- .../services/store-review-mappers.helper.ts | 6 ++++- .../services/store-review.service.spec.ts | 22 +++++++++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/features/store/repositories/store-review.repository.ts b/src/features/store/repositories/store-review.repository.ts index 658dccd..4c7c96e 100644 --- a/src/features/store/repositories/store-review.repository.ts +++ b/src/features/store/repositories/store-review.repository.ts @@ -16,7 +16,9 @@ export interface StoreReviewRow { rating: Prisma.Decimal; content: string | null; created_at: Date; - account: { user_profile: { nickname: string } | null }; + account: { + user_profile: { nickname: string; deleted_at: Date | null } | null; + }; order_item: { product_name_snapshot: string }; media: StoreReviewMediaRow[]; } @@ -51,7 +53,11 @@ export class StoreReviewRepository { content: true, created_at: true, account: { - select: { user_profile: { select: { nickname: true } } }, + // soft-delete extension은 nested relation에 deleted_at을 주입하지 않으므로 + // deleted_at을 함께 읽어 탈퇴 작성자 닉네임은 매퍼에서 익명화한다 + select: { + user_profile: { select: { nickname: true, deleted_at: true } }, + }, }, order_item: { select: { product_name_snapshot: true } }, media: { diff --git a/src/features/store/services/store-review-mappers.helper.spec.ts b/src/features/store/services/store-review-mappers.helper.spec.ts index 70bceee..5246caa 100644 --- a/src/features/store/services/store-review-mappers.helper.spec.ts +++ b/src/features/store/services/store-review-mappers.helper.spec.ts @@ -9,7 +9,7 @@ function makeRow(o: Partial = {}): StoreReviewRow { rating: new Prisma.Decimal('4.5'), content: '맛있어요', created_at: new Date('2026-01-01T00:00:00.000Z'), - account: { user_profile: { nickname: '구매자1' } }, + account: { user_profile: { nickname: '구매자1', deleted_at: null } }, order_item: { product_name_snapshot: '레터링 케이크' }, media: [ { @@ -55,6 +55,19 @@ describe('toStoreReview', () => { 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([]); diff --git a/src/features/store/services/store-review-mappers.helper.ts b/src/features/store/services/store-review-mappers.helper.ts index 8f96024..d6f8b52 100644 --- a/src/features/store/services/store-review-mappers.helper.ts +++ b/src/features/store/services/store-review-mappers.helper.ts @@ -18,7 +18,11 @@ export function toStoreReview( })), likeCount, isLiked, - authorNickname: row.account.user_profile?.nickname ?? null, + // 탈퇴(soft-delete)한 작성자는 nickname이 deleted_로 덮어써지므로 익명화한다 + 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, }; diff --git a/src/features/store/services/store-review.service.spec.ts b/src/features/store/services/store-review.service.spec.ts index 43f4dc0..4cb9587 100644 --- a/src/features/store/services/store-review.service.spec.ts +++ b/src/features/store/services/store-review.service.spec.ts @@ -179,4 +179,26 @@ describe('StoreReviewService (real DB)', () => { expect(result.items).toEqual([]); expect(result.totalCount).toBe(0); }); + + it('탈퇴(soft-delete)한 작성자의 닉네임은 노출하지 않는다', async () => { + const store = await createStore(prisma); + const account = await createAccount(prisma, { account_type: 'USER' }); + await prisma.userProfile.create({ + data: { + account_id: account.id, + nickname: `deleted_${account.id}`, + deleted_at: new Date(), + }, + }); + const order = await createOrder(prisma, { account_id: account.id }); + const orderItem = await createOrderItem(prisma, { + order_id: order.id, + store_id: store.id, + }); + await createReview(prisma, { order_item_id: orderItem.id, rating: 5 }); + + const result = await service.storeReviews({ storeId: store.id.toString() }); + + expect(result.items[0].authorNickname).toBeNull(); + }); });