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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- AlterTable
ALTER TABLE `store` ADD COLUMN `access_guide_text` VARCHAR(500) NULL,
ADD COLUMN `regular_closure_text` VARCHAR(200) NULL;

-- CreateTable
CREATE TABLE `store_image` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`store_id` BIGINT UNSIGNED NOT NULL,
`image_url` VARCHAR(2048) NOT NULL,
`sort_order` INTEGER NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
`deleted_at` DATETIME(3) NULL,

INDEX `idx_store_image_store`(`store_id`, `sort_order`),
INDEX `idx_store_image_deleted_at`(`deleted_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- AddForeignKey
ALTER TABLE `store_image` ADD CONSTRAINT `store_image_store_id_fkey` FOREIGN KEY (`store_id`) REFERENCES `store`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
23 changes: 22 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,9 @@ model Store {

map_provider StoreMapProvider @default(NONE)

business_hours_text String? @db.VarChar(500)
business_hours_text String? @db.VarChar(500)
access_guide_text String? @db.VarChar(500) // 찾아오는 길 안내("냉삼 꽃삼겹 골목으로 바로...")
regular_closure_text String? @db.VarChar(200) // 정기 휴무 표기(자유 텍스트, "매주 2,4주 월요일 쉽니다")

pickup_slot_interval_minutes Int @default(30) @db.UnsignedSmallInt
min_lead_time_minutes Int @default(30) @db.UnsignedInt
Expand All @@ -361,6 +363,7 @@ model Store {
business_hours StoreBusinessHour[]
special_closures StoreSpecialClosure[]
products Product[]
store_images StoreImage[]
order_items OrderItem[]
reviews Review[]
banners Banner[] @relation("BannerStoreLink")
Expand Down Expand Up @@ -418,6 +421,24 @@ model StoreSpecialClosure {
@@map("store_special_closure")
}

model StoreImage {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
store_id BigInt @db.UnsignedBigInt

image_url String @db.VarChar(2048)
sort_order Int @default(0)

created_at DateTime @default(now()) @db.DateTime(3)
updated_at DateTime @updatedAt @db.DateTime(3)
deleted_at DateTime? @db.DateTime(3)

store Store @relation(fields: [store_id], references: [id])

@@index([store_id, sort_order], map: "idx_store_image_store")
@@index([deleted_at], map: "idx_store_image_deleted_at")
@@map("store_image")
}

/**
* =========================
* 3) Catalog
Expand Down
4 changes: 4 additions & 0 deletions prisma/seed/idempotent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ export async function resetSeedScope(prisma: PrismaClient): Promise<void> {
await prisma.storeSpecialClosure.deleteMany({
where: { store_id: { in: storeIds } },
});
// store_image FK는 ON DELETE RESTRICT → store 삭제 전에 정리해야 재시드 멱등성 유지
await prisma.storeImage.deleteMany({
where: { store_id: { in: storeIds } },
});
await prisma.store.deleteMany({ where: { id: { in: storeIds } } });
}

Expand Down
16 changes: 16 additions & 0 deletions prisma/seed/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,24 @@ export async function seedStores(prisma: PrismaClient): Promise<SeededStores> {
region_id: gangnam.id, // 서울 강남구
latitude: 37.5012 as unknown as never,
longitude: 127.0396 as unknown as never,
map_provider: 'NAVER',
business_hours_text: '매일 09:00 ~ 18:00 (화요일 정기 휴무)',
access_guide_text:
'강남역 3번 출구에서 도보 5분, 1층 케이크 거리 안쪽입니다.',
regular_closure_text: '매주 화요일 정기 휴무',
is_active: true,
store_images: {
create: [
Comment on lines +59 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Delete seeded store images before deleting stores

When yarn prisma:seed is run a second time, these newly-created store_image rows remain during resetSeedScope: that cleanup deletes storeBusinessHour and storeSpecialClosure and then calls prisma.store.deleteMany for the seed stores, but it never deletes storeImage rows. Because the new FK is ON DELETE RESTRICT, the second seed run will fail with a foreign-key violation instead of staying idempotent; add prisma.storeImage.deleteMany({ where: { store_id: { in: storeIds } } }) before deleting stores or make the relation cascade intentionally.

Useful? React with 👍 / 👎.

{
image_url: 'https://placehold.co/800x600/png?text=Store+A+Exterior',
sort_order: 0,
},
{
image_url: 'https://placehold.co/800x600/png?text=Store+A+Interior',
sort_order: 1,
},
],
},
},
});

Expand Down
4 changes: 4 additions & 0 deletions src/features/store/constants/store-detail-error-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** 매장 상세 조회 에러 메시지. */
export const STORE_DETAIL_ERRORS = {
STORE_NOT_FOUND: '매장을 찾을 수 없습니다.',
} as const;
48 changes: 48 additions & 0 deletions src/features/store/repositories/store.repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Prisma, type StoreMapProvider } from '@prisma/client';

import {
POPULAR_STORE_CAKE_IMAGE_LIMIT,
Expand All @@ -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) {}
Expand Down Expand Up @@ -54,6 +74,34 @@ export class StoreRepository {
return Boolean(found);
}

/** 매장 상세 헤더 조회. 활성·미삭제 매장만. 대표 이미지는 sort_order asc. */
async findStoreDetailById(storeId: bigint): Promise<StoreDetailRow | null> {
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[],
Expand Down
77 changes: 77 additions & 0 deletions src/features/store/resolvers/store-detail-query.resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions src/features/store/resolvers/store-detail-query.resolver.ts
Original file line number Diff line number Diff line change
@@ -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<StoreDetail> {
const accountId = user ? parseAccountId(user) : undefined;
return this.storeDetailService.storeDetail(storeId, accountId);
}
}
Loading
Loading