From 78992f88598fe5719590bdb61594355f91bc2e5e Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 03:06:46 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat(store):=20=EA=B5=AC=EB=A7=A4?= =?UTF-8?q?=EC=9E=90=20=EB=A7=A4=EC=9E=A5=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C(storeDetail)=EC=99=80=20StoreImage=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storeDetail 쿼리 신설: 매장 헤더(이미지·평점·리뷰수·찜)·전화·주소·좌표·영업/휴무/안내문 노출. OptionalJwtAuthGuard로 비로그인 조회 + 로그인 시 isWishlisted - StoreImage 테이블 + Store.access_guide_text·regular_closure_text 컬럼 추가 (구매자 조회용, seller 입력 API는 차기) - 시드/스토어 팩토리 보강, 단위(mapper)·통합(service/resolver) 테스트 18건 --- .../migration.sql | 21 +++ prisma/schema.prisma | 23 ++- prisma/seed/stores.ts | 16 ++ .../constants/store-detail-error-messages.ts | 4 + .../store/repositories/store.repository.ts | 48 ++++++ .../store-detail-query.resolver.spec.ts | 77 +++++++++ .../resolvers/store-detail-query.resolver.ts | 30 ++++ .../store-detail-mappers.helper.spec.ts | 129 +++++++++++++++ .../services/store-detail-mappers.helper.ts | 42 +++++ .../services/store-detail.service.spec.ts | 152 ++++++++++++++++++ .../store/services/store-detail.service.ts | 47 ++++++ .../store/services/store-mappers.helper.ts | 6 +- src/features/store/store-detail.graphql | 42 +++++ src/features/store/store.module.ts | 4 + .../store/types/store-detail-output.type.ts | 23 +++ src/test/factories/store.factory.ts | 12 ++ 16 files changed, 674 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260623175315_add_store_image_and_detail_columns/migration.sql create mode 100644 src/features/store/constants/store-detail-error-messages.ts create mode 100644 src/features/store/resolvers/store-detail-query.resolver.spec.ts create mode 100644 src/features/store/resolvers/store-detail-query.resolver.ts create mode 100644 src/features/store/services/store-detail-mappers.helper.spec.ts create mode 100644 src/features/store/services/store-detail-mappers.helper.ts create mode 100644 src/features/store/services/store-detail.service.spec.ts create mode 100644 src/features/store/services/store-detail.service.ts create mode 100644 src/features/store/store-detail.graphql create mode 100644 src/features/store/types/store-detail-output.type.ts diff --git a/prisma/migrations/20260623175315_add_store_image_and_detail_columns/migration.sql b/prisma/migrations/20260623175315_add_store_image_and_detail_columns/migration.sql new file mode 100644 index 0000000..d93b60e --- /dev/null +++ b/prisma/migrations/20260623175315_add_store_image_and_detail_columns/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable +ALTER TABLE `store` ADD COLUMN `access_guide_text` VARCHAR(500) NULL, + ADD COLUMN `regular_closure_text` VARCHAR(200) NULL; + +-- CreateTable +CREATE TABLE `store_image` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `store_id` BIGINT UNSIGNED NOT NULL, + `image_url` VARCHAR(2048) NOT NULL, + `sort_order` INTEGER NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + `deleted_at` DATETIME(3) NULL, + + INDEX `idx_store_image_store`(`store_id`, `sort_order`), + INDEX `idx_store_image_deleted_at`(`deleted_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `store_image` ADD CONSTRAINT `store_image_store_id_fkey` FOREIGN KEY (`store_id`) REFERENCES `store`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bab4b8d..445240e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -342,7 +342,9 @@ model Store { map_provider StoreMapProvider @default(NONE) - business_hours_text String? @db.VarChar(500) + business_hours_text String? @db.VarChar(500) + access_guide_text String? @db.VarChar(500) // 찾아오는 길 안내("냉삼 꽃삼겹 골목으로 바로...") + regular_closure_text String? @db.VarChar(200) // 정기 휴무 표기(자유 텍스트, "매주 2,4주 월요일 쉽니다") pickup_slot_interval_minutes Int @default(30) @db.UnsignedSmallInt min_lead_time_minutes Int @default(30) @db.UnsignedInt @@ -361,6 +363,7 @@ model Store { business_hours StoreBusinessHour[] special_closures StoreSpecialClosure[] products Product[] + store_images StoreImage[] order_items OrderItem[] reviews Review[] banners Banner[] @relation("BannerStoreLink") @@ -418,6 +421,24 @@ model StoreSpecialClosure { @@map("store_special_closure") } +model StoreImage { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + store_id BigInt @db.UnsignedBigInt + + image_url String @db.VarChar(2048) + sort_order Int @default(0) + + created_at DateTime @default(now()) @db.DateTime(3) + updated_at DateTime @updatedAt @db.DateTime(3) + deleted_at DateTime? @db.DateTime(3) + + store Store @relation(fields: [store_id], references: [id]) + + @@index([store_id, sort_order], map: "idx_store_image_store") + @@index([deleted_at], map: "idx_store_image_deleted_at") + @@map("store_image") +} + /** * ========================= * 3) Catalog diff --git a/prisma/seed/stores.ts b/prisma/seed/stores.ts index adffa16..90ab901 100644 --- a/prisma/seed/stores.ts +++ b/prisma/seed/stores.ts @@ -50,8 +50,24 @@ export async function seedStores(prisma: PrismaClient): Promise { region_id: gangnam.id, // 서울 강남구 latitude: 37.5012 as unknown as never, longitude: 127.0396 as unknown as never, + map_provider: 'NAVER', business_hours_text: '매일 09:00 ~ 18:00 (화요일 정기 휴무)', + access_guide_text: + '강남역 3번 출구에서 도보 5분, 1층 케이크 거리 안쪽입니다.', + regular_closure_text: '매주 화요일 정기 휴무', is_active: true, + store_images: { + create: [ + { + image_url: 'https://placehold.co/800x600/png?text=Store+A+Exterior', + sort_order: 0, + }, + { + image_url: 'https://placehold.co/800x600/png?text=Store+A+Interior', + sort_order: 1, + }, + ], + }, }, }); diff --git a/src/features/store/constants/store-detail-error-messages.ts b/src/features/store/constants/store-detail-error-messages.ts new file mode 100644 index 0000000..c0e0491 --- /dev/null +++ b/src/features/store/constants/store-detail-error-messages.ts @@ -0,0 +1,4 @@ +/** 매장 상세 조회 에러 메시지. */ +export const STORE_DETAIL_ERRORS = { + STORE_NOT_FOUND: '매장을 찾을 수 없습니다.', +} as const; diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index 44d55ff..0158c03 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { Prisma, type StoreMapProvider } from '@prisma/client'; import { POPULAR_STORE_CAKE_IMAGE_LIMIT, @@ -19,6 +20,25 @@ export interface StoreReviewStat { count: number; } +/** 매장 상세 조회 결과 row. storeDetail 매퍼 입력. */ +export interface StoreDetailRow { + id: bigint; + store_name: string; + store_phone: string; + address_full: string; + address_city: string | null; + address_neighborhood: string | null; + latitude: Prisma.Decimal | null; + longitude: Prisma.Decimal | null; + map_provider: StoreMapProvider; + business_hours_text: string | null; + access_guide_text: string | null; + regular_closure_text: string | null; + website_url: string | null; + region: { name: string } | null; + store_images: { image_url: string }[]; +} + @Injectable() export class StoreRepository { constructor(private readonly prisma: PrismaService) {} @@ -54,6 +74,34 @@ export class StoreRepository { return Boolean(found); } + /** 매장 상세 헤더 조회. 활성·미삭제 매장만. 대표 이미지는 sort_order asc. */ + async findStoreDetailById(storeId: bigint): Promise { + return this.prisma.store.findFirst({ + where: { id: storeId, is_active: true, deleted_at: null }, + select: { + id: true, + store_name: true, + store_phone: true, + address_full: true, + address_city: true, + address_neighborhood: true, + latitude: true, + longitude: true, + map_provider: true, + business_hours_text: true, + access_guide_text: true, + regular_closure_text: true, + website_url: true, + region: { select: { name: true } }, + store_images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + select: { image_url: true }, + }, + }, + }); + } + /** 매장별 활성 찜 수. */ async aggregateWishlistCounts( storeIds: bigint[], diff --git a/src/features/store/resolvers/store-detail-query.resolver.spec.ts b/src/features/store/resolvers/store-detail-query.resolver.spec.ts new file mode 100644 index 0000000..b573ba6 --- /dev/null +++ b/src/features/store/resolvers/store-detail-query.resolver.spec.ts @@ -0,0 +1,77 @@ +import { NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +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 { StoreDetailService } from '@/features/store/services/store-detail.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/집계 세부 검증은 service.spec.ts에서 담당. + */ +describe('Store Detail Query Resolver (real DB)', () => { + let resolver: StoreDetailQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreDetailQueryResolver, + StoreDetailService, + StoreRepository, + StoreWishlistRepository, + ], + }); + resolver = module.get(StoreDetailQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('storeDetail: 비로그인 사용자에게 매장 상세를 반환한다', async () => { + const store = await createStore(prisma, { store_name: '해즈케이크' }); + + const result = await resolver.storeDetail(store.id.toString(), undefined); + + expect(result.id).toBe(store.id.toString()); + expect(result.storeName).toBe('해즈케이크'); + expect(result.isWishlisted).toBe(false); + }); + + it('storeDetail: 로그인 사용자(JwtUser)의 찜 여부를 채운다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const result = await resolver.storeDetail(store.id.toString(), { + accountId: account.id.toString(), + }); + + expect(result.isWishlisted).toBe(true); + }); + + it('storeDetail: 없는 매장은 NotFoundException', async () => { + await expect( + resolver.storeDetail('999999', undefined), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/src/features/store/resolvers/store-detail-query.resolver.ts b/src/features/store/resolvers/store-detail-query.resolver.ts new file mode 100644 index 0000000..8532cef --- /dev/null +++ b/src/features/store/resolvers/store-detail-query.resolver.ts @@ -0,0 +1,30 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { StoreDetailService } from '@/features/store/services/store-detail.service'; +import type { StoreDetail } from '@/features/store/types/store-detail-output.type'; +import { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** + * 매장 상세 조회 resolver. 비로그인도 접근 가능한 public query. + * 옵셔널 인증으로 로그인 시에만 isWishlisted를 채운다. + */ +@Resolver('Query') +export class StoreDetailQueryResolver { + constructor(private readonly storeDetailService: StoreDetailService) {} + + @Query('storeDetail') + @UseGuards(OptionalJwtAuthGuard) + storeDetail( + @Args('storeId') storeId: string, + @CurrentUser() user: JwtUser | undefined, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.storeDetailService.storeDetail(storeId, accountId); + } +} diff --git a/src/features/store/services/store-detail-mappers.helper.spec.ts b/src/features/store/services/store-detail-mappers.helper.spec.ts new file mode 100644 index 0000000..e7d118d --- /dev/null +++ b/src/features/store/services/store-detail-mappers.helper.spec.ts @@ -0,0 +1,129 @@ +import { Prisma } from '@prisma/client'; + +import type { + StoreDetailRow, + StoreReviewStat, +} from '@/features/store/repositories/store.repository'; +import { toStoreDetail } from '@/features/store/services/store-detail-mappers.helper'; + +function makeRow(overrides: Partial = {}): StoreDetailRow { + return { + id: 1n, + store_name: '해즈케이크', + store_phone: '05930905934', + address_full: '인천 서구 청라루비로 93', + address_city: '인천광역시', + address_neighborhood: '청라동', + latitude: null, + longitude: null, + map_provider: 'NONE', + business_hours_text: '10:00AM ~ 19:00PM', + access_guide_text: '냉삼 꽃삼겹 골목으로 바로 들어오시면 있습니다.', + regular_closure_text: '매주 2,4주 월요일 쉽니다', + website_url: null, + region: null, + store_images: [], + ...overrides, + }; +} + +describe('toStoreDetail', () => { + it('row를 StoreDetail로 매핑한다(id 문자열, 이미지 url 배열, 신규 필드 포함)', () => { + const result = toStoreDetail( + makeRow({ + store_images: [{ image_url: 'a.png' }, { image_url: 'b.png' }], + website_url: 'https://store.example', + }), + undefined, + false, + ); + + expect(result).toMatchObject({ + id: '1', + storeName: '해즈케이크', + phoneNumber: '05930905934', + addressFull: '인천 서구 청라루비로 93', + images: ['a.png', 'b.png'], + mapProvider: 'NONE', + businessHoursText: '10:00AM ~ 19:00PM', + accessGuideText: '냉삼 꽃삼겹 골목으로 바로 들어오시면 있습니다.', + regularClosureText: '매주 2,4주 월요일 쉽니다', + websiteUrl: 'https://store.example', + }); + }); + + it('regionLabel: 시/동 우선, 없으면 region.name, 둘 다 없으면 null', () => { + expect( + toStoreDetail( + makeRow({ address_city: '인천', address_neighborhood: '청라동' }), + undefined, + false, + ).regionLabel, + ).toBe('인천 청라동'); + + expect( + toStoreDetail( + makeRow({ + address_city: null, + address_neighborhood: null, + region: { name: '서구' }, + }), + undefined, + false, + ).regionLabel, + ).toBe('서구'); + + expect( + toStoreDetail( + makeRow({ + address_city: null, + address_neighborhood: null, + region: null, + }), + undefined, + false, + ).regionLabel, + ).toBeNull(); + }); + + it('평점은 소수 첫째 자리로 반올림하고 리뷰 수를 채운다', () => { + const stat: StoreReviewStat = { average: 4.666, count: 122 }; + const result = toStoreDetail(makeRow(), stat, false); + expect(result.ratingAverage).toBe(4.7); + expect(result.reviewCount).toBe(122); + }); + + it('리뷰 통계가 없으면 평점 0, 리뷰 수 0', () => { + const result = toStoreDetail(makeRow(), undefined, false); + expect(result.ratingAverage).toBe(0); + expect(result.reviewCount).toBe(0); + }); + + it('좌표 Decimal은 number로 변환하고 null은 유지한다', () => { + const withCoord = toStoreDetail( + makeRow({ + latitude: new Prisma.Decimal('37.5012'), + longitude: new Prisma.Decimal('127.0396'), + }), + undefined, + false, + ); + expect(withCoord.latitude).toBeCloseTo(37.5012); + expect(withCoord.longitude).toBeCloseTo(127.0396); + + const noCoord = toStoreDetail(makeRow(), undefined, false); + expect(noCoord.latitude).toBeNull(); + expect(noCoord.longitude).toBeNull(); + }); + + it('isWishlisted 인자를 그대로 반영한다', () => { + expect(toStoreDetail(makeRow(), undefined, true).isWishlisted).toBe(true); + expect(toStoreDetail(makeRow(), undefined, false).isWishlisted).toBe(false); + }); + + it('이미지가 없으면 빈 배열', () => { + expect( + toStoreDetail(makeRow({ store_images: [] }), undefined, false).images, + ).toEqual([]); + }); +}); diff --git a/src/features/store/services/store-detail-mappers.helper.ts b/src/features/store/services/store-detail-mappers.helper.ts new file mode 100644 index 0000000..94eca81 --- /dev/null +++ b/src/features/store/services/store-detail-mappers.helper.ts @@ -0,0 +1,42 @@ +import type { + StoreDetailRow, + StoreReviewStat, +} from '@/features/store/repositories/store.repository'; +import { buildRegionLabel } from '@/features/store/services/store-mappers.helper'; +import type { StoreDetail } from '@/features/store/types/store-detail-output.type'; + +/** 소수 첫째 자리 반올림(예: 4.666 → 4.7). 리뷰 없으면 0. */ +function toRatingAverage(stat: StoreReviewStat | undefined): number { + if (!stat) return 0; + return Math.round(stat.average * 10) / 10; +} + +/** Decimal(위/경도)을 number로. null은 유지. */ +function toCoordinate(value: { toString(): string } | null): number | null { + return value !== null ? Number(value) : null; +} + +export function toStoreDetail( + row: StoreDetailRow, + reviewStat: StoreReviewStat | undefined, + isWishlisted: boolean, +): StoreDetail { + return { + id: row.id.toString(), + storeName: row.store_name, + regionLabel: buildRegionLabel(row), + ratingAverage: toRatingAverage(reviewStat), + reviewCount: reviewStat?.count ?? 0, + isWishlisted, + images: row.store_images.map((image) => image.image_url), + phoneNumber: row.store_phone, + addressFull: row.address_full, + latitude: toCoordinate(row.latitude), + longitude: toCoordinate(row.longitude), + mapProvider: row.map_provider, + businessHoursText: row.business_hours_text, + regularClosureText: row.regular_closure_text, + accessGuideText: row.access_guide_text, + websiteUrl: row.website_url, + }; +} diff --git a/src/features/store/services/store-detail.service.spec.ts b/src/features/store/services/store-detail.service.spec.ts new file mode 100644 index 0000000..a87b514 --- /dev/null +++ b/src/features/store/services/store-detail.service.spec.ts @@ -0,0 +1,152 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreDetailService } from '@/features/store/services/store-detail.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createOrderItem, + createReview, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('StoreDetailService (real DB)', () => { + let service: StoreDetailService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [StoreDetailService, StoreRepository, StoreWishlistRepository], + }); + service = module.get(StoreDetailService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('존재하지 않는 매장은 NotFoundException', async () => { + await expect(service.storeDetail('999999')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('비활성 매장은 NotFoundException', async () => { + const store = await createStore(prisma, { is_active: false }); + await expect( + service.storeDetail(store.id.toString()), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('잘못된 id 형식은 BadRequestException', async () => { + await expect(service.storeDetail('abc')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('매장 헤더와 신규 필드, 이미지(sort_order asc)를 반환한다', async () => { + const store = await createStore(prisma, { + store_name: '해즈케이크', + store_phone: '05930905934', + address_full: '인천 서구 청라루비로 93', + address_city: '인천광역시', + address_neighborhood: '청라동', + latitude: 37.5, + longitude: 127.0, + map_provider: 'NAVER', + business_hours_text: '10:00AM ~ 19:00PM', + access_guide_text: '냉삼 꽃삼겹 골목으로 바로 들어오시면 있습니다.', + regular_closure_text: '매주 2,4주 월요일 쉽니다', + }); + // sort_order 역순으로 생성 → 결과는 asc 정렬 확인 + await prisma.storeImage.create({ + data: { store_id: store.id, image_url: 'second.png', sort_order: 1 }, + }); + await prisma.storeImage.create({ + data: { store_id: store.id, image_url: 'first.png', sort_order: 0 }, + }); + + const result = await service.storeDetail(store.id.toString()); + + expect(result).toMatchObject({ + id: store.id.toString(), + storeName: '해즈케이크', + phoneNumber: '05930905934', + addressFull: '인천 서구 청라루비로 93', + regionLabel: '인천광역시 청라동', + mapProvider: 'NAVER', + businessHoursText: '10:00AM ~ 19:00PM', + accessGuideText: '냉삼 꽃삼겹 골목으로 바로 들어오시면 있습니다.', + regularClosureText: '매주 2,4주 월요일 쉽니다', + isWishlisted: false, + }); + expect(result.images).toEqual(['first.png', 'second.png']); + expect(result.latitude).toBeCloseTo(37.5); + expect(result.longitude).toBeCloseTo(127.0); + }); + + it('soft-delete된 이미지는 제외한다', async () => { + const store = await createStore(prisma); + await prisma.storeImage.create({ + data: { store_id: store.id, image_url: 'visible.png', sort_order: 0 }, + }); + await prisma.storeImage.create({ + data: { + store_id: store.id, + image_url: 'deleted.png', + sort_order: 1, + deleted_at: new Date(), + }, + }); + + const result = await service.storeDetail(store.id.toString()); + + expect(result.images).toEqual(['visible.png']); + }); + + it('평균 평점(소수 첫째)과 리뷰 수를 집계한다', async () => { + const store = await createStore(prisma); + for (const rating of [4, 5]) { + const orderItem = await createOrderItem(prisma, { store_id: store.id }); + await createReview(prisma, { order_item_id: orderItem.id, rating }); + } + + const result = await service.storeDetail(store.id.toString()); + + expect(result.ratingAverage).toBe(4.5); + expect(result.reviewCount).toBe(2); + }); + + it('리뷰가 없으면 평점 0, 리뷰 수 0', async () => { + const store = await createStore(prisma); + const result = await service.storeDetail(store.id.toString()); + expect(result.ratingAverage).toBe(0); + expect(result.reviewCount).toBe(0); + }); + + it('로그인 사용자의 찜 매장은 isWishlisted=true, 비로그인은 false', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const loggedIn = await service.storeDetail(store.id.toString(), account.id); + expect(loggedIn.isWishlisted).toBe(true); + + const anonymous = await service.storeDetail(store.id.toString()); + expect(anonymous.isWishlisted).toBe(false); + }); +}); diff --git a/src/features/store/services/store-detail.service.ts b/src/features/store/services/store-detail.service.ts new file mode 100644 index 0000000..e9c6830 --- /dev/null +++ b/src/features/store/services/store-detail.service.ts @@ -0,0 +1,47 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { STORE_DETAIL_ERRORS } from '@/features/store/constants/store-detail-error-messages'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { toStoreDetail } from '@/features/store/services/store-detail-mappers.helper'; +import type { StoreDetail } from '@/features/store/types/store-detail-output.type'; + +@Injectable() +export class StoreDetailService { + constructor( + private readonly repo: StoreRepository, + private readonly wishlistRepo: StoreWishlistRepository, + ) {} + + /** + * 매장 상세 헤더. 비활성/삭제 매장은 NOT_FOUND. 평점·리뷰수는 실시간 집계하고, + * isWishlisted는 로그인 사용자에 한해 채운다(비로그인 false). + */ + async storeDetail( + storeIdRaw: string, + accountId?: bigint, + ): Promise { + const storeId = parseId(storeIdRaw); + const row = await this.repo.findStoreDetailById(storeId); + if (!row) { + throw new NotFoundException(STORE_DETAIL_ERRORS.STORE_NOT_FOUND); + } + + const [reviewStats, wishlistedIds] = await Promise.all([ + this.repo.aggregateReviewStats([storeId]), + accountId + ? this.wishlistRepo.findWishlistedStoreIds({ + accountId, + storeIds: [storeId], + }) + : Promise.resolve(new Set()), + ]); + + return toStoreDetail( + row, + reviewStats.get(storeId), + wishlistedIds.has(storeId.toString()), + ); + } +} diff --git a/src/features/store/services/store-mappers.helper.ts b/src/features/store/services/store-mappers.helper.ts index 4d7720c..4f1c737 100644 --- a/src/features/store/services/store-mappers.helper.ts +++ b/src/features/store/services/store-mappers.helper.ts @@ -3,7 +3,11 @@ import type { StoreMetrics } from '@/features/store/services/store-ranking.helpe import type { PopularStore } from '@/features/store/types/store-output.type'; /** 매장 위치 표기. 시/동 조합 우선, 없으면 2차 지역명. (표기 규칙 확정 전 기본형) */ -export function buildRegionLabel(row: StoreCandidateRow): string | null { +export function buildRegionLabel(row: { + address_city: string | null; + address_neighborhood: string | null; + region: { name: string } | null; +}): string | null { const parts = [row.address_city, row.address_neighborhood].filter( (p): p is string => Boolean(p), ); diff --git a/src/features/store/store-detail.graphql b/src/features/store/store-detail.graphql new file mode 100644 index 0000000..36c8fe6 --- /dev/null +++ b/src/features/store/store-detail.graphql @@ -0,0 +1,42 @@ +extend type Query { + """매장 상세 헤더. 없거나 비활성/삭제 매장이면 NOT_FOUND. 비로그인 접근 가능.""" + storeDetail(storeId: ID!): StoreDetail! +} + +"""매장 상세 헤더(구매자용).""" +type StoreDetail { + id: ID! + storeName: String! + """매장 위치 표기(예: 인천 청라동).""" + regionLabel: String + """평균 평점(0.0~5.0, 소수 첫째 자리).""" + ratingAverage: Float! + reviewCount: Int! + """로그인 사용자의 찜 여부(비로그인 시 false).""" + isWishlisted: Boolean! + """매장 대표 이미지(캐러셀, sort_order asc).""" + images: [String!]! + """매장 전화번호(전화 액션시트).""" + phoneNumber: String! + """전체 주소(표시·복사용).""" + addressFull: String! + """위도(지도). 미설정 시 null.""" + latitude: Float + """경도(지도). 미설정 시 null.""" + longitude: Float + mapProvider: StoreMapProvider! + """영업시간 표기 텍스트(예: 10:00AM ~ 19:00PM).""" + businessHoursText: String + """정기 휴무 표기(예: 매주 2,4주 월요일 쉽니다).""" + regularClosureText: String + """찾아오는 길 안내.""" + accessGuideText: String + websiteUrl: String +} + +"""매장 지도 연동 provider.""" +enum StoreMapProvider { + NAVER + KAKAO + NONE +} diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts index a817aa1..e025622 100644 --- a/src/features/store/store.module.ts +++ b/src/features/store/store.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; 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 { 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 { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; @@ -13,8 +15,10 @@ import { StoreWishlistService } from '@/features/store/services/store-wishlist.s StoreWishlistRepository, StoreListingService, StoreWishlistService, + StoreDetailService, StoreQueryResolver, StoreWishlistMutationResolver, + StoreDetailQueryResolver, ], exports: [StoreRepository], }) diff --git a/src/features/store/types/store-detail-output.type.ts b/src/features/store/types/store-detail-output.type.ts new file mode 100644 index 0000000..29cdc36 --- /dev/null +++ b/src/features/store/types/store-detail-output.type.ts @@ -0,0 +1,23 @@ +/** + * storeDetail resolver 반환용 도메인 출력 타입. + * SDL(store-detail.graphql)의 StoreDetail 와 필드 일치. + */ + +export interface StoreDetail { + id: string; + storeName: string; + regionLabel: string | null; + ratingAverage: number; + reviewCount: number; + isWishlisted: boolean; + images: string[]; + phoneNumber: string; + addressFull: string; + latitude: number | null; + longitude: number | null; + mapProvider: 'NAVER' | 'KAKAO' | 'NONE'; + businessHoursText: string | null; + regularClosureText: string | null; + accessGuideText: string | null; + websiteUrl: string | null; +} diff --git a/src/test/factories/store.factory.ts b/src/test/factories/store.factory.ts index bf6574d..952a777 100644 --- a/src/test/factories/store.factory.ts +++ b/src/test/factories/store.factory.ts @@ -13,6 +13,12 @@ export interface StoreOverrides { address_neighborhood?: string | null; region_id?: bigint | null; is_active?: boolean; + latitude?: number | null; + longitude?: number | null; + map_provider?: 'NAVER' | 'KAKAO' | 'NONE'; + business_hours_text?: string | null; + access_guide_text?: string | null; + regular_closure_text?: string | null; } export async function createStore( @@ -36,6 +42,12 @@ export async function createStore( address_neighborhood: overrides.address_neighborhood ?? '테스트동', region_id: overrides.region_id ?? null, is_active: overrides.is_active ?? true, + latitude: overrides.latitude ?? null, + longitude: overrides.longitude ?? null, + map_provider: overrides.map_provider ?? 'NONE', + business_hours_text: overrides.business_hours_text ?? null, + access_guide_text: overrides.access_guide_text ?? null, + regular_closure_text: overrides.regular_closure_text ?? null, }, }); } From 04d5fc678e74b352c274ac128767c4d4ef03a8cf Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 03:19:31 +0900 Subject: [PATCH 02/12] =?UTF-8?q?fix(store):=20=EC=9E=AC=EC=8B=9C=EB=93=9C?= =?UTF-8?q?=20=EC=8B=9C=20store=5Fimage=20FK=20=EC=9C=84=EB=B0=98=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20(resetSeedScope=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit store_image FK가 ON DELETE RESTRICT라 store 삭제 전 store_image를 정리하지 않으면 2회차 yarn prisma:seed가 FK 위반으로 실패한다. resetSeedScope에 storeImage.deleteMany를 추가해 멱등성을 보장한다. (Codex P2 반영) --- prisma/seed/idempotent.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/prisma/seed/idempotent.ts b/prisma/seed/idempotent.ts index aac045a..ee052cb 100644 --- a/prisma/seed/idempotent.ts +++ b/prisma/seed/idempotent.ts @@ -238,6 +238,10 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { await prisma.storeSpecialClosure.deleteMany({ where: { store_id: { in: storeIds } }, }); + // store_image FK는 ON DELETE RESTRICT → store 삭제 전에 정리해야 재시드 멱등성 유지 + await prisma.storeImage.deleteMany({ + where: { store_id: { in: storeIds } }, + }); await prisma.store.deleteMany({ where: { id: { in: storeIds } } }); } From 703b5ae235b68524ac2808f3261d906151b77222 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 03:32:03 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(product):=20=EA=B5=AC=EB=A7=A4?= =?UTF-8?q?=EC=9E=90=20=EB=A7=A4=EC=9E=A5=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storeProducts(input): 활성 상품(+활성 매장)만, 카테고리/매장내 검색(상품명·태그)/커서 페이지네이션. 대표 이미지 thumbnail + 할인율 계산 - storeProductCategories(storeId): 매장 보유 활성 상품의 카테고리만(빈 카테고리 제외) + productCount + sort_order 정렬 - product feature에 구매자 조회 레이어 신설(resolver/service/mapper/dto/types/constants), ProductRepository에 listActiveProductsByStore·listStoreProductCategories 추가 - 개인화 없는 public 쿼리. 단위(mapper)·통합(service/resolver) 테스트 19건 --- .../constants/product-storefront.constants.ts | 2 + .../dto/inputs/store-products.input.ts | 24 ++ .../product/product-storefront.graphql | 58 ++++ src/features/product/product.module.ts | 8 +- .../repositories/product.repository.ts | 131 ++++++++- .../product-storefront-query.resolver.spec.ts | 71 +++++ .../product-storefront-query.resolver.ts | 30 ++ .../product-storefront-mappers.helper.spec.ts | 97 +++++++ .../product-storefront-mappers.helper.ts | 45 +++ .../product-storefront.service.spec.ts | 259 ++++++++++++++++++ .../services/product-storefront.service.ts | 51 ++++ .../types/product-storefront-output.type.ts | 30 ++ 12 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 src/features/product/constants/product-storefront.constants.ts create mode 100644 src/features/product/dto/inputs/store-products.input.ts create mode 100644 src/features/product/product-storefront.graphql create mode 100644 src/features/product/resolvers/product-storefront-query.resolver.spec.ts create mode 100644 src/features/product/resolvers/product-storefront-query.resolver.ts create mode 100644 src/features/product/services/product-storefront-mappers.helper.spec.ts create mode 100644 src/features/product/services/product-storefront-mappers.helper.ts create mode 100644 src/features/product/services/product-storefront.service.spec.ts create mode 100644 src/features/product/services/product-storefront.service.ts create mode 100644 src/features/product/types/product-storefront-output.type.ts diff --git a/src/features/product/constants/product-storefront.constants.ts b/src/features/product/constants/product-storefront.constants.ts new file mode 100644 index 0000000..a971cca --- /dev/null +++ b/src/features/product/constants/product-storefront.constants.ts @@ -0,0 +1,2 @@ +/** 매장 상품 목록 기본 페이지 크기. */ +export const DEFAULT_STORE_PRODUCTS_LIMIT = 20; diff --git a/src/features/product/dto/inputs/store-products.input.ts b/src/features/product/dto/inputs/store-products.input.ts new file mode 100644 index 0000000..ead9ac6 --- /dev/null +++ b/src/features/product/dto/inputs/store-products.input.ts @@ -0,0 +1,24 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class StoreProductsInput { + @IsString() + storeId!: string; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + cursor?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/src/features/product/product-storefront.graphql b/src/features/product/product-storefront.graphql new file mode 100644 index 0000000..a300302 --- /dev/null +++ b/src/features/product/product-storefront.graphql @@ -0,0 +1,58 @@ +extend type Query { + """매장이 보유한 활성 상품의 카테고리 목록(좌측 사이드바). 빈 카테고리 제외. 비로그인 접근 가능.""" + storeProductCategories(storeId: ID!): [StoreProductCategory!]! + + """매장 상품 목록(카테고리 필터 / 매장 내 검색 / 커서). 비로그인 접근 가능.""" + storeProducts(input: StoreProductsInput!): StoreProductConnection! +} + +"""매장 상품 카테고리(사이드바 항목).""" +type StoreProductCategory { + id: ID! + name: String! + categoryType: CategoryType! + sortOrder: Int! + """이 매장의 해당 카테고리 활성 상품 수.""" + productCount: Int! +} + +"""상품 카테고리 분류.""" +enum CategoryType { + EVENT + STYLE + OTHER +} + +input StoreProductsInput { + storeId: ID! + """특정 카테고리 섹션만. 비우면 전체.""" + categoryId: ID + """매장 내 상품명·태그 검색어.""" + search: String + """이전 페이지 마지막 항목 id(이후부터 조회).""" + cursor: ID + limit: Int = 20 +} + +"""매장 상품 목록(커서 기반).""" +type StoreProductConnection { + items: [StoreProduct!]! + hasMore: Boolean! + nextCursor: ID +} + +"""매장 상품 카드.""" +type StoreProduct { + id: ID! + name: String! + description: String + """대표 이미지(sort_order 최소). 없으면 null.""" + thumbnailUrl: String + regularPrice: Int! + salePrice: Int + """할인율(0~100). salePrice 없으면 0.""" + discountRate: Int! + currency: String! + """소속 카테고리 ID(FE 섹션 그룹핑/스크롤 스파이용).""" + categoryIds: [ID!]! +} diff --git a/src/features/product/product.module.ts b/src/features/product/product.module.ts index 33fa7be..34b7b10 100644 --- a/src/features/product/product.module.ts +++ b/src/features/product/product.module.ts @@ -1,9 +1,15 @@ import { Module } from '@nestjs/common'; import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { ProductStorefrontQueryResolver } from '@/features/product/resolvers/product-storefront-query.resolver'; +import { ProductStorefrontService } from '@/features/product/services/product-storefront.service'; @Module({ - providers: [ProductRepository], + providers: [ + ProductRepository, + ProductStorefrontService, + ProductStorefrontQueryResolver, + ], exports: [ProductRepository], }) export class ProductModule {} diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 22c10ba..a06fc2e 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -1,8 +1,29 @@ import { Injectable } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { type CategoryType, Prisma } from '@prisma/client'; import { PrismaService } from '@/prisma'; +/** 구매자 매장 상품 카드 row. product-storefront 매퍼 입력. */ +export interface StoreProductRow { + id: bigint; + name: string; + description: string | null; + regular_price: number; + sale_price: number | null; + currency: string; + images: { image_url: string }[]; + product_categories: { category_id: bigint }[]; +} + +/** 매장 상품 카테고리(사이드바) row. */ +export interface StoreProductCategoryRow { + id: bigint; + name: string; + category_type: CategoryType; + sort_order: number; + product_count: number; +} + @Injectable() export class ProductRepository { constructor(private readonly prisma: PrismaService) {} @@ -697,4 +718,112 @@ export class ProductRepository { }, }); } + + /** + * 구매자용 매장 상품 목록. 활성 상품(+활성 매장)만, 카테고리/검색 필터. + * 카드용 가벼운 select(대표 이미지 1장 + 카테고리 id). 커서는 id < cursor(desc). + */ + async listActiveProductsByStore(args: { + storeId: bigint; + limit: number; + cursor?: bigint; + categoryId?: bigint; + search?: string; + }): Promise { + return this.prisma.product.findMany({ + where: { + store_id: args.storeId, + is_active: true, + deleted_at: null, + store: { is_active: true, deleted_at: null }, + ...(args.cursor ? { id: { lt: args.cursor } } : {}), + ...(args.categoryId + ? { + product_categories: { + some: { category_id: args.categoryId, deleted_at: null }, + }, + } + : {}), + ...(args.search + ? { + OR: [ + { name: { contains: args.search } }, + { + product_tags: { + some: { + deleted_at: null, + tag: { name: { contains: args.search } }, + }, + }, + }, + ], + } + : {}), + }, + select: { + id: true, + name: true, + description: true, + regular_price: true, + sale_price: true, + currency: true, + images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + take: 1, + select: { image_url: true }, + }, + product_categories: { + where: { deleted_at: null }, + select: { category_id: true }, + }, + }, + orderBy: { id: 'desc' }, + take: args.limit + 1, + }); + } + + /** + * 매장이 보유한 활성 상품의 카테고리(사이드바). 빈 카테고리 제외. + * sort_order asc, productCount는 이 매장의 활성 상품 기준. + */ + async listStoreProductCategories( + storeId: bigint, + ): Promise { + const grouped = await this.prisma.productCategory.groupBy({ + by: ['category_id'], + where: { + deleted_at: null, + product: { store_id: storeId, is_active: true, deleted_at: null }, + }, + _count: { _all: true }, + }); + if (grouped.length === 0) return []; + + const countByCategory = new Map( + grouped.map((g) => [g.category_id, g._count._all]), + ); + const categories = await this.prisma.category.findMany({ + where: { + id: { in: grouped.map((g) => g.category_id) }, + is_active: true, + deleted_at: null, + }, + select: { + id: true, + name: true, + category_type: true, + sort_order: true, + }, + orderBy: [{ sort_order: 'asc' }, { id: 'asc' }], + }); + + return categories.map((category) => ({ + id: category.id, + name: category.name, + category_type: category.category_type, + sort_order: category.sort_order, + product_count: countByCategory.get(category.id) ?? 0, + })); + } } diff --git a/src/features/product/resolvers/product-storefront-query.resolver.spec.ts b/src/features/product/resolvers/product-storefront-query.resolver.spec.ts new file mode 100644 index 0000000..53506d1 --- /dev/null +++ b/src/features/product/resolvers/product-storefront-query.resolver.spec.ts @@ -0,0 +1,71 @@ +import type { PrismaClient } from '@prisma/client'; + +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { ProductStorefrontQueryResolver } from '@/features/product/resolvers/product-storefront-query.resolver'; +import { ProductStorefrontService } from '@/features/product/services/product-storefront.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createProduct, createStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/필터 세부 검증은 service.spec.ts에서 담당. + */ +describe('ProductStorefront Query Resolver (real DB)', () => { + let resolver: ProductStorefrontQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + ProductStorefrontQueryResolver, + ProductStorefrontService, + ProductRepository, + ], + }); + resolver = module.get(ProductStorefrontQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('storeProducts: 서비스에 위임해 상품 목록을 반환한다', async () => { + const store = await createStore(prisma); + await createProduct(prisma, { store_id: store.id, name: '케이크' }); + + const result = await resolver.storeProducts({ + storeId: store.id.toString(), + }); + + expect(result.items.map((p) => p.name)).toEqual(['케이크']); + expect(result.hasMore).toBe(false); + }); + + it('storeProductCategories: 서비스에 위임해 카테고리를 반환한다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const category = await prisma.category.create({ + data: { + name: '생일', + category_type: 'EVENT', + sort_order: 0, + is_active: true, + }, + }); + await prisma.productCategory.create({ + data: { product_id: product.id, category_id: category.id }, + }); + + const result = await resolver.storeProductCategories(store.id.toString()); + + expect(result.map((c) => c.name)).toEqual(['생일']); + }); +}); diff --git a/src/features/product/resolvers/product-storefront-query.resolver.ts b/src/features/product/resolvers/product-storefront-query.resolver.ts new file mode 100644 index 0000000..e5039ce --- /dev/null +++ b/src/features/product/resolvers/product-storefront-query.resolver.ts @@ -0,0 +1,30 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { StoreProductsInput } from '@/features/product/dto/inputs/store-products.input'; +import { ProductStorefrontService } from '@/features/product/services/product-storefront.service'; +import type { + StoreProductCategory, + StoreProductConnection, +} from '@/features/product/types/product-storefront-output.type'; + +/** + * 구매자 매장 상품 조회 resolver. 개인화 필드가 없는 public query(인증 불필요). + */ +@Resolver('Query') +export class ProductStorefrontQueryResolver { + constructor(private readonly service: ProductStorefrontService) {} + + @Query('storeProducts') + storeProducts( + @Args('input') input: StoreProductsInput, + ): Promise { + return this.service.storeProducts(input); + } + + @Query('storeProductCategories') + storeProductCategories( + @Args('storeId') storeId: string, + ): Promise { + return this.service.storeProductCategories(storeId); + } +} diff --git a/src/features/product/services/product-storefront-mappers.helper.spec.ts b/src/features/product/services/product-storefront-mappers.helper.spec.ts new file mode 100644 index 0000000..d9aacc1 --- /dev/null +++ b/src/features/product/services/product-storefront-mappers.helper.spec.ts @@ -0,0 +1,97 @@ +import type { + StoreProductCategoryRow, + StoreProductRow, +} from '@/features/product/repositories/product.repository'; +import { + calcDiscountRate, + toStoreProduct, + toStoreProductCategory, +} from '@/features/product/services/product-storefront-mappers.helper'; + +function makeProductRow(o: Partial = {}): StoreProductRow { + return { + id: 1n, + name: '레터링 케이크', + description: '설명', + regular_price: 40000, + sale_price: 35000, + currency: 'KRW', + images: [{ image_url: 'thumb.png' }], + product_categories: [{ category_id: 10n }, { category_id: 20n }], + ...o, + }; +} + +describe('calcDiscountRate', () => { + it('정상 할인율을 정수로 반올림한다', () => { + expect(calcDiscountRate(40000, 35000)).toBe(13); // 12.5 → 13 + expect(calcDiscountRate(33000, 31350)).toBe(5); + }); + + it('salePrice가 null이면 0', () => { + expect(calcDiscountRate(40000, null)).toBe(0); + }); + + it('salePrice가 정가 이상이면 0', () => { + expect(calcDiscountRate(40000, 40000)).toBe(0); + expect(calcDiscountRate(40000, 45000)).toBe(0); + }); + + it('정가가 0 이하이면 0', () => { + expect(calcDiscountRate(0, 0)).toBe(0); + }); +}); + +describe('toStoreProduct', () => { + it('row를 카드로 매핑한다(id 문자열·대표이미지·할인율·카테고리ids)', () => { + const result = toStoreProduct(makeProductRow()); + expect(result).toEqual({ + id: '1', + name: '레터링 케이크', + description: '설명', + thumbnailUrl: 'thumb.png', + regularPrice: 40000, + salePrice: 35000, + discountRate: 13, + currency: 'KRW', + categoryIds: ['10', '20'], + }); + }); + + it('이미지가 없으면 thumbnailUrl은 null', () => { + expect( + toStoreProduct(makeProductRow({ images: [] })).thumbnailUrl, + ).toBeNull(); + }); + + it('salePrice가 없으면 discountRate는 0', () => { + const r = toStoreProduct(makeProductRow({ sale_price: null })); + expect(r.salePrice).toBeNull(); + expect(r.discountRate).toBe(0); + }); + + it('카테고리가 없으면 categoryIds는 빈 배열', () => { + expect( + toStoreProduct(makeProductRow({ product_categories: [] })).categoryIds, + ).toEqual([]); + }); +}); + +describe('toStoreProductCategory', () => { + it('카테고리 row를 매핑한다', () => { + const row: StoreProductCategoryRow = { + id: 5n, + name: '생일 케이크', + category_type: 'EVENT', + sort_order: 2, + product_count: 7, + }; + expect(toStoreProductCategory(row)).toEqual({ + id: '5', + name: '생일 케이크', + categoryType: 'EVENT', + sortOrder: 2, + productCount: 7, + }); + }); +}); diff --git a/src/features/product/services/product-storefront-mappers.helper.ts b/src/features/product/services/product-storefront-mappers.helper.ts new file mode 100644 index 0000000..5efc70d --- /dev/null +++ b/src/features/product/services/product-storefront-mappers.helper.ts @@ -0,0 +1,45 @@ +import type { + StoreProductCategoryRow, + StoreProductRow, +} from '@/features/product/repositories/product.repository'; +import type { + StoreProduct, + StoreProductCategory, +} from '@/features/product/types/product-storefront-output.type'; + +/** 할인율(0~100, 정수). salePrice가 없거나 비정상(정가 이상)이면 0. */ +export function calcDiscountRate( + regularPrice: number, + salePrice: number | null, +): number { + if (salePrice === null || regularPrice <= 0 || salePrice >= regularPrice) { + return 0; + } + return Math.round((1 - salePrice / regularPrice) * 100); +} + +export function toStoreProduct(row: StoreProductRow): StoreProduct { + return { + id: row.id.toString(), + name: row.name, + description: row.description, + thumbnailUrl: row.images[0]?.image_url ?? null, + regularPrice: row.regular_price, + salePrice: row.sale_price, + discountRate: calcDiscountRate(row.regular_price, row.sale_price), + currency: row.currency, + categoryIds: row.product_categories.map((pc) => pc.category_id.toString()), + }; +} + +export function toStoreProductCategory( + row: StoreProductCategoryRow, +): StoreProductCategory { + return { + id: row.id.toString(), + name: row.name, + categoryType: row.category_type, + sortOrder: row.sort_order, + productCount: row.product_count, + }; +} diff --git a/src/features/product/services/product-storefront.service.spec.ts b/src/features/product/services/product-storefront.service.spec.ts new file mode 100644 index 0000000..f007488 --- /dev/null +++ b/src/features/product/services/product-storefront.service.spec.ts @@ -0,0 +1,259 @@ +import type { PrismaClient } from '@prisma/client'; + +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { ProductStorefrontService } from '@/features/product/services/product-storefront.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createProduct, createStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +async function addCategory( + prisma: PrismaClient, + productId: bigint, + opts: { name: string; sortOrder?: number }, +): Promise { + const category = await prisma.category.create({ + data: { + name: opts.name, + category_type: 'EVENT', + sort_order: opts.sortOrder ?? 0, + is_active: true, + }, + }); + await prisma.productCategory.create({ + data: { product_id: productId, category_id: category.id }, + }); + return category.id; +} + +describe('ProductStorefrontService (real DB)', () => { + let service: ProductStorefrontService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ProductStorefrontService, ProductRepository], + }); + service = module.get(ProductStorefrontService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + describe('storeProducts', () => { + it('활성 상품만 반환하고 비활성/삭제 상품은 제외한다', async () => { + const store = await createStore(prisma); + await createProduct(prisma, { store_id: store.id, name: '활성' }); + await createProduct(prisma, { + store_id: store.id, + name: '비활성', + is_active: false, + }); + const deleted = await createProduct(prisma, { + store_id: store.id, + name: '삭제', + }); + await prisma.product.update({ + where: { id: deleted.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.storeProducts({ + storeId: store.id.toString(), + }); + + expect(result.items.map((p) => p.name)).toEqual(['활성']); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it('매장이 비활성이면 빈 결과', async () => { + const store = await createStore(prisma, { is_active: false }); + await createProduct(prisma, { store_id: store.id }); + + const result = await service.storeProducts({ + storeId: store.id.toString(), + }); + + expect(result.items).toEqual([]); + }); + + it('대표 이미지(sort_order 최소)와 할인율을 채운다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { + store_id: store.id, + regular_price: 40000, + sale_price: 35000, + }); + await prisma.productImage.create({ + data: { product_id: product.id, image_url: 'b.png', sort_order: 1 }, + }); + await prisma.productImage.create({ + data: { product_id: product.id, image_url: 'a.png', sort_order: 0 }, + }); + + const result = await service.storeProducts({ + storeId: store.id.toString(), + }); + + expect(result.items[0].thumbnailUrl).toBe('a.png'); + expect(result.items[0].discountRate).toBe(13); + }); + + it('categoryId로 필터한다', async () => { + const store = await createStore(prisma); + const p1 = await createProduct(prisma, { + store_id: store.id, + name: '생일', + }); + const p2 = await createProduct(prisma, { + store_id: store.id, + name: '돌잔치', + }); + const birthdayId = await addCategory(prisma, p1.id, { + name: '생일 케이크', + }); + await addCategory(prisma, p2.id, { name: '돌잔치 케이크' }); + + const result = await service.storeProducts({ + storeId: store.id.toString(), + categoryId: birthdayId.toString(), + }); + + expect(result.items.map((p) => p.name)).toEqual(['생일']); + }); + + it('search로 상품명·태그를 부분일치 검색한다', async () => { + const store = await createStore(prisma); + await createProduct(prisma, { + store_id: store.id, + name: '강아지 케이크', + }); + const tagged = await createProduct(prisma, { + store_id: store.id, + name: '미니 케이크', + }); + const tag = await prisma.tag.create({ data: { name: '강아지' } }); + await prisma.productTag.create({ + data: { product_id: tagged.id, tag_id: tag.id }, + }); + await createProduct(prisma, { + store_id: store.id, + name: '초콜릿 케이크', + }); + + const result = await service.storeProducts({ + storeId: store.id.toString(), + search: '강아지', + }); + + expect(result.items.map((p) => p.name).sort()).toEqual([ + '강아지 케이크', + '미니 케이크', + ]); + }); + + it('커서 페이지네이션으로 hasMore/nextCursor를 처리한다', async () => { + const store = await createStore(prisma); + for (let i = 0; i < 3; i++) { + await createProduct(prisma, { store_id: store.id, name: `P${i}` }); + } + + const first = await service.storeProducts({ + storeId: store.id.toString(), + limit: 2, + }); + expect(first.items).toHaveLength(2); + expect(first.hasMore).toBe(true); + expect(first.nextCursor).not.toBeNull(); + + const second = await service.storeProducts({ + storeId: store.id.toString(), + limit: 2, + cursor: first.nextCursor ?? undefined, + }); + expect(second.items).toHaveLength(1); + expect(second.hasMore).toBe(false); + expect(second.nextCursor).toBeNull(); + }); + }); + + describe('storeProductCategories', () => { + it('매장 보유 카테고리만 sort_order 순으로, productCount와 함께 반환한다', async () => { + const store = await createStore(prisma); + const p1 = await createProduct(prisma, { store_id: store.id }); + const p2 = await createProduct(prisma, { store_id: store.id }); + + const birthday = await prisma.category.create({ + data: { + name: '생일', + category_type: 'EVENT', + sort_order: 2, + is_active: true, + }, + }); + const dol = await prisma.category.create({ + data: { + name: '돌잔치', + category_type: 'EVENT', + sort_order: 1, + is_active: true, + }, + }); + // 어떤 상품도 속하지 않은 빈 카테고리(제외돼야 함) + await prisma.category.create({ + data: { + name: '크리스마스', + category_type: 'EVENT', + sort_order: 3, + is_active: true, + }, + }); + await prisma.productCategory.create({ + data: { product_id: p1.id, category_id: birthday.id }, + }); + await prisma.productCategory.create({ + data: { product_id: p2.id, category_id: birthday.id }, + }); + await prisma.productCategory.create({ + data: { product_id: p1.id, category_id: dol.id }, + }); + + const result = await service.storeProductCategories(store.id.toString()); + + expect(result.map((c) => c.name)).toEqual(['돌잔치', '생일']); + expect(result.find((c) => c.name === '생일')?.productCount).toBe(2); + expect(result.find((c) => c.name === '돌잔치')?.productCount).toBe(1); + }); + + it('비활성 상품의 카테고리는 제외한다', async () => { + const store = await createStore(prisma); + const inactive = await createProduct(prisma, { + store_id: store.id, + is_active: false, + }); + const cat = await prisma.category.create({ + data: { + name: '생일', + category_type: 'EVENT', + sort_order: 0, + is_active: true, + }, + }); + await prisma.productCategory.create({ + data: { product_id: inactive.id, category_id: cat.id }, + }); + + const result = await service.storeProductCategories(store.id.toString()); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/features/product/services/product-storefront.service.ts b/src/features/product/services/product-storefront.service.ts new file mode 100644 index 0000000..86df628 --- /dev/null +++ b/src/features/product/services/product-storefront.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { DEFAULT_STORE_PRODUCTS_LIMIT } from '@/features/product/constants/product-storefront.constants'; +import type { StoreProductsInput } from '@/features/product/dto/inputs/store-products.input'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { + toStoreProduct, + toStoreProductCategory, +} from '@/features/product/services/product-storefront-mappers.helper'; +import type { + StoreProductCategory, + StoreProductConnection, +} from '@/features/product/types/product-storefront-output.type'; + +@Injectable() +export class ProductStorefrontService { + constructor(private readonly repo: ProductRepository) {} + + /** 매장 상품 목록(커서). 활성 상품만. 카테고리/검색 필터. */ + async storeProducts( + input: StoreProductsInput, + ): Promise { + const limit = input.limit ?? DEFAULT_STORE_PRODUCTS_LIMIT; + const search = input.search?.trim(); + const rows = await this.repo.listActiveProductsByStore({ + storeId: parseId(input.storeId), + limit, + cursor: input.cursor ? parseId(input.cursor) : undefined, + categoryId: input.categoryId ? parseId(input.categoryId) : undefined, + search: search ? search : undefined, + }); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + + return { + items: page.map(toStoreProduct), + hasMore, + nextCursor: hasMore ? page[page.length - 1].id.toString() : null, + }; + } + + /** 매장 보유 카테고리(사이드바). 빈 카테고리 제외. */ + async storeProductCategories( + storeId: string, + ): Promise { + const rows = await this.repo.listStoreProductCategories(parseId(storeId)); + return rows.map(toStoreProductCategory); + } +} diff --git a/src/features/product/types/product-storefront-output.type.ts b/src/features/product/types/product-storefront-output.type.ts new file mode 100644 index 0000000..cb0a48d --- /dev/null +++ b/src/features/product/types/product-storefront-output.type.ts @@ -0,0 +1,30 @@ +/** + * product-storefront resolver 반환용 도메인 출력 타입. + * SDL(product-storefront.graphql)의 타입과 필드 일치. + */ + +export interface StoreProduct { + id: string; + name: string; + description: string | null; + thumbnailUrl: string | null; + regularPrice: number; + salePrice: number | null; + discountRate: number; + currency: string; + categoryIds: string[]; +} + +export interface StoreProductConnection { + items: StoreProduct[]; + hasMore: boolean; + nextCursor: string | null; +} + +export interface StoreProductCategory { + id: string; + name: string; + categoryType: 'EVENT' | 'STYLE' | 'OTHER'; + sortOrder: number; + productCount: number; +} From 24db098bc61208040417fc60f43de5fdcbd79e7a Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 03:43:19 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix(product):=20storeProductCategories?= =?UTF-8?q?=EC=97=90=20=EB=A7=A4=EC=9E=A5=20=ED=99=9C=EC=84=B1=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80=20(storeProducts=EC=99=80=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비활성/삭제 매장이 활성 상품을 보유하면 storeProductCategories가 카테고리·productCount를 노출하던 불일치를 수정한다. groupBy 필터에 store active/deleted 술어를 추가해 storeProducts와 가시성을 일치시킨다. (Codex P2 반영) --- .../repositories/product.repository.ts | 8 +++++++- .../product-storefront.service.spec.ts | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index a06fc2e..95de241 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -794,7 +794,13 @@ export class ProductRepository { by: ['category_id'], where: { deleted_at: null, - product: { store_id: storeId, is_active: true, deleted_at: null }, + product: { + store_id: storeId, + is_active: true, + deleted_at: null, + // storeProducts와 동일하게 비활성/삭제 매장은 카테고리도 노출하지 않는다 + store: { is_active: true, deleted_at: null }, + }, }, _count: { _all: true }, }); diff --git a/src/features/product/services/product-storefront.service.spec.ts b/src/features/product/services/product-storefront.service.spec.ts index f007488..77170d9 100644 --- a/src/features/product/services/product-storefront.service.spec.ts +++ b/src/features/product/services/product-storefront.service.spec.ts @@ -255,5 +255,25 @@ describe('ProductStorefrontService (real DB)', () => { expect(result).toEqual([]); }); + + it('비활성/삭제 매장의 카테고리는 노출하지 않는다', async () => { + const store = await createStore(prisma, { is_active: false }); + const product = await createProduct(prisma, { store_id: store.id }); + const cat = await prisma.category.create({ + data: { + name: '생일', + category_type: 'EVENT', + sort_order: 0, + is_active: true, + }, + }); + await prisma.productCategory.create({ + data: { product_id: product.id, category_id: cat.id }, + }); + + const result = await service.storeProductCategories(store.id.toString()); + + expect(result).toEqual([]); + }); }); }); From 3f737eeb5a067395b9a83485352bf040bde47cb7 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 03:46:24 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat(store):=20=EA=B5=AC=EB=A7=A4?= =?UTF-8?q?=EC=9E=90=20=EB=A7=A4=EC=9E=A5=20=ED=9B=84=EA=B8=B0=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C(storeReviews)=EC=99=80=20?= =?UTF-8?q?=EC=A2=8B=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 06/12] =?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 e7efcdfd935a73c34aa6c4151e464b9d98bea710 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 03:59:11 +0900 Subject: [PATCH 07/12] =?UTF-8?q?fix(product):=20storeProducts=20categoryI?= =?UTF-8?q?ds=C2=B7=ED=95=84=ED=84=B0=EB=A5=BC=20=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=EB=A1=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storeProductCategories는 카테고리를 is_active/deleted_at로 거르는데, storeProducts의 categoryIds와 categoryId 필터는 비활성/삭제 카테고리를 포함해 사이드바와 불일치했다. listActiveProductsByStore의 categoryId 필터와 product_categories select에 category active/deleted 술어를 추가한다. (Codex P2 :778 반영) --- .../repositories/product.repository.ts | 12 +++++-- .../product-storefront.service.spec.ts | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 95de241..3fa90ed 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -740,7 +740,11 @@ export class ProductRepository { ...(args.categoryId ? { product_categories: { - some: { category_id: args.categoryId, deleted_at: null }, + some: { + category_id: args.categoryId, + deleted_at: null, + category: { is_active: true, deleted_at: null }, + }, }, } : {}), @@ -774,7 +778,11 @@ export class ProductRepository { select: { image_url: true }, }, product_categories: { - where: { deleted_at: null }, + // storeProductCategories와 동일하게 비활성/삭제 카테고리는 categoryIds에서 제외 + where: { + deleted_at: null, + category: { is_active: true, deleted_at: null }, + }, select: { category_id: true }, }, }, diff --git a/src/features/product/services/product-storefront.service.spec.ts b/src/features/product/services/product-storefront.service.spec.ts index 77170d9..f2f7843 100644 --- a/src/features/product/services/product-storefront.service.spec.ts +++ b/src/features/product/services/product-storefront.service.spec.ts @@ -183,6 +183,39 @@ describe('ProductStorefrontService (real DB)', () => { expect(second.hasMore).toBe(false); expect(second.nextCursor).toBeNull(); }); + + it('비활성/삭제 카테고리는 categoryIds에서 제외한다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const active = await prisma.category.create({ + data: { + name: '활성', + category_type: 'EVENT', + sort_order: 0, + is_active: true, + }, + }); + const inactive = await prisma.category.create({ + data: { + name: '비활성', + category_type: 'EVENT', + sort_order: 1, + is_active: false, + }, + }); + await prisma.productCategory.create({ + data: { product_id: product.id, category_id: active.id }, + }); + await prisma.productCategory.create({ + data: { product_id: product.id, category_id: inactive.id }, + }); + + const result = await service.storeProducts({ + storeId: store.id.toString(), + }); + + expect(result.items[0].categoryIds).toEqual([active.id.toString()]); + }); }); describe('storeProductCategories', () => { From fa88dc1ccb4d9f52023c347690aea1fea237e4c6 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 04:08:20 +0900 Subject: [PATCH 08/12] =?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(); + }); }); From 6ea5913ce5e49db1430a56658253c90f03c41bdb Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 04:12:55 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix(product):=20storeProducts=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=97=90=EC=84=9C=20soft-delete=EB=90=9C=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search가 태그로 매칭될 때 tag.deleted_at을 확인하지 않아 삭제된 태그명으로도 상품이 검색되던 문제를 수정한다. product_tags.some.tag 조건에 deleted_at: null을 추가한다. (Codex P2 :759 반영) --- .../repositories/product.repository.ts | 5 ++++- .../product-storefront.service.spec.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 3fa90ed..dc1c29c 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -756,7 +756,10 @@ export class ProductRepository { product_tags: { some: { deleted_at: null, - tag: { name: { contains: args.search } }, + tag: { + name: { contains: args.search }, + deleted_at: null, + }, }, }, }, diff --git a/src/features/product/services/product-storefront.service.spec.ts b/src/features/product/services/product-storefront.service.spec.ts index f2f7843..ef5c7af 100644 --- a/src/features/product/services/product-storefront.service.spec.ts +++ b/src/features/product/services/product-storefront.service.spec.ts @@ -216,6 +216,27 @@ describe('ProductStorefrontService (real DB)', () => { expect(result.items[0].categoryIds).toEqual([active.id.toString()]); }); + + it('soft-delete된 태그로는 검색되지 않는다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { + store_id: store.id, + name: '미니 케이크', + }); + const tag = await prisma.tag.create({ + data: { name: '강아지', deleted_at: new Date() }, + }); + await prisma.productTag.create({ + data: { product_id: product.id, tag_id: tag.id }, + }); + + const result = await service.storeProducts({ + storeId: store.id.toString(), + search: '강아지', + }); + + expect(result.items).toEqual([]); + }); }); describe('storeProductCategories', () => { From b778b39728cc4833a3a48c1d0e9314be33e251e8 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 04:26:18 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix(product):=20storeProducts=EC=9D=98=20?= =?UTF-8?q?0=20=EA=B0=92=20ID=20=ED=95=84=ED=84=B0=EB=A5=BC=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseId("0")=0n이 args.cursor/args.categoryId의 truthiness 체크에서 falsy로 떨어져, 잘못된 categoryId가 전체 목록을 반환하고 zero cursor가 페이지를 리셋하던 문제를 수정한다. 0n도 유효 인자로 다루도록 !== undefined로 분기한다. (Codex P2 :740 반영) --- .../repositories/product.repository.ts | 6 +++-- .../product-storefront.service.spec.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index dc1c29c..b248ca3 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -736,8 +736,10 @@ export class ProductRepository { is_active: true, deleted_at: null, store: { is_active: true, deleted_at: null }, - ...(args.cursor ? { id: { lt: args.cursor } } : {}), - ...(args.categoryId + // 0n도 유효한 인자로 다뤄야 한다(parseId("0")=0n). truthiness 체크는 0n을 + // falsy로 떨궈 잘못된 필터를 전체조회로 만들므로 undefined로만 분기한다. + ...(args.cursor !== undefined ? { id: { lt: args.cursor } } : {}), + ...(args.categoryId !== undefined ? { product_categories: { some: { diff --git a/src/features/product/services/product-storefront.service.spec.ts b/src/features/product/services/product-storefront.service.spec.ts index ef5c7af..bc023e0 100644 --- a/src/features/product/services/product-storefront.service.spec.ts +++ b/src/features/product/services/product-storefront.service.spec.ts @@ -237,6 +237,30 @@ describe('ProductStorefrontService (real DB)', () => { expect(result.items).toEqual([]); }); + + it('categoryId "0"은 전체 목록이 아니라 빈 결과를 반환한다', async () => { + const store = await createStore(prisma); + await createProduct(prisma, { store_id: store.id }); + + const result = await service.storeProducts({ + storeId: store.id.toString(), + categoryId: '0', + }); + + expect(result.items).toEqual([]); + }); + + it('cursor "0"은 페이지를 리셋하지 않고 빈 결과를 반환한다', async () => { + const store = await createStore(prisma); + await createProduct(prisma, { store_id: store.id }); + + const result = await service.storeProducts({ + storeId: store.id.toString(), + cursor: '0', + }); + + expect(result.items).toEqual([]); + }); }); describe('storeProductCategories', () => { From a8fd88ba9d21f317bf4c41d19615dbd7f1345e82 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 04:29:50 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix(store):=20storeReviews=EC=9D=98=200?= =?UTF-8?q?=20=EA=B0=92=20cursor=EB=A5=BC=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storeProducts(#158)와 동일하게 parseId("0")=0n이 args.cursor truthiness 체크에서 falsy로 떨어져 zero cursor가 페이지를 리셋하던 문제를 수정한다. listStoreReviews의 cursor 분기를 !== undefined로 바꾼다. --- .../store/repositories/store-review.repository.ts | 4 +++- .../store/services/store-review.service.spec.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/features/store/repositories/store-review.repository.ts b/src/features/store/repositories/store-review.repository.ts index 4c7c96e..2931f5c 100644 --- a/src/features/store/repositories/store-review.repository.ts +++ b/src/features/store/repositories/store-review.repository.ts @@ -45,7 +45,9 @@ export class StoreReviewRepository { deleted_at: null, // storeDetail과 동일하게 비활성/삭제 매장의 리뷰는 노출하지 않는다 store: { is_active: true, deleted_at: null }, - ...(args.cursor ? { id: { lt: args.cursor } } : {}), + // 0n도 유효 인자(parseId("0")=0n). truthiness는 0n을 falsy로 떨궈 + // zero cursor가 페이지를 리셋하므로 undefined로만 분기한다. + ...(args.cursor !== undefined ? { id: { lt: args.cursor } } : {}), }, select: { id: true, diff --git a/src/features/store/services/store-review.service.spec.ts b/src/features/store/services/store-review.service.spec.ts index 4cb9587..ade210e 100644 --- a/src/features/store/services/store-review.service.spec.ts +++ b/src/features/store/services/store-review.service.spec.ts @@ -201,4 +201,16 @@ describe('StoreReviewService (real DB)', () => { expect(result.items[0].authorNickname).toBeNull(); }); + + it('cursor "0"은 페이지를 리셋하지 않고 빈 결과를 반환한다', async () => { + const store = await createStore(prisma); + await makeReview(store.id, {}); + + const result = await service.storeReviews({ + storeId: store.id.toString(), + cursor: '0', + }); + + expect(result.items).toEqual([]); + }); }); From 41c1df9de18f9e68d148b50acda7a1c3e8dbda00 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 21:28:29 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(StoreImage=20soft-de?= =?UTF-8?q?lete=20=EB=93=B1=EB=A1=9D=C2=B7=EB=B9=88=EA=B0=92/0n=20ID=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=C2=B7discountRate=20clamp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StoreImage를 SOFT_DELETE_MODELS에 등록해 직접 조회 시 deleted_at 자동 필터 적용 (Codex P2) - storeProducts/storeReviews input의 ID(storeId·categoryId·cursor)에 @IsNotEmpty 추가 (빈 문자열 차단) - discountRate를 0~100으로 clamp (음수 salePrice 방어) - accountId 0n falsy 분기를 !== undefined로 (storeReviews·storeDetail — account id 0의 좋아요/찜 누락 방지) - product-storefront service spec: 비활성/삭제 매장 케이스 분리 + soft-delete 검증 추가 CodeRabbit Functional + Codex P2 반영. CodeRabbit Major(spec을 mock으로 전환)는 testcontainers realDB 통합 컨벤션 유지로 미반영. --- .../dto/inputs/store-products.input.ts | 12 ++++++++- .../product-storefront-mappers.helper.spec.ts | 4 +++ .../product-storefront-mappers.helper.ts | 4 ++- .../product-storefront.service.spec.ts | 26 ++++++++++++++++++- .../store/dto/inputs/store-reviews.input.ts | 11 +++++++- .../store/services/store-detail.service.ts | 2 +- .../store/services/store-review.service.ts | 2 +- src/prisma/soft-delete.middleware.ts | 1 + 8 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/features/product/dto/inputs/store-products.input.ts b/src/features/product/dto/inputs/store-products.input.ts index ead9ac6..7dda606 100644 --- a/src/features/product/dto/inputs/store-products.input.ts +++ b/src/features/product/dto/inputs/store-products.input.ts @@ -1,11 +1,20 @@ -import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; export class StoreProductsInput { @IsString() + @IsNotEmpty() storeId!: string; @IsOptional() @IsString() + @IsNotEmpty() categoryId?: string; @IsOptional() @@ -14,6 +23,7 @@ export class StoreProductsInput { @IsOptional() @IsString() + @IsNotEmpty() cursor?: string; @IsOptional() diff --git a/src/features/product/services/product-storefront-mappers.helper.spec.ts b/src/features/product/services/product-storefront-mappers.helper.spec.ts index d9aacc1..8f66648 100644 --- a/src/features/product/services/product-storefront-mappers.helper.spec.ts +++ b/src/features/product/services/product-storefront-mappers.helper.spec.ts @@ -40,6 +40,10 @@ describe('calcDiscountRate', () => { it('정가가 0 이하이면 0', () => { expect(calcDiscountRate(0, 0)).toBe(0); }); + + it('salePrice가 음수 등 비정상이면 0~100으로 clamp한다', () => { + expect(calcDiscountRate(40000, -10000)).toBe(100); + }); }); describe('toStoreProduct', () => { diff --git a/src/features/product/services/product-storefront-mappers.helper.ts b/src/features/product/services/product-storefront-mappers.helper.ts index 5efc70d..2589b0d 100644 --- a/src/features/product/services/product-storefront-mappers.helper.ts +++ b/src/features/product/services/product-storefront-mappers.helper.ts @@ -15,7 +15,9 @@ export function calcDiscountRate( if (salePrice === null || regularPrice <= 0 || salePrice >= regularPrice) { return 0; } - return Math.round((1 - salePrice / regularPrice) * 100); + // salePrice가 음수 등 비정상 값이어도 공개 계약(0~100)을 지키도록 clamp + const rate = Math.round((1 - salePrice / regularPrice) * 100); + return Math.min(100, Math.max(0, rate)); } export function toStoreProduct(row: StoreProductRow): StoreProduct { diff --git a/src/features/product/services/product-storefront.service.spec.ts b/src/features/product/services/product-storefront.service.spec.ts index bc023e0..72a899d 100644 --- a/src/features/product/services/product-storefront.service.spec.ts +++ b/src/features/product/services/product-storefront.service.spec.ts @@ -334,7 +334,7 @@ describe('ProductStorefrontService (real DB)', () => { expect(result).toEqual([]); }); - it('비활성/삭제 매장의 카테고리는 노출하지 않는다', async () => { + it('비활성 매장의 카테고리는 노출하지 않는다', async () => { const store = await createStore(prisma, { is_active: false }); const product = await createProduct(prisma, { store_id: store.id }); const cat = await prisma.category.create({ @@ -353,5 +353,29 @@ describe('ProductStorefrontService (real DB)', () => { expect(result).toEqual([]); }); + + it('soft-delete된 매장의 카테고리는 노출하지 않는다', async () => { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const cat = await prisma.category.create({ + data: { + name: '돌잔치', + category_type: 'EVENT', + sort_order: 0, + is_active: true, + }, + }); + await prisma.productCategory.create({ + data: { product_id: product.id, category_id: cat.id }, + }); + await prisma.store.update({ + where: { id: store.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.storeProductCategories(store.id.toString()); + + expect(result).toEqual([]); + }); }); }); diff --git a/src/features/store/dto/inputs/store-reviews.input.ts b/src/features/store/dto/inputs/store-reviews.input.ts index 81d2e49..8216e45 100644 --- a/src/features/store/dto/inputs/store-reviews.input.ts +++ b/src/features/store/dto/inputs/store-reviews.input.ts @@ -1,11 +1,20 @@ -import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; export class StoreReviewsInput { @IsString() + @IsNotEmpty() storeId!: string; @IsOptional() @IsString() + @IsNotEmpty() cursor?: string; @IsOptional() diff --git a/src/features/store/services/store-detail.service.ts b/src/features/store/services/store-detail.service.ts index e9c6830..aaa9073 100644 --- a/src/features/store/services/store-detail.service.ts +++ b/src/features/store/services/store-detail.service.ts @@ -30,7 +30,7 @@ export class StoreDetailService { const [reviewStats, wishlistedIds] = await Promise.all([ this.repo.aggregateReviewStats([storeId]), - accountId + accountId !== undefined ? this.wishlistRepo.findWishlistedStoreIds({ accountId, storeIds: [storeId], diff --git a/src/features/store/services/store-review.service.ts b/src/features/store/services/store-review.service.ts index 874f1ca..d1f45c3 100644 --- a/src/features/store/services/store-review.service.ts +++ b/src/features/store/services/store-review.service.ts @@ -37,7 +37,7 @@ export class StoreReviewService { const [likeCounts, likedIds] = await Promise.all([ this.repo.aggregateLikeCounts(reviewIds), - accountId + accountId !== undefined ? this.repo.findLikedReviewIds({ reviewIds, accountId }) : Promise.resolve(new Set()), ]); diff --git a/src/prisma/soft-delete.middleware.ts b/src/prisma/soft-delete.middleware.ts index fdb4542..9d25cda 100644 --- a/src/prisma/soft-delete.middleware.ts +++ b/src/prisma/soft-delete.middleware.ts @@ -10,6 +10,7 @@ const SOFT_DELETE_MODELS = new Set([ 'Store', 'StoreBusinessHour', 'StoreSpecialClosure', + 'StoreImage', 'Category', 'Tag', 'Product',