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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions src/features/store/repositories/store-wishlist.repository.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
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 매퍼 입력. */
Expand All @@ -14,7 +13,6 @@ export interface WishlistedStoreRow {
address_city: string | null;
address_neighborhood: string | null;
region: { name: string } | null;
store_images: { image_url: string }[];
};
}

Expand Down Expand Up @@ -129,12 +127,6 @@ export class StoreWishlistRepository {
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 },
},
},
},
},
Expand Down
3 changes: 2 additions & 1 deletion src/features/store/repositories/store.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ export class StoreRepository {
/** 페이지 매장들의 대표 케이크 이미지(매장당 최대 N장, 활성 상품 1장씩). */
async findStoreCakeImages(
storeIds: bigint[],
limit: number = POPULAR_STORE_CAKE_IMAGE_LIMIT,
): Promise<Map<bigint, string[]>> {
if (storeIds.length === 0) return new Map();

Expand All @@ -396,7 +397,7 @@ export class StoreRepository {
images: { some: { deleted_at: null } },
},
orderBy: { id: 'desc' },
take: POPULAR_STORE_CAKE_IMAGE_LIMIT,
take: limit,
select: {
images: {
where: { deleted_at: null },
Expand Down
74 changes: 61 additions & 13 deletions src/features/store/services/store-wishlist.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { closeTruncateConnection, truncateAll } from '@/test/db/truncate';
import {
createAccount,
createOrderItem,
createProduct,
createReview,
createStore,
createStoreWishlist,
Expand Down Expand Up @@ -142,13 +143,14 @@ describe('StoreWishlistService (real DB)', () => {
});

describe('myWishlistedStores', () => {
async function addImages(storeId: bigint, urls: string[]): Promise<void> {
await prisma.storeImage.createMany({
data: urls.map((url, index) => ({
store_id: storeId,
image_url: url,
sort_order: index,
})),
/** 상품 1개 + 대표 이미지 1장 생성(카드 이미지 소스). */
async function addProductWithImage(
storeId: bigint,
imageUrl: string,
): Promise<void> {
const product = await createProduct(prisma, { store_id: storeId });
await prisma.productImage.create({
data: { product_id: product.id, image_url: imageUrl, sort_order: 0 },
});
}

Expand Down Expand Up @@ -186,13 +188,59 @@ describe('StoreWishlistService (real DB)', () => {
expect(haz.addedAt).toBeInstanceOf(Date);
});

it('대표 이미지는 sort_order asc 최대 3장, 삭제된 이미지는 제외한다', async () => {
it('카드 이미지는 상품 대표 이미지 최대 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() },
const empty = await createStore(prisma);
// 상품 4개 → 최신(id desc) 3개의 대표 이미지만 포함
await addProductWithImage(store.id, 'u0');
await addProductWithImage(store.id, 'u1');
await addProductWithImage(store.id, 'u2');
await addProductWithImage(store.id, 'u3');
await createStoreWishlist(prisma, {
account_id: account.id,
store_id: store.id,
});
await createStoreWishlist(prisma, {
account_id: account.id,
store_id: empty.id,
});

const result = await service.myWishlistedStores(account.id);

const withImages = result.items.find(
(i) => i.storeId === store.id.toString(),
);
const withoutImages = result.items.find(
(i) => i.storeId === empty.id.toString(),
);
expect(withImages?.imageUrls).toEqual(['u3', 'u2', 'u1']);
expect(withoutImages?.imageUrls).toEqual([]);
});

it('삭제된 이미지·비활성 상품은 카드 이미지에서 제외한다', async () => {
const account = await createAccount(prisma, { account_type: 'USER' });
const store = await createStore(prisma);
await addProductWithImage(store.id, 'kept');
// 이미지가 soft-delete된 상품 → 이미지 보유 상품이 아니므로 제외
const deletedImageProduct = await createProduct(prisma, {
store_id: store.id,
});
await prisma.productImage.create({
data: {
product_id: deletedImageProduct.id,
image_url: 'deleted',
sort_order: 0,
deleted_at: new Date(),
},
});
// 비활성 상품 → 제외
const inactive = await createProduct(prisma, {
store_id: store.id,
is_active: false,
});
await prisma.productImage.create({
data: { product_id: inactive.id, image_url: 'inactive', sort_order: 0 },
});
await createStoreWishlist(prisma, {
account_id: account.id,
Expand All @@ -201,7 +249,7 @@ describe('StoreWishlistService (real DB)', () => {

const result = await service.myWishlistedStores(account.id);

expect(result.items[0].imageUrls).toEqual(['u0', 'u2', 'u3']);
expect(result.items[0].imageUrls).toEqual(['kept']);
});

it('평점은 소수 첫째 자리 반올림, 리뷰 없으면 0.0/0건이다', async () => {
Expand Down
21 changes: 15 additions & 6 deletions src/features/store/services/store-wishlist.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ 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 {
DEFAULT_WISHLISTED_STORES_LIMIT,
WISHLISTED_STORE_IMAGE_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';
Expand Down Expand Up @@ -60,10 +63,16 @@ export class StoreWishlistService {
limit,
});

// 평점은 페이지 매장들만 단일 groupBy로 집계(N+1 회피). 랭킹과 동일 소스.
const reviewStats = await this.storeRepo.aggregateReviewStats(
items.map((row) => row.store.id),
);
const storeIds = items.map((row) => row.store.id);
// 평점·이미지는 페이지 매장들만 집계(N+1 회피).
// 카드 이미지는 인기 매장 카드(PopularStore)와 동일하게 상품 대표 이미지를 쓴다(#216).
const [reviewStats, cakeImages] = await Promise.all([
this.storeRepo.aggregateReviewStats(storeIds),
this.storeRepo.findStoreCakeImages(
storeIds,
WISHLISTED_STORE_IMAGE_LIMIT,
),
]);

return {
items: items.map((row) => {
Expand All @@ -76,7 +85,7 @@ export class StoreWishlistService {
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),
imageUrls: cakeImages.get(row.store.id) ?? [],
addedAt: row.created_at,
};
}),
Expand Down
2 changes: 1 addition & 1 deletion src/features/store/store-wishlist.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type WishlistedStoreSummary {
reviewCount: Int!
"""매장 위치 표기(예: 인천 청라동)."""
regionLabel: String
"""매장 대표 이미지(최대 3장, sort_order asc)."""
"""대표 케이크 이미지(활성 상품 대표 이미지, 최대 3장). 인기 매장 카드와 동일 소스."""
imageUrls: [String!]!
"""찜한 시각."""
addedAt: DateTime!
Expand Down
Loading