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/store/constants/store-detail-error-messages.ts b/src/features/store/constants/store-detail-error-messages.ts new file mode 100644 index 0000000..c0e0491 --- /dev/null +++ b/src/features/store/constants/store-detail-error-messages.ts @@ -0,0 +1,4 @@ +/** 매장 상세 조회 에러 메시지. */ +export const STORE_DETAIL_ERRORS = { + STORE_NOT_FOUND: '매장을 찾을 수 없습니다.', +} as const; diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index 44d55ff..0158c03 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { Prisma, type StoreMapProvider } from '@prisma/client'; import { POPULAR_STORE_CAKE_IMAGE_LIMIT, @@ -19,6 +20,25 @@ export interface StoreReviewStat { count: number; } +/** 매장 상세 조회 결과 row. storeDetail 매퍼 입력. */ +export interface StoreDetailRow { + id: bigint; + store_name: string; + store_phone: string; + address_full: string; + address_city: string | null; + address_neighborhood: string | null; + latitude: Prisma.Decimal | null; + longitude: Prisma.Decimal | null; + map_provider: StoreMapProvider; + business_hours_text: string | null; + access_guide_text: string | null; + regular_closure_text: string | null; + website_url: string | null; + region: { name: string } | null; + store_images: { image_url: string }[]; +} + @Injectable() export class StoreRepository { constructor(private readonly prisma: PrismaService) {} @@ -54,6 +74,34 @@ export class StoreRepository { return Boolean(found); } + /** 매장 상세 헤더 조회. 활성·미삭제 매장만. 대표 이미지는 sort_order asc. */ + async findStoreDetailById(storeId: bigint): Promise { + return this.prisma.store.findFirst({ + where: { id: storeId, is_active: true, deleted_at: null }, + select: { + id: true, + store_name: true, + store_phone: true, + address_full: true, + address_city: true, + address_neighborhood: true, + latitude: true, + longitude: true, + map_provider: true, + business_hours_text: true, + access_guide_text: true, + regular_closure_text: true, + website_url: true, + region: { select: { name: true } }, + store_images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + select: { image_url: true }, + }, + }, + }); + } + /** 매장별 활성 찜 수. */ async aggregateWishlistCounts( storeIds: bigint[], diff --git a/src/features/store/resolvers/store-detail-query.resolver.spec.ts b/src/features/store/resolvers/store-detail-query.resolver.spec.ts new file mode 100644 index 0000000..b573ba6 --- /dev/null +++ b/src/features/store/resolvers/store-detail-query.resolver.spec.ts @@ -0,0 +1,77 @@ +import { NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreDetailQueryResolver } from '@/features/store/resolvers/store-detail-query.resolver'; +import { StoreDetailService } from '@/features/store/services/store-detail.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/집계 세부 검증은 service.spec.ts에서 담당. + */ +describe('Store Detail Query Resolver (real DB)', () => { + let resolver: StoreDetailQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreDetailQueryResolver, + StoreDetailService, + StoreRepository, + StoreWishlistRepository, + ], + }); + resolver = module.get(StoreDetailQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('storeDetail: 비로그인 사용자에게 매장 상세를 반환한다', async () => { + const store = await createStore(prisma, { store_name: '해즈케이크' }); + + const result = await resolver.storeDetail(store.id.toString(), undefined); + + expect(result.id).toBe(store.id.toString()); + expect(result.storeName).toBe('해즈케이크'); + expect(result.isWishlisted).toBe(false); + }); + + it('storeDetail: 로그인 사용자(JwtUser)의 찜 여부를 채운다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const result = await resolver.storeDetail(store.id.toString(), { + accountId: account.id.toString(), + }); + + expect(result.isWishlisted).toBe(true); + }); + + it('storeDetail: 없는 매장은 NotFoundException', async () => { + await expect( + resolver.storeDetail('999999', undefined), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/src/features/store/resolvers/store-detail-query.resolver.ts b/src/features/store/resolvers/store-detail-query.resolver.ts new file mode 100644 index 0000000..8532cef --- /dev/null +++ b/src/features/store/resolvers/store-detail-query.resolver.ts @@ -0,0 +1,30 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { StoreDetailService } from '@/features/store/services/store-detail.service'; +import type { StoreDetail } from '@/features/store/types/store-detail-output.type'; +import { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** + * 매장 상세 조회 resolver. 비로그인도 접근 가능한 public query. + * 옵셔널 인증으로 로그인 시에만 isWishlisted를 채운다. + */ +@Resolver('Query') +export class StoreDetailQueryResolver { + constructor(private readonly storeDetailService: StoreDetailService) {} + + @Query('storeDetail') + @UseGuards(OptionalJwtAuthGuard) + storeDetail( + @Args('storeId') storeId: string, + @CurrentUser() user: JwtUser | undefined, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.storeDetailService.storeDetail(storeId, accountId); + } +} diff --git a/src/features/store/services/store-detail-mappers.helper.spec.ts b/src/features/store/services/store-detail-mappers.helper.spec.ts new file mode 100644 index 0000000..e7d118d --- /dev/null +++ b/src/features/store/services/store-detail-mappers.helper.spec.ts @@ -0,0 +1,129 @@ +import { Prisma } from '@prisma/client'; + +import type { + StoreDetailRow, + StoreReviewStat, +} from '@/features/store/repositories/store.repository'; +import { toStoreDetail } from '@/features/store/services/store-detail-mappers.helper'; + +function makeRow(overrides: Partial = {}): StoreDetailRow { + return { + id: 1n, + store_name: '해즈케이크', + store_phone: '05930905934', + address_full: '인천 서구 청라루비로 93', + address_city: '인천광역시', + address_neighborhood: '청라동', + latitude: null, + longitude: null, + map_provider: 'NONE', + business_hours_text: '10:00AM ~ 19:00PM', + access_guide_text: '냉삼 꽃삼겹 골목으로 바로 들어오시면 있습니다.', + regular_closure_text: '매주 2,4주 월요일 쉽니다', + website_url: null, + region: null, + store_images: [], + ...overrides, + }; +} + +describe('toStoreDetail', () => { + it('row를 StoreDetail로 매핑한다(id 문자열, 이미지 url 배열, 신규 필드 포함)', () => { + const result = toStoreDetail( + makeRow({ + store_images: [{ image_url: 'a.png' }, { image_url: 'b.png' }], + website_url: 'https://store.example', + }), + undefined, + false, + ); + + expect(result).toMatchObject({ + id: '1', + storeName: '해즈케이크', + phoneNumber: '05930905934', + addressFull: '인천 서구 청라루비로 93', + images: ['a.png', 'b.png'], + mapProvider: 'NONE', + businessHoursText: '10:00AM ~ 19:00PM', + accessGuideText: '냉삼 꽃삼겹 골목으로 바로 들어오시면 있습니다.', + regularClosureText: '매주 2,4주 월요일 쉽니다', + websiteUrl: 'https://store.example', + }); + }); + + it('regionLabel: 시/동 우선, 없으면 region.name, 둘 다 없으면 null', () => { + expect( + toStoreDetail( + makeRow({ address_city: '인천', address_neighborhood: '청라동' }), + undefined, + false, + ).regionLabel, + ).toBe('인천 청라동'); + + expect( + toStoreDetail( + makeRow({ + address_city: null, + address_neighborhood: null, + region: { name: '서구' }, + }), + undefined, + false, + ).regionLabel, + ).toBe('서구'); + + expect( + toStoreDetail( + makeRow({ + address_city: null, + address_neighborhood: null, + region: null, + }), + undefined, + false, + ).regionLabel, + ).toBeNull(); + }); + + it('평점은 소수 첫째 자리로 반올림하고 리뷰 수를 채운다', () => { + const stat: StoreReviewStat = { average: 4.666, count: 122 }; + const result = toStoreDetail(makeRow(), stat, false); + expect(result.ratingAverage).toBe(4.7); + expect(result.reviewCount).toBe(122); + }); + + it('리뷰 통계가 없으면 평점 0, 리뷰 수 0', () => { + const result = toStoreDetail(makeRow(), undefined, false); + expect(result.ratingAverage).toBe(0); + expect(result.reviewCount).toBe(0); + }); + + it('좌표 Decimal은 number로 변환하고 null은 유지한다', () => { + const withCoord = toStoreDetail( + makeRow({ + latitude: new Prisma.Decimal('37.5012'), + longitude: new Prisma.Decimal('127.0396'), + }), + undefined, + false, + ); + expect(withCoord.latitude).toBeCloseTo(37.5012); + expect(withCoord.longitude).toBeCloseTo(127.0396); + + const noCoord = toStoreDetail(makeRow(), undefined, false); + expect(noCoord.latitude).toBeNull(); + expect(noCoord.longitude).toBeNull(); + }); + + it('isWishlisted 인자를 그대로 반영한다', () => { + expect(toStoreDetail(makeRow(), undefined, true).isWishlisted).toBe(true); + expect(toStoreDetail(makeRow(), undefined, false).isWishlisted).toBe(false); + }); + + it('이미지가 없으면 빈 배열', () => { + expect( + toStoreDetail(makeRow({ store_images: [] }), undefined, false).images, + ).toEqual([]); + }); +}); diff --git a/src/features/store/services/store-detail-mappers.helper.ts b/src/features/store/services/store-detail-mappers.helper.ts new file mode 100644 index 0000000..94eca81 --- /dev/null +++ b/src/features/store/services/store-detail-mappers.helper.ts @@ -0,0 +1,42 @@ +import type { + StoreDetailRow, + StoreReviewStat, +} from '@/features/store/repositories/store.repository'; +import { buildRegionLabel } from '@/features/store/services/store-mappers.helper'; +import type { StoreDetail } from '@/features/store/types/store-detail-output.type'; + +/** 소수 첫째 자리 반올림(예: 4.666 → 4.7). 리뷰 없으면 0. */ +function toRatingAverage(stat: StoreReviewStat | undefined): number { + if (!stat) return 0; + return Math.round(stat.average * 10) / 10; +} + +/** Decimal(위/경도)을 number로. null은 유지. */ +function toCoordinate(value: { toString(): string } | null): number | null { + return value !== null ? Number(value) : null; +} + +export function toStoreDetail( + row: StoreDetailRow, + reviewStat: StoreReviewStat | undefined, + isWishlisted: boolean, +): StoreDetail { + return { + id: row.id.toString(), + storeName: row.store_name, + regionLabel: buildRegionLabel(row), + ratingAverage: toRatingAverage(reviewStat), + reviewCount: reviewStat?.count ?? 0, + isWishlisted, + images: row.store_images.map((image) => image.image_url), + phoneNumber: row.store_phone, + addressFull: row.address_full, + latitude: toCoordinate(row.latitude), + longitude: toCoordinate(row.longitude), + mapProvider: row.map_provider, + businessHoursText: row.business_hours_text, + regularClosureText: row.regular_closure_text, + accessGuideText: row.access_guide_text, + websiteUrl: row.website_url, + }; +} diff --git a/src/features/store/services/store-detail.service.spec.ts b/src/features/store/services/store-detail.service.spec.ts new file mode 100644 index 0000000..a87b514 --- /dev/null +++ b/src/features/store/services/store-detail.service.spec.ts @@ -0,0 +1,152 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreDetailService } from '@/features/store/services/store-detail.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createOrderItem, + createReview, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('StoreDetailService (real DB)', () => { + let service: StoreDetailService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [StoreDetailService, StoreRepository, StoreWishlistRepository], + }); + service = module.get(StoreDetailService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('존재하지 않는 매장은 NotFoundException', async () => { + await expect(service.storeDetail('999999')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('비활성 매장은 NotFoundException', async () => { + const store = await createStore(prisma, { is_active: false }); + await expect( + service.storeDetail(store.id.toString()), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('잘못된 id 형식은 BadRequestException', async () => { + await expect(service.storeDetail('abc')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('매장 헤더와 신규 필드, 이미지(sort_order asc)를 반환한다', async () => { + const store = await createStore(prisma, { + store_name: '해즈케이크', + store_phone: '05930905934', + address_full: '인천 서구 청라루비로 93', + address_city: '인천광역시', + address_neighborhood: '청라동', + latitude: 37.5, + longitude: 127.0, + map_provider: 'NAVER', + business_hours_text: '10:00AM ~ 19:00PM', + access_guide_text: '냉삼 꽃삼겹 골목으로 바로 들어오시면 있습니다.', + regular_closure_text: '매주 2,4주 월요일 쉽니다', + }); + // sort_order 역순으로 생성 → 결과는 asc 정렬 확인 + await prisma.storeImage.create({ + data: { store_id: store.id, image_url: 'second.png', sort_order: 1 }, + }); + await prisma.storeImage.create({ + data: { store_id: store.id, image_url: 'first.png', sort_order: 0 }, + }); + + const result = await service.storeDetail(store.id.toString()); + + expect(result).toMatchObject({ + id: store.id.toString(), + storeName: '해즈케이크', + phoneNumber: '05930905934', + addressFull: '인천 서구 청라루비로 93', + regionLabel: '인천광역시 청라동', + mapProvider: 'NAVER', + businessHoursText: '10:00AM ~ 19:00PM', + accessGuideText: '냉삼 꽃삼겹 골목으로 바로 들어오시면 있습니다.', + regularClosureText: '매주 2,4주 월요일 쉽니다', + isWishlisted: false, + }); + expect(result.images).toEqual(['first.png', 'second.png']); + expect(result.latitude).toBeCloseTo(37.5); + expect(result.longitude).toBeCloseTo(127.0); + }); + + it('soft-delete된 이미지는 제외한다', async () => { + const store = await createStore(prisma); + await prisma.storeImage.create({ + data: { store_id: store.id, image_url: 'visible.png', sort_order: 0 }, + }); + await prisma.storeImage.create({ + data: { + store_id: store.id, + image_url: 'deleted.png', + sort_order: 1, + deleted_at: new Date(), + }, + }); + + const result = await service.storeDetail(store.id.toString()); + + expect(result.images).toEqual(['visible.png']); + }); + + it('평균 평점(소수 첫째)과 리뷰 수를 집계한다', async () => { + const store = await createStore(prisma); + for (const rating of [4, 5]) { + const orderItem = await createOrderItem(prisma, { store_id: store.id }); + await createReview(prisma, { order_item_id: orderItem.id, rating }); + } + + const result = await service.storeDetail(store.id.toString()); + + expect(result.ratingAverage).toBe(4.5); + expect(result.reviewCount).toBe(2); + }); + + it('리뷰가 없으면 평점 0, 리뷰 수 0', async () => { + const store = await createStore(prisma); + const result = await service.storeDetail(store.id.toString()); + expect(result.ratingAverage).toBe(0); + expect(result.reviewCount).toBe(0); + }); + + it('로그인 사용자의 찜 매장은 isWishlisted=true, 비로그인은 false', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const loggedIn = await service.storeDetail(store.id.toString(), account.id); + expect(loggedIn.isWishlisted).toBe(true); + + const anonymous = await service.storeDetail(store.id.toString()); + expect(anonymous.isWishlisted).toBe(false); + }); +}); diff --git a/src/features/store/services/store-detail.service.ts b/src/features/store/services/store-detail.service.ts new file mode 100644 index 0000000..e9c6830 --- /dev/null +++ b/src/features/store/services/store-detail.service.ts @@ -0,0 +1,47 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { STORE_DETAIL_ERRORS } from '@/features/store/constants/store-detail-error-messages'; +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { toStoreDetail } from '@/features/store/services/store-detail-mappers.helper'; +import type { StoreDetail } from '@/features/store/types/store-detail-output.type'; + +@Injectable() +export class StoreDetailService { + constructor( + private readonly repo: StoreRepository, + private readonly wishlistRepo: StoreWishlistRepository, + ) {} + + /** + * 매장 상세 헤더. 비활성/삭제 매장은 NOT_FOUND. 평점·리뷰수는 실시간 집계하고, + * isWishlisted는 로그인 사용자에 한해 채운다(비로그인 false). + */ + async storeDetail( + storeIdRaw: string, + accountId?: bigint, + ): Promise { + const storeId = parseId(storeIdRaw); + const row = await this.repo.findStoreDetailById(storeId); + if (!row) { + throw new NotFoundException(STORE_DETAIL_ERRORS.STORE_NOT_FOUND); + } + + const [reviewStats, wishlistedIds] = await Promise.all([ + this.repo.aggregateReviewStats([storeId]), + accountId + ? this.wishlistRepo.findWishlistedStoreIds({ + accountId, + storeIds: [storeId], + }) + : Promise.resolve(new Set()), + ]); + + return toStoreDetail( + row, + reviewStats.get(storeId), + wishlistedIds.has(storeId.toString()), + ); + } +} diff --git a/src/features/store/services/store-mappers.helper.ts b/src/features/store/services/store-mappers.helper.ts index 4d7720c..4f1c737 100644 --- a/src/features/store/services/store-mappers.helper.ts +++ b/src/features/store/services/store-mappers.helper.ts @@ -3,7 +3,11 @@ import type { StoreMetrics } from '@/features/store/services/store-ranking.helpe import type { PopularStore } from '@/features/store/types/store-output.type'; /** 매장 위치 표기. 시/동 조합 우선, 없으면 2차 지역명. (표기 규칙 확정 전 기본형) */ -export function buildRegionLabel(row: StoreCandidateRow): string | null { +export function buildRegionLabel(row: { + address_city: string | null; + address_neighborhood: string | null; + region: { name: string } | null; +}): string | null { const parts = [row.address_city, row.address_neighborhood].filter( (p): p is string => Boolean(p), ); diff --git a/src/features/store/store-detail.graphql b/src/features/store/store-detail.graphql new file mode 100644 index 0000000..36c8fe6 --- /dev/null +++ b/src/features/store/store-detail.graphql @@ -0,0 +1,42 @@ +extend type Query { + """매장 상세 헤더. 없거나 비활성/삭제 매장이면 NOT_FOUND. 비로그인 접근 가능.""" + storeDetail(storeId: ID!): StoreDetail! +} + +"""매장 상세 헤더(구매자용).""" +type StoreDetail { + id: ID! + storeName: String! + """매장 위치 표기(예: 인천 청라동).""" + regionLabel: String + """평균 평점(0.0~5.0, 소수 첫째 자리).""" + ratingAverage: Float! + reviewCount: Int! + """로그인 사용자의 찜 여부(비로그인 시 false).""" + isWishlisted: Boolean! + """매장 대표 이미지(캐러셀, sort_order asc).""" + images: [String!]! + """매장 전화번호(전화 액션시트).""" + phoneNumber: String! + """전체 주소(표시·복사용).""" + addressFull: String! + """위도(지도). 미설정 시 null.""" + latitude: Float + """경도(지도). 미설정 시 null.""" + longitude: Float + mapProvider: StoreMapProvider! + """영업시간 표기 텍스트(예: 10:00AM ~ 19:00PM).""" + businessHoursText: String + """정기 휴무 표기(예: 매주 2,4주 월요일 쉽니다).""" + regularClosureText: String + """찾아오는 길 안내.""" + accessGuideText: String + websiteUrl: String +} + +"""매장 지도 연동 provider.""" +enum StoreMapProvider { + NAVER + KAKAO + NONE +} diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts index a817aa1..e025622 100644 --- a/src/features/store/store.module.ts +++ b/src/features/store/store.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreDetailQueryResolver } from '@/features/store/resolvers/store-detail-query.resolver'; import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; import { StoreWishlistMutationResolver } from '@/features/store/resolvers/store-wishlist-mutation.resolver'; +import { StoreDetailService } from '@/features/store/services/store-detail.service'; import { StoreListingService } from '@/features/store/services/store-listing.service'; import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; @@ -13,8 +15,10 @@ import { StoreWishlistService } from '@/features/store/services/store-wishlist.s StoreWishlistRepository, StoreListingService, StoreWishlistService, + StoreDetailService, StoreQueryResolver, StoreWishlistMutationResolver, + StoreDetailQueryResolver, ], exports: [StoreRepository], }) diff --git a/src/features/store/types/store-detail-output.type.ts b/src/features/store/types/store-detail-output.type.ts new file mode 100644 index 0000000..29cdc36 --- /dev/null +++ b/src/features/store/types/store-detail-output.type.ts @@ -0,0 +1,23 @@ +/** + * storeDetail resolver 반환용 도메인 출력 타입. + * SDL(store-detail.graphql)의 StoreDetail 와 필드 일치. + */ + +export interface StoreDetail { + id: string; + storeName: string; + regionLabel: string | null; + ratingAverage: number; + reviewCount: number; + isWishlisted: boolean; + images: string[]; + phoneNumber: string; + addressFull: string; + latitude: number | null; + longitude: number | null; + mapProvider: 'NAVER' | 'KAKAO' | 'NONE'; + businessHoursText: string | null; + regularClosureText: string | null; + accessGuideText: string | null; + websiteUrl: string | null; +} diff --git a/src/test/factories/store.factory.ts b/src/test/factories/store.factory.ts index bf6574d..952a777 100644 --- a/src/test/factories/store.factory.ts +++ b/src/test/factories/store.factory.ts @@ -13,6 +13,12 @@ export interface StoreOverrides { address_neighborhood?: string | null; region_id?: bigint | null; is_active?: boolean; + latitude?: number | null; + longitude?: number | null; + map_provider?: 'NAVER' | 'KAKAO' | 'NONE'; + business_hours_text?: string | null; + access_guide_text?: string | null; + regular_closure_text?: string | null; } export async function createStore( @@ -36,6 +42,12 @@ export async function createStore( address_neighborhood: overrides.address_neighborhood ?? '테스트동', region_id: overrides.region_id ?? null, is_active: overrides.is_active ?? true, + latitude: overrides.latitude ?? null, + longitude: overrides.longitude ?? null, + map_provider: overrides.map_provider ?? 'NONE', + business_hours_text: overrides.business_hours_text ?? null, + access_guide_text: overrides.access_guide_text ?? null, + regular_closure_text: overrides.regular_closure_text ?? null, }, }); }