-
Notifications
You must be signed in to change notification settings - Fork 0
feat(store): 구매자 매장 후기 목록 조회(storeReviews)와 좋아요 집계 #159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| /** 매장 리뷰 목록 기본 페이지 크기. */ | ||
| export const DEFAULT_STORE_REVIEWS_LIMIT = 20; |
| 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; | ||
| } |
| 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, | ||
| // 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user deletes their account after writing a review, 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())); | ||
| } | ||
| } | ||
| 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); | ||
| }); | ||
| }); |
| 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); | ||
| } | ||
| } |
| 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); | ||
| }); | ||
| }); |
| 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, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a store is soft-deleted or marked inactive,
storeDetailhides it viafindStoreDetailById(is_active: true, deleted_at: null), but this new public review query only filters reviews bystore_id. Anyone who still knows that store ID can callstoreReviewsand receive the review content/media andtotalCount; add the same active-store relation filter or an existence check, and mirror it incountStoreReviews.Useful? React with 👍 / 👎.