From 703b5ae235b68524ac2808f3261d906151b77222 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 03:32:03 +0900 Subject: [PATCH 1/5] =?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 2/5] =?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 e7efcdfd935a73c34aa6c4151e464b9d98bea710 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 03:59:11 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(product):=20storeProducts=20categoryIds?= =?UTF-8?q?=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 6ea5913ce5e49db1430a56658253c90f03c41bdb Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Wed, 24 Jun 2026 04:12:55 +0900 Subject: [PATCH 4/5] =?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 5/5] =?UTF-8?q?fix(product):=20storeProducts=EC=9D=98=200?= =?UTF-8?q?=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', () => {