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/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 } } }); } 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/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..7dda606 --- /dev/null +++ b/src/features/product/dto/inputs/store-products.input.ts @@ -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; + + @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..b248ca3 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,131 @@ 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 }, + // 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 { + 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, + })); + } } 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..8f66648 --- /dev/null +++ b/src/features/product/services/product-storefront-mappers.helper.spec.ts @@ -0,0 +1,101 @@ +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); + }); + + it('salePrice가 음수 등 비정상이면 0~100으로 clamp한다', () => { + expect(calcDiscountRate(40000, -10000)).toBe(100); + }); +}); + +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..2589b0d --- /dev/null +++ b/src/features/product/services/product-storefront-mappers.helper.ts @@ -0,0 +1,47 @@ +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; + } + // 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 { + 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..72a899d --- /dev/null +++ b/src/features/product/services/product-storefront.service.spec.ts @@ -0,0 +1,381 @@ +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(); + }); + + 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()]); + }); + + 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([]); + }); + + 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', () => { + 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([]); + }); + + 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([]); + }); + + 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/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; +} 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/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..8216e45 --- /dev/null +++ b/src/features/store/dto/inputs/store-reviews.input.ts @@ -0,0 +1,25 @@ +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class StoreReviewsInput { + @IsString() + @IsNotEmpty() + storeId!: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + 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..2931f5c --- /dev/null +++ b/src/features/store/repositories/store-review.repository.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, type ReviewMediaType } from '@prisma/client'; + +import { PrismaService } from '@/prisma'; + +export interface StoreReviewMediaRow { + media_type: ReviewMediaType; + media_url: string; + thumbnail_url: string | null; + sort_order: number; +} + +/** 매장 공개 리뷰 조회 결과 row. storeReviews 매퍼 입력. */ +export interface StoreReviewRow { + id: bigint; + rating: Prisma.Decimal; + content: string | null; + created_at: Date; + account: { + user_profile: { nickname: string; deleted_at: Date | null } | null; + }; + order_item: { product_name_snapshot: string }; + media: StoreReviewMediaRow[]; +} + +/** + * 매장 공개 리뷰 조회 전용 repository. + * + * user feature의 ReviewRepository(본인 리뷰 작성/관리)와 책임이 분리된다. + * 같은 review 테이블을 읽지만 "매장의 공개 리뷰 목록 + 좋아요 집계"는 매장 조회 유스케이스. + */ +@Injectable() +export class StoreReviewRepository { + constructor(private readonly prisma: PrismaService) {} + + /** 매장 공개 리뷰 목록(최신순, 커서 id desc). soft-delete 제외. */ + async listStoreReviews(args: { + storeId: bigint; + limit: number; + cursor?: bigint; + }): Promise { + return this.prisma.review.findMany({ + where: { + store_id: args.storeId, + deleted_at: null, + // storeDetail과 동일하게 비활성/삭제 매장의 리뷰는 노출하지 않는다 + store: { is_active: true, deleted_at: null }, + // 0n도 유효 인자(parseId("0")=0n). truthiness는 0n을 falsy로 떨궈 + // zero cursor가 페이지를 리셋하므로 undefined로만 분기한다. + ...(args.cursor !== undefined ? { id: { lt: args.cursor } } : {}), + }, + select: { + id: true, + rating: true, + content: true, + created_at: true, + account: { + // soft-delete extension은 nested relation에 deleted_at을 주입하지 않으므로 + // deleted_at을 함께 읽어 탈퇴 작성자 닉네임은 매퍼에서 익명화한다 + select: { + user_profile: { select: { nickname: true, deleted_at: true } }, + }, + }, + order_item: { select: { product_name_snapshot: true } }, + media: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + select: { + media_type: true, + media_url: true, + thumbnail_url: true, + sort_order: true, + }, + }, + }, + orderBy: { id: 'desc' }, + take: args.limit + 1, + }); + } + + /** 매장 활성 리뷰 수(후기 탭 카운트). 비활성/삭제 매장은 0. */ + async countStoreReviews(storeId: bigint): Promise { + return this.prisma.review.count({ + where: { + store_id: storeId, + deleted_at: null, + store: { is_active: true, deleted_at: null }, + }, + }); + } + + /** 리뷰별 좋아요 수. */ + async aggregateLikeCounts(reviewIds: bigint[]): Promise> { + if (reviewIds.length === 0) return new Map(); + const rows = await this.prisma.reviewLike.groupBy({ + by: ['review_id'], + where: { review_id: { in: reviewIds }, deleted_at: null }, + _count: { _all: true }, + }); + return new Map(rows.map((r) => [r.review_id, r._count._all])); + } + + /** 로그인 사용자가 좋아요한 review_id 집합(string). */ + async findLikedReviewIds(args: { + reviewIds: bigint[]; + accountId: bigint; + }): Promise> { + if (args.reviewIds.length === 0) return new Set(); + const rows = await this.prisma.reviewLike.findMany({ + where: { + review_id: { in: args.reviewIds }, + account_id: args.accountId, + deleted_at: null, + }, + select: { review_id: true }, + }); + return new Set(rows.map((r) => r.review_id.toString())); + } +} diff --git a/src/features/store/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/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-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..aaa9073 --- /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 !== undefined + ? 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/services/store-review-mappers.helper.spec.ts b/src/features/store/services/store-review-mappers.helper.spec.ts new file mode 100644 index 0000000..5246caa --- /dev/null +++ b/src/features/store/services/store-review-mappers.helper.spec.ts @@ -0,0 +1,77 @@ +import { Prisma } from '@prisma/client'; + +import type { StoreReviewRow } from '@/features/store/repositories/store-review.repository'; +import { toStoreReview } from '@/features/store/services/store-review-mappers.helper'; + +function makeRow(o: Partial = {}): StoreReviewRow { + return { + id: 1n, + rating: new Prisma.Decimal('4.5'), + content: '맛있어요', + created_at: new Date('2026-01-01T00:00:00.000Z'), + account: { user_profile: { nickname: '구매자1', deleted_at: null } }, + order_item: { product_name_snapshot: '레터링 케이크' }, + media: [ + { + media_type: 'IMAGE', + media_url: 'a.png', + thumbnail_url: 't.png', + sort_order: 0, + }, + ], + ...o, + }; +} + +describe('toStoreReview', () => { + it('row를 StoreReview로 매핑한다(rating number·media·productName·author)', () => { + const result = toStoreReview(makeRow(), 3, true); + expect(result).toEqual({ + id: '1', + rating: 4.5, + content: '맛있어요', + media: [ + { + mediaType: 'IMAGE', + mediaUrl: 'a.png', + thumbnailUrl: 't.png', + sortOrder: 0, + }, + ], + likeCount: 3, + isLiked: true, + authorNickname: '구매자1', + productName: '레터링 케이크', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }); + }); + + it('user_profile이 없으면 authorNickname은 null', () => { + const result = toStoreReview( + makeRow({ account: { user_profile: null } }), + 0, + false, + ); + expect(result.authorNickname).toBeNull(); + }); + + it('탈퇴(soft-delete)한 작성자의 닉네임은 익명화한다(null)', () => { + const result = toStoreReview( + makeRow({ + account: { + user_profile: { nickname: 'deleted_123', deleted_at: new Date() }, + }, + }), + 0, + false, + ); + expect(result.authorNickname).toBeNull(); + }); + + it('media가 없으면 빈 배열, likeCount/isLiked 인자를 반영', () => { + const result = toStoreReview(makeRow({ media: [] }), 0, false); + expect(result.media).toEqual([]); + expect(result.likeCount).toBe(0); + expect(result.isLiked).toBe(false); + }); +}); diff --git a/src/features/store/services/store-review-mappers.helper.ts b/src/features/store/services/store-review-mappers.helper.ts new file mode 100644 index 0000000..d6f8b52 --- /dev/null +++ b/src/features/store/services/store-review-mappers.helper.ts @@ -0,0 +1,29 @@ +import type { StoreReviewRow } from '@/features/store/repositories/store-review.repository'; +import type { StoreReview } from '@/features/store/types/store-review-output.type'; + +export function toStoreReview( + row: StoreReviewRow, + likeCount: number, + isLiked: boolean, +): StoreReview { + return { + id: row.id.toString(), + rating: Number(row.rating), + content: row.content, + media: row.media.map((m) => ({ + mediaType: m.media_type, + mediaUrl: m.media_url, + thumbnailUrl: m.thumbnail_url, + sortOrder: m.sort_order, + })), + likeCount, + isLiked, + // 탈퇴(soft-delete)한 작성자는 nickname이 deleted_로 덮어써지므로 익명화한다 + authorNickname: + row.account.user_profile && row.account.user_profile.deleted_at === null + ? row.account.user_profile.nickname + : null, + productName: row.order_item.product_name_snapshot, + createdAt: row.created_at, + }; +} diff --git a/src/features/store/services/store-review.service.spec.ts b/src/features/store/services/store-review.service.spec.ts new file mode 100644 index 0000000..ade210e --- /dev/null +++ b/src/features/store/services/store-review.service.spec.ts @@ -0,0 +1,216 @@ +import type { PrismaClient } from '@prisma/client'; + +import { StoreReviewRepository } from '@/features/store/repositories/store-review.repository'; +import { StoreReviewService } from '@/features/store/services/store-review.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createOrder, + createOrderItem, + createReview, + createStore, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('StoreReviewService (real DB)', () => { + let service: StoreReviewService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [StoreReviewService, StoreReviewRepository], + }); + service = module.get(StoreReviewService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function makeReview( + storeId: bigint, + opts: { rating?: number; nickname?: string; productName?: string } = {}, + ) { + const account = await createAccount(prisma, { account_type: 'USER' }); + if (opts.nickname !== undefined) { + await prisma.userProfile.create({ + data: { account_id: account.id, nickname: opts.nickname }, + }); + } + const order = await createOrder(prisma, { account_id: account.id }); + const orderItem = await createOrderItem(prisma, { + order_id: order.id, + store_id: storeId, + product_name_snapshot: opts.productName ?? '케이크', + }); + return createReview(prisma, { + order_item_id: orderItem.id, + rating: opts.rating ?? 5, + }); + } + + it('리뷰가 없으면 빈 목록과 totalCount 0', async () => { + const store = await createStore(prisma); + const result = await service.storeReviews({ storeId: store.id.toString() }); + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it('작성자 닉네임·연결 상품명·평점·미디어를 반환한다', async () => { + const store = await createStore(prisma); + const review = await makeReview(store.id, { + rating: 4, + nickname: '구매자1', + productName: '레터링 케이크', + }); + await prisma.reviewMedia.create({ + data: { + review_id: review.id, + media_type: 'IMAGE', + media_url: 'a.png', + thumbnail_url: 't.png', + sort_order: 0, + }, + }); + + const result = await service.storeReviews({ storeId: store.id.toString() }); + + expect(result.totalCount).toBe(1); + expect(result.items[0]).toMatchObject({ + rating: 4, + authorNickname: '구매자1', + productName: '레터링 케이크', + }); + expect(result.items[0].media).toEqual([ + { + mediaType: 'IMAGE', + mediaUrl: 'a.png', + thumbnailUrl: 't.png', + sortOrder: 0, + }, + ]); + }); + + it('user_profile이 없으면 authorNickname은 null', async () => { + const store = await createStore(prisma); + await makeReview(store.id, {}); + const result = await service.storeReviews({ storeId: store.id.toString() }); + expect(result.items[0].authorNickname).toBeNull(); + }); + + it('좋아요 수를 집계하고 isLiked는 로그인 사용자 기준(비로그인 false)', async () => { + const store = await createStore(prisma); + const review = await makeReview(store.id, {}); + const liker1 = await createAccount(prisma, { account_type: 'USER' }); + const liker2 = await createAccount(prisma, { account_type: 'USER' }); + await prisma.reviewLike.create({ + data: { review_id: review.id, account_id: liker1.id }, + }); + await prisma.reviewLike.create({ + data: { review_id: review.id, account_id: liker2.id }, + }); + + const anon = await service.storeReviews({ storeId: store.id.toString() }); + expect(anon.items[0].likeCount).toBe(2); + expect(anon.items[0].isLiked).toBe(false); + + const loggedIn = await service.storeReviews( + { storeId: store.id.toString() }, + liker1.id, + ); + expect(loggedIn.items[0].isLiked).toBe(true); + }); + + it('soft-delete된 리뷰는 목록·카운트에서 제외한다', async () => { + const store = await createStore(prisma); + const review = await makeReview(store.id, {}); + await prisma.review.update({ + where: { id: review.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.storeReviews({ storeId: store.id.toString() }); + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + }); + + it('최신순(id desc) + 커서 페이지네이션을 처리한다', async () => { + const store = await createStore(prisma); + const r1 = await makeReview(store.id, {}); + const r2 = await makeReview(store.id, {}); + const r3 = await makeReview(store.id, {}); + + const first = await service.storeReviews({ + storeId: store.id.toString(), + limit: 2, + }); + expect(first.items.map((r) => r.id)).toEqual([ + r3.id.toString(), + r2.id.toString(), + ]); + expect(first.hasMore).toBe(true); + expect(first.totalCount).toBe(3); + + const second = await service.storeReviews({ + storeId: store.id.toString(), + limit: 2, + cursor: first.nextCursor ?? undefined, + }); + expect(second.items.map((r) => r.id)).toEqual([r1.id.toString()]); + expect(second.hasMore).toBe(false); + expect(second.nextCursor).toBeNull(); + }); + + it('비활성/삭제 매장의 리뷰는 목록·카운트에서 제외한다', async () => { + const store = await createStore(prisma, { is_active: false }); + await makeReview(store.id, {}); + + const result = await service.storeReviews({ storeId: store.id.toString() }); + + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + }); + + it('탈퇴(soft-delete)한 작성자의 닉네임은 노출하지 않는다', async () => { + const store = await createStore(prisma); + const account = await createAccount(prisma, { account_type: 'USER' }); + await prisma.userProfile.create({ + data: { + account_id: account.id, + nickname: `deleted_${account.id}`, + deleted_at: new Date(), + }, + }); + const order = await createOrder(prisma, { account_id: account.id }); + const orderItem = await createOrderItem(prisma, { + order_id: order.id, + store_id: store.id, + }); + await createReview(prisma, { order_item_id: orderItem.id, rating: 5 }); + + const result = await service.storeReviews({ storeId: store.id.toString() }); + + expect(result.items[0].authorNickname).toBeNull(); + }); + + 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([]); + }); +}); 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..d1f45c3 --- /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 !== undefined + ? 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-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-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 a817aa1..9e808f2 100644 --- a/src/features/store/store.module.ts +++ b/src/features/store/store.module.ts @@ -1,20 +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-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/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; +} 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', 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, }, }); }