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
17 changes: 17 additions & 0 deletions src/features/product/repositories/product.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ export class ProductRepository {
});
}

/**
* active product가 존재하는지(soft-delete 아님 + 매장도 active/soft-delete 아님) 가벼운 검증.
* 판매 가능한 상품인지 확인하는 용도. 다른 도메인(wishlist, cart 등)에서 활용.
*/
async existsActiveProduct(productId: bigint): Promise<boolean> {
const found = await this.prisma.product.findFirst({
where: {
id: productId,
is_active: true,
deleted_at: null,
store: { is_active: true, deleted_at: null },
},
select: { id: true },
});
return Boolean(found);
}

async findProductById(args: { productId: bigint; storeId: bigint }) {
return this.prisma.product.findFirst({
where: {
Expand Down
5 changes: 5 additions & 0 deletions src/features/user/constants/user-wishlist-error-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const USER_WISHLIST_ERRORS = {
PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.',
INVALID_OFFSET: '오프셋은 0 이상이어야 합니다.',
INVALID_LIMIT: '조회 개수는 1~50 사이여야 합니다.',
} as const;
142 changes: 138 additions & 4 deletions src/features/user/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,27 @@ export class UserRepository {
return { ...where, deleted_at: null };
}

/**
* 화면에 노출 가능한 wishlist row 조건.
* - wishlist 자체가 active (deleted_at: null)
* - 연결된 product 가 active + soft-delete 아님
* - 연결된 store 가 active + soft-delete 아님
*
* count 와 list 가 같은 가시성 기준을 공유하도록 하여
* 마이페이지 카운트 카드와 실제 목록 길이 불일치를 방지한다.
*/
private visibleWishlistWhere(accountId: bigint) {
return {
account_id: accountId,
deleted_at: null,
product: {
deleted_at: null,
is_active: true,
store: { deleted_at: null, is_active: true },
},
} as const;
}

async findAccountWithProfile(
accountId: bigint,
options?: { withDeleted?: boolean },
Expand Down Expand Up @@ -197,9 +218,7 @@ export class UserRepository {
},
}),
this.prisma.wishlistItem.count({
where: {
account_id: accountId,
},
where: this.visibleWishlistWhere(accountId),
}),
]);

Expand Down Expand Up @@ -368,10 +387,125 @@ export class UserRepository {

async countWishlistItems(accountId: bigint): Promise<number> {
return this.prisma.wishlistItem.count({
where: { account_id: accountId },
where: this.visibleWishlistWhere(accountId),
});
}

/**
* 찜 추가 (멱등). 이미 있으면 그대로, soft-delete된 경우 deleted_at=null로 복원.
*/
async upsertWishlistItem(args: {
accountId: bigint;
productId: bigint;
now: Date;
}): Promise<void> {
await this.prisma.wishlistItem.upsert({
where: {
account_id_product_id: {
account_id: args.accountId,
product_id: args.productId,
},
},
create: {
account_id: args.accountId,
product_id: args.productId,
},
update: { deleted_at: null, updated_at: args.now },
});
}

/**
* 찜 해제 (멱등). active 항목만 soft-delete.
*/
async softDeleteWishlistItem(args: {
accountId: bigint;
productId: bigint;
now: Date;
}): Promise<void> {
await this.prisma.wishlistItem.updateMany({
where: {
account_id: args.accountId,
product_id: args.productId,
deleted_at: null,
},
data: { deleted_at: args.now },
});
}

/**
* 주어진 productIds 중 사용자가 찜한 것들의 product_id 집합을 단일 IN 쿼리로 반환.
* 매핑(N+1 회피)용.
*/
async findWishlistedProductIds(args: {
accountId: bigint;
productIds: bigint[];
}): Promise<Set<string>> {
if (args.productIds.length === 0) return new Set();
const rows = await this.prisma.wishlistItem.findMany({
where: {
account_id: args.accountId,
deleted_at: null,
product_id: { in: args.productIds },
},
select: { product_id: true },
});
return new Set(rows.map((r) => r.product_id.toString()));
}

/**
* 내 찜 목록 조회. 비활성/soft-delete된 product/store는 제외.
*/
async findWishlistItems(args: {
accountId: bigint;
offset: number;
limit: number;
}): Promise<{
items: {
product_id: bigint;
created_at: Date;
product: {
name: string;
regular_price: number;
sale_price: number | null;
images: { image_url: string }[];
store: { store_name: string };
};
}[];
totalCount: number;
}> {
const where = this.visibleWishlistWhere(args.accountId);

const [rows, totalCount] = await this.prisma.$transaction([
this.prisma.wishlistItem.findMany({
where,
orderBy: { created_at: 'desc' },
skip: args.offset,
take: args.limit,
select: {
product_id: true,
created_at: true,
product: {
select: {
name: true,
regular_price: true,
sale_price: true,
store: { select: { store_name: true } },
images: {
where: { deleted_at: null },
orderBy: { sort_order: 'asc' },
take: 1,
select: { image_url: true },
},
},
},
},
}),
this.prisma.wishlistItem.count({ where }),
]);

return { items: rows, totalCount };
}

async countMyReviews(accountId: bigint): Promise<number> {
return this.prisma.review.count({
where: { account_id: accountId },
Expand Down
2 changes: 2 additions & 0 deletions src/features/user/resolvers/user-recent-view.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PrismaClient } from '@prisma/client';

import { ProductRepository } from '@/features/product/repositories/product.repository';
import { RecentProductViewRepository } from '@/features/user/repositories/recent-product-view.repository';
import { UserRepository } from '@/features/user/repositories/user.repository';
import { UserRecentViewMutationResolver } from '@/features/user/resolvers/user-recent-view-mutation.resolver';
import { UserRecentViewQueryResolver } from '@/features/user/resolvers/user-recent-view-query.resolver';
import { UserRecentViewService } from '@/features/user/services/user-recent-view.service';
Expand Down Expand Up @@ -33,6 +34,7 @@ describe('User Recent View Resolvers (real DB)', () => {
UserRecentViewService,
RecentProductViewRepository,
ProductRepository,
UserRepository,
],
});

Expand Down
35 changes: 35 additions & 0 deletions src/features/user/resolvers/user-wishlist-mutation.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';

import { UserWishlistService } from '@/features/user/services/user-wishlist.service';
import {
CurrentUser,
JwtAuthGuard,
parseAccountId,
type JwtUser,
} from '@/global/auth';

@Resolver('Mutation')
@UseGuards(JwtAuthGuard)
export class UserWishlistMutationResolver {
constructor(private readonly wishlistService: UserWishlistService) {}

@Mutation('addToWishlist')
addToWishlist(
@CurrentUser() user: JwtUser,
@Args('productId') productId: string,
): Promise<boolean> {
return this.wishlistService.addToWishlist(parseAccountId(user), productId);
}

@Mutation('removeFromWishlist')
removeFromWishlist(
@CurrentUser() user: JwtUser,
@Args('productId') productId: string,
): Promise<boolean> {
return this.wishlistService.removeFromWishlist(
parseAccountId(user),
productId,
);
}
}
28 changes: 28 additions & 0 deletions src/features/user/resolvers/user-wishlist-query.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { UseGuards } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';

import { UserWishlistService } from '@/features/user/services/user-wishlist.service';
import type {
MyWishlistConnection,
MyWishlistInput,
} from '@/features/user/types/user-wishlist-output.type';
import {
CurrentUser,
JwtAuthGuard,
parseAccountId,
type JwtUser,
} from '@/global/auth';

@Resolver('Query')
@UseGuards(JwtAuthGuard)
export class UserWishlistQueryResolver {
constructor(private readonly wishlistService: UserWishlistService) {}

@Query('myWishlist')
myWishlist(
@CurrentUser() user: JwtUser,
@Args('input') input?: MyWishlistInput,
): Promise<MyWishlistConnection> {
return this.wishlistService.myWishlist(parseAccountId(user), input);
}
}
88 changes: 88 additions & 0 deletions src/features/user/resolvers/user-wishlist.resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { NotFoundException } from '@nestjs/common';
import type { PrismaClient } from '@prisma/client';

import { ProductRepository } from '@/features/product/repositories/product.repository';
import { UserRepository } from '@/features/user/repositories/user.repository';
import { UserWishlistMutationResolver } from '@/features/user/resolvers/user-wishlist-mutation.resolver';
import { UserWishlistQueryResolver } from '@/features/user/resolvers/user-wishlist-query.resolver';
import { UserWishlistService } from '@/features/user/services/user-wishlist.service';
import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client';
import { closeTruncateConnection, truncateAll } from '@/test/db/truncate';
import {
createAccount,
createProduct,
createStore,
createUserProfile,
} from '@/test/factories';
import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder';

describe('User Wishlist Resolver (real DB)', () => {
let mutationResolver: UserWishlistMutationResolver;
let queryResolver: UserWishlistQueryResolver;
let prisma: PrismaClient;

beforeAll(async () => {
const { module, prisma: p } = await createTestingModuleWithRealDb({
providers: [
UserWishlistMutationResolver,
UserWishlistQueryResolver,
UserWishlistService,
UserRepository,
ProductRepository,
],
});
mutationResolver = module.get(UserWishlistMutationResolver);
queryResolver = module.get(UserWishlistQueryResolver);
prisma = p;
});

afterAll(async () => {
await closeTruncateConnection();
await disconnectTestPrismaClient();
});

beforeEach(async () => {
await truncateAll();
});

it('addToWishlist → 존재하지 않는 productId면 NotFoundException 전파', async () => {
const account = await createAccount(prisma, { account_type: 'USER' });
await createUserProfile(prisma, { account_id: account.id });

await expect(
mutationResolver.addToWishlist(
{ accountId: account.id.toString() },
'999999',
),
).rejects.toThrow(NotFoundException);
});

it('addToWishlist → removeFromWishlist → myWishlist 시나리오', async () => {
const account = await createAccount(prisma, { account_type: 'USER' });
await createUserProfile(prisma, { account_id: account.id });
const store = await createStore(prisma);
const product = await createProduct(prisma, { store_id: store.id });

await mutationResolver.addToWishlist(
{ accountId: account.id.toString() },
product.id.toString(),
);

const list1 = await queryResolver.myWishlist({
accountId: account.id.toString(),
});
expect(list1.totalCount).toBe(1);
expect(list1.items[0].productId).toBe(product.id.toString());

await mutationResolver.removeFromWishlist(
{ accountId: account.id.toString() },
product.id.toString(),
);

const list2 = await queryResolver.myWishlist({
accountId: account.id.toString(),
});
expect(list2.totalCount).toBe(0);
expect(list2.items).toEqual([]);
});
});
Loading
Loading