From 9ef7b0c5b421378dde046dae1e9bca6c50683eda Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 02:19:17 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(store):=20=EC=B0=9C=ED=95=9C=20?= =?UTF-8?q?=EB=A7=A4=EC=9E=A5=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C(my?= =?UTF-8?q?WishlistedStores)=EC=99=80=20=EB=A7=A4=EC=9E=A5=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 찜 화면 시안(매장 찜 목록) 기준. 카드에 매장 프로필(로고)·매장명·평점(리뷰 수)·지역·대표 이미지 3장을 표시한다. 스펙 문서가 없어 정책은 사용자 문답으로 확정했다. 정책 결정(시안 외, 사용자 확정): - 프로필 이미지는 Store 전용 컬럼(profile_image_url) 신설. 셀러 등록은 기존 sellerUpdateStoreBasicInfo에 필드 추가로 처리(null 전달 시 제거, 미전달 시 유지). - 카드 대표 이미지는 StoreImage sort_order asc 상위 3장(매장 상세 캐러셀과 동일 소스). - 정렬은 찜 최신순, offset 페이지네이션(기존 myWishlist와 통일). - 비활성·삭제 매장은 목록·카운트 제외(상품 찜 가시성 정책과 동일). - 평점은 리뷰 없으면 0.0/0건, 소수 첫째 자리 반올림(PopularStore와 동일). 변경점: - prisma: Store.profile_image_url(VarChar 2048, nullable) 마이그레이션 + 시드 반영 - SDL: myWishlistedStores 쿼리·MyWishlistedStoresConnection·WishlistedStoreSummary 신설, SellerStore/SellerUpdateStoreBasicInfoInput에 profileImageUrl 추가 - StoreWishlistRepository.findWishlistedStores: 단일 트랜잭션(목록+카운트), nested select에 deleted_at: null 명시 - StoreWishlistService.myWishlistedStores: aggregateReviewStats·buildRegionLabel 재사용 - StoreWishlistQueryResolver 신설, 모듈 등록 - store.factory에 profile_image_url override 추가 회귀 테스트 12건: - service 6: 최신순 정렬·필드 매핑 / 이미지 3장 제한·삭제 이미지 제외 / 평점 집계·0건 0.0 / 비활성·삭제 매장 및 soft-delete 찜 제외 / 타 사용자 격리 / 페이지네이션 경계 - resolver 2: 전체 경로 통합(목록 반환·빈 목록) - input 5종·seller 3: profileImageUrl 등록/수정/제거/유지, DTO 검증 Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- .../migration.sql | 2 + prisma/schema.prisma | 4 +- prisma/seed/stores.ts | 2 + ...ller-update-store-basic-info.input.spec.ts | 11 ++ .../seller-update-store-basic-info.input.ts | 4 + src/features/seller/seller-store.graphql | 4 + .../services/seller-store-mappers.helper.ts | 2 + .../seller-store-profile.service.spec.ts | 37 ++++ .../services/seller-store-profile.service.ts | 9 + .../seller/types/seller-output.type.ts | 1 + .../constants/store-wishlist.constants.ts | 5 + .../inputs/my-wishlisted-stores.input.spec.ts | 35 ++++ .../dto/inputs/my-wishlisted-stores.input.ts | 14 ++ .../repositories/store-wishlist.repository.ts | 64 +++++++ .../store-wishlist-query.resolver.spec.ts | 77 ++++++++ .../store-wishlist-query.resolver.ts | 29 +++ .../services/store-wishlist.service.spec.ts | 181 ++++++++++++++++++ .../store/services/store-wishlist.service.ts | 46 +++++ src/features/store/store-wishlist.graphql | 33 ++++ src/features/store/store.module.ts | 2 + .../store/types/store-wishlist-output.type.ts | 21 ++ src/test/factories/store.factory.ts | 2 + 22 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260826171140_add_store_profile_image_url/migration.sql create mode 100644 src/features/store/constants/store-wishlist.constants.ts create mode 100644 src/features/store/dto/inputs/my-wishlisted-stores.input.spec.ts create mode 100644 src/features/store/dto/inputs/my-wishlisted-stores.input.ts create mode 100644 src/features/store/resolvers/store-wishlist-query.resolver.spec.ts create mode 100644 src/features/store/resolvers/store-wishlist-query.resolver.ts create mode 100644 src/features/store/types/store-wishlist-output.type.ts 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..56183c5 100644 --- a/src/features/store/repositories/store-wishlist.repository.ts +++ b/src/features/store/repositories/store-wishlist.repository.ts @@ -1,7 +1,22 @@ import { Injectable } from '@nestjs/common'; +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) {} @@ -61,6 +76,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..6058be0 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'; @@ -120,6 +122,185 @@ 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 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: From 760dff322b640df20f5979a83e5d944ed8def580 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 02:30:41 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(store):=20=EB=A7=A4=EC=9E=A5=20?= =?UTF-8?q?=EC=9E=AC=EC=B0=9C=20=EC=8B=9C=20created=5Fat=EC=9D=84=20?= =?UTF-8?q?=EC=9E=AC=EC=B0=9C=20=EC=8B=9C=EC=A0=90=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #213 Codex 리뷰 반영. soft-delete 복원 시 created_at이 최초 찜 시점으로 남아 '찜 최신순' 정렬과 addedAt 표기가 재찜을 반영하지 못하는 문제 수정. 회귀 테스트 1건 추가(재찜 매장이 목록 최상단으로 온다). Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- .../repositories/store-wishlist.repository.ts | 4 +++- .../services/store-wishlist.service.spec.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/features/store/repositories/store-wishlist.repository.ts b/src/features/store/repositories/store-wishlist.repository.ts index 56183c5..0c670ca 100644 --- a/src/features/store/repositories/store-wishlist.repository.ts +++ b/src/features/store/repositories/store-wishlist.repository.ts @@ -35,7 +35,9 @@ export class StoreWishlistRepository { }, }, create: { account_id: args.accountId, store_id: args.storeId }, - update: { deleted_at: null, updated_at: args.now }, + // 복원(재찜) 시 created_at도 재찜 시점으로 갱신한다 — 목록 '찜 최신순' 정렬과 + // addedAt 표기가 재찜을 반영하도록(과거 찜 시점으로 밀리는 문제 방지). + update: { deleted_at: null, created_at: args.now, updated_at: args.now }, }); } diff --git a/src/features/store/services/store-wishlist.service.spec.ts b/src/features/store/services/store-wishlist.service.spec.ts index 6058be0..498adff 100644 --- a/src/features/store/services/store-wishlist.service.spec.ts +++ b/src/features/store/services/store-wishlist.service.spec.ts @@ -253,6 +253,26 @@ describe('StoreWishlistService (real DB)', () => { ]); }); + 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' }); From a87312e05115ad639dcb802ff268330aedfa53d0 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 02:40:15 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(store):=20=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=EC=B0=9C=20=EC=A4=91=EB=B3=B5=20=EC=B6=94=EA=B0=80=20=EC=8B=9C?= =?UTF-8?q?=20created=5Fat=20=EB=B3=B4=EC=A1=B4=20(=EB=B3=B5=EC=9B=90=20?= =?UTF-8?q?=EC=8B=9C=EC=97=90=EB=A7=8C=20=EA=B0=B1=EC=8B=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #213 Codex 리뷰 2차 반영. upsert update 브랜치가 active 찜에도 실행되어 더블 탭·재시도가 created_at을 리셋(목록 순서·addedAt 변경)하는 문제 수정. 복원(updateMany, deleted_at != null)과 신규 생성(create + P2002 멱등 처리)을 분리해 active 찜 중복 요청은 아무것도 변경하지 않는다. 회귀 테스트: 중복 추가 멱등 케이스에 created_at 불변 검증 추가. Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- .../repositories/store-wishlist.repository.ts | 38 ++++++++++++++----- .../services/store-wishlist.service.spec.ts | 21 +++++++++- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/features/store/repositories/store-wishlist.repository.ts b/src/features/store/repositories/store-wishlist.repository.ts index 0c670ca..07cb816 100644 --- a/src/features/store/repositories/store-wishlist.repository.ts +++ b/src/features/store/repositories/store-wishlist.repository.ts @@ -1,4 +1,5 @@ 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'; @@ -21,24 +22,41 @@ export interface WishlistedStoreRow { 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 }, - // 복원(재찜) 시 created_at도 재찜 시점으로 갱신한다 — 목록 '찜 최신순' 정렬과 - // addedAt 표기가 재찜을 반영하도록(과거 찜 시점으로 밀리는 문제 방지). - update: { deleted_at: null, created_at: args.now, 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. */ diff --git a/src/features/store/services/store-wishlist.service.spec.ts b/src/features/store/services/store-wishlist.service.spec.ts index 498adff..7cf59c0 100644 --- a/src/features/store/services/store-wishlist.service.spec.ts +++ b/src/features/store/services/store-wishlist.service.spec.ts @@ -67,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 () => { From 9149f58bc3df7497150f7237df7570f9f685e565 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 02:27:15 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(user):=20=EC=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=B9=B4=EB=93=9C=20=ED=99=95=EC=9E=A5(=EB=A7=A4?= =?UTF-8?q?=EC=9E=A5=EB=B3=84=20=ED=95=84=ED=84=B0=C2=B7=ED=8F=89=EC=A0=90?= =?UTF-8?q?=C2=B7=ED=95=A0=EC=9D=B8=EC=9C=A8)=EA=B3=BC=20=EB=A7=A4?= =?UTF-8?q?=EC=9E=A5=EB=B3=84=20=EA=B7=B8=EB=A3=B9(myWishlistStoreGroups)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 찜 화면 시안(상품 찜 목록·매장별 보기) 기준. 상품 카드에 지역 뱃지·상품 평점(리뷰 수)·할인율을 표시하고, '매장별 보기'는 찜 상품을 매장 단위로 묶어 프로필·찜 상품 수를 보여준다. 스펙 문서가 없어 정책은 사용자 문답으로 확정했다. 정책 결정(시안 외, 사용자 확정): - 특정 매장의 찜 상품 화면은 별도 쿼리 대신 MyWishlistInput.storeId 옵션 필터로 처리. - 매장 그룹 정렬은 찜 상품 수 desc, 동점 시 최근 찜 순. - 상품 평점은 리뷰 없으면 0.0/0건, 소수 첫째 자리 반올림(매장 평점 표기와 동일). - 할인율은 서버 제공(discountRate 0~100) — PopularCake 등 기존 카드 컨벤션과 통일. - 가시성(비활성·삭제 상품/매장 제외)은 기존 visibleWishlistWhere를 그룹핑에도 공유해 상품 찜 목록 totalCount와 그룹 카운트 합이 일치한다. 변경점: - SDL: WishlistItemSummary에 storeId·discountRate·regionLabel·ratingAverage·reviewCount 추가, MyWishlistInput에 storeId 추가, myWishlistStoreGroups 쿼리·타입 신설 - UserRepository: visibleWishlistWhere에 storeId 필터, findWishlistItems select 확장, findVisibleWishlistItemsForGrouping 신설 (WishlistItem에 store_id가 없어 groupBy 불가 → 최소 필드 조회 후 service 그룹핑, 찜은 사용자당 소규모 전제) - UserWishlistService: 카드 매핑 확장(aggregateProductReviewStats·calcDiscountRate· buildRegionLabel 재사용), myWishlistStoreGroups 구현 - product 배럴에 calcDiscountRate 공개(기존 aggregateProductReviewStats는 그대로 재사용) 회귀 테스트 11건: - service 8: 카드 필드 매핑 / 평점 0건 / storeId 필터 / 그룹 집계·정렬 / 동점 최근 찜 순 / 가시성 일치(그룹 합 == 목록 totalCount) / 그룹 페이지네이션 / 빈 목록 - resolver 통합 1: myWishlistStoreGroups 전체 경로 - input spec 2: storeId 검증 + 상속 페이지네이션 Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- src/features/product/index.ts | 2 + .../inputs/my-wishlist-store-groups.input.ts | 3 + .../user/dto/inputs/my-wishlist.input.spec.ts | 33 +++ .../user/dto/inputs/my-wishlist.input.ts | 8 +- .../user/repositories/user.repository.ts | 63 +++++- .../resolvers/user-wishlist-query.resolver.ts | 17 +- .../resolvers/user-wishlist.resolver.spec.ts | 20 ++ .../services/user-wishlist.service.spec.ts | 192 ++++++++++++++++++ .../user/services/user-wishlist.service.ts | 111 +++++++++- .../user/types/user-wishlist-output.type.ts | 18 ++ src/features/user/user-wishlist.graphql | 34 ++++ 11 files changed, 485 insertions(+), 16 deletions(-) create mode 100644 src/features/user/dto/inputs/my-wishlist-store-groups.input.ts create mode 100644 src/features/user/dto/inputs/my-wishlist.input.spec.ts diff --git a/src/features/product/index.ts b/src/features/product/index.ts index 890de72..d606cfa 100644 --- a/src/features/product/index.ts +++ b/src/features/product/index.ts @@ -5,3 +5,5 @@ export { // 주문 생성(order feature)의 옵션 검증·가격 스냅샷 입력 타입 type ProductDetailRow, } from '@/features/product/repositories/product.repository'; +// 할인율 산식(0~100). 상품 카드 표기 규칙 — 찜 목록(user feature)이 동일 정책을 공유한다. +export { calcDiscountRate } from '@/features/product/services/product-storefront-mappers.helper'; diff --git a/src/features/user/dto/inputs/my-wishlist-store-groups.input.ts b/src/features/user/dto/inputs/my-wishlist-store-groups.input.ts new file mode 100644 index 0000000..52aec2e --- /dev/null +++ b/src/features/user/dto/inputs/my-wishlist-store-groups.input.ts @@ -0,0 +1,3 @@ +import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; + +export class MyWishlistStoreGroupsInput extends UserPaginationInput {} diff --git a/src/features/user/dto/inputs/my-wishlist.input.spec.ts b/src/features/user/dto/inputs/my-wishlist.input.spec.ts new file mode 100644 index 0000000..12ac0f8 --- /dev/null +++ b/src/features/user/dto/inputs/my-wishlist.input.spec.ts @@ -0,0 +1,33 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { MyWishlistInput } from '@/features/user/dto/inputs/my-wishlist.input'; + +function build(plain: object): MyWishlistInput { + return plainToInstance(MyWishlistInput, plain); +} + +describe('MyWishlistInput', () => { + it('빈 입력 통과 (모두 optional)', async () => { + expect(await validate(build({}))).toHaveLength(0); + }); + + it('storeId 문자열 + offset/limit 통과', async () => { + const errors = await validate( + build({ storeId: '1', offset: 0, limit: 20 }), + ); + expect(errors).toHaveLength(0); + }); + + it('storeId 가 문자열이 아니면 거절', async () => { + const errors = await validate(build({ storeId: 1 })); + expect(errors[0].property).toBe('storeId'); + }); + + it('offset 음수 거절 (UserPaginationInput 상속)', async () => { + const errors = await validate(build({ offset: -1 })); + expect(errors[0].property).toBe('offset'); + }); +}); diff --git a/src/features/user/dto/inputs/my-wishlist.input.ts b/src/features/user/dto/inputs/my-wishlist.input.ts index 7b91c36..033046e 100644 --- a/src/features/user/dto/inputs/my-wishlist.input.ts +++ b/src/features/user/dto/inputs/my-wishlist.input.ts @@ -1,3 +1,9 @@ +import { IsOptional, IsString } from 'class-validator'; + import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input'; -export class MyWishlistInput extends UserPaginationInput {} +export class MyWishlistInput extends UserPaginationInput { + @IsOptional() + @IsString() + storeId?: string; +} diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 7d79e51..3e1f560 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -52,13 +52,15 @@ export class UserRepository { * count 와 list 가 같은 가시성 기준을 공유하도록 하여 * 마이페이지 카운트 카드와 실제 목록 길이 불일치를 방지한다. */ - private visibleWishlistWhere(accountId: bigint) { + private visibleWishlistWhere(accountId: bigint, storeId?: bigint) { return { account_id: accountId, deleted_at: null, product: { deleted_at: null, is_active: true, + // 매장별 보기 → 매장 선택 화면의 매장 필터 + ...(storeId !== undefined ? { store_id: storeId } : {}), store: { deleted_at: null, is_active: true }, }, } as const; @@ -510,21 +512,28 @@ export class UserRepository { accountId: bigint; offset: number; limit: number; + storeId?: bigint; }): Promise<{ items: { product_id: bigint; created_at: Date; product: { + store_id: bigint; name: string; regular_price: number; sale_price: number | null; images: { image_url: string }[]; - store: { store_name: string }; + store: { + store_name: string; + address_city: string | null; + address_neighborhood: string | null; + region: { name: string } | null; + }; }; }[]; totalCount: number; }> { - const where = this.visibleWishlistWhere(args.accountId); + const where = this.visibleWishlistWhere(args.accountId, args.storeId); const [rows, totalCount] = await this.prisma.$transaction([ this.prisma.wishlistItem.findMany({ @@ -538,10 +547,18 @@ export class UserRepository { created_at: true, product: { select: { + store_id: true, name: true, regular_price: true, sale_price: true, - store: { select: { store_name: true } }, + store: { + select: { + store_name: true, + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + }, + }, images: { where: { deleted_at: null }, orderBy: { sort_order: 'asc' }, @@ -558,6 +575,44 @@ export class UserRepository { return { items: rows, totalCount }; } + /** + * 매장별 그룹핑용 가시 찜 목록 전체 조회. + * WishlistItem에는 store_id가 없어(product 경유) Prisma groupBy로 매장 단위 집계가 + * 불가능하다 → 최소 필드만 가져와 service에서 그룹핑한다(찜은 사용자당 소규모 전제). + * 가시성 조건은 findWishlistItems/wishlistCount와 동일(visibleWishlistWhere)해야 + * 상품 찜 목록 totalCount와 그룹 카운트 합이 일치한다. + */ + async findVisibleWishlistItemsForGrouping(accountId: bigint): Promise< + { + created_at: Date; + product: { + store: { + id: bigint; + store_name: string; + profile_image_url: string | null; + }; + }; + }[] + > { + return this.prisma.wishlistItem.findMany({ + where: this.visibleWishlistWhere(accountId), + select: { + created_at: true, + product: { + select: { + store: { + select: { + id: true, + store_name: true, + profile_image_url: true, + }, + }, + }, + }, + }, + }); + } + async countMyReviews(accountId: bigint): Promise { return this.prisma.review.count({ where: { account_id: accountId }, diff --git a/src/features/user/resolvers/user-wishlist-query.resolver.ts b/src/features/user/resolvers/user-wishlist-query.resolver.ts index 0385598..128f17f 100644 --- a/src/features/user/resolvers/user-wishlist-query.resolver.ts +++ b/src/features/user/resolvers/user-wishlist-query.resolver.ts @@ -1,9 +1,13 @@ import { UseGuards } from '@nestjs/common'; import { Args, Query, Resolver } from '@nestjs/graphql'; +import { MyWishlistStoreGroupsInput } from '@/features/user/dto/inputs/my-wishlist-store-groups.input'; import { MyWishlistInput } from '@/features/user/dto/inputs/my-wishlist.input'; import { UserWishlistService } from '@/features/user/services/user-wishlist.service'; -import type { MyWishlistConnection } from '@/features/user/types/user-wishlist-output.type'; +import type { + MyWishlistConnection, + MyWishlistStoreGroupsConnection, +} from '@/features/user/types/user-wishlist-output.type'; import { CurrentUser, JwtAuthGuard, @@ -23,4 +27,15 @@ export class UserWishlistQueryResolver { ): Promise { return this.wishlistService.myWishlist(parseAccountId(user), input); } + + @Query('myWishlistStoreGroups') + myWishlistStoreGroups( + @CurrentUser() user: JwtUser, + @Args('input') input?: MyWishlistStoreGroupsInput, + ): Promise { + return this.wishlistService.myWishlistStoreGroups( + parseAccountId(user), + input, + ); + } } diff --git a/src/features/user/resolvers/user-wishlist.resolver.spec.ts b/src/features/user/resolvers/user-wishlist.resolver.spec.ts index ae2a04b..01de650 100644 --- a/src/features/user/resolvers/user-wishlist.resolver.spec.ts +++ b/src/features/user/resolvers/user-wishlist.resolver.spec.ts @@ -85,4 +85,24 @@ describe('User Wishlist Resolver (real DB)', () => { expect(list2.totalCount).toBe(0); expect(list2.items).toEqual([]); }); + + it('myWishlistStoreGroups → 매장 그룹을 반환한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + const store = await createStore(prisma, { store_name: '해즈케이크' }); + const product = await createProduct(prisma, { store_id: store.id }); + await mutationResolver.addToWishlist( + { accountId: account.id.toString() }, + product.id.toString(), + ); + + const groups = await queryResolver.myWishlistStoreGroups({ + accountId: account.id.toString(), + }); + + expect(groups.totalCount).toBe(1); + expect(groups.items[0].storeId).toBe(store.id.toString()); + expect(groups.items[0].storeName).toBe('해즈케이크'); + expect(groups.items[0].wishlistedProductCount).toBe(1); + }); }); diff --git a/src/features/user/services/user-wishlist.service.spec.ts b/src/features/user/services/user-wishlist.service.spec.ts index b949127..5f94fa9 100644 --- a/src/features/user/services/user-wishlist.service.spec.ts +++ b/src/features/user/services/user-wishlist.service.spec.ts @@ -8,7 +8,9 @@ import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; import { createAccount, + createOrderItem, createProduct, + createReview, createStore, createUserProfile, } from '@/test/factories'; @@ -293,5 +295,195 @@ describe('UserWishlistService (real DB)', () => { }); // offset/limit 범위 검증은 DTO (MyWishlistInput → UserPaginationInput) 로 이전됨. + + it('카드 필드(storeId/regionLabel/discountRate/평점)를 매핑한다', async () => { + const account = await setupUser(); + const store = await createStore(prisma, { + store_name: '해즈케이크', + address_city: '서울', + address_neighborhood: '대치동', + }); + const product = await createProduct(prisma, { + store_id: store.id, + regular_price: 40000, + sale_price: 26000, + }); + const oi1 = await createOrderItem(prisma, { product_id: product.id }); + const oi2 = await createOrderItem(prisma, { product_id: product.id }); + await createReview(prisma, { order_item_id: oi1.id, rating: 4.5 }); + await createReview(prisma, { order_item_id: oi2.id, rating: 5 }); + await service.addToWishlist(account.id, product.id.toString()); + + const result = await service.myWishlist(account.id); + + const item = result.items[0]; + expect(item.storeId).toBe(store.id.toString()); + expect(item.regionLabel).toBe('서울 대치동'); + // (40000 - 26000) / 40000 = 35% + expect(item.discountRate).toBe(35); + // (4.5 + 5.0) / 2 = 4.75 → 4.8 + expect(item.ratingAverage).toBe(4.8); + expect(item.reviewCount).toBe(2); + }); + + it('리뷰 없는 상품의 평점은 0.0/0건이다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { + store_id: store.id, + sale_price: null, + }); + await service.addToWishlist(account.id, product.id.toString()); + + const result = await service.myWishlist(account.id); + + expect(result.items[0].ratingAverage).toBe(0); + expect(result.items[0].reviewCount).toBe(0); + expect(result.items[0].discountRate).toBe(0); + }); + + it('storeId 필터로 해당 매장 찜 상품만 반환한다', async () => { + const account = await setupUser(); + const storeA = await createStore(prisma); + const storeB = await createStore(prisma); + const pA = await createProduct(prisma, { store_id: storeA.id }); + const pB = await createProduct(prisma, { store_id: storeB.id }); + await service.addToWishlist(account.id, pA.id.toString()); + await service.addToWishlist(account.id, pB.id.toString()); + + const result = await service.myWishlist(account.id, { + storeId: storeA.id.toString(), + }); + + expect(result.totalCount).toBe(1); + expect(result.items[0].productId).toBe(pA.id.toString()); + expect(result.items[0].storeId).toBe(storeA.id.toString()); + }); + }); + + // ─── myWishlistStoreGroups ─── + describe('myWishlistStoreGroups', () => { + it('매장별 찜 상품 수를 집계하고 찜 수 desc로 정렬한다', async () => { + const account = await setupUser(); + const storeA = await createStore(prisma, { + store_name: '해즈 케이크', + profile_image_url: 'https://cdn.example.com/haz.png', + }); + const storeB = await createStore(prisma, { store_name: '달콤 케이크' }); + // A에 2개, B에 1개 찜 — A는 먼저 찜해도 개수 우선으로 앞에 온다 + for (let i = 0; i < 2; i++) { + const p = await createProduct(prisma, { store_id: storeA.id }); + await service.addToWishlist(account.id, p.id.toString()); + } + const pB = await createProduct(prisma, { store_id: storeB.id }); + await service.addToWishlist(account.id, pB.id.toString()); + + const result = await service.myWishlistStoreGroups(account.id); + + expect(result.totalCount).toBe(2); + expect(result.items).toEqual([ + { + storeId: storeA.id.toString(), + storeName: '해즈 케이크', + profileImageUrl: 'https://cdn.example.com/haz.png', + wishlistedProductCount: 2, + }, + { + storeId: storeB.id.toString(), + storeName: '달콤 케이크', + profileImageUrl: null, + wishlistedProductCount: 1, + }, + ]); + }); + + it('찜 수 동점이면 최근 찜한 매장이 먼저 온다', async () => { + const account = await setupUser(); + const storeA = await createStore(prisma); + const storeB = await createStore(prisma); + const pA = await createProduct(prisma, { store_id: storeA.id }); + const pB = await createProduct(prisma, { store_id: storeB.id }); + await service.addToWishlist(account.id, pA.id.toString()); + await new Promise((r) => setTimeout(r, 10)); + await service.addToWishlist(account.id, pB.id.toString()); + + const result = await service.myWishlistStoreGroups(account.id); + + expect(result.items.map((i) => i.storeId)).toEqual([ + storeB.id.toString(), + storeA.id.toString(), + ]); + }); + + it('가시성은 myWishlist와 일치한다(비활성 상품·매장/soft-delete 찜 제외)', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const visible = await createProduct(prisma, { store_id: store.id }); + const inactivated = await createProduct(prisma, { store_id: store.id }); + const removed = await createProduct(prisma, { store_id: store.id }); + const inactiveStore = await createStore(prisma); + const orphan = await createProduct(prisma, { + store_id: inactiveStore.id, + }); + await service.addToWishlist(account.id, visible.id.toString()); + await service.addToWishlist(account.id, inactivated.id.toString()); + await service.addToWishlist(account.id, removed.id.toString()); + await service.addToWishlist(account.id, orphan.id.toString()); + await prisma.product.update({ + where: { id: inactivated.id }, + data: { is_active: false }, + }); + await service.removeFromWishlist(account.id, removed.id.toString()); + await prisma.store.update({ + where: { id: inactiveStore.id }, + data: { is_active: false }, + }); + + const groups = await service.myWishlistStoreGroups(account.id); + const list = await service.myWishlist(account.id); + + expect(groups.totalCount).toBe(1); + expect(groups.items[0].wishlistedProductCount).toBe(1); + // 그룹 카운트 합 == 상품 찜 목록 totalCount (화면 01·02 카운트 일관성) + const groupSum = groups.items.reduce( + (sum, g) => sum + g.wishlistedProductCount, + 0, + ); + expect(groupSum).toBe(list.totalCount); + }); + + it('offset/limit 페이지네이션과 hasMore를 계산한다', async () => { + const account = await setupUser(); + for (let i = 0; i < 3; i++) { + const store = await createStore(prisma); + const p = await createProduct(prisma, { store_id: store.id }); + await service.addToWishlist(account.id, p.id.toString()); + } + + const page1 = await service.myWishlistStoreGroups(account.id, { + offset: 0, + limit: 2, + }); + const page2 = await service.myWishlistStoreGroups(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); + }); + + it('찜이 없으면 빈 목록을 반환한다', async () => { + const account = await setupUser(); + + const result = await service.myWishlistStoreGroups(account.id); + + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + expect(result.hasMore).toBe(false); + }); }); }); diff --git a/src/features/user/services/user-wishlist.service.ts b/src/features/user/services/user-wishlist.service.ts index 3a8a9e3..fd2c32a 100644 --- a/src/features/user/services/user-wishlist.service.ts +++ b/src/features/user/services/user-wishlist.service.ts @@ -1,13 +1,18 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { parseId } from '@/common/utils/id-parser'; -import { ProductRepository } from '@/features/product'; +import { calcDiscountRate, ProductRepository } from '@/features/product'; +import { buildRegionLabel } from '@/features/store'; import { USER_WISHLIST_ERRORS } from '@/features/user/constants/user-wishlist-error-messages'; import { DEFAULT_PAGINATION_LIMIT } from '@/features/user/constants/user.constants'; +import type { MyWishlistStoreGroupsInput } from '@/features/user/dto/inputs/my-wishlist-store-groups.input'; import type { MyWishlistInput } from '@/features/user/dto/inputs/my-wishlist.input'; import { UserRepository } from '@/features/user/repositories/user.repository'; import { UserBaseService } from '@/features/user/services/user-base.service'; -import type { MyWishlistConnection } from '@/features/user/types/user-wishlist-output.type'; +import type { + MyWishlistConnection, + MyWishlistStoreGroupsConnection, +} from '@/features/user/types/user-wishlist-output.type'; @Injectable() export class UserWishlistService extends UserBaseService { @@ -61,22 +66,108 @@ export class UserWishlistService extends UserBaseService { const offset = input?.offset ?? 0; const limit = input?.limit ?? DEFAULT_PAGINATION_LIMIT; + // "0"도 유효 후보로 취급해 truthy 체크가 아닌 null/undefined 체크로 거른다(parseId가 검증). + const storeId = input?.storeId != null ? parseId(input.storeId) : undefined; const { items, totalCount } = await this.repo.findWishlistItems({ accountId, offset, limit, + storeId, }); + // 상품 평점은 페이지 상품들만 단일 groupBy로 집계(N+1 회피) + const reviewStats = + await this.productRepository.aggregateProductReviewStats( + items.map((row) => row.product_id), + ); + + return { + items: items.map((row) => { + const stat = reviewStats.get(row.product_id); + return { + productId: row.product_id.toString(), + storeId: row.product.store_id.toString(), + productName: row.product.name, + representativeImageUrl: row.product.images[0]?.image_url ?? null, + salePrice: row.product.sale_price, + regularPrice: row.product.regular_price, + discountRate: calcDiscountRate( + row.product.regular_price, + row.product.sale_price, + ), + storeName: row.product.store.store_name, + regionLabel: buildRegionLabel(row.product.store), + // 소수 첫째 자리까지(예: 4.666 → 4.7). 매장 평점 표기와 동일 정책. + ratingAverage: Math.round((stat?.average ?? 0) * 10) / 10, + reviewCount: stat?.count ?? 0, + addedAt: row.created_at, + }; + }), + totalCount, + hasMore: offset + limit < totalCount, + }; + } + + /** + * 찜 상품의 매장별 그룹 목록 (찜 상품 수 desc → 최근 찜 desc → storeId desc). + * 가시성은 myWishlist와 동일 조건 → totalCount 합이 상품 찜 목록과 일치한다. + */ + async myWishlistStoreGroups( + accountId: bigint, + input?: MyWishlistStoreGroupsInput, + ): Promise { + await this.requireActiveUser(accountId); + + const offset = input?.offset ?? 0; + const limit = input?.limit ?? DEFAULT_PAGINATION_LIMIT; + + const rows = await this.repo.findVisibleWishlistItemsForGrouping(accountId); + + const groups = new Map< + bigint, + { + storeId: bigint; + storeName: string; + profileImageUrl: string | null; + count: number; + lastAddedAt: Date; + } + >(); + for (const row of rows) { + const store = row.product.store; + const existing = groups.get(store.id); + if (existing) { + existing.count += 1; + if (row.created_at > existing.lastAddedAt) { + existing.lastAddedAt = row.created_at; + } + } else { + groups.set(store.id, { + storeId: store.id, + storeName: store.store_name, + profileImageUrl: store.profile_image_url, + count: 1, + lastAddedAt: row.created_at, + }); + } + } + + const sorted = [...groups.values()].sort((a, b) => { + if (b.count !== a.count) return b.count - a.count; + const timeDiff = b.lastAddedAt.getTime() - a.lastAddedAt.getTime(); + if (timeDiff !== 0) return timeDiff; + // 같은 밀리초 찜까지 동률이면 storeId desc로 안정적 순서 보장 + return b.storeId > a.storeId ? 1 : -1; + }); + + const totalCount = sorted.length; return { - items: items.map((row) => ({ - productId: row.product_id.toString(), - productName: row.product.name, - representativeImageUrl: row.product.images[0]?.image_url ?? null, - salePrice: row.product.sale_price, - regularPrice: row.product.regular_price, - storeName: row.product.store.store_name, - addedAt: row.created_at, + items: sorted.slice(offset, offset + limit).map((group) => ({ + storeId: group.storeId.toString(), + storeName: group.storeName, + profileImageUrl: group.profileImageUrl, + wishlistedProductCount: group.count, })), totalCount, hasMore: offset + limit < totalCount, diff --git a/src/features/user/types/user-wishlist-output.type.ts b/src/features/user/types/user-wishlist-output.type.ts index 3801dad..55e92a8 100644 --- a/src/features/user/types/user-wishlist-output.type.ts +++ b/src/features/user/types/user-wishlist-output.type.ts @@ -1,10 +1,15 @@ export interface WishlistItemSummary { productId: string; + storeId: string; productName: string; representativeImageUrl: string | null; salePrice: number | null; regularPrice: number; + discountRate: number; storeName: string; + regionLabel: string | null; + ratingAverage: number; + reviewCount: number; addedAt: Date; } @@ -13,3 +18,16 @@ export interface MyWishlistConnection { totalCount: number; hasMore: boolean; } + +export interface WishlistStoreGroup { + storeId: string; + storeName: string; + profileImageUrl: string | null; + wishlistedProductCount: number; +} + +export interface MyWishlistStoreGroupsConnection { + items: WishlistStoreGroup[]; + totalCount: number; + hasMore: boolean; +} diff --git a/src/features/user/user-wishlist.graphql b/src/features/user/user-wishlist.graphql index 17f8650..ab1c601 100644 --- a/src/features/user/user-wishlist.graphql +++ b/src/features/user/user-wishlist.graphql @@ -1,6 +1,8 @@ extend type Query { """내 찜 목록""" myWishlist(input: MyWishlistInput): MyWishlistConnection! + """찜 상품의 매장별 그룹 목록 (찜 상품 수 desc, 동점 시 최근 찜 순). 로그인 필요.""" + myWishlistStoreGroups(input: MyWishlistStoreGroupsInput): MyWishlistStoreGroupsConnection! } extend type Mutation { @@ -13,6 +15,8 @@ extend type Mutation { input MyWishlistInput { offset: Int = 0 limit: Int = 20 + """특정 매장의 찜 상품만 조회(매장별 보기 → 매장 선택 화면).""" + storeId: ID } type MyWishlistConnection { @@ -23,10 +27,40 @@ type MyWishlistConnection { type WishlistItemSummary { productId: ID! + """소속 매장 ID(상세 이동용).""" + storeId: ID! productName: String! representativeImageUrl: String salePrice: Int regularPrice: Int! + """할인율(0~100). salePrice 없으면 0.""" + discountRate: Int! storeName: String! + """매장 위치 표기(예: 서울 대치동).""" + regionLabel: String + """상품 평균 평점(0.0~5.0, 소수 첫째 자리). 리뷰 없으면 0.0.""" + ratingAverage: Float! + reviewCount: Int! addedAt: DateTime! } + +input MyWishlistStoreGroupsInput { + offset: Int = 0 + limit: Int = 20 +} + +type MyWishlistStoreGroupsConnection { + items: [WishlistStoreGroup!]! + totalCount: Int! + hasMore: Boolean! +} + +"""매장별 보기 행(매장 + 찜 상품 수)""" +type WishlistStoreGroup { + storeId: ID! + storeName: String! + """매장 프로필(로고) 이미지. 미등록 시 null.""" + profileImageUrl: String + """이 매장에서 찜한 상품 수.""" + wishlistedProductCount: Int! +} From aaf5d9ae6f7bef9cd1c971779e023aa74daeaa3f Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 27 Aug 2026 02:31:47 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(user):=20=EC=83=81=ED=92=88=20=EC=9E=AC?= =?UTF-8?q?=EC=B0=9C=20=EC=8B=9C=20created=5Fat=20=EA=B0=B1=EC=8B=A0=20(?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=20=EC=8B=9C=EC=97=90=EB=A7=8C,=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B6=94=EA=B0=80=EB=8A=94=20=EB=B3=B4=EC=A1=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #213 Codex 리뷰(매장 찜 동일 이슈 2건)를 상품 찜에도 반영. - soft-delete 복원 시 created_at이 최초 찜 시점으로 남아 '찜 최신순' 정렬과 addedAt 표기가 재찜을 반영하지 못하는 문제 수정. - 단, active 찜에 대한 중복 요청(더블 탭·재시도)은 created_at을 건드리지 않도록 복원(updateMany, deleted_at != null)과 신규 생성(create + P2002 멱등 처리)을 분리. 회귀 테스트 2건: 재찜 상품 최상단 정렬 / 중복 추가 시 created_at 불변. Claude-Session: https://claude.ai/code/session_01KfiGgWooJdsa4iPQPmqvBj --- .../user/repositories/user.repository.ts | 35 ++++++++++++---- .../services/user-wishlist.service.spec.ts | 42 ++++++++++++++++++- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 3e1f560..91d98a2 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -444,26 +444,43 @@ export class UserRepository { } /** - * 찜 추가 (멱등). 이미 있으면 그대로, soft-delete된 경우 deleted_at=null로 복원. + * 찜 추가 (멱등). 없으면 생성, soft-delete된 경우 복원. + * 복원(재찜) 시에만 created_at을 재찜 시점으로 갱신한다 — 목록 '찜 최신순' 정렬과 + * addedAt 표기가 재찜을 반영하되, 이미 active인 찜에 대한 중복 요청(더블 탭·재시도)은 + * created_at을 건드리지 않아 멱등 계약을 지킨다(매장 찜 upsertStoreWishlist와 동일 정책). */ async upsertWishlistItem(args: { accountId: bigint; productId: bigint; now: Date; }): Promise { - await this.prisma.wishlistItem.upsert({ + const restored = await this.prisma.wishlistItem.updateMany({ where: { - account_id_product_id: { - account_id: args.accountId, - product_id: args.productId, - }, - }, - create: { account_id: args.accountId, product_id: args.productId, + deleted_at: { not: null }, }, - 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.wishlistItem.create({ + data: { + account_id: args.accountId, + product_id: args.productId, + }, + }); + } catch (error) { + // active 찜이 이미 존재(unique 충돌) — 멱등이므로 무시 + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return; + } + throw error; + } } /** diff --git a/src/features/user/services/user-wishlist.service.spec.ts b/src/features/user/services/user-wishlist.service.spec.ts index 5f94fa9..5cddb15 100644 --- a/src/features/user/services/user-wishlist.service.spec.ts +++ b/src/features/user/services/user-wishlist.service.spec.ts @@ -68,18 +68,37 @@ describe('UserWishlistService (real DB)', () => { expect(row?.deleted_at).toBeNull(); }); - it('이미 active 상태로 있으면 멱등 (true 반환, 추가 row 없음)', async () => { + it('이미 active 상태로 있으면 멱등 (1건 유지, created_at 불변)', async () => { const account = await setupUser(); const store = await createStore(prisma); const product = await createProduct(prisma, { store_id: store.id }); await service.addToWishlist(account.id, product.id.toString()); + const before = await prisma.wishlistItem.findUniqueOrThrow({ + where: { + account_id_product_id: { + account_id: account.id, + product_id: product.id, + }, + }, + }); + await new Promise((r) => setTimeout(r, 10)); await service.addToWishlist(account.id, product.id.toString()); const count = await prisma.wishlistItem.count({ where: { account_id: account.id, product_id: product.id }, }); expect(count).toBe(1); + const after = await prisma.wishlistItem.findUniqueOrThrow({ + where: { + account_id_product_id: { + account_id: account.id, + product_id: product.id, + }, + }, + }); + // 더블 탭·재시도가 찜 시각(목록 정렬 기준)을 밀지 않는다 + expect(after.created_at.getTime()).toBe(before.created_at.getTime()); }); it('soft-delete된 row가 있으면 deleted_at=null로 복원된다', async () => { @@ -296,6 +315,27 @@ describe('UserWishlistService (real DB)', () => { // offset/limit 범위 검증은 DTO (MyWishlistInput → UserPaginationInput) 로 이전됨. + it('재찜(복원)한 상품은 목록 최상단으로 온다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const first = await createProduct(prisma, { store_id: store.id }); + const second = await createProduct(prisma, { store_id: store.id }); + await service.addToWishlist(account.id, first.id.toString()); + await new Promise((r) => setTimeout(r, 10)); + await service.addToWishlist(account.id, second.id.toString()); + // first를 해제 후 재찜 → 재찜 시점 기준으로 second보다 앞서야 한다 + await service.removeFromWishlist(account.id, first.id.toString()); + await new Promise((r) => setTimeout(r, 10)); + await service.addToWishlist(account.id, first.id.toString()); + + const result = await service.myWishlist(account.id); + + expect(result.items.map((i) => i.productId)).toEqual([ + first.id.toString(), + second.id.toString(), + ]); + }); + it('카드 필드(storeId/regionLabel/discountRate/평점)를 매핑한다', async () => { const account = await setupUser(); const store = await createStore(prisma, {