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..4c7c96e --- /dev/null +++ b/src/features/store/repositories/store-review.repository.ts @@ -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 { + 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 } }, + }, + }, + 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 { + return this.prisma.review.count({ + where: { + store_id: storeId, + deleted_at: null, + store: { is_active: true, 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..5246caa --- /dev/null +++ b/src/features/store/services/store-review-mappers.helper.spec.ts @@ -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 { + 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); + }); +}); 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..d6f8b52 --- /dev/null +++ b/src/features/store/services/store-review-mappers.helper.ts @@ -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_로 덮어써지므로 익명화한다 + 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 new file mode 100644 index 0000000..4cb9587 --- /dev/null +++ b/src/features/store/services/store-review.service.spec.ts @@ -0,0 +1,204 @@ +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(); + }); + + 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); + }); + + 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(); + }); +}); 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; +}