diff --git a/prisma/migrations/20260826171140_add_store_profile_image_url/migration.sql b/prisma/migrations/20260826171140_add_store_profile_image_url/migration.sql new file mode 100644 index 0000000..e29d323 --- /dev/null +++ b/prisma/migrations/20260826171140_add_store_profile_image_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `store` ADD COLUMN `profile_image_url` VARCHAR(2048) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c29455a..3d47bec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -356,7 +356,9 @@ model Store { max_days_ahead Int @default(30) @db.UnsignedSmallInt website_url String? @db.VarChar(2048) - is_active Boolean @default(true) @db.TinyInt + // 매장 프로필(로고) 이미지. 찜/매장별 보기 카드의 원형 프로필 표기용 (figma liked 02·04) + profile_image_url String? @db.VarChar(2048) + is_active Boolean @default(true) @db.TinyInt created_at DateTime @default(now()) @db.DateTime(3) updated_at DateTime @updatedAt @db.DateTime(3) diff --git a/prisma/seed/stores.ts b/prisma/seed/stores.ts index 3db2150..883d171 100644 --- a/prisma/seed/stores.ts +++ b/prisma/seed/stores.ts @@ -59,6 +59,7 @@ export async function seedStores( access_guide_text: '강남역 3번 출구에서 도보 5분, 1층 케이크 거리 안쪽입니다.', regular_closure_text: '매주 화요일 정기 휴무', + profile_image_url: 'https://placehold.co/200x200/png?text=Store+A+Logo', is_active: true, store_images: { create: [ @@ -108,6 +109,7 @@ export async function seedStores( address_neighborhood: '서교동', region_id: mapo.id, // 서울 마포구 business_hours_text: '평일 11:00 ~ 21:00', + // B는 프로필 미등록 상태(placeholder UI 확인용) — profile_image_url 미설정 is_active: true, }, }); diff --git a/src/features/seller/dto/inputs/seller-update-store-basic-info.input.spec.ts b/src/features/seller/dto/inputs/seller-update-store-basic-info.input.spec.ts index e3bd497..475eb18 100644 --- a/src/features/seller/dto/inputs/seller-update-store-basic-info.input.spec.ts +++ b/src/features/seller/dto/inputs/seller-update-store-basic-info.input.spec.ts @@ -36,4 +36,15 @@ describe('SellerUpdateStoreBasicInfoInput', () => { const errors = await validate(dto); expect(errors[0].property).toBe('storeName'); }); + + it('profileImageUrl 문자열 통과', async () => { + const dto = build({ profileImageUrl: 'https://cdn.example.com/logo.png' }); + expect(await validate(dto)).toHaveLength(0); + }); + + it('profileImageUrl 이 문자열이 아니면 거절', async () => { + const dto = build({ profileImageUrl: 123 }); + const errors = await validate(dto); + expect(errors[0].property).toBe('profileImageUrl'); + }); }); diff --git a/src/features/seller/dto/inputs/seller-update-store-basic-info.input.ts b/src/features/seller/dto/inputs/seller-update-store-basic-info.input.ts index 04fa225..3b75fb7 100644 --- a/src/features/seller/dto/inputs/seller-update-store-basic-info.input.ts +++ b/src/features/seller/dto/inputs/seller-update-store-basic-info.input.ts @@ -47,4 +47,8 @@ export class SellerUpdateStoreBasicInfoInput { @IsOptional() @IsString() businessHoursText?: string; + + @IsOptional() + @IsString() + profileImageUrl?: string; } diff --git a/src/features/seller/seller-store.graphql b/src/features/seller/seller-store.graphql index ecaab9a..ac4e3d7 100644 --- a/src/features/seller/seller-store.graphql +++ b/src/features/seller/seller-store.graphql @@ -56,6 +56,8 @@ type SellerStore { mapProvider: SellerStoreMapProvider! websiteUrl: String businessHoursText: String + """매장 프로필(로고) 이미지 URL. 미등록 시 null.""" + profileImageUrl: String pickupSlotIntervalMinutes: Int! minLeadTimeMinutes: Int! maxDaysAhead: Int! @@ -118,6 +120,8 @@ input SellerUpdateStoreBasicInfoInput { mapProvider: SellerStoreMapProvider websiteUrl: String businessHoursText: String + """매장 프로필(로고) 이미지 URL. null 전달 시 제거.""" + profileImageUrl: String } """SellerUpsertStoreBusinessHourInput 입력 타입""" diff --git a/src/features/seller/services/seller-store-mappers.helper.ts b/src/features/seller/services/seller-store-mappers.helper.ts index c662b97..f2943a8 100644 --- a/src/features/seller/services/seller-store-mappers.helper.ts +++ b/src/features/seller/services/seller-store-mappers.helper.ts @@ -27,6 +27,7 @@ export interface StoreRow { map_provider: 'NAVER' | 'KAKAO' | 'NONE'; website_url: string | null; business_hours_text: string | null; + profile_image_url: string | null; pickup_slot_interval_minutes: number; min_lead_time_minutes: number; max_days_ahead: number; @@ -76,6 +77,7 @@ export function toStoreOutput(row: StoreRow): SellerStoreOutput { mapProvider: row.map_provider, websiteUrl: row.website_url, businessHoursText: row.business_hours_text, + profileImageUrl: row.profile_image_url, pickupSlotIntervalMinutes: row.pickup_slot_interval_minutes, minLeadTimeMinutes: row.min_lead_time_minutes, maxDaysAhead: row.max_days_ahead, diff --git a/src/features/seller/services/seller-store-profile.service.spec.ts b/src/features/seller/services/seller-store-profile.service.spec.ts index 25338f6..5e81990 100644 --- a/src/features/seller/services/seller-store-profile.service.spec.ts +++ b/src/features/seller/services/seller-store-profile.service.spec.ts @@ -3,6 +3,7 @@ import type { PrismaClient } from '@prisma/client'; import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import type { SellerUpdateStoreBasicInfoInput } from '@/features/seller/dto/inputs/seller-update-store-basic-info.input'; import { SellerRepository } from '@/features/seller/repositories/seller.repository'; import { SellerStoreProfileService } from '@/features/seller/services/seller-store-profile.service'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; @@ -151,5 +152,41 @@ describe('SellerStoreProfileService (real DB)', () => { expect(result.websiteUrl).toBe('https://example.com'); expect(result.businessHoursText).toBe('월~금 09-18'); }); + + it('profileImageUrl을 등록·수정한다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + + const result = await service.sellerUpdateStoreBasicInfo(account.id, { + profileImageUrl: 'https://cdn.example.com/logo.png', + }); + + expect(result.profileImageUrl).toBe('https://cdn.example.com/logo.png'); + const dbStore = await prisma.store.findUniqueOrThrow({ + where: { id: store.id }, + }); + expect(dbStore.profile_image_url).toBe( + 'https://cdn.example.com/logo.png', + ); + }); + + it('profileImageUrl에 null을 전달하면 제거하고, 미전달 시 유지한다', async () => { + const { account, store } = await setupSellerWithStore(prisma); + await prisma.store.update({ + where: { id: store.id }, + data: { profile_image_url: 'https://cdn.example.com/old-logo.png' }, + }); + + // 미전달(undefined) → 유지 + const kept = await service.sellerUpdateStoreBasicInfo(account.id, { + storeName: '이름만 수정', + }); + expect(kept.profileImageUrl).toBe('https://cdn.example.com/old-logo.png'); + + // null 전달 → 제거 (GraphQL 런타임은 명시적 null을 전달할 수 있다) + const removed = await service.sellerUpdateStoreBasicInfo(account.id, { + profileImageUrl: null, + } as unknown as SellerUpdateStoreBasicInfoInput); + expect(removed.profileImageUrl).toBeNull(); + }); }); }); diff --git a/src/features/seller/services/seller-store-profile.service.ts b/src/features/seller/services/seller-store-profile.service.ts index 2e24b0f..3651b55 100644 --- a/src/features/seller/services/seller-store-profile.service.ts +++ b/src/features/seller/services/seller-store-profile.service.ts @@ -154,6 +154,15 @@ export class SellerStoreProfileService ), } : {}), + // 프로필(로고) 이미지. null/빈 문자열 전달 시 제거, 미전달(undefined) 시 유지. + ...(input.profileImageUrl !== undefined + ? { + profile_image_url: cleanNullableText( + input.profileImageUrl, + MAX_URL_LENGTH, + ), + } + : {}), }; } } diff --git a/src/features/seller/types/seller-output.type.ts b/src/features/seller/types/seller-output.type.ts index 1901e0e..f48a34a 100644 --- a/src/features/seller/types/seller-output.type.ts +++ b/src/features/seller/types/seller-output.type.ts @@ -12,6 +12,7 @@ export interface SellerStoreOutput { mapProvider: 'NAVER' | 'KAKAO' | 'NONE'; websiteUrl: string | null; businessHoursText: string | null; + profileImageUrl: string | null; pickupSlotIntervalMinutes: number; minLeadTimeMinutes: number; maxDaysAhead: number; diff --git a/src/features/store/constants/store-wishlist.constants.ts b/src/features/store/constants/store-wishlist.constants.ts new file mode 100644 index 0000000..42733bd --- /dev/null +++ b/src/features/store/constants/store-wishlist.constants.ts @@ -0,0 +1,5 @@ +/** 찜한 매장 카드의 대표 이미지 수(figma liked 04 시안 기준 3장). */ +export const WISHLISTED_STORE_IMAGE_LIMIT = 3; + +/** 찜한 매장 목록 기본 페이지 크기(SDL 기본값과 동일). */ +export const DEFAULT_WISHLISTED_STORES_LIMIT = 20; diff --git a/src/features/store/dto/inputs/my-wishlisted-stores.input.spec.ts b/src/features/store/dto/inputs/my-wishlisted-stores.input.spec.ts new file mode 100644 index 0000000..71dfe5a --- /dev/null +++ b/src/features/store/dto/inputs/my-wishlisted-stores.input.spec.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { MyWishlistedStoresInput } from '@/features/store/dto/inputs/my-wishlisted-stores.input'; + +function build(plain: object): MyWishlistedStoresInput { + return plainToInstance(MyWishlistedStoresInput, plain); +} + +describe('MyWishlistedStoresInput', () => { + it('빈 입력 통과 (모두 optional)', async () => { + expect(await validate(build({}))).toHaveLength(0); + }); + + it('offset/limit 통과', async () => { + expect(await validate(build({ offset: 0, limit: 20 }))).toHaveLength(0); + }); + + it('offset 음수 거절', async () => { + const errors = await validate(build({ offset: -1 })); + expect(errors[0].property).toBe('offset'); + }); + + it('limit 하한(0)·상한(51) 거절', async () => { + expect((await validate(build({ limit: 0 })))[0].property).toBe('limit'); + expect((await validate(build({ limit: 51 })))[0].property).toBe('limit'); + }); + + it('정수가 아닌 limit 거절', async () => { + const errors = await validate(build({ limit: 1.5 })); + expect(errors[0].property).toBe('limit'); + }); +}); diff --git a/src/features/store/dto/inputs/my-wishlisted-stores.input.ts b/src/features/store/dto/inputs/my-wishlisted-stores.input.ts new file mode 100644 index 0000000..884d392 --- /dev/null +++ b/src/features/store/dto/inputs/my-wishlisted-stores.input.ts @@ -0,0 +1,14 @@ +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class MyWishlistedStoresInput { + @IsOptional() + @IsInt() + @Min(0) + offset?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + limit?: number; +} diff --git a/src/features/store/repositories/store-wishlist.repository.ts b/src/features/store/repositories/store-wishlist.repository.ts index 3d7de06..07cb816 100644 --- a/src/features/store/repositories/store-wishlist.repository.ts +++ b/src/features/store/repositories/store-wishlist.repository.ts @@ -1,27 +1,62 @@ import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { WISHLISTED_STORE_IMAGE_LIMIT } from '@/features/store/constants/store-wishlist.constants'; import { PrismaService } from '@/prisma'; +/** 찜한 매장 목록 조회 결과 row. myWishlistedStores 매퍼 입력. */ +export interface WishlistedStoreRow { + created_at: Date; + store: { + id: bigint; + store_name: string; + profile_image_url: string | null; + address_city: string | null; + address_neighborhood: string | null; + region: { name: string } | null; + store_images: { image_url: string }[]; + }; +} + @Injectable() export class StoreWishlistRepository { constructor(private readonly prisma: PrismaService) {} - /** 매장 찜 추가 (멱등). 없으면 생성, soft-delete된 경우 복원. */ + /** + * 매장 찜 추가 (멱등). 없으면 생성, soft-delete된 경우 복원. + * 복원(재찜) 시에만 created_at을 재찜 시점으로 갱신한다 — 목록 '찜 최신순' 정렬과 + * addedAt 표기가 재찜을 반영하되, 이미 active인 찜에 대한 중복 요청(더블 탭·재시도)은 + * created_at을 건드리지 않아 멱등 계약을 지킨다. + */ async upsertStoreWishlist(args: { accountId: bigint; storeId: bigint; now: Date; }): Promise { - await this.prisma.storeWishlistItem.upsert({ + const restored = await this.prisma.storeWishlistItem.updateMany({ where: { - account_id_store_id: { - account_id: args.accountId, - store_id: args.storeId, - }, + account_id: args.accountId, + store_id: args.storeId, + deleted_at: { not: null }, }, - create: { account_id: args.accountId, store_id: args.storeId }, - update: { deleted_at: null, updated_at: args.now }, + data: { deleted_at: null, created_at: args.now, updated_at: args.now }, }); + if (restored.count > 0) return; + + try { + await this.prisma.storeWishlistItem.create({ + data: { account_id: args.accountId, store_id: args.storeId }, + }); + } catch (error) { + // active 찜이 이미 존재(unique 충돌) — 멱등이므로 무시 + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return; + } + throw error; + } } /** 매장 찜 해제 (멱등). active 항목만 soft-delete. */ @@ -61,6 +96,55 @@ export class StoreWishlistRepository { return new Set(rows.map((r) => r.store_id.toString())); } + /** + * 내가 찜한 매장 목록 (찜 최신순). 비활성/soft-delete 매장은 목록·카운트 모두 제외해 + * findWishlistedStoreIds의 가시성 조건과 일관되게 한다. + * soft-delete extension은 nested select에 deleted_at을 주입하지 않으므로 직접 명시한다. + */ + async findWishlistedStores(args: { + accountId: bigint; + offset: number; + limit: number; + }): Promise<{ items: WishlistedStoreRow[]; totalCount: number }> { + const where = { + account_id: args.accountId, + deleted_at: null, + store: { is_active: true, deleted_at: null }, + }; + + const [items, totalCount] = await this.prisma.$transaction([ + this.prisma.storeWishlistItem.findMany({ + where, + // 같은 밀리초 생성 시 페이지 경계 흔들림 방지를 위해 id를 보조 정렬키로 둔다. + orderBy: [{ created_at: 'desc' }, { id: 'desc' }], + skip: args.offset, + take: args.limit, + select: { + created_at: true, + store: { + select: { + id: true, + store_name: true, + profile_image_url: true, + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + store_images: { + where: { deleted_at: null }, + orderBy: { sort_order: 'asc' }, + take: WISHLISTED_STORE_IMAGE_LIMIT, + select: { image_url: true }, + }, + }, + }, + }, + }), + this.prisma.storeWishlistItem.count({ where }), + ]); + + return { items, totalCount }; + } + /** 활성 USER 계정 여부. 매장 찜은 구매자(USER)만 가능 → 인기 랭킹 무결성 보호. */ async isActiveUserAccount(accountId: bigint): Promise { const account = await this.prisma.account.findFirst({ diff --git a/src/features/store/resolvers/store-wishlist-query.resolver.spec.ts b/src/features/store/resolvers/store-wishlist-query.resolver.spec.ts new file mode 100644 index 0000000..c7b07a5 --- /dev/null +++ b/src/features/store/resolvers/store-wishlist-query.resolver.spec.ts @@ -0,0 +1,77 @@ +// 전체 경로(리졸버→서비스→레포지토리→DB) 통합 검증. 분기/집계 세부 검증은 store-wishlist.service.spec.ts에서 담당. +import type { PrismaClient } from '@prisma/client'; + +import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreWishlistQueryResolver } from '@/features/store/resolvers/store-wishlist-query.resolver'; +import { StoreWishlistService } from '@/features/store/services/store-wishlist.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'; + +describe('Store Wishlist Query Resolver (real DB)', () => { + let resolver: StoreWishlistQueryResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreWishlistQueryResolver, + StoreWishlistService, + StoreWishlistRepository, + StoreRepository, + ], + }); + resolver = module.get(StoreWishlistQueryResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('myWishlistedStores: accountId 변환 후 찜한 매장 목록을 반환한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma, { + store_name: '해즈케이크', + profile_image_url: 'https://cdn.example.com/haz-logo.png', + }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const result = await resolver.myWishlistedStores({ + accountId: account.id.toString(), + }); + + expect(result.totalCount).toBe(1); + expect(result.items[0].storeId).toBe(store.id.toString()); + expect(result.items[0].storeName).toBe('해즈케이크'); + expect(result.items[0].profileImageUrl).toBe( + 'https://cdn.example.com/haz-logo.png', + ); + }); + + it('myWishlistedStores: 찜이 없으면 빈 목록을 반환한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + + const result = await resolver.myWishlistedStores({ + accountId: account.id.toString(), + }); + + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + expect(result.hasMore).toBe(false); + }); +}); diff --git a/src/features/store/resolvers/store-wishlist-query.resolver.ts b/src/features/store/resolvers/store-wishlist-query.resolver.ts new file mode 100644 index 0000000..99ea3a0 --- /dev/null +++ b/src/features/store/resolvers/store-wishlist-query.resolver.ts @@ -0,0 +1,29 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { MyWishlistedStoresInput } from '@/features/store/dto/inputs/my-wishlisted-stores.input'; +import { StoreWishlistService } from '@/features/store/services/store-wishlist.service'; +import type { MyWishlistedStoresConnection } from '@/features/store/types/store-wishlist-output.type'; +import { + CurrentUser, + JwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +@Resolver('Query') +@UseGuards(JwtAuthGuard) +export class StoreWishlistQueryResolver { + constructor(private readonly storeWishlistService: StoreWishlistService) {} + + @Query('myWishlistedStores') + myWishlistedStores( + @CurrentUser() user: JwtUser, + @Args('input') input?: MyWishlistedStoresInput, + ): Promise { + return this.storeWishlistService.myWishlistedStores( + parseAccountId(user), + input, + ); + } +} diff --git a/src/features/store/services/store-wishlist.service.spec.ts b/src/features/store/services/store-wishlist.service.spec.ts index ec3e719..7cf59c0 100644 --- a/src/features/store/services/store-wishlist.service.spec.ts +++ b/src/features/store/services/store-wishlist.service.spec.ts @@ -12,6 +12,8 @@ import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, + createOrderItem, + createReview, createStore, createStoreWishlist, } from '@/test/factories'; @@ -65,14 +67,33 @@ describe('StoreWishlistService (real DB)', () => { expect(await activeWishlistCount(account.id, store.id)).toBe(1); }); - it('중복 추가는 멱등하다(1건 유지)', async () => { + it('중복 추가는 멱등하다(1건 유지, created_at 불변)', async () => { const account = await createAccount(prisma, { account_type: 'USER' }); const store = await createStore(prisma); await service.addStoreToWishlist(account.id, store.id.toString()); + const before = await prisma.storeWishlistItem.findUniqueOrThrow({ + where: { + account_id_store_id: { + account_id: account.id, + store_id: store.id, + }, + }, + }); + await new Promise((r) => setTimeout(r, 10)); await service.addStoreToWishlist(account.id, store.id.toString()); expect(await activeWishlistCount(account.id, store.id)).toBe(1); + const after = await prisma.storeWishlistItem.findUniqueOrThrow({ + where: { + account_id_store_id: { + account_id: account.id, + store_id: store.id, + }, + }, + }); + // 더블 탭·재시도가 찜 시각(목록 정렬 기준)을 밀지 않는다 + expect(after.created_at.getTime()).toBe(before.created_at.getTime()); }); it('soft-delete된 찜은 복원한다', async () => { @@ -120,6 +141,205 @@ describe('StoreWishlistService (real DB)', () => { }); }); + describe('myWishlistedStores', () => { + async function addImages(storeId: bigint, urls: string[]): Promise { + await prisma.storeImage.createMany({ + data: urls.map((url, index) => ({ + store_id: storeId, + image_url: url, + sort_order: index, + })), + }); + } + + it('찜한 매장 목록을 찜 최신순으로 반환한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const storeA = await createStore(prisma, { + store_name: '해즈케이크', + profile_image_url: 'https://cdn.example.com/haz-logo.png', + address_city: '인천', + address_neighborhood: '청라동', + }); + const storeB = await createStore(prisma, { store_name: '달달케이크' }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: storeA.id, + }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: storeB.id, + }); + + const result = await service.myWishlistedStores(account.id); + + expect(result.totalCount).toBe(2); + expect(result.hasMore).toBe(false); + // 나중에 찜한 storeB가 먼저(최신순) + expect(result.items.map((i) => i.storeId)).toEqual([ + storeB.id.toString(), + storeA.id.toString(), + ]); + const haz = result.items[1]; + expect(haz.storeName).toBe('해즈케이크'); + expect(haz.profileImageUrl).toBe('https://cdn.example.com/haz-logo.png'); + expect(haz.regionLabel).toBe('인천 청라동'); + expect(haz.addedAt).toBeInstanceOf(Date); + }); + + it('대표 이미지는 sort_order asc 최대 3장, 삭제된 이미지는 제외한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await addImages(store.id, ['u0', 'u1', 'u2', 'u3']); + await prisma.storeImage.updateMany({ + where: { store_id: store.id, image_url: 'u1' }, + data: { deleted_at: new Date() }, + }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + + const result = await service.myWishlistedStores(account.id); + + expect(result.items[0].imageUrls).toEqual(['u0', 'u2', 'u3']); + }); + + it('평점은 소수 첫째 자리 반올림, 리뷰 없으면 0.0/0건이다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const rated = await createStore(prisma); + const unrated = await createStore(prisma); + const oi1 = await createOrderItem(prisma, { store_id: rated.id }); + const oi2 = await createOrderItem(prisma, { store_id: rated.id }); + await createReview(prisma, { order_item_id: oi1.id, rating: 4.5 }); + await createReview(prisma, { order_item_id: oi2.id, rating: 5 }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: rated.id, + }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: unrated.id, + }); + + const result = await service.myWishlistedStores(account.id); + + const ratedItem = result.items.find( + (i) => i.storeId === rated.id.toString(), + ); + const unratedItem = result.items.find( + (i) => i.storeId === unrated.id.toString(), + ); + // (4.5 + 5.0) / 2 = 4.75 → 4.8 + expect(ratedItem?.ratingAverage).toBe(4.8); + expect(ratedItem?.reviewCount).toBe(2); + expect(unratedItem?.ratingAverage).toBe(0); + expect(unratedItem?.reviewCount).toBe(0); + }); + + it('비활성·삭제 매장과 soft-delete된 찜은 목록·카운트에서 제외한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const active = await createStore(prisma); + const inactive = await createStore(prisma, { is_active: false }); + const deleted = await createStore(prisma); + await prisma.store.update({ + where: { id: deleted.id }, + data: { deleted_at: new Date() }, + }); + const removedWish = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: active.id, + }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: inactive.id, + }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: deleted.id, + }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: removedWish.id, + deleted_at: new Date(), + }); + + const result = await service.myWishlistedStores(account.id); + + expect(result.totalCount).toBe(1); + expect(result.items.map((i) => i.storeId)).toEqual([ + active.id.toString(), + ]); + }); + + it('재찜(복원)한 매장은 목록 최상단으로 온다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const first = await createStore(prisma); + const second = await createStore(prisma); + await service.addStoreToWishlist(account.id, first.id.toString()); + await new Promise((r) => setTimeout(r, 10)); + await service.addStoreToWishlist(account.id, second.id.toString()); + // first를 해제 후 재찜 → 재찜 시점 기준으로 second보다 앞서야 한다 + await service.removeStoreFromWishlist(account.id, first.id.toString()); + await new Promise((r) => setTimeout(r, 10)); + await service.addStoreToWishlist(account.id, first.id.toString()); + + const result = await service.myWishlistedStores(account.id); + + expect(result.items.map((i) => i.storeId)).toEqual([ + first.id.toString(), + second.id.toString(), + ]); + }); + + it('다른 사용자의 찜은 포함하지 않는다', async () => { + const me = await createAccount(prisma, { account_type: 'USER' }); + const other = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: other.id, + store_id: store.id, + }); + + const result = await service.myWishlistedStores(me.id); + + expect(result.totalCount).toBe(0); + expect(result.items).toEqual([]); + }); + + it('offset/limit 페이지네이션과 hasMore를 계산한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const stores = []; + for (let i = 0; i < 3; i += 1) { + const store = await createStore(prisma); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + stores.push(store); + } + + const page1 = await service.myWishlistedStores(account.id, { + offset: 0, + limit: 2, + }); + const page2 = await service.myWishlistedStores(account.id, { + offset: 2, + limit: 2, + }); + + expect(page1.items).toHaveLength(2); + expect(page1.totalCount).toBe(3); + expect(page1.hasMore).toBe(true); + expect(page2.items).toHaveLength(1); + expect(page2.hasMore).toBe(false); + // 페이지를 이어 붙이면 최신순 전체와 일치(경계 중복/누락 없음) + expect([...page1.items, ...page2.items].map((i) => i.storeId)).toEqual( + stores.map((s) => s.id.toString()).reverse(), + ); + }); + }); + describe('removeStoreFromWishlist', () => { it('찜을 해제한다', async () => { const account = await createAccount(prisma, { account_type: 'USER' }); diff --git a/src/features/store/services/store-wishlist.service.ts b/src/features/store/services/store-wishlist.service.ts index 4f7ca96..d03e5b0 100644 --- a/src/features/store/services/store-wishlist.service.ts +++ b/src/features/store/services/store-wishlist.service.ts @@ -6,8 +6,12 @@ import { import { parseId } from '@/common/utils/id-parser'; import { STORE_WISHLIST_ERRORS } from '@/features/store/constants/store-wishlist-error-messages'; +import { DEFAULT_WISHLISTED_STORES_LIMIT } from '@/features/store/constants/store-wishlist.constants'; +import type { MyWishlistedStoresInput } from '@/features/store/dto/inputs/my-wishlisted-stores.input'; import { StoreWishlistRepository } from '@/features/store/repositories/store-wishlist.repository'; import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { buildRegionLabel } from '@/features/store/services/store-mappers.helper'; +import type { MyWishlistedStoresConnection } from '@/features/store/types/store-wishlist-output.type'; @Injectable() export class StoreWishlistService { @@ -39,6 +43,48 @@ export class StoreWishlistService { return true; } + /** + * 내가 찜한 매장 목록 (찜 최신순, offset 페이지네이션). + * 계정 타입 제한 없음 — 찜 추가가 USER 전용이므로 타 계정은 자연히 빈 목록. + */ + async myWishlistedStores( + accountId: bigint, + input?: MyWishlistedStoresInput, + ): Promise { + const offset = input?.offset ?? 0; + const limit = input?.limit ?? DEFAULT_WISHLISTED_STORES_LIMIT; + + const { items, totalCount } = await this.wishlistRepo.findWishlistedStores({ + accountId, + offset, + limit, + }); + + // 평점은 페이지 매장들만 단일 groupBy로 집계(N+1 회피). 랭킹과 동일 소스. + const reviewStats = await this.storeRepo.aggregateReviewStats( + items.map((row) => row.store.id), + ); + + return { + items: items.map((row) => { + const stat = reviewStats.get(row.store.id); + return { + storeId: row.store.id.toString(), + storeName: row.store.store_name, + profileImageUrl: row.store.profile_image_url, + // 소수 첫째 자리까지(예: 4.666 → 4.7). toPopularStore와 동일 정책. + ratingAverage: Math.round((stat?.average ?? 0) * 10) / 10, + reviewCount: stat?.count ?? 0, + regionLabel: buildRegionLabel(row.store), + imageUrls: row.store.store_images.map((image) => image.image_url), + addedAt: row.created_at, + }; + }), + totalCount, + hasMore: offset + limit < totalCount, + }; + } + /** 매장 찜 해제 (멱등). 없는 항목이어도 true. */ async removeStoreFromWishlist( accountId: bigint, diff --git a/src/features/store/store-wishlist.graphql b/src/features/store/store-wishlist.graphql index 2d9dd0a..cf63411 100644 --- a/src/features/store/store-wishlist.graphql +++ b/src/features/store/store-wishlist.graphql @@ -1,6 +1,39 @@ +extend type Query { + """내가 찜한 매장 목록 (찜 최신순). 비활성/삭제 매장은 제외. 로그인 필요.""" + myWishlistedStores(input: MyWishlistedStoresInput): MyWishlistedStoresConnection! +} + extend type Mutation { """매장 찜 추가 (멱등: 이미 있어도 true, soft-delete된 항목은 복원). 로그인 필요.""" addStoreToWishlist(storeId: ID!): Boolean! """매장 찜 해제 (멱등: 이미 없어도 true). 로그인 필요.""" removeStoreFromWishlist(storeId: ID!): Boolean! } + +input MyWishlistedStoresInput { + offset: Int = 0 + limit: Int = 20 +} + +type MyWishlistedStoresConnection { + items: [WishlistedStoreSummary!]! + totalCount: Int! + hasMore: Boolean! +} + +"""찜한 매장 카드""" +type WishlistedStoreSummary { + storeId: ID! + storeName: String! + """매장 프로필(로고) 이미지. 미등록 시 null.""" + profileImageUrl: String + """평균 평점(0.0~5.0, 소수 첫째 자리). 리뷰 없으면 0.0.""" + ratingAverage: Float! + reviewCount: Int! + """매장 위치 표기(예: 인천 청라동).""" + regionLabel: String + """매장 대표 이미지(최대 3장, sort_order asc).""" + imageUrls: [String!]! + """찜한 시각.""" + addedAt: DateTime! +} diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts index 47c7e7a..49e84d5 100644 --- a/src/features/store/store.module.ts +++ b/src/features/store/store.module.ts @@ -9,6 +9,7 @@ import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resol import { StoreReviewQueryResolver } from '@/features/store/resolvers/store-review-query.resolver'; import { StoreTodayPickupQueryResolver } from '@/features/store/resolvers/store-today-pickup-query.resolver'; import { StoreWishlistMutationResolver } from '@/features/store/resolvers/store-wishlist-mutation.resolver'; +import { StoreWishlistQueryResolver } from '@/features/store/resolvers/store-wishlist-query.resolver'; import { StoreDetailService } from '@/features/store/services/store-detail.service'; import { StoreListingService } from '@/features/store/services/store-listing.service'; import { StorePickupScheduleService } from '@/features/store/services/store-pickup-schedule.service'; @@ -27,6 +28,7 @@ import { StoreWishlistService } from '@/features/store/services/store-wishlist.s StoreReviewService, StoreQueryResolver, StoreWishlistMutationResolver, + StoreWishlistQueryResolver, StoreDetailQueryResolver, StoreReviewQueryResolver, StoreTodayPickupService, diff --git a/src/features/store/types/store-wishlist-output.type.ts b/src/features/store/types/store-wishlist-output.type.ts new file mode 100644 index 0000000..ffaa980 --- /dev/null +++ b/src/features/store/types/store-wishlist-output.type.ts @@ -0,0 +1,21 @@ +/** + * store-wishlist resolver 반환용 도메인 출력 타입. + * SDL(store-wishlist.graphql)의 WishlistedStoreSummary / MyWishlistedStoresConnection 와 필드 일치. + */ + +export interface WishlistedStoreSummary { + storeId: string; + storeName: string; + profileImageUrl: string | null; + ratingAverage: number; + reviewCount: number; + regionLabel: string | null; + imageUrls: string[]; + addedAt: Date; +} + +export interface MyWishlistedStoresConnection { + items: WishlistedStoreSummary[]; + totalCount: number; + hasMore: boolean; +} diff --git a/src/test/factories/store.factory.ts b/src/test/factories/store.factory.ts index 4ef7e46..9f5363b 100644 --- a/src/test/factories/store.factory.ts +++ b/src/test/factories/store.factory.ts @@ -17,6 +17,7 @@ export interface StoreOverrides { longitude?: number | null; map_provider?: 'NAVER' | 'KAKAO' | 'NONE'; business_hours_text?: string | null; + profile_image_url?: string | null; access_guide_text?: string | null; regular_closure_text?: string | null; pickup_slot_interval_minutes?: number; @@ -49,6 +50,7 @@ export async function createStore( longitude: overrides.longitude ?? null, map_provider: overrides.map_provider ?? 'NONE', business_hours_text: overrides.business_hours_text ?? null, + profile_image_url: overrides.profile_image_url ?? null, access_guide_text: overrides.access_guide_text ?? null, regular_closure_text: overrides.regular_closure_text ?? null, pickup_slot_interval_minutes: