Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
78992f8
feat(store): 구매자 매장 상세 조회(storeDetail)와 StoreImage 스키마 추가
chanwoo7 Jun 23, 2026
04d5fc6
fix(store): 재시드 시 store_image FK 위반 방지 (resetSeedScope 정리 추가)
chanwoo7 Jun 23, 2026
03ab4d7
Merge pull request #157 from CaQuick/feat/store-detail-header
chanwoo7 Jun 23, 2026
703b5ae
feat(product): 구매자 매장 상품 목록·카테고리 조회 API
chanwoo7 Jun 23, 2026
24db098
fix(product): storeProductCategories에 매장 활성 필터 추가 (storeProducts와 일관)
chanwoo7 Jun 23, 2026
3f737ee
feat(store): 구매자 매장 후기 목록 조회(storeReviews)와 좋아요 집계
chanwoo7 Jun 23, 2026
847f8ac
fix(store): storeReviews를 활성 매장으로 제한 (storeDetail과 일관)
chanwoo7 Jun 23, 2026
e7efcdf
fix(product): storeProducts categoryIds·필터를 활성 카테고리로 제한
chanwoo7 Jun 23, 2026
fa88dc1
fix(store): storeReviews에서 탈퇴 작성자 닉네임 익명화
chanwoo7 Jun 23, 2026
6ea5913
fix(product): storeProducts 검색에서 soft-delete된 태그 제외
chanwoo7 Jun 23, 2026
7583830
Merge pull request #159 from CaQuick/feat/store-detail-reviews
chanwoo7 Jun 23, 2026
b778b39
fix(product): storeProducts의 0 값 ID 필터를 보존
chanwoo7 Jun 23, 2026
a8fd88b
fix(store): storeReviews의 0 값 cursor를 보존
chanwoo7 Jun 23, 2026
e028054
Merge pull request #158 from CaQuick/feat/store-detail-products
chanwoo7 Jun 23, 2026
93882a4
Merge pull request #160 from CaQuick/feat/store-review-cursor-fix
chanwoo7 Jun 23, 2026
41c1df9
fix: 릴리즈 리뷰 반영 (StoreImage soft-delete 등록·빈값/0n ID 방어·discountRate cl…
chanwoo7 Jun 24, 2026
64ea223
Merge pull request #162 from CaQuick/fix/store-detail-review-followups
chanwoo7 Jun 24, 2026
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,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;
23 changes: 22 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register StoreImage with soft-delete filtering

This new soft-deletable model is not added to SOFT_DELETE_MODELS in src/prisma/soft-delete.middleware.ts, so any direct prisma.storeImage.findMany/findFirst/count read will not get the repository-wide deleted_at: null filter that other soft-deletable tables rely on. The current detail query filters nested images manually, but the first direct StoreImage read can return or count deleted carousel images unless StoreImage is registered there.

Useful? React with 👍 / 👎.


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
Expand Down
4 changes: 4 additions & 0 deletions prisma/seed/idempotent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ export async function resetSeedScope(prisma: PrismaClient): Promise<void> {
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 } } });
}

Expand Down
16 changes: 16 additions & 0 deletions prisma/seed/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,24 @@ export async function seedStores(prisma: PrismaClient): Promise<SeededStores> {
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,
},
],
},
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** 매장 상품 목록 기본 페이지 크기. */
export const DEFAULT_STORE_PRODUCTS_LIMIT = 20;
34 changes: 34 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,34 @@
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';

export class StoreProductsInput {
@IsString()
@IsNotEmpty()
storeId!: string;

@IsOptional()
@IsString()
@IsNotEmpty()
categoryId?: string;

@IsOptional()
@IsString()
search?: string;

@IsOptional()
@IsString()
@IsNotEmpty()
cursor?: string;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@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,
}));
}
}
Loading
Loading