Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
00a87fd
chore(gitignore): figma 작업 디렉터리 ignore 추가
chanwoo7 Apr 29, 2026
f59f382
feat(user): 생년월일 1900-01-01 이전 입력 거부
chanwoo7 Apr 29, 2026
7cfb5ea
fix(user): normalizeBirthDate UTC 기준 정규화로 timezone 의존성 제거
chanwoo7 Apr 29, 2026
c210942
Merge pull request #75 from CaQuick/feat/mypage-figma-birth-date-floor
chanwoo7 Apr 29, 2026
f10dc2c
feat(user): 전화번호 정규식 010-XXXX-XXXX 고정으로 강화
chanwoo7 Apr 29, 2026
7af4b11
Merge pull request #76 from CaQuick/feat/mypage-figma-phone-regex
chanwoo7 Apr 29, 2026
4e7a443
feat(user): 회원정보 수정에 name 필드 추가 + 필수값 검증
chanwoo7 Apr 29, 2026
defcddb
Merge pull request #77 from CaQuick/feat/mypage-figma-profile-name
chanwoo7 Apr 29, 2026
e8c4717
feat(user): 리뷰 미디어 분리 제한 (사진 10 / 동영상 1)
chanwoo7 Apr 29, 2026
063910c
Merge pull request #78 from CaQuick/feat/mypage-figma-review-media-split
chanwoo7 Apr 29, 2026
75a36e8
feat(user): 주문 카드에 hasReviewableItem 노출
chanwoo7 Apr 29, 2026
0779ac9
test(user): listMyOrders 주문 0건 케이스 회귀 추가
chanwoo7 Apr 29, 2026
ef9eb87
Merge pull request #79 from CaQuick/feat/mypage-figma-order-card-cta
chanwoo7 Apr 29, 2026
9a0f2d7
feat(user): 찜 토글 mutation + isWishlisted 노출 + 카운트 일관성 수정
chanwoo7 Apr 29, 2026
02be4b2
fix(user): wishlistCount와 myWishlist 가시성 기준 통일
chanwoo7 Apr 29, 2026
911dfec
Merge pull request #80 from CaQuick/feat/mypage-figma-wishlist-toggle
chanwoo7 Apr 29, 2026
4c35731
fix(user): wishlist 가시성 일관성 + 목록 정렬 안정화 (PR #81 리뷰 반영)
chanwoo7 Apr 29, 2026
97af31f
Merge pull request #82 from CaQuick/fix/coderabbit-codex-pr81-feedback
chanwoo7 Apr 29, 2026
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,5 @@ docs/*
.env.example
caquick_ddl.sql
src/features/example/*

.figma/
32 changes: 32 additions & 0 deletions src/features/order/repositories/order.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,38 @@ export class OrderRepository {
});
}

/**
* 주어진 orderId 중 PICKED_UP 상태이며 active 리뷰가 미작성인 OrderItem을
* 1건 이상 가진 order의 ID 집합을 반환한다.
*
* 주의: list 매핑에서 order별 개별 쿼리(N+1) 회피용. 단일 IN 쿼리로 처리.
*/
async findReviewableOrderIds(args: {
accountId: bigint;
orderIds: bigint[];
}): Promise<Set<string>> {
if (args.orderIds.length === 0) return new Set();

const rows = await this.prisma.orderItem.findMany({
where: {
order_id: { in: args.orderIds },
deleted_at: null,
order: {
account_id: args.accountId,
status: OrderStatus.PICKED_UP,
},
OR: [
{ review: { is: null } },
{ review: { is: { deleted_at: { not: null } } } },
],
},
select: { order_id: true },
distinct: ['order_id'],
});

return new Set(rows.map((r) => r.order_id.toString()));
}

async findOrderDetailByAccount(args: { orderId: bigint; accountId: bigint }) {
return this.prisma.order.findFirst({
where: {
Expand Down
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
3 changes: 2 additions & 1 deletion src/features/user/constants/user-review-error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ export const USER_REVIEW_ERRORS = {
INVALID_RATING: '별점은 1.0~5.0 사이, 0.5 단위여야 합니다.',
CONTENT_TOO_SHORT: '리뷰는 최소 20자 이상이어야 합니다.',
CONTENT_TOO_LONG: '리뷰는 최대 1000자까지 작성 가능합니다.',
TOO_MANY_MEDIA: '미디어는 최대 10개까지 첨부할 수 있습니다.',
TOO_MANY_IMAGES: '사진은 최대 10장까지 첨부할 수 있습니다.',
TOO_MANY_VIDEOS: '동영상은 최대 1개까지 첨부할 수 있습니다.',
ORDER_ITEM_NOT_FOUND: '주문 아이템을 찾을 수 없습니다.',
CANNOT_WRITE_REVIEW: '리뷰를 작성할 수 없는 주문입니다.',
REVIEW_ALREADY_EXISTS: '이미 리뷰가 작성된 주문 아이템입니다.',
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;
12 changes: 10 additions & 2 deletions src/features/user/constants/user.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ export const MAX_NICKNAME_LENGTH = 20;

// ── 전화번호 ──

export const MIN_PHONE_LENGTH = 7;
export const MAX_PHONE_LENGTH = 20;
// 정책: 010-XXXX-XXXX 고정 (13자). figma 명세 기준.
export const PHONE_REGEX = /^010-\d{4}-\d{4}$/;
export const PHONE_FORMAT_EXAMPLE = '010-XXXX-XXXX';

// ── 생년월일 ──

// figma 명세 외 정책 결정: 1900-01-01 이전 입력은 거부 (사실상 봇/오입력 방지).
// GraphQL DateTime은 ISO string을 UTC로 해석하므로 비교 기준도 UTC 자정으로 둔다.
// 운영 timezone과 무관하게 동일하게 동작.
export const MIN_BIRTH_DATE = new Date(Date.UTC(1900, 0, 1));

// ── 페이지네이션 ──

Expand Down
183 changes: 170 additions & 13 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 @@ -96,18 +117,39 @@ export class UserRepository {
async updateProfile(args: {
accountId: bigint;
nickname?: string;
name?: string;
birthDate?: Date | null;
phoneNumber?: string | null;
}): Promise<void> {
await this.prisma.userProfile.update({
where: { account_id: args.accountId },
data: {
...(args.nickname !== undefined ? { nickname: args.nickname } : {}),
...(args.birthDate !== undefined ? { birth_date: args.birthDate } : {}),
...(args.phoneNumber !== undefined
? { phone_number: args.phoneNumber }
: {}),
},
const hasName = args.name !== undefined;
const hasProfileFields =
args.nickname !== undefined ||
args.birthDate !== undefined ||
args.phoneNumber !== undefined;

// name은 account 테이블, 나머지는 user_profile 테이블이라
// 두 테이블 부분 실패 방지를 위해 transaction으로 묶는다.
await this.prisma.$transaction(async (tx) => {
if (hasName) {
await tx.account.update({
where: { id: args.accountId },
data: { name: args.name },
});
}
if (hasProfileFields) {
await tx.userProfile.update({
where: { account_id: args.accountId },
data: {
...(args.nickname !== undefined ? { nickname: args.nickname } : {}),
...(args.birthDate !== undefined
? { birth_date: args.birthDate }
: {}),
...(args.phoneNumber !== undefined
? { phone_number: args.phoneNumber }
: {}),
},
});
}
});
}

Expand Down Expand Up @@ -176,9 +218,7 @@ export class UserRepository {
},
}),
this.prisma.wishlistItem.count({
where: {
account_id: accountId,
},
where: this.visibleWishlistWhere(accountId),
}),
]);

Expand Down Expand Up @@ -347,8 +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 회피)용. 가시성 조건(visibleWishlistWhere)을 myWishlist/wishlistCount와
* 공유하여, recent-view 등에 노출되는 isWishlisted 플래그가 실제 wishlist 표면
* (목록/카운트)과 일관되도록 한다.
*/
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: {
...this.visibleWishlistWhere(args.accountId),
product_id: { in: args.productIds },
},
Comment on lines +447 to +450
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply visibility filters when marking wishlisted products

findWishlistedProductIds only checks account_id, deleted_at, and product_id, while this same commit made myWishlist/wishlistCount depend on stricter visibility (product.is_active and store.is_active, non-deleted). Because recent-view queries still include products from inactive stores, this can return isWishlisted=true for items that are excluded from both myWishlist and the wishlist count, creating inconsistent mypage/recent-view state for users. Reuse the same visibility predicate here to keep the flag aligned with the exposed wishlist surface.

Useful? React with 👍 / 👎.

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,
// 같은 밀리초 생성 시 페이지 경계 흔들림 방지를 위해 product_id를 보조 정렬키로 둔다.
orderBy: [{ created_at: 'desc' }, { product_id: '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 };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async countMyReviews(accountId: bigint): Promise<number> {
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);
}
}
Loading
Loading