From 00a87fd119492d0cb32d78f48396f5373fed1055 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 00:12:31 +0900 Subject: [PATCH 01/11] =?UTF-8?q?chore(gitignore):=20figma=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20ignore=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획자 화면 명세를 로컬에서 관리하는 .figma/ 디렉터리를 ignore에 등록. 스크린샷/스펙 텍스트는 작업자 본인 로컬에서만 참고하도록 한다. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 553a01f..dfd9e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,5 @@ docs/* .env.example caquick_ddl.sql src/features/example/* + +.figma/ From f59f38266e052ebc6721d28bc8ce4ffdf8d42bdd Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 00:17:37 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(user):=20=EC=83=9D=EB=85=84=EC=9B=94?= =?UTF-8?q?=EC=9D=BC=201900-01-01=20=EC=9D=B4=EC=A0=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EA=B1=B0=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획 명세에 별도 하한 정책이 없으나, 봇/오입력 방지를 위해 normalizeBirthDate에 1900-01-01 이전 날짜 거부 로직을 추가한다. 비교 기준은 normalize와 동일한 로컬 타임존 자정으로 둔다. - MIN_BIRTH_DATE 상수 추가 (user.constants.ts) - 회귀 테스트 2건 추가 (1899-12-31/1850 reject, 1900-01-01 통과) --- src/features/user/constants/user.constants.ts | 7 +++++++ .../user/services/user-base.service.spec.ts | 15 +++++++++++++++ src/features/user/services/user-base.service.ts | 6 ++++++ 3 files changed, 28 insertions(+) diff --git a/src/features/user/constants/user.constants.ts b/src/features/user/constants/user.constants.ts index db7fac5..f823153 100644 --- a/src/features/user/constants/user.constants.ts +++ b/src/features/user/constants/user.constants.ts @@ -8,6 +8,13 @@ export const MAX_NICKNAME_LENGTH = 20; export const MIN_PHONE_LENGTH = 7; export const MAX_PHONE_LENGTH = 20; +// ── 생년월일 ── + +// figma 명세 외 정책 결정: 1900-01-01 이전 입력은 거부 (사실상 봇/오입력 방지). +// normalizeBirthDate가 로컬 타임존 기준 setHours(0,0,0,0)으로 정렬하므로 +// 비교 기준도 동일하게 로컬 타임존 자정으로 둔다. +export const MIN_BIRTH_DATE = new Date(1900, 0, 1); + // ── 페이지네이션 ── export const DEFAULT_PAGINATION_LIMIT = 20; diff --git a/src/features/user/services/user-base.service.spec.ts b/src/features/user/services/user-base.service.spec.ts index ce4bd08..c15f3ad 100644 --- a/src/features/user/services/user-base.service.spec.ts +++ b/src/features/user/services/user-base.service.spec.ts @@ -222,6 +222,21 @@ describe('UserBaseService (real DB)', () => { ); }); + it('1899-12-31 등 1900-01-01 이전이면 BadRequestException을 던진다', () => { + expect(() => service.testNormalizeBirthDate('1899-12-31')).toThrow( + BadRequestException, + ); + expect(() => + service.testNormalizeBirthDate(new Date(1850, 0, 1)), + ).toThrow(BadRequestException); + }); + + it('1900-01-01은 통과한다', () => { + const result = service.testNormalizeBirthDate(new Date(1900, 0, 1)); + expect(result).toBeInstanceOf(Date); + expect(result?.getFullYear()).toBe(1900); + }); + it('문자열 날짜를 Date 객체로 변환한다', () => { const result = service.testNormalizeBirthDate('1990-05-15'); expect(result).toBeInstanceOf(Date); diff --git a/src/features/user/services/user-base.service.ts b/src/features/user/services/user-base.service.ts index 9aeab63..f45abf0 100644 --- a/src/features/user/services/user-base.service.ts +++ b/src/features/user/services/user-base.service.ts @@ -10,6 +10,7 @@ import { MAX_NICKNAME_LENGTH, MAX_PAGINATION_LIMIT, MAX_PHONE_LENGTH, + MIN_BIRTH_DATE, MIN_NICKNAME_LENGTH, MIN_PHONE_LENGTH, } from '@/features/user/constants/user.constants'; @@ -111,6 +112,11 @@ export abstract class UserBaseService { today.setHours(0, 0, 0, 0); const normalized = new Date(date); normalized.setHours(0, 0, 0, 0); + if (normalized < MIN_BIRTH_DATE) { + throw new BadRequestException( + 'birthDate is too old (before 1900-01-01).', + ); + } if (normalized > today) { throw new BadRequestException('birthDate cannot be in the future.'); } From 7cfb5eaf2ba9e8004ff2633021fbdd7dc84145ef Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 00:41:15 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix(user):=20normalizeBirthDate=20UTC=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=A0=95=EA=B7=9C=ED=99=94=EB=A1=9C=20tim?= =?UTF-8?q?ezone=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 setHours(0,0,0,0) 로컬 자정 정규화 + 로컬 자정 MIN_BIRTH_DATE는 KST 환경에선 동작하나 음수 오프셋(예: America/New_York) 또는 UTC 환경에서는 '1900-01-01' 같은 ISO date string이 1899-12-31로 정렬되어 부당하게 reject되는 회귀가 있었음 (Codex 리뷰 P2). GraphQL DateTime은 ISO string을 UTC로 해석하고 DB 컬럼은 @db.Date(시간 무시)이므로 UTC 자정 기준으로 정규화/비교하도록 통일한다. - MIN_BIRTH_DATE = new Date(Date.UTC(1900, 0, 1)) - normalize: getUTCFullYear/getUTCMonth/getUTCDate로 UTC 자정 정렬 - 미래 비교의 today도 동일하게 UTC midnight 기준 - 회귀 테스트 입력을 ISO string으로 조정 + getUTCFullYear 검증 --- src/features/user/constants/user.constants.ts | 6 ++-- .../user/services/user-base.service.spec.ts | 31 +++++++++++-------- .../user/services/user-base.service.ts | 15 ++++++--- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/features/user/constants/user.constants.ts b/src/features/user/constants/user.constants.ts index f823153..5c64aa7 100644 --- a/src/features/user/constants/user.constants.ts +++ b/src/features/user/constants/user.constants.ts @@ -11,9 +11,9 @@ export const MAX_PHONE_LENGTH = 20; // ── 생년월일 ── // figma 명세 외 정책 결정: 1900-01-01 이전 입력은 거부 (사실상 봇/오입력 방지). -// normalizeBirthDate가 로컬 타임존 기준 setHours(0,0,0,0)으로 정렬하므로 -// 비교 기준도 동일하게 로컬 타임존 자정으로 둔다. -export const MIN_BIRTH_DATE = new Date(1900, 0, 1); +// GraphQL DateTime은 ISO string을 UTC로 해석하므로 비교 기준도 UTC 자정으로 둔다. +// 운영 timezone과 무관하게 동일하게 동작. +export const MIN_BIRTH_DATE = new Date(Date.UTC(1900, 0, 1)); // ── 페이지네이션 ── diff --git a/src/features/user/services/user-base.service.spec.ts b/src/features/user/services/user-base.service.spec.ts index c15f3ad..6590cd0 100644 --- a/src/features/user/services/user-base.service.spec.ts +++ b/src/features/user/services/user-base.service.spec.ts @@ -226,32 +226,37 @@ describe('UserBaseService (real DB)', () => { expect(() => service.testNormalizeBirthDate('1899-12-31')).toThrow( BadRequestException, ); - expect(() => - service.testNormalizeBirthDate(new Date(1850, 0, 1)), - ).toThrow(BadRequestException); + expect(() => service.testNormalizeBirthDate('1850-01-01')).toThrow( + BadRequestException, + ); }); - it('1900-01-01은 통과한다', () => { - const result = service.testNormalizeBirthDate(new Date(1900, 0, 1)); + it('1900-01-01은 통과한다 (UTC 기준)', () => { + const result = service.testNormalizeBirthDate('1900-01-01'); expect(result).toBeInstanceOf(Date); - expect(result?.getFullYear()).toBe(1900); + expect(result?.getUTCFullYear()).toBe(1900); + expect(result?.getUTCMonth()).toBe(0); + expect(result?.getUTCDate()).toBe(1); }); it('문자열 날짜를 Date 객체로 변환한다', () => { const result = service.testNormalizeBirthDate('1990-05-15'); expect(result).toBeInstanceOf(Date); - expect(result?.getFullYear()).toBe(1990); + expect(result?.getUTCFullYear()).toBe(1990); + expect(result?.getUTCMonth()).toBe(4); + expect(result?.getUTCDate()).toBe(15); }); - it('오늘 날짜는 미래로 취급하지 않고 그대로 반환한다', () => { - const today = new Date(); - today.setHours(12, 0, 0, 0); + it('오늘(UTC) 날짜는 미래로 취급하지 않고 그대로 반환한다', () => { + const now = new Date(); + const todayIso = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')}`; - const result = service.testNormalizeBirthDate(today); + const result = service.testNormalizeBirthDate(todayIso); expect(result).toBeInstanceOf(Date); - // 시간은 00:00:00으로 내부 정규화되지만 날짜 자체는 오늘과 같아야 함 - expect(result?.toDateString()).toBe(today.toDateString()); + expect(result?.getUTCFullYear()).toBe(now.getUTCFullYear()); + expect(result?.getUTCMonth()).toBe(now.getUTCMonth()); + expect(result?.getUTCDate()).toBe(now.getUTCDate()); }); }); diff --git a/src/features/user/services/user-base.service.ts b/src/features/user/services/user-base.service.ts index f45abf0..920512b 100644 --- a/src/features/user/services/user-base.service.ts +++ b/src/features/user/services/user-base.service.ts @@ -108,16 +108,21 @@ export abstract class UserBaseService { if (Number.isNaN(date.getTime())) { throw new BadRequestException('Invalid birthDate.'); } - const today = new Date(); - today.setHours(0, 0, 0, 0); - const normalized = new Date(date); - normalized.setHours(0, 0, 0, 0); + // DB가 @db.Date(시간 무시) + GraphQL DateTime이 ISO string을 UTC로 해석하므로 + // timezone 독립적으로 UTC 자정 기준으로 정규화한다. + const normalized = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); if (normalized < MIN_BIRTH_DATE) { throw new BadRequestException( 'birthDate is too old (before 1900-01-01).', ); } - if (normalized > today) { + const now = new Date(); + const todayUtc = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); + if (normalized > todayUtc) { throw new BadRequestException('birthDate cannot be in the future.'); } return normalized; From f10dc2c0393748f0c730984694ea08f9398b018a Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 00:52:00 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(user):=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=A0=95=EA=B7=9C=EC=8B=9D=20010-XXXX-XXXX=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 ^[0-9-]+\$ + 7~20자 정책은 '01-2-3' 같은 비정상 패턴도 통과시켜 사실상 비검증 상태였음. 기획 figma 명세에 맞춰 010만 13자 고정 규칙으로 강화한다. - PHONE_REGEX = /^010-\\d{4}-\\d{4}\$/ 상수 추가 - PHONE_FORMAT_EXAMPLE 예시 문자열 상수 추가 - MIN_PHONE_LENGTH/MAX_PHONE_LENGTH 제거 (정규식으로 대체) - normalizePhoneNumber: 정규식 단일 검증으로 단순화 + 명확한 에러 메시지 - 회귀 테스트: 정상 3건 + trim 1건 + 비정상 10건으로 보강 --- src/features/user/constants/user.constants.ts | 5 +-- .../user/services/user-base.service.spec.ts | 34 +++++++++++++------ .../user/services/user-base.service.ts | 16 ++++----- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/features/user/constants/user.constants.ts b/src/features/user/constants/user.constants.ts index 5c64aa7..bbabab9 100644 --- a/src/features/user/constants/user.constants.ts +++ b/src/features/user/constants/user.constants.ts @@ -5,8 +5,9 @@ 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'; // ── 생년월일 ── diff --git a/src/features/user/services/user-base.service.spec.ts b/src/features/user/services/user-base.service.spec.ts index 6590cd0..d5d639d 100644 --- a/src/features/user/services/user-base.service.spec.ts +++ b/src/features/user/services/user-base.service.spec.ts @@ -183,23 +183,35 @@ describe('UserBaseService (real DB)', () => { expect(service.testNormalizePhoneNumber(' ')).toBeNull(); }); - it('길이가 하한 미만이면 BadRequestException을 던진다', () => { - expect(() => service.testNormalizePhoneNumber('12345')).toThrow( - BadRequestException, + it.each([['010-0000-0000'], ['010-1234-5678'], ['010-9999-9999']])( + '정상 형식 %s 은 그대로 반환한다', + (raw) => { + expect(service.testNormalizePhoneNumber(raw)).toBe(raw); + }, + ); + + it('앞뒤 공백을 trim하여 검증한다', () => { + expect(service.testNormalizePhoneNumber(' 010-1234-5678 ')).toBe( + '010-1234-5678', ); }); - it('숫자와 하이픈 외 문자가 포함되면 BadRequestException을 던진다', () => { - expect(() => service.testNormalizePhoneNumber('010-abc-1234')).toThrow( + it.each([ + ['011-1234-5678'], // 010 prefix 외 + ['019-1234-5678'], // 010 prefix 외 + ['010-123-4567'], // 자릿수 부족 + ['010-12345-6789'], // 자릿수 초과 + ['01012345678'], // 하이픈 없음 + ['010 1234 5678'], // 공백 구분자 + ['+82-10-1234-5678'], // 국가코드 포함 + ['010-abc-1234'], // 문자 포함 + ['010--1234-5678'], // 하이픈 위치 잘못 + ['12345'], // 짧은 임의 문자열 + ])('비정상 형식 %s 은 BadRequestException을 던진다', (raw) => { + expect(() => service.testNormalizePhoneNumber(raw)).toThrow( BadRequestException, ); }); - - it('유효한 전화번호를 반환한다', () => { - expect(service.testNormalizePhoneNumber('010-1234-5678')).toBe( - '010-1234-5678', - ); - }); }); describe('normalizeBirthDate', () => { diff --git a/src/features/user/services/user-base.service.ts b/src/features/user/services/user-base.service.ts index 920512b..dd8709b 100644 --- a/src/features/user/services/user-base.service.ts +++ b/src/features/user/services/user-base.service.ts @@ -9,10 +9,10 @@ import { DEFAULT_PAGINATION_LIMIT, MAX_NICKNAME_LENGTH, MAX_PAGINATION_LIMIT, - MAX_PHONE_LENGTH, MIN_BIRTH_DATE, MIN_NICKNAME_LENGTH, - MIN_PHONE_LENGTH, + PHONE_FORMAT_EXAMPLE, + PHONE_REGEX, } from '@/features/user/constants/user.constants'; import type { UserAccountWithProfile } from '@/features/user/repositories/user.repository'; import { UserRepository } from '@/features/user/repositories/user.repository'; @@ -90,14 +90,10 @@ export abstract class UserBaseService { if (raw === undefined || raw === null) return null; const trimmed = raw.trim(); if (trimmed.length === 0) return null; - if ( - trimmed.length < MIN_PHONE_LENGTH || - trimmed.length > MAX_PHONE_LENGTH - ) { - throw new BadRequestException('Invalid phone number length.'); - } - if (!/^[0-9-]+$/.test(trimmed)) { - throw new BadRequestException('Invalid phone number format.'); + if (!PHONE_REGEX.test(trimmed)) { + throw new BadRequestException( + `Invalid phone number format. Expected ${PHONE_FORMAT_EXAMPLE}.`, + ); } return trimmed; } From 4e7a443c21d489b79675591f98a54982133ae331 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 01:00:41 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat(user):=20=ED=9A=8C=EC=9B=90=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=88=98=EC=A0=95=EC=97=90=20name=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20+=20=ED=95=84=EC=88=98?= =?UTF-8?q?=EA=B0=92=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이페이지 figma 명세에 따라 회원정보 수정 화면에서 이름 변경을 지원한다. '필수값' 표시에 따라 전송 시 trim 후 빈 문자열은 reject. - SDL UpdateMyProfileInput에 name: String 추가 - TS DTO 동기화 - service: hasName 분기 + normalizeName 검증 + repository 호출 - repository: updateProfile args에 name 추가, account/user_profile 양쪽 update를 transaction으로 묶어 부분 실패 방지 - 회귀 테스트 6건 (name 단독/trim/빈값/공백/동시업데이트/미지정 시 유지) --- .../user/repositories/user.repository.ts | 39 ++++++-- .../services/user-profile.service.spec.ts | 92 +++++++++++++++++++ .../user/services/user-profile.service.ts | 14 ++- src/features/user/types/user-input.type.ts | 1 + src/features/user/user-profile.graphql | 2 + 5 files changed, 138 insertions(+), 10 deletions(-) diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 5d4739d..d917fed 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -96,18 +96,39 @@ export class UserRepository { async updateProfile(args: { accountId: bigint; nickname?: string; + name?: string; birthDate?: Date | null; phoneNumber?: string | null; }): Promise { - 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 } + : {}), + }, + }); + } }); } diff --git a/src/features/user/services/user-profile.service.spec.ts b/src/features/user/services/user-profile.service.spec.ts index e5af413..f0711c8 100644 --- a/src/features/user/services/user-profile.service.spec.ts +++ b/src/features/user/services/user-profile.service.spec.ts @@ -222,6 +222,98 @@ describe('UserProfileService (real DB)', () => { service.updateMyProfile(me.id, { nickname: 'taken' }), ).rejects.toThrow(ConflictException); }); + + it('name만 단독 업데이트하면 Account.name이 갱신된다', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '구이름', + }); + await createUserProfile(prisma, { account_id: account.id }); + + const result = await service.updateMyProfile(account.id, { + name: '새이름', + }); + + expect(result.name).toBe('새이름'); + const saved = await prisma.account.findUniqueOrThrow({ + where: { id: account.id }, + }); + expect(saved.name).toBe('새이름'); + }); + + it('name 입력 시 trim 후 저장한다', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '구이름', + }); + await createUserProfile(prisma, { account_id: account.id }); + + const result = await service.updateMyProfile(account.id, { + name: ' 홍길동 ', + }); + + expect(result.name).toBe('홍길동'); + }); + + it('name이 빈 문자열이면 BadRequestException', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '구이름', + }); + await createUserProfile(prisma, { account_id: account.id }); + + await expect( + service.updateMyProfile(account.id, { name: '' }), + ).rejects.toThrow(BadRequestException); + }); + + it('name이 공백-only이면 BadRequestException', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '구이름', + }); + await createUserProfile(prisma, { account_id: account.id }); + + await expect( + service.updateMyProfile(account.id, { name: ' ' }), + ).rejects.toThrow(BadRequestException); + }); + + it('name + nickname 동시 업데이트 시 둘 다 반영된다', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '구이름', + }); + await createUserProfile(prisma, { + account_id: account.id, + nickname: 'oldNick', + }); + + const result = await service.updateMyProfile(account.id, { + name: '새이름', + nickname: 'newNick', + }); + + expect(result.name).toBe('새이름'); + expect(result.profile.nickname).toBe('newNick'); + }); + + it('name 미지정 시 기존 Account.name이 유지된다', async () => { + const account = await createAccount(prisma, { + account_type: 'USER', + name: '유지될이름', + }); + await createUserProfile(prisma, { account_id: account.id }); + + await service.updateMyProfile(account.id, { + nickname: 'newNick', + }); + + const saved = await prisma.account.findUniqueOrThrow({ + where: { id: account.id }, + }); + expect(saved.name).toBe('유지될이름'); + }); }); // ─── updateMyProfileImage ─── diff --git a/src/features/user/services/user-profile.service.ts b/src/features/user/services/user-profile.service.ts index 6c32d01..9564388 100644 --- a/src/features/user/services/user-profile.service.ts +++ b/src/features/user/services/user-profile.service.ts @@ -73,10 +73,11 @@ export class UserProfileService extends UserBaseService { await this.requireActiveUser(accountId); const hasNickname = input.nickname !== undefined; + const hasName = input.name !== undefined; const hasBirthDate = input.birthDate !== undefined; const hasPhoneNumber = input.phoneNumber !== undefined; - if (!hasNickname && !hasBirthDate && !hasPhoneNumber) { + if (!hasNickname && !hasName && !hasBirthDate && !hasPhoneNumber) { throw new BadRequestException('No fields to update.'); } @@ -89,6 +90,16 @@ export class UserProfileService extends UserBaseService { if (isTaken) throw new ConflictException('Nickname already exists.'); } + // figma 명세: 이름은 필수값. 전송되었지만 trim 후 빈 문자열이면 reject. + let name: string | undefined = undefined; + if (hasName) { + const normalized = this.normalizeName(input.name); + if (!normalized) { + throw new BadRequestException('Name cannot be empty.'); + } + name = normalized; + } + const birthDate = hasBirthDate ? this.normalizeBirthDate(input.birthDate) : undefined; @@ -99,6 +110,7 @@ export class UserProfileService extends UserBaseService { await this.repo.updateProfile({ accountId, ...(hasNickname ? { nickname } : {}), + ...(hasName ? { name } : {}), ...(hasBirthDate ? { birthDate } : {}), ...(hasPhoneNumber ? { phoneNumber } : {}), }); diff --git a/src/features/user/types/user-input.type.ts b/src/features/user/types/user-input.type.ts index 3fc9ce9..d0edb35 100644 --- a/src/features/user/types/user-input.type.ts +++ b/src/features/user/types/user-input.type.ts @@ -7,6 +7,7 @@ export interface CompleteOnboardingInput { export interface UpdateMyProfileInput { nickname?: string | null; + name?: string | null; birthDate?: Date | null; phoneNumber?: string | null; } diff --git a/src/features/user/user-profile.graphql b/src/features/user/user-profile.graphql index f4865ac..a52b9f5 100644 --- a/src/features/user/user-profile.graphql +++ b/src/features/user/user-profile.graphql @@ -62,6 +62,8 @@ input CompleteOnboardingInput { input UpdateMyProfileInput { """닉네임""" nickname: String + """이름(전송 시 trim 후 비어있으면 reject)""" + name: String """생년월일""" birthDate: DateTime """전화번호""" From e8c4717f891ab4084356d0adfda3ea026cd663fe Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 01:06:18 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat(user):=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=AF=B8=EB=94=94=EC=96=B4=20=EB=B6=84=EB=A6=AC=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20(=EC=82=AC=EC=A7=84=2010=20/=20=EB=8F=99=EC=98=81?= =?UTF-8?q?=EC=83=81=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 정책은 IMAGE/VIDEO 합산 10개였으나, 마이페이지 figma 명세에 따라 사진 10장 / 동영상 1개로 분리한다 (총 11개까지 허용). - MAX_IMAGE_COUNT(10) / MAX_VIDEO_COUNT(1) 상수 분리 - validateMedia: 단일 카운트에서 type별 카운트로 변경 - 에러 메시지 분리: TOO_MANY_MEDIA → TOO_MANY_IMAGES, TOO_MANY_VIDEOS - 회귀 테스트 4건 (총 11개 통과 / 사진 11 reject / 동영상 2 reject / 동영상 단독 통과) --- .../constants/user-review-error-messages.ts | 3 +- .../user/services/user-review.service.spec.ts | 66 ++++++++++++++++++- .../user/services/user-review.service.ts | 20 +++++- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/features/user/constants/user-review-error-messages.ts b/src/features/user/constants/user-review-error-messages.ts index 23d2863..ceb2c32 100644 --- a/src/features/user/constants/user-review-error-messages.ts +++ b/src/features/user/constants/user-review-error-messages.ts @@ -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: '이미 리뷰가 작성된 주문 아이템입니다.', diff --git a/src/features/user/services/user-review.service.spec.ts b/src/features/user/services/user-review.service.spec.ts index 4c8daf7..aede91b 100644 --- a/src/features/user/services/user-review.service.spec.ts +++ b/src/features/user/services/user-review.service.spec.ts @@ -175,7 +175,30 @@ describe('UserReviewService (real DB)', () => { ).rejects.toThrow(BadRequestException); }); - it('media가 10개를 초과하면 BadRequestException', async () => { + it('사진 10장 + 동영상 1개(총 11개)는 통과한다', async () => { + const ctx = await setupReviewableOrderItem(); + await expect( + service.writeReview(ctx.accountId, { + orderItemId: ctx.orderItemId.toString(), + rating: 5, + content: VALID_CONTENT, + media: [ + ...Array.from({ length: 10 }, (_, i) => ({ + mediaType: 'IMAGE' as const, + mediaUrl: `https://s3.example.com/${i}.jpg`, + sortOrder: i, + })), + { + mediaType: 'VIDEO' as const, + mediaUrl: 'https://s3.example.com/v.mp4', + sortOrder: 10, + }, + ], + }), + ).resolves.toBeDefined(); + }); + + it('사진 11장이면 BadRequestException (TOO_MANY_IMAGES)', async () => { const ctx = await setupReviewableOrderItem(); await expect( service.writeReview(ctx.accountId, { @@ -191,6 +214,47 @@ describe('UserReviewService (real DB)', () => { ).rejects.toThrow(BadRequestException); }); + it('동영상 2개면 BadRequestException (TOO_MANY_VIDEOS)', async () => { + const ctx = await setupReviewableOrderItem(); + await expect( + service.writeReview(ctx.accountId, { + orderItemId: ctx.orderItemId.toString(), + rating: 5, + content: VALID_CONTENT, + media: [ + { + mediaType: 'VIDEO' as const, + mediaUrl: 'https://s3.example.com/v1.mp4', + sortOrder: 0, + }, + { + mediaType: 'VIDEO' as const, + mediaUrl: 'https://s3.example.com/v2.mp4', + sortOrder: 1, + }, + ], + }), + ).rejects.toThrow(BadRequestException); + }); + + it('사진 0 + 동영상 1개만 있어도 통과한다', async () => { + const ctx = await setupReviewableOrderItem(); + await expect( + service.writeReview(ctx.accountId, { + orderItemId: ctx.orderItemId.toString(), + rating: 5, + content: VALID_CONTENT, + media: [ + { + mediaType: 'VIDEO' as const, + mediaUrl: 'https://s3.example.com/v.mp4', + sortOrder: 0, + }, + ], + }), + ).resolves.toBeDefined(); + }); + it('orderItem이 본인 소유가 아니면 NotFoundException', async () => { const me = await createAccount(prisma, { account_type: 'USER' }); await createUserProfile(prisma, { account_id: me.id }); diff --git a/src/features/user/services/user-review.service.ts b/src/features/user/services/user-review.service.ts index 0981cf4..ebea84a 100644 --- a/src/features/user/services/user-review.service.ts +++ b/src/features/user/services/user-review.service.ts @@ -46,7 +46,9 @@ interface ReviewRow { const MIN_CONTENT_LENGTH = 20; const MAX_CONTENT_LENGTH = 1000; -const MAX_MEDIA_COUNT = 10; +// figma 명세: 사진 최대 10장 / 동영상 1개. 합쳐서 최대 11개까지 허용. +const MAX_IMAGE_COUNT = 10; +const MAX_VIDEO_COUNT = 1; const MAX_LIMIT = 50; @Injectable() @@ -230,8 +232,20 @@ export class UserReviewService { private validateMedia( media?: { mediaType: string; mediaUrl: string }[], ): void { - if (media && media.length > MAX_MEDIA_COUNT) { - throw new BadRequestException(USER_REVIEW_ERRORS.TOO_MANY_MEDIA); + if (!media || media.length === 0) return; + + let imageCount = 0; + let videoCount = 0; + for (const m of media) { + if (m.mediaType === 'VIDEO') videoCount++; + else imageCount++; + } + + if (imageCount > MAX_IMAGE_COUNT) { + throw new BadRequestException(USER_REVIEW_ERRORS.TOO_MANY_IMAGES); + } + if (videoCount > MAX_VIDEO_COUNT) { + throw new BadRequestException(USER_REVIEW_ERRORS.TOO_MANY_VIDEOS); } } From 75a36e8a598fd09cdc2c4103dc7a779779c58eb5 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 01:15:03 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat(user):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EC=97=90=20hasReviewableItem=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이페이지 figma 04-order-list 명세 '픽업 완료 상태일 경우 리뷰 작성 버튼 노출'을 백엔드 카드 단위로 노출 가능하도록 hasReviewableItem 필드를 추가한다. 조건: status === PICKED_UP && (active 리뷰 미작성 item이 1건이라도 존재). - SDL MyOrderSummary에 hasReviewableItem: Boolean! 추가 - TS DTO 동기화 - OrderRepository.findReviewableOrderIds 메서드 신규 (단일 IN 쿼리, N+1 회피) - user-order.service에서 list 매핑 시 set 조회 후 hasReviewableItem 계산 - 회귀 테스트 6건 (미작성/active리뷰/soft-delete/CONFIRMED/CANCELED/혼합) --- .../order/repositories/order.repository.ts | 32 ++++ .../user/services/user-order.service.spec.ts | 142 ++++++++++++++++++ .../user/services/user-order.service.ts | 8 + .../user/types/user-order-output.type.ts | 1 + src/features/user/user-order.graphql | 2 + 5 files changed, 185 insertions(+) diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index d252967..81d9d43 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -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> { + 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: { diff --git a/src/features/user/services/user-order.service.spec.ts b/src/features/user/services/user-order.service.spec.ts index 5029e14..7d86efa 100644 --- a/src/features/user/services/user-order.service.spec.ts +++ b/src/features/user/services/user-order.service.spec.ts @@ -171,6 +171,148 @@ describe('UserOrderService (real DB)', () => { service.listMyOrders(account.id, { limit: 51 }), ).rejects.toThrow(BadRequestException); }); + + // ─── hasReviewableItem ─── + describe('hasReviewableItem', () => { + async function createPickedUpOrderWithItem(accountId: bigint) { + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: accountId, + status: 'PICKED_UP', + }); + const item = await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + return { store, product, order, item }; + } + + async function createReview(args: { + orderItemId: bigint; + accountId: bigint; + storeId: bigint; + productId: bigint; + deletedAt?: Date | null; + }) { + return prisma.review.create({ + data: { + order_item_id: args.orderItemId, + account_id: args.accountId, + store_id: args.storeId, + product_id: args.productId, + rating: 5, + content: '리뷰 더미 텍스트입니다. 만족합니다.', + deleted_at: args.deletedAt ?? null, + }, + }); + } + + it('PICKED_UP + 리뷰 미작성 item이 1건이면 true', async () => { + const account = await setupUser(); + await createPickedUpOrderWithItem(account.id); + + const result = await service.listMyOrders(account.id); + + expect(result.items).toHaveLength(1); + expect(result.items[0].hasReviewableItem).toBe(true); + }); + + it('PICKED_UP + 모든 item에 active 리뷰가 있으면 false', async () => { + const account = await setupUser(); + const { item, store, product } = await createPickedUpOrderWithItem( + account.id, + ); + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('PICKED_UP + 모든 item의 리뷰가 soft-delete면 true', async () => { + const account = await setupUser(); + const { item, store, product } = await createPickedUpOrderWithItem( + account.id, + ); + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + deletedAt: new Date(), + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(true); + }); + + it('CONFIRMED 등 비-PICKED_UP 상태는 false', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: account.id, + status: 'CONFIRMED', + }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].status).toBe('CONFIRMED'); + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('CANCELED 상태는 false', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + const order = await createOrder(prisma, { + account_id: account.id, + status: 'CANCELED', + }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].status).toBe('CANCELED'); + expect(result.items[0].hasReviewableItem).toBe(false); + }); + + it('PICKED_UP + 일부 item에만 리뷰 미작성이면 true (혼합 케이스)', async () => { + const account = await setupUser(); + const { item, store, product, order } = + await createPickedUpOrderWithItem(account.id); + // item1에는 리뷰가 있고, item2에는 없음 + await createReview({ + orderItemId: item.id, + accountId: account.id, + storeId: store.id, + productId: product.id, + }); + const product2 = await createProduct(prisma, { store_id: store.id }); + await createOrderItem(prisma, { + order_id: order.id, + product_id: product2.id, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].hasReviewableItem).toBe(true); + }); + }); }); // ─── getMyOrder ─── diff --git a/src/features/user/services/user-order.service.ts b/src/features/user/services/user-order.service.ts index dc7cf25..594165c 100644 --- a/src/features/user/services/user-order.service.ts +++ b/src/features/user/services/user-order.service.ts @@ -51,6 +51,13 @@ export class UserOrderService { const hasMore = orders.length > limit; const sliced = hasMore ? orders.slice(0, limit) : orders; + // N+1 회피: PICKED_UP + 미작성 리뷰가 있는 order id 집합을 단일 IN 쿼리로 조회 + const reviewableOrderIds = + await this.orderRepository.findReviewableOrderIds({ + accountId, + orderIds: sliced.map((o) => o.id), + }); + return { items: sliced.map((order) => { const firstItem = order.items[0]; @@ -69,6 +76,7 @@ export class UserOrderService { additionalItemCount: Math.max(0, itemCount - 1), totalPrice: order.total_price, storeName: firstItem?.store?.store_name ?? '매장 정보 없음', + hasReviewableItem: reviewableOrderIds.has(order.id.toString()), }; }), totalCount, diff --git a/src/features/user/types/user-order-output.type.ts b/src/features/user/types/user-order-output.type.ts index 4013c1f..76ee5db 100644 --- a/src/features/user/types/user-order-output.type.ts +++ b/src/features/user/types/user-order-output.type.ts @@ -11,6 +11,7 @@ export interface MyOrderSummary { additionalItemCount: number; totalPrice: number; storeName: string; + hasReviewableItem: boolean; } export interface MyOrderConnection { diff --git a/src/features/user/user-order.graphql b/src/features/user/user-order.graphql index 0ed313a..41025f7 100644 --- a/src/features/user/user-order.graphql +++ b/src/features/user/user-order.graphql @@ -37,6 +37,8 @@ type MyOrderSummary { totalPrice: Int! """매장명""" storeName: String! + """주문 내 OrderItem 중 리뷰 작성 가능한 것이 1건이라도 있는지 (PICKED_UP + 활성 리뷰 미존재)""" + hasReviewableItem: Boolean! } """주문 상세""" From 0779ac9dbee355d4351437c88760a7eb1faa1194 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 01:22:02 +0900 Subject: [PATCH 08/11] =?UTF-8?q?test(user):=20listMyOrders=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=200=EA=B1=B4=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orderRepository.findReviewableOrderIds의 빈 배열 early return 분기 커버. codecov patch coverage가 100%가 되도록 보강. --- src/features/user/services/user-order.service.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/features/user/services/user-order.service.spec.ts b/src/features/user/services/user-order.service.spec.ts index 7d86efa..6cf9019 100644 --- a/src/features/user/services/user-order.service.spec.ts +++ b/src/features/user/services/user-order.service.spec.ts @@ -172,6 +172,16 @@ describe('UserOrderService (real DB)', () => { ).rejects.toThrow(BadRequestException); }); + it('주문이 0건이면 빈 connection을 반환한다', async () => { + const account = await setupUser(); + + const result = await service.listMyOrders(account.id); + + expect(result.totalCount).toBe(0); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + }); + // ─── hasReviewableItem ─── describe('hasReviewableItem', () => { async function createPickedUpOrderWithItem(accountId: bigint) { From 9a0f2d75af6e159fe548e379651b4a8e03dd0bbf Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 01:35:35 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat(user):=20=EC=B0=9C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20mutation=20+=20isWishlisted=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?+=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=9D=BC=EA=B4=80=EC=84=B1?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이페이지 figma 02-main-login의 찜 기능을 백엔드에 도입한다. 하트 버튼 클릭 시 추가/해제하는 멱등 mutation과 목록 조회, 그리고 최근 본 상품에 isWishlisted 매핑까지 함께 반영한다. ## 변경 사항 - SDL 신규: user-wishlist.graphql (myWishlist / addToWishlist / removeFromWishlist) - SDL 갱신: RecentViewedProductSummary.isWishlisted: Boolean! - 신규 service: UserWishlistService (UserBaseService 상속) - 신규 resolver: UserWishlistQueryResolver / UserWishlistMutationResolver - UserRepository: upsertWishlistItem / softDeleteWishlistItem / findWishlistedProductIds (단일 IN 쿼리, N+1 회피) / findWishlistItems - ProductRepository: existsActiveProduct (active+soft-delete 검증, 재사용 가능한 가벼운 헬퍼) - 카운트 일관성 버그 수정: countWishlistItems / getViewerCounts.wishlistCount에 deleted_at: null 필터 누락 → soft-delete된 위시리스트도 카운트에 포함되던 문제 수정 - user-mypage.service / user-recent-view.service: 매핑 시 isWishlisted 계산 (findWishlistedProductIds set으로 일괄 조회, N+1 회피) - 회귀 테스트 다수 - UserWishlistService spec 15건 - UserWishlistResolver spec 2건 (NotFound 전파 / 추가→목록→해제 시나리오) - mypage spec: recentViewedProducts.isWishlisted 매핑 검증 보강 - recent-view spec: list isWishlisted 매핑 검증 추가 --- .../repositories/product.repository.ts | 17 + .../constants/user-wishlist-error-messages.ts | 5 + .../user/repositories/user.repository.ts | 126 ++++++- .../user-recent-view.resolver.spec.ts | 2 + .../user-wishlist-mutation.resolver.ts | 35 ++ .../resolvers/user-wishlist-query.resolver.ts | 28 ++ .../resolvers/user-wishlist.resolver.spec.ts | 88 +++++ .../user/services/user-mypage.service.spec.ts | 32 ++ .../user/services/user-mypage.service.ts | 8 + .../services/user-recent-view.service.spec.ts | 28 ++ .../user/services/user-recent-view.service.ts | 9 + .../services/user-wishlist.service.spec.ts | 309 ++++++++++++++++++ .../user/services/user-wishlist.service.ts | 101 ++++++ .../user/types/user-mypage-output.type.ts | 1 + .../user/types/user-wishlist-output.type.ts | 20 ++ src/features/user/user-mypage.graphql | 2 + src/features/user/user-wishlist.graphql | 32 ++ src/features/user/user.module.ts | 6 + 18 files changed, 848 insertions(+), 1 deletion(-) create mode 100644 src/features/user/constants/user-wishlist-error-messages.ts create mode 100644 src/features/user/resolvers/user-wishlist-mutation.resolver.ts create mode 100644 src/features/user/resolvers/user-wishlist-query.resolver.ts create mode 100644 src/features/user/resolvers/user-wishlist.resolver.spec.ts create mode 100644 src/features/user/services/user-wishlist.service.spec.ts create mode 100644 src/features/user/services/user-wishlist.service.ts create mode 100644 src/features/user/types/user-wishlist-output.type.ts create mode 100644 src/features/user/user-wishlist.graphql diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 8c3dc41..22c10ba 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -78,6 +78,23 @@ export class ProductRepository { }); } + /** + * active product가 존재하는지(soft-delete 아님 + 매장도 active/soft-delete 아님) 가벼운 검증. + * 판매 가능한 상품인지 확인하는 용도. 다른 도메인(wishlist, cart 등)에서 활용. + */ + async existsActiveProduct(productId: bigint): Promise { + 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: { diff --git a/src/features/user/constants/user-wishlist-error-messages.ts b/src/features/user/constants/user-wishlist-error-messages.ts new file mode 100644 index 0000000..70ccf22 --- /dev/null +++ b/src/features/user/constants/user-wishlist-error-messages.ts @@ -0,0 +1,5 @@ +export const USER_WISHLIST_ERRORS = { + PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.', + INVALID_OFFSET: '오프셋은 0 이상이어야 합니다.', + INVALID_LIMIT: '조회 개수는 1~50 사이여야 합니다.', +} as const; diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index d917fed..38f48ea 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -199,6 +199,7 @@ export class UserRepository { this.prisma.wishlistItem.count({ where: { account_id: accountId, + deleted_at: null, }, }), ]); @@ -368,10 +369,133 @@ export class UserRepository { async countWishlistItems(accountId: bigint): Promise { return this.prisma.wishlistItem.count({ - where: { account_id: accountId }, + where: { account_id: accountId, deleted_at: null }, + }); + } + + /** + * 찜 추가 (멱등). 이미 있으면 그대로, soft-delete된 경우 deleted_at=null로 복원. + */ + async upsertWishlistItem(args: { + accountId: bigint; + productId: bigint; + now: Date; + }): Promise { + 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 { + 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> { + 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 = { + account_id: args.accountId, + deleted_at: null, + product: { + deleted_at: null, + is_active: true, + store: { deleted_at: null, is_active: true }, + }, + }; + + 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 { return this.prisma.review.count({ where: { account_id: accountId }, diff --git a/src/features/user/resolvers/user-recent-view.resolver.spec.ts b/src/features/user/resolvers/user-recent-view.resolver.spec.ts index d835208..df74cc0 100644 --- a/src/features/user/resolvers/user-recent-view.resolver.spec.ts +++ b/src/features/user/resolvers/user-recent-view.resolver.spec.ts @@ -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'; @@ -33,6 +34,7 @@ describe('User Recent View Resolvers (real DB)', () => { UserRecentViewService, RecentProductViewRepository, ProductRepository, + UserRepository, ], }); diff --git a/src/features/user/resolvers/user-wishlist-mutation.resolver.ts b/src/features/user/resolvers/user-wishlist-mutation.resolver.ts new file mode 100644 index 0000000..a11d57d --- /dev/null +++ b/src/features/user/resolvers/user-wishlist-mutation.resolver.ts @@ -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 { + return this.wishlistService.addToWishlist(parseAccountId(user), productId); + } + + @Mutation('removeFromWishlist') + removeFromWishlist( + @CurrentUser() user: JwtUser, + @Args('productId') productId: string, + ): Promise { + return this.wishlistService.removeFromWishlist( + parseAccountId(user), + productId, + ); + } +} diff --git a/src/features/user/resolvers/user-wishlist-query.resolver.ts b/src/features/user/resolvers/user-wishlist-query.resolver.ts new file mode 100644 index 0000000..baf8b9a --- /dev/null +++ b/src/features/user/resolvers/user-wishlist-query.resolver.ts @@ -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 { + return this.wishlistService.myWishlist(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 new file mode 100644 index 0000000..ae2a04b --- /dev/null +++ b/src/features/user/resolvers/user-wishlist.resolver.spec.ts @@ -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([]); + }); +}); diff --git a/src/features/user/services/user-mypage.service.spec.ts b/src/features/user/services/user-mypage.service.spec.ts index 9efea63..902760e 100644 --- a/src/features/user/services/user-mypage.service.spec.ts +++ b/src/features/user/services/user-mypage.service.spec.ts @@ -237,7 +237,39 @@ describe('UserMypageService (real DB)', () => { storeName: '케이크샵', regularPrice: 40000, salePrice: 35000, + isWishlisted: false, }); }); + + it('recentViewedProducts에 찜한 상품은 isWishlisted=true로 매핑된다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const wishlisted = await createProduct(prisma, { store_id: store.id }); + const notWishlisted = await createProduct(prisma, { store_id: store.id }); + await createRecentProductView(prisma, { + account_id: account.id, + product_id: wishlisted.id, + }); + await createRecentProductView(prisma, { + account_id: account.id, + product_id: notWishlisted.id, + }); + await prisma.wishlistItem.create({ + data: { account_id: account.id, product_id: wishlisted.id }, + }); + // 다른 계정 찜 + 본인 soft-delete 찜은 isWishlisted에 영향 안 줌 + const other = await setupUser(); + await prisma.wishlistItem.create({ + data: { account_id: other.id, product_id: notWishlisted.id }, + }); + + const result = await service.getOverview(account.id); + + const map = new Map( + result.recentViewedProducts.map((p) => [p.productId, p.isWishlisted]), + ); + expect(map.get(wishlisted.id.toString())).toBe(true); + expect(map.get(notWishlisted.id.toString())).toBe(false); + }); }); }); diff --git a/src/features/user/services/user-mypage.service.ts b/src/features/user/services/user-mypage.service.ts index 62c47f1..947449a 100644 --- a/src/features/user/services/user-mypage.service.ts +++ b/src/features/user/services/user-mypage.service.ts @@ -45,6 +45,13 @@ export class UserMypageService { ), ]); + // N+1 회피: 최근 본 상품 productId 묶음으로 단일 IN 쿼리로 찜 여부 조회 + const wishlistedProductIds = + await this.userRepository.findWishlistedProductIds({ + accountId, + productIds: recentViews.map((v) => v.product_id), + }); + return { counts: { customDraftCount, @@ -76,6 +83,7 @@ export class UserMypageService { regularPrice: view.product.regular_price, storeName: view.product.store.store_name, viewedAt: view.viewed_at, + isWishlisted: wishlistedProductIds.has(view.product_id.toString()), })), }; } diff --git a/src/features/user/services/user-recent-view.service.spec.ts b/src/features/user/services/user-recent-view.service.spec.ts index dd76e97..b8eabaa 100644 --- a/src/features/user/services/user-recent-view.service.spec.ts +++ b/src/features/user/services/user-recent-view.service.spec.ts @@ -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 { UserRecentViewService } from '@/features/user/services/user-recent-view.service'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; @@ -26,6 +27,7 @@ describe('UserRecentViewService (real DB)', () => { UserRecentViewService, RecentProductViewRepository, ProductRepository, + UserRepository, ], }); @@ -87,6 +89,32 @@ describe('UserRecentViewService (real DB)', () => { expect(result.items[1].salePrice).toBe(9000); }); + it('찜한 상품은 isWishlisted=true, 안 한 상품은 false로 매핑된다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const store = await createStore(prisma); + const wishlisted = await createProduct(prisma, { store_id: store.id }); + const notWishlisted = await createProduct(prisma, { store_id: store.id }); + await createRecentProductView(prisma, { + account_id: account.id, + product_id: wishlisted.id, + }); + await createRecentProductView(prisma, { + account_id: account.id, + product_id: notWishlisted.id, + }); + await prisma.wishlistItem.create({ + data: { account_id: account.id, product_id: wishlisted.id }, + }); + + const result = await service.list(account.id); + + const map = new Map( + result.items.map((p) => [p.productId, p.isWishlisted]), + ); + expect(map.get(wishlisted.id.toString())).toBe(true); + expect(map.get(notWishlisted.id.toString())).toBe(false); + }); + it('pagination: offset + limit < totalCount면 hasMore true', async () => { const account = await createAccount(prisma, { account_type: 'USER' }); const store = await createStore(prisma); diff --git a/src/features/user/services/user-recent-view.service.ts b/src/features/user/services/user-recent-view.service.ts index 4af9cce..163ce4a 100644 --- a/src/features/user/services/user-recent-view.service.ts +++ b/src/features/user/services/user-recent-view.service.ts @@ -7,6 +7,7 @@ import { import { parseId } from '@/common/utils/id-parser'; 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 type { RecentViewedProductConnection } from '@/features/user/types/user-mypage-output.type'; /** 계정당 최대 보관 개수 */ @@ -23,6 +24,7 @@ export class UserRecentViewService { constructor( private readonly recentViewRepo: RecentProductViewRepository, private readonly productRepo: ProductRepository, + private readonly userRepo: UserRepository, ) {} async list( @@ -46,6 +48,12 @@ export class UserRecentViewService { limit, }); + // N+1 회피: 단일 IN 쿼리로 찜 여부 조회 + const wishlistedProductIds = await this.userRepo.findWishlistedProductIds({ + accountId, + productIds: items.map((v) => v.product_id), + }); + return { items: items.map((view) => ({ productId: view.product_id.toString(), @@ -55,6 +63,7 @@ export class UserRecentViewService { regularPrice: view.product.regular_price, storeName: view.product.store.store_name, viewedAt: view.viewed_at, + isWishlisted: wishlistedProductIds.has(view.product_id.toString()), })), totalCount, hasMore: offset + limit < totalCount, diff --git a/src/features/user/services/user-wishlist.service.spec.ts b/src/features/user/services/user-wishlist.service.spec.ts new file mode 100644 index 0000000..9667d2b --- /dev/null +++ b/src/features/user/services/user-wishlist.service.spec.ts @@ -0,0 +1,309 @@ +import { BadRequestException, 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 { 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('UserWishlistService (real DB)', () => { + let service: UserWishlistService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [UserWishlistService, UserRepository, ProductRepository], + }); + service = module.get(UserWishlistService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function setupUser() { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + return account; + } + + // ─── addToWishlist ─── + describe('addToWishlist', () => { + it('처음 추가 시 wishlistItem row가 생성된다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + + const result = await service.addToWishlist( + account.id, + product.id.toString(), + ); + + expect(result).toBe(true); + const row = await prisma.wishlistItem.findUnique({ + where: { + account_id_product_id: { + account_id: account.id, + product_id: product.id, + }, + }, + }); + expect(row).not.toBeNull(); + expect(row?.deleted_at).toBeNull(); + }); + + it('이미 active 상태로 있으면 멱등 (true 반환, 추가 row 없음)', 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()); + 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); + }); + + it('soft-delete된 row가 있으면 deleted_at=null로 복원된다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + + await prisma.wishlistItem.create({ + data: { + account_id: account.id, + product_id: product.id, + deleted_at: new Date(), + }, + }); + + await service.addToWishlist(account.id, product.id.toString()); + + const row = await prisma.wishlistItem.findUnique({ + where: { + account_id_product_id: { + account_id: account.id, + product_id: product.id, + }, + }, + }); + expect(row?.deleted_at).toBeNull(); + }); + + it('존재하지 않는 productId면 NotFoundException', async () => { + const account = await setupUser(); + await expect(service.addToWishlist(account.id, '999999')).rejects.toThrow( + NotFoundException, + ); + }); + + it('비활성 product면 NotFoundException', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { + store_id: store.id, + is_active: false, + }); + + await expect( + service.addToWishlist(account.id, product.id.toString()), + ).rejects.toThrow(NotFoundException); + }); + + it('soft-delete된 product면 NotFoundException', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + await prisma.product.update({ + where: { id: product.id }, + data: { deleted_at: new Date() }, + }); + + await expect( + service.addToWishlist(account.id, product.id.toString()), + ).rejects.toThrow(NotFoundException); + }); + + it('비활성 store에 속한 product면 NotFoundException', async () => { + const account = await setupUser(); + const store = await createStore(prisma, { is_active: false }); + const product = await createProduct(prisma, { store_id: store.id }); + + await expect( + service.addToWishlist(account.id, product.id.toString()), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ─── removeFromWishlist ─── + describe('removeFromWishlist', () => { + it('정상 soft-delete', 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 result = await service.removeFromWishlist( + account.id, + product.id.toString(), + ); + + expect(result).toBe(true); + const row = await prisma.wishlistItem.findUnique({ + where: { + account_id_product_id: { + account_id: account.id, + product_id: product.id, + }, + }, + }); + expect(row?.deleted_at).not.toBeNull(); + }); + + it('이미 없는 상품을 제거해도 멱등 (true 반환)', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const product = await createProduct(prisma, { store_id: store.id }); + + const result = await service.removeFromWishlist( + account.id, + product.id.toString(), + ); + + expect(result).toBe(true); + }); + }); + + // ─── myWishlist ─── + describe('myWishlist', () => { + it('자기 찜만 반환 + 추가 시각 desc 정렬', async () => { + const me = await setupUser(); + const other = await setupUser(); + const store = await createStore(prisma, { store_name: '매장A' }); + const p1 = await createProduct(prisma, { + store_id: store.id, + name: '상품1', + }); + const p2 = await createProduct(prisma, { + store_id: store.id, + name: '상품2', + }); + + await service.addToWishlist(me.id, p1.id.toString()); + await new Promise((r) => setTimeout(r, 10)); + await service.addToWishlist(me.id, p2.id.toString()); + await service.addToWishlist(other.id, p1.id.toString()); + + const result = await service.myWishlist(me.id); + + expect(result.totalCount).toBe(2); + expect(result.items).toHaveLength(2); + expect(result.items[0].productId).toBe(p2.id.toString()); // 최근 추가가 먼저 + expect(result.items[0].productName).toBe('상품2'); + expect(result.items[0].storeName).toBe('매장A'); + }); + + it('soft-delete된 wishlist 항목은 제외된다', 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()); + await service.removeFromWishlist(account.id, product.id.toString()); + + const result = await service.myWishlist(account.id); + + expect(result.totalCount).toBe(0); + expect(result.items).toEqual([]); + }); + + it('비활성/삭제된 product는 제외된다', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const activeProduct = await createProduct(prisma, { store_id: store.id }); + const inactiveProduct = await createProduct(prisma, { + store_id: store.id, + }); + const deletedProduct = await createProduct(prisma, { + store_id: store.id, + }); + + // active 상태에서 모두 찜 추가 + await prisma.wishlistItem.createMany({ + data: [ + { account_id: account.id, product_id: activeProduct.id }, + { account_id: account.id, product_id: inactiveProduct.id }, + { account_id: account.id, product_id: deletedProduct.id }, + ], + }); + // 이후 product 상태 변경 + await prisma.product.update({ + where: { id: inactiveProduct.id }, + data: { is_active: false }, + }); + await prisma.product.update({ + where: { id: deletedProduct.id }, + data: { deleted_at: new Date() }, + }); + + const result = await service.myWishlist(account.id); + + expect(result.totalCount).toBe(1); + expect(result.items[0].productId).toBe(activeProduct.id.toString()); + }); + + it('페이지네이션이 동작한다 (offset/limit/hasMore)', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + for (let i = 0; i < 5; i++) { + const p = await createProduct(prisma, { store_id: store.id }); + await service.addToWishlist(account.id, p.id.toString()); + } + + const page1 = await service.myWishlist(account.id, { + offset: 0, + limit: 2, + }); + expect(page1.totalCount).toBe(5); + expect(page1.items).toHaveLength(2); + expect(page1.hasMore).toBe(true); + + const page2 = await service.myWishlist(account.id, { + offset: 4, + limit: 2, + }); + expect(page2.items).toHaveLength(1); + expect(page2.hasMore).toBe(false); + }); + + it('offset 음수는 BadRequestException', async () => { + const account = await setupUser(); + await expect( + service.myWishlist(account.id, { offset: -1 }), + ).rejects.toThrow(BadRequestException); + }); + + it('limit이 50 초과면 BadRequestException', async () => { + const account = await setupUser(); + await expect( + service.myWishlist(account.id, { limit: 51 }), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/features/user/services/user-wishlist.service.ts b/src/features/user/services/user-wishlist.service.ts new file mode 100644 index 0000000..bf51870 --- /dev/null +++ b/src/features/user/services/user-wishlist.service.ts @@ -0,0 +1,101 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import { parseId } from '@/common/utils/id-parser'; +import { ProductRepository } from '@/features/product/repositories/product.repository'; +import { USER_WISHLIST_ERRORS } from '@/features/user/constants/user-wishlist-error-messages'; +import { + DEFAULT_PAGINATION_LIMIT, + MAX_PAGINATION_LIMIT, +} from '@/features/user/constants/user.constants'; +import { UserRepository } from '@/features/user/repositories/user.repository'; +import { UserBaseService } from '@/features/user/services/user-base.service'; +import type { + MyWishlistConnection, + MyWishlistInput, +} from '@/features/user/types/user-wishlist-output.type'; + +@Injectable() +export class UserWishlistService extends UserBaseService { + constructor( + repo: UserRepository, + private readonly productRepository: ProductRepository, + ) { + super(repo); + } + + async addToWishlist( + accountId: bigint, + productIdStr: string, + ): Promise { + await this.requireActiveUser(accountId); + const productId = parseId(productIdStr); + + const exists = await this.productRepository.existsActiveProduct(productId); + if (!exists) { + throw new NotFoundException(USER_WISHLIST_ERRORS.PRODUCT_NOT_FOUND); + } + + await this.repo.upsertWishlistItem({ + accountId, + productId, + now: new Date(), + }); + return true; + } + + async removeFromWishlist( + accountId: bigint, + productIdStr: string, + ): Promise { + await this.requireActiveUser(accountId); + const productId = parseId(productIdStr); + + await this.repo.softDeleteWishlistItem({ + accountId, + productId, + now: new Date(), + }); + return true; + } + + async myWishlist( + accountId: bigint, + input?: MyWishlistInput, + ): Promise { + await this.requireActiveUser(accountId); + + const offset = input?.offset ?? 0; + const limit = input?.limit ?? DEFAULT_PAGINATION_LIMIT; + + if (offset < 0) { + throw new BadRequestException(USER_WISHLIST_ERRORS.INVALID_OFFSET); + } + if (limit < 1 || limit > MAX_PAGINATION_LIMIT) { + throw new BadRequestException(USER_WISHLIST_ERRORS.INVALID_LIMIT); + } + + const { items, totalCount } = await this.repo.findWishlistItems({ + accountId, + offset, + limit, + }); + + 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, + })), + totalCount, + hasMore: offset + limit < totalCount, + }; + } +} diff --git a/src/features/user/types/user-mypage-output.type.ts b/src/features/user/types/user-mypage-output.type.ts index 7ee99ae..7608e3f 100644 --- a/src/features/user/types/user-mypage-output.type.ts +++ b/src/features/user/types/user-mypage-output.type.ts @@ -26,6 +26,7 @@ export interface RecentViewedProductSummary { regularPrice: number; storeName: string; viewedAt: Date; + isWishlisted: boolean; } export interface RecentViewedProductConnection { diff --git a/src/features/user/types/user-wishlist-output.type.ts b/src/features/user/types/user-wishlist-output.type.ts new file mode 100644 index 0000000..9fe2115 --- /dev/null +++ b/src/features/user/types/user-wishlist-output.type.ts @@ -0,0 +1,20 @@ +export interface WishlistItemSummary { + productId: string; + productName: string; + representativeImageUrl: string | null; + salePrice: number | null; + regularPrice: number; + storeName: string; + addedAt: Date; +} + +export interface MyWishlistConnection { + items: WishlistItemSummary[]; + totalCount: number; + hasMore: boolean; +} + +export interface MyWishlistInput { + offset?: number | null; + limit?: number | null; +} diff --git a/src/features/user/user-mypage.graphql b/src/features/user/user-mypage.graphql index 233951e..2a9037e 100644 --- a/src/features/user/user-mypage.graphql +++ b/src/features/user/user-mypage.graphql @@ -46,4 +46,6 @@ type RecentViewedProductSummary { regularPrice: Int! storeName: String! viewedAt: DateTime! + """현재 사용자의 찜 여부""" + isWishlisted: Boolean! } diff --git a/src/features/user/user-wishlist.graphql b/src/features/user/user-wishlist.graphql new file mode 100644 index 0000000..17f8650 --- /dev/null +++ b/src/features/user/user-wishlist.graphql @@ -0,0 +1,32 @@ +extend type Query { + """내 찜 목록""" + myWishlist(input: MyWishlistInput): MyWishlistConnection! +} + +extend type Mutation { + """찜 추가 (멱등: 이미 있어도 true 반환, soft-delete된 항목은 복원)""" + addToWishlist(productId: ID!): Boolean! + """찜 해제 (멱등: 이미 없어도 true 반환)""" + removeFromWishlist(productId: ID!): Boolean! +} + +input MyWishlistInput { + offset: Int = 0 + limit: Int = 20 +} + +type MyWishlistConnection { + items: [WishlistItemSummary!]! + totalCount: Int! + hasMore: Boolean! +} + +type WishlistItemSummary { + productId: ID! + productName: String! + representativeImageUrl: String + salePrice: Int + regularPrice: Int! + storeName: String! + addedAt: DateTime! +} diff --git a/src/features/user/user.module.ts b/src/features/user/user.module.ts index 7c48247..6ed7e06 100644 --- a/src/features/user/user.module.ts +++ b/src/features/user/user.module.ts @@ -18,6 +18,8 @@ import { UserReviewMutationResolver } from '@/features/user/resolvers/user-revie import { UserReviewQueryResolver } from '@/features/user/resolvers/user-review-query.resolver'; import { UserSearchMutationResolver } from '@/features/user/resolvers/user-search-mutation.resolver'; import { UserSearchQueryResolver } from '@/features/user/resolvers/user-search-query.resolver'; +import { UserWishlistMutationResolver } from '@/features/user/resolvers/user-wishlist-mutation.resolver'; +import { UserWishlistQueryResolver } from '@/features/user/resolvers/user-wishlist-query.resolver'; import { UserEngagementService } from '@/features/user/services/user-engagement.service'; import { UserMypageService } from '@/features/user/services/user-mypage.service'; import { UserNotificationService } from '@/features/user/services/user-notification.service'; @@ -26,6 +28,7 @@ import { UserProfileService } from '@/features/user/services/user-profile.servic import { UserRecentViewService } from '@/features/user/services/user-recent-view.service'; import { UserReviewService } from '@/features/user/services/user-review.service'; import { UserSearchService } from '@/features/user/services/user-search.service'; +import { UserWishlistService } from '@/features/user/services/user-wishlist.service'; /** * User 도메인 모듈 @@ -41,6 +44,7 @@ import { UserSearchService } from '@/features/user/services/user-search.service' UserOrderService, UserRecentViewService, UserReviewService, + UserWishlistService, UserRepository, RecentProductViewRepository, ReviewRepository, @@ -57,6 +61,8 @@ import { UserSearchService } from '@/features/user/services/user-search.service' UserNotificationMutationResolver, UserSearchMutationResolver, UserEngagementMutationResolver, + UserWishlistQueryResolver, + UserWishlistMutationResolver, ], }) export class UserModule {} From 02be4b2a31608e5e02c1c84d3896817bb2ab1350 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 01:43:52 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix(user):=20wishlistCount=EC=99=80=20myW?= =?UTF-8?q?ishlist=20=EA=B0=80=EC=8B=9C=EC=84=B1=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit countWishlistItems / getViewerCounts.wishlistCount 가 wishlist soft-delete만 필터링하고 비활성/삭제 product/store에 연결된 row는 카운트에 포함하던 문제 수정. findWishlistItems와 동일한 가시성 조건(product/store active+not-deleted)을 공유하도록 visibleWishlistWhere helper로 통일하여 카운트와 목록 길이가 항상 일치하도록 한다. (Codex 리뷰 P2: count badge vs. list contents 불일치 회피) - visibleWishlistWhere private helper 도입 (UserRepository) - getViewerCounts.wishlistCount / countWishlistItems / findWishlistItems 모두 동일 helper 사용 - 회귀 테스트 1건: 4건 wishlist 중 inactive product / soft-delete product / inactive store 의 product 3건은 카운트에서 제외, visible 1건만 카운트 --- .../user/repositories/user.repository.ts | 38 ++++++++++------ .../user/services/user-mypage.service.spec.ts | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 38f48ea..09f5724 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -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 }, @@ -197,10 +218,7 @@ export class UserRepository { }, }), this.prisma.wishlistItem.count({ - where: { - account_id: accountId, - deleted_at: null, - }, + where: this.visibleWishlistWhere(accountId), }), ]); @@ -369,7 +387,7 @@ export class UserRepository { async countWishlistItems(accountId: bigint): Promise { return this.prisma.wishlistItem.count({ - where: { account_id: accountId, deleted_at: null }, + where: this.visibleWishlistWhere(accountId), }); } @@ -455,15 +473,7 @@ export class UserRepository { }[]; totalCount: number; }> { - const where = { - account_id: args.accountId, - deleted_at: null, - product: { - deleted_at: null, - is_active: true, - store: { deleted_at: null, is_active: true }, - }, - }; + const where = this.visibleWishlistWhere(args.accountId); const [rows, totalCount] = await this.prisma.$transaction([ this.prisma.wishlistItem.findMany({ diff --git a/src/features/user/services/user-mypage.service.spec.ts b/src/features/user/services/user-mypage.service.spec.ts index 902760e..67a9e6e 100644 --- a/src/features/user/services/user-mypage.service.spec.ts +++ b/src/features/user/services/user-mypage.service.spec.ts @@ -241,6 +241,51 @@ describe('UserMypageService (real DB)', () => { }); }); + it('wishlistCount는 비활성/삭제 product가 연결된 wishlist는 제외한다 (myWishlist 가시성과 일치)', async () => { + const account = await setupUser(); + const store = await createStore(prisma); + const visibleProduct = await createProduct(prisma, { + store_id: store.id, + }); + const inactiveProduct = await createProduct(prisma, { + store_id: store.id, + }); + const deletedProduct = await createProduct(prisma, { + store_id: store.id, + }); + const inactiveStore = await createStore(prisma); + const productOfInactiveStore = await createProduct(prisma, { + store_id: inactiveStore.id, + }); + + // 4개 모두 active 상태에서 찜 추가 + await prisma.wishlistItem.createMany({ + data: [ + { account_id: account.id, product_id: visibleProduct.id }, + { account_id: account.id, product_id: inactiveProduct.id }, + { account_id: account.id, product_id: deletedProduct.id }, + { account_id: account.id, product_id: productOfInactiveStore.id }, + ], + }); + // 이후 상태 변경 (3개는 invisible로 만든다) + await prisma.product.update({ + where: { id: inactiveProduct.id }, + data: { is_active: false }, + }); + await prisma.product.update({ + where: { id: deletedProduct.id }, + data: { deleted_at: new Date() }, + }); + await prisma.store.update({ + where: { id: inactiveStore.id }, + data: { is_active: false }, + }); + + const result = await service.getOverview(account.id); + + expect(result.counts.wishlistCount).toBe(1); + }); + it('recentViewedProducts에 찜한 상품은 isWishlisted=true로 매핑된다', async () => { const account = await setupUser(); const store = await createStore(prisma); From 4c3573166f29fd2a23c35b30639c4136e15e0bf0 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 07:58:12 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix(user):=20wishlist=20=EA=B0=80?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=9D=BC=EA=B4=80=EC=84=B1=20+=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=95=EB=A0=AC=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=ED=99=94=20(PR=20#81=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #81 develop→main 릴리즈에 달린 Codex/CodeRabbit 리뷰 반영. - Codex P2: findWishlistedProductIds도 visibleWishlistWhere 사용하여 myWishlist/wishlistCount와 동일한 가시성 기준 공유. 비활성 store에 속한 product가 recent-view에서 isWishlisted=true로 보이지만 myWishlist에는 안 보이는 모순 회피. - CodeRabbit Major: findWishlistItems orderBy에 product_id 보조 정렬키 추가. 같은 밀리초에 생성된 항목의 페이지 경계 흔들림 방지. - 회귀 테스트 1건: 비활성 store의 product에 wishlist가 있어도 recent-view list에서 isWishlisted=false로 매핑됨 (가시성 일관) --- .../user/repositories/user.repository.ts | 10 ++++--- .../services/user-recent-view.service.spec.ts | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/features/user/repositories/user.repository.ts b/src/features/user/repositories/user.repository.ts index 09f5724..b5f8d55 100644 --- a/src/features/user/repositories/user.repository.ts +++ b/src/features/user/repositories/user.repository.ts @@ -434,7 +434,9 @@ export class UserRepository { /** * 주어진 productIds 중 사용자가 찜한 것들의 product_id 집합을 단일 IN 쿼리로 반환. - * 매핑(N+1 회피)용. + * 매핑(N+1 회피)용. 가시성 조건(visibleWishlistWhere)을 myWishlist/wishlistCount와 + * 공유하여, recent-view 등에 노출되는 isWishlisted 플래그가 실제 wishlist 표면 + * (목록/카운트)과 일관되도록 한다. */ async findWishlistedProductIds(args: { accountId: bigint; @@ -443,8 +445,7 @@ export class UserRepository { if (args.productIds.length === 0) return new Set(); const rows = await this.prisma.wishlistItem.findMany({ where: { - account_id: args.accountId, - deleted_at: null, + ...this.visibleWishlistWhere(args.accountId), product_id: { in: args.productIds }, }, select: { product_id: true }, @@ -478,7 +479,8 @@ export class UserRepository { const [rows, totalCount] = await this.prisma.$transaction([ this.prisma.wishlistItem.findMany({ where, - orderBy: { created_at: 'desc' }, + // 같은 밀리초 생성 시 페이지 경계 흔들림 방지를 위해 product_id를 보조 정렬키로 둔다. + orderBy: [{ created_at: 'desc' }, { product_id: 'desc' }], skip: args.offset, take: args.limit, select: { diff --git a/src/features/user/services/user-recent-view.service.spec.ts b/src/features/user/services/user-recent-view.service.spec.ts index b8eabaa..b9f3ed5 100644 --- a/src/features/user/services/user-recent-view.service.spec.ts +++ b/src/features/user/services/user-recent-view.service.spec.ts @@ -115,6 +115,34 @@ describe('UserRecentViewService (real DB)', () => { expect(map.get(notWishlisted.id.toString())).toBe(false); }); + it('비활성 store/product에 대한 wishlist는 isWishlisted=false로 매핑된다 (myWishlist 가시성과 일치)', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const inactiveStore = await createStore(prisma, { is_active: false }); + const productOfInactiveStore = await createProduct(prisma, { + store_id: inactiveStore.id, + }); + // recent-view 항목으로는 보이지만, 그 product의 store가 비활성이라 + // myWishlist에는 노출되지 않음 → isWishlisted도 false여야 일관됨 + await createRecentProductView(prisma, { + account_id: account.id, + product_id: productOfInactiveStore.id, + }); + await prisma.wishlistItem.create({ + data: { + account_id: account.id, + product_id: productOfInactiveStore.id, + }, + }); + + const result = await service.list(account.id); + + expect(result.items).toHaveLength(1); + expect(result.items[0].productId).toBe( + productOfInactiveStore.id.toString(), + ); + expect(result.items[0].isWishlisted).toBe(false); + }); + it('pagination: offset + limit < totalCount면 hasMore true', async () => { const account = await createAccount(prisma, { account_type: 'USER' }); const store = await createStore(prisma);