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
2 changes: 2 additions & 0 deletions src/features/product/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { UserPaginationInput } from '@/features/user/dto/inputs/user-pagination.input';

export class MyWishlistStoreGroupsInput extends UserPaginationInput {}
33 changes: 33 additions & 0 deletions src/features/user/dto/inputs/my-wishlist.input.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
8 changes: 7 additions & 1 deletion src/features/user/dto/inputs/my-wishlist.input.ts
Original file line number Diff line number Diff line change
@@ -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;
}
98 changes: 85 additions & 13 deletions src/features/user/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -442,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<void> {
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;
}
}

/**
Expand Down Expand Up @@ -510,21 +529,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({
Expand All @@ -538,10 +564,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' },
Expand All @@ -558,6 +592,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<number> {
return this.prisma.review.count({
where: { account_id: accountId },
Expand Down
17 changes: 16 additions & 1 deletion src/features/user/resolvers/user-wishlist-query.resolver.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,4 +27,15 @@ export class UserWishlistQueryResolver {
): Promise<MyWishlistConnection> {
return this.wishlistService.myWishlist(parseAccountId(user), input);
}

@Query('myWishlistStoreGroups')
myWishlistStoreGroups(
@CurrentUser() user: JwtUser,
@Args('input') input?: MyWishlistStoreGroupsInput,
): Promise<MyWishlistStoreGroupsConnection> {
return this.wishlistService.myWishlistStoreGroups(
parseAccountId(user),
input,
);
}
}
20 changes: 20 additions & 0 deletions src/features/user/resolvers/user-wishlist.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading