Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** 매장 상품 목록 기본 페이지 크기. */
export const DEFAULT_STORE_PRODUCTS_LIMIT = 20;
24 changes: 24 additions & 0 deletions src/features/product/dto/inputs/store-products.input.ts
Original file line number Diff line number Diff line change
@@ -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;
}
58 changes: 58 additions & 0 deletions src/features/product/product-storefront.graphql
Original file line number Diff line number Diff line change
@@ -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!]!
}
8 changes: 7 additions & 1 deletion src/features/product/product.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
150 changes: 149 additions & 1 deletion src/features/product/repositories/product.repository.ts
Original file line number Diff line number Diff line change
@@ -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) {}
Expand Down Expand Up @@ -697,4 +718,131 @@ export class ProductRepository {
},
});
}

/**
* 구매자용 매장 상품 목록. 활성 상품(+활성 매장)만, 카테고리/검색 필터.
* 카드용 가벼운 select(대표 이미지 1장 + 카테고리 id). 커서는 id < cursor(desc).
*/
async listActiveProductsByStore(args: {
storeId: bigint;
limit: number;
cursor?: bigint;
categoryId?: bigint;
search?: string;
}): Promise<StoreProductRow[]> {
return this.prisma.product.findMany({
where: {
store_id: args.storeId,
is_active: true,
deleted_at: null,
store: { is_active: true, deleted_at: null },
// 0n도 유효한 인자로 다뤄야 한다(parseId("0")=0n). truthiness 체크는 0n을
// falsy로 떨궈 잘못된 필터를 전체조회로 만들므로 undefined로만 분기한다.
...(args.cursor !== undefined ? { id: { lt: args.cursor } } : {}),
...(args.categoryId !== undefined
? {
product_categories: {
some: {
category_id: args.categoryId,
deleted_at: null,
category: { is_active: true, deleted_at: null },
},
},
}
: {}),
...(args.search
? {
OR: [
{ name: { contains: args.search } },
{
product_tags: {
some: {
deleted_at: null,
tag: {
name: { contains: args.search },
deleted_at: null,
},
},
},
},
],
}
: {}),
},
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: {
// storeProductCategories와 동일하게 비활성/삭제 카테고리는 categoryIds에서 제외
where: {
deleted_at: null,
category: { is_active: true, deleted_at: null },
},
select: { category_id: true },
},
},
orderBy: { id: 'desc' },
take: args.limit + 1,
});
}

/**
* 매장이 보유한 활성 상품의 카테고리(사이드바). 빈 카테고리 제외.
* sort_order asc, productCount는 이 매장의 활성 상품 기준.
*/
async listStoreProductCategories(
storeId: bigint,
): Promise<StoreProductCategoryRow[]> {
const grouped = await this.prisma.productCategory.groupBy({
by: ['category_id'],
where: {
deleted_at: null,
product: {
store_id: storeId,
is_active: true,
deleted_at: null,
// storeProducts와 동일하게 비활성/삭제 매장은 카테고리도 노출하지 않는다
store: { 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,
}));
}
}
Original file line number Diff line number Diff line change
@@ -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(['생일']);
});
});
Original file line number Diff line number Diff line change
@@ -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<StoreProductConnection> {
return this.service.storeProducts(input);
}

@Query('storeProductCategories')
storeProductCategories(
@Args('storeId') storeId: string,
): Promise<StoreProductCategory[]> {
return this.service.storeProductCategories(storeId);
}
}
Loading
Loading