diff --git a/apps/api/src/common/exception/filters/global-exception.filter.spec.ts b/apps/api/src/common/exception/filters/global-exception.filter.spec.ts new file mode 100644 index 00000000..fab1159b --- /dev/null +++ b/apps/api/src/common/exception/filters/global-exception.filter.spec.ts @@ -0,0 +1,200 @@ +/** + * GlobalExceptionFilter 테스트 + * + * Prisma P2002 매핑, BusinessException, HttpException, 알 수 없는 에러 처리 검증 + */ + +import { ErrorCode } from "@aido/errors"; +import { HttpException, HttpStatus } from "@nestjs/common"; +import { PinoLogger } from "nestjs-pino"; +import { Prisma } from "@/generated/prisma/client"; +import { BusinessExceptions } from "../services/business-exception.service"; +import { GlobalExceptionFilter } from "./global-exception.filter"; + +describe("GlobalExceptionFilter", () => { + let filter: GlobalExceptionFilter; + let mockLogger: jest.Mocked; + let mockResponse: { status: jest.Mock; json: jest.Mock }; + let mockRequest: { method: string; url: string; user?: { userId: string } }; + let mockHost: { switchToHttp: jest.Mock }; + + beforeEach(() => { + mockLogger = { + setContext: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + } as unknown as jest.Mocked; + + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + mockRequest = { + method: "POST", + url: "/test", + }; + + mockHost = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: () => mockResponse, + getRequest: () => mockRequest, + }), + }; + + filter = new GlobalExceptionFilter(mockLogger); + }); + + describe("P2002 Prisma 에러 처리", () => { + it("알려진 constraint(email)를 BusinessException으로 매핑해야 한다", () => { + // Given + const error = new Prisma.PrismaClientKnownRequestError( + "Unique constraint", + { + code: "P2002", + meta: { target: ["email"] }, + clientVersion: "7.0.0", + }, + ); + + // When + filter.catch(error, mockHost as never); + + // Then + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + const jsonArg = mockResponse.json.mock.calls[0][0]; + expect(jsonArg.error.code).toBe(ErrorCode.EMAIL_0501); + }); + + it("알려진 constraint(userId_name)를 BusinessException으로 매핑해야 한다", () => { + // Given + const error = new Prisma.PrismaClientKnownRequestError( + "Unique constraint", + { + code: "P2002", + meta: { target: ["userId", "name"] }, + clientVersion: "7.0.0", + }, + ); + + // When + filter.catch(error, mockHost as never); + + // Then + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + const jsonArg = mockResponse.json.mock.calls[0][0]; + expect(jsonArg.error.code).toBe(ErrorCode.TODO_CATEGORY_0853); + }); + + it("알려진 constraint(followerId_followingId)를 BusinessException으로 매핑해야 한다", () => { + // Given + const error = new Prisma.PrismaClientKnownRequestError( + "Unique constraint", + { + code: "P2002", + meta: { target: ["followerId", "followingId"] }, + clientVersion: "7.0.0", + }, + ); + + // When + filter.catch(error, mockHost as never); + + // Then + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + const jsonArg = mockResponse.json.mock.calls[0][0]; + expect(jsonArg.error.code).toBe(ErrorCode.FOLLOW_0901); + }); + + it("알 수 없는 constraint를 SYS_0004 (409)로 폴백해야 한다", () => { + // Given + const error = new Prisma.PrismaClientKnownRequestError( + "Unique constraint", + { + code: "P2002", + meta: { target: ["unknownField"] }, + clientVersion: "7.0.0", + }, + ); + + // When + filter.catch(error, mockHost as never); + + // Then + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + const jsonArg = mockResponse.json.mock.calls[0][0]; + expect(jsonArg.error.code).toBe(ErrorCode.SYS_0004); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("Unknown P2002 constraint"), + ); + }); + + it("P2002가 아닌 Prisma 에러는 500 SYS_0001로 처리해야 한다", () => { + // Given + const error = new Prisma.PrismaClientKnownRequestError( + "Foreign key constraint", + { + code: "P2003", + meta: {}, + clientVersion: "7.0.0", + }, + ); + + // When + filter.catch(error, mockHost as never); + + // Then + expect(mockResponse.status).toHaveBeenCalledWith( + HttpStatus.INTERNAL_SERVER_ERROR, + ); + const jsonArg = mockResponse.json.mock.calls[0][0]; + expect(jsonArg.error.code).toBe(ErrorCode.SYS_0001); + }); + }); + + describe("기존 동작 유지", () => { + it("BusinessException을 올바르게 처리해야 한다", () => { + // Given + const exception = BusinessExceptions.todoCategoryNotFound(1); + + // When + filter.catch(exception, mockHost as never); + + // Then + expect(mockResponse.status).toHaveBeenCalledWith(exception.getStatus()); + const jsonArg = mockResponse.json.mock.calls[0][0]; + expect(jsonArg.error.code).toBe(ErrorCode.TODO_CATEGORY_0851); + }); + + it("HttpException을 올바르게 처리해야 한다", () => { + // Given + const exception = new HttpException( + { message: "Bad Request" }, + HttpStatus.BAD_REQUEST, + ); + + // When + filter.catch(exception, mockHost as never); + + // Then + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + const jsonArg = mockResponse.json.mock.calls[0][0]; + expect(jsonArg.error.code).toBe(ErrorCode.SYS_0002); + }); + + it("알 수 없는 에러를 500 SYS_0001로 처리해야 한다", () => { + // Given + const exception = new Error("unexpected error"); + + // When + filter.catch(exception, mockHost as never); + + // Then + expect(mockResponse.status).toHaveBeenCalledWith( + HttpStatus.INTERNAL_SERVER_ERROR, + ); + const jsonArg = mockResponse.json.mock.calls[0][0]; + expect(jsonArg.error.code).toBe(ErrorCode.SYS_0001); + }); + }); +}); diff --git a/apps/api/src/common/exception/filters/global-exception.filter.ts b/apps/api/src/common/exception/filters/global-exception.filter.ts index 1cf4818b..c51caf4e 100644 --- a/apps/api/src/common/exception/filters/global-exception.filter.ts +++ b/apps/api/src/common/exception/filters/global-exception.filter.ts @@ -8,8 +8,12 @@ import { } from "@nestjs/common"; import type { Request, Response } from "express"; import { PinoLogger } from "nestjs-pino"; +import { Prisma } from "@/generated/prisma/client"; import type { ErrorResponse } from "../interfaces/error.interface"; -import { BusinessException } from "../services/business-exception.service"; +import { + BusinessException, + BusinessExceptions, +} from "../services/business-exception.service"; /** * 전역 예외 필터 @@ -63,6 +67,14 @@ export class GlobalExceptionFilter implements ExceptionFilter { timestamp: Date.now(), }; } + } else if ( + exception instanceof Prisma.PrismaClientKnownRequestError && + exception.code === "P2002" + ) { + // Prisma unique constraint 위반 처리 + const businessException = this.mapP2002ToBusinessException(exception); + statusCode = businessException.getStatus(); + errorResponse = businessException.getResponse() as ErrorResponse; } else { // 알 수 없는 예외 처리 statusCode = HttpStatus.INTERNAL_SERVER_ERROR; @@ -100,4 +112,52 @@ export class GlobalExceptionFilter implements ExceptionFilter { response.status(statusCode).json(errorResponse); } + + /** + * Prisma P2002 (unique constraint violation) → BusinessException 매핑 + * + * 알려진 constraint는 구체적인 에러 코드로, 미지의 constraint는 SYS_0004로 폴백 + */ + private mapP2002ToBusinessException( + error: InstanceType, + ): BusinessException { + const target = error.meta?.target; + const constraintKey = Array.isArray(target) + ? target.join("_") + : String(target ?? "unknown"); + + const constraintMap: Record BusinessException> = { + User_email_key: () => BusinessExceptions.emailAlreadyRegistered(""), + email: () => BusinessExceptions.emailAlreadyRegistered(""), + User_userTag_key: () => + BusinessExceptions.internalServerError({ detail: "userTag collision" }), + userTag: () => + BusinessExceptions.internalServerError({ detail: "userTag collision" }), + TodoCategory_userId_name_key: () => + BusinessExceptions.todoCategoryNameDuplicate(""), + userId_name: () => BusinessExceptions.todoCategoryNameDuplicate(""), + Follow_followerId_followingId_key: () => + BusinessExceptions.followRequestAlreadySent(""), + followerId_followingId: () => + BusinessExceptions.followRequestAlreadySent(""), + Account_provider_providerAccountId_key: () => + BusinessExceptions.accountAlreadyExists(), + provider_providerAccountId: () => + BusinessExceptions.accountAlreadyExists(), + Account_userId_provider_key: () => + BusinessExceptions.accountAlreadyExists(), + userId_provider: () => BusinessExceptions.accountAlreadyExists(), + }; + + const factory = constraintMap[constraintKey]; + if (factory) { + return factory(); + } + + // 알 수 없는 constraint → warn 로그 + SYS_0004 폴백 + this.logger.warn( + `Unknown P2002 constraint: ${constraintKey} (meta: ${JSON.stringify(error.meta)})`, + ); + return BusinessExceptions.concurrentModification(); + } } diff --git a/apps/api/src/modules/auth/services/auth.service.spec.ts b/apps/api/src/modules/auth/services/auth.service.spec.ts index 1e7eb69e..25204d77 100644 --- a/apps/api/src/modules/auth/services/auth.service.spec.ts +++ b/apps/api/src/modules/auth/services/auth.service.spec.ts @@ -18,8 +18,12 @@ import { SessionBuilder, UserBuilder } from "@test/builders"; type TransactionCallback = (tx: any) => Promise; import { CacheService } from "@/common/cache/cache.service"; -import { BusinessException } from "@/common/exception/services/business-exception.service"; +import { + BusinessException, + BusinessExceptions, +} from "@/common/exception/services/business-exception.service"; import { DatabaseService } from "@/database"; +import { Prisma } from "@/generated/prisma/client"; import { TodoCategoryRepository } from "../../todo-category/todo-category.repository"; import { REVOKE_REASON, SECURITY_EVENT } from "../constants/auth.constants"; import { AccountRepository } from "../repositories/account.repository"; @@ -242,6 +246,40 @@ describe("AuthService", () => { ); }); + it("P2002 unique constraint(이메일 중복) 시 emailAlreadyRegistered를 던져야 한다", async () => { + // Given - 트랜잭션에서 P2002 발생 (동시 가입 race condition) + database.$transaction.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + meta: { target: ["email"] }, + clientVersion: "7.0.0", + }), + ); + + // When & Then + await expect(service.register(registerInput)).rejects.toThrow( + BusinessExceptions.emailAlreadyRegistered(registerInput.email), + ); + }); + + it("P2002 unique constraint(이메일 외) 시 원본 에러를 re-throw해야 한다", async () => { + // Given - userTag 충돌 등 email이 아닌 P2002 + const prismaError = new Prisma.PrismaClientKnownRequestError( + "Unique constraint", + { + code: "P2002", + meta: { target: ["userTag"] }, + clientVersion: "7.0.0", + }, + ); + database.$transaction.mockRejectedValue(prismaError); + + // When & Then - emailAlreadyRegistered가 아닌 원본 에러가 던져져야 함 + await expect(service.register(registerInput)).rejects.toThrow( + prismaError, + ); + }); + it("이메일 전송 실패해도 회원가입은 성공한다", async () => { // Given const mockUser = UserBuilder.create() diff --git a/apps/api/src/modules/auth/services/auth.service.ts b/apps/api/src/modules/auth/services/auth.service.ts index 8671b57e..beb1c6b0 100644 --- a/apps/api/src/modules/auth/services/auth.service.ts +++ b/apps/api/src/modules/auth/services/auth.service.ts @@ -16,7 +16,7 @@ import { } from "@/common/date"; import { BusinessExceptions } from "@/common/exception/services/business-exception.service"; import { DatabaseService } from "@/database"; -import type { UserStatus } from "@/generated/prisma/client"; +import { Prisma, type UserStatus } from "@/generated/prisma/client"; import { TodoCategoryRepository } from "../../todo-category/todo-category.repository"; import { DEFAULT_CATEGORIES } from "../../todo-category/types/todo-category.types"; import { @@ -94,77 +94,97 @@ export class AuthService { const hashedPassword = await this.passwordService.hash(password); // 트랜잭션으로 User + Account + UserConsent + Verification 생성 - const result = await this.database.$transaction(async (tx) => { - // User 생성 (PENDING_VERIFY 상태) - const newUser = await this.userRepository.create( - { - email, - status: "PENDING_VERIFY", - }, - tx, - ); + let result: { + user: { id: string; email: string }; + verificationCode: string; + }; + try { + result = await this.database.$transaction(async (tx) => { + // User 생성 (PENDING_VERIFY 상태) + const newUser = await this.userRepository.create( + { + email, + status: "PENDING_VERIFY", + }, + tx, + ); - // Credential Account 생성 - await this.accountRepository.createCredentialAccount( - newUser.id, - hashedPassword, - tx, - ); + // Credential Account 생성 + await this.accountRepository.createCredentialAccount( + newUser.id, + hashedPassword, + tx, + ); - // 프로필 생성 - await this.userRepository.createProfile(newUser.id, { name }, tx); - - // 약관 동의 기록 - const currentTime = now(); - await tx.userConsent.create({ - data: { - userId: newUser.id, - termsAgreedAt: termsAgreed ? currentTime : null, - privacyAgreedAt: privacyAgreed ? currentTime : null, - marketingAgreedAt: marketingAgreed ? currentTime : null, - }, - }); + // 프로필 생성 + await this.userRepository.createProfile(newUser.id, { name }, tx); + + // 약관 동의 기록 + const currentTime = now(); + await tx.userConsent.create({ + data: { + userId: newUser.id, + termsAgreedAt: termsAgreed ? currentTime : null, + privacyAgreedAt: privacyAgreed ? currentTime : null, + marketingAgreedAt: marketingAgreed ? currentTime : null, + }, + }); - // 푸시 알림 설정 초기화 (기본값: 모두 OFF) - await tx.userPreference.create({ - data: { - userId: newUser.id, - pushEnabled: false, - nightPushEnabled: false, - }, - }); + // 푸시 알림 설정 초기화 (기본값: 모두 OFF) + await tx.userPreference.create({ + data: { + userId: newUser.id, + pushEnabled: false, + nightPushEnabled: false, + }, + }); - // 기본 카테고리 생성 ("중요한 일", "할 일") - await this.todoCategoryRepository.createMany( - DEFAULT_CATEGORIES.map((category) => ({ - userId: newUser.id, - name: category.name, - color: category.color, - sortOrder: category.sortOrder, - })), - tx, - ); + // 기본 카테고리 생성 ("중요한 일", "할 일") + await this.todoCategoryRepository.createMany( + DEFAULT_CATEGORIES.map((category) => ({ + userId: newUser.id, + name: category.name, + color: category.color, + sortOrder: category.sortOrder, + })), + tx, + ); - // 이메일 인증 코드 생성 (Verification 레코드만 DB에 저장) - const verificationResult = - await this.verificationService.createEmailVerification(newUser.id, tx); + // 이메일 인증 코드 생성 (Verification 레코드만 DB에 저장) + const verificationResult = + await this.verificationService.createEmailVerification( + newUser.id, + tx, + ); - // 보안 로그 기록 - await this.securityLogRepository.create( - { - userId: newUser.id, - event: SECURITY_EVENT.REGISTRATION, - ipAddress: AUTH_DEFAULTS.UNKNOWN_IP, - userAgent: AUTH_DEFAULTS.UNKNOWN_USER_AGENT, - }, - tx, - ); + // 보안 로그 기록 + await this.securityLogRepository.create( + { + userId: newUser.id, + event: SECURITY_EVENT.REGISTRATION, + ipAddress: AUTH_DEFAULTS.UNKNOWN_IP, + userAgent: AUTH_DEFAULTS.UNKNOWN_USER_AGENT, + }, + tx, + ); - return { - user: newUser, - verificationCode: verificationResult.code, - }; - }); + return { + user: newUser, + verificationCode: verificationResult.code, + }; + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + const target = error.meta?.target as string[] | undefined; + if (target?.includes("email")) { + throw BusinessExceptions.emailAlreadyRegistered(email); + } + } + throw error; + } // 트랜잭션 후 이메일 발송 (외부 서비스) // 이메일 발송 실패는 로그만 남고 회원가입은 성공 처리 diff --git a/apps/api/src/modules/auth/services/oauth.service.spec.ts b/apps/api/src/modules/auth/services/oauth.service.spec.ts index ad12ebb1..1a571713 100644 --- a/apps/api/src/modules/auth/services/oauth.service.spec.ts +++ b/apps/api/src/modules/auth/services/oauth.service.spec.ts @@ -14,8 +14,12 @@ import { TestBed } from "@suites/unit"; import { AccountBuilder, SessionBuilder, UserBuilder } from "@test/builders"; import { TypedConfigService } from "@/common/config/services/config.service"; -import { BusinessException } from "@/common/exception/services/business-exception.service"; +import { + BusinessException, + BusinessExceptions, +} from "@/common/exception/services/business-exception.service"; import { DatabaseService } from "@/database"; +import { Prisma } from "@/generated/prisma/client"; import { TodoCategoryRepository } from "../../todo-category/todo-category.repository"; import { LOGIN_FAILURE_REASON, @@ -495,6 +499,25 @@ describe("OAuthService", () => { expect(accountRepo.createOAuthAccount).not.toHaveBeenCalled(); }); + it("P2002 unique constraint 시 provider별 alreadyLinked를 던져야 한다", async () => { + // Given - 계정 없음 + createOAuthAccount에서 P2002 발생 + accountRepo.findByProviderAccountId.mockResolvedValue(null); + accountRepo.createOAuthAccount.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + meta: { target: ["provider", "providerAccountId"] }, + clientVersion: "7.0.0", + }), + ); + + // When & Then - KAKAO provider + await expect( + service.linkAccount("user-123", "KAKAO", "kakao-account-789"), + ).rejects.toThrow( + BusinessExceptions.kakaoAccountAlreadyLinked("kakao-account-789"), + ); + }); + it("다른 사용자에 연결된 계정은 에러를 발생시킨다", async () => { // Given - Builder로 다른 사용자의 계정 생성 const otherUserAccount = AccountBuilder.create("other-user-789") diff --git a/apps/api/src/modules/auth/services/oauth.service.ts b/apps/api/src/modules/auth/services/oauth.service.ts index f045fbf7..fbcedf55 100644 --- a/apps/api/src/modules/auth/services/oauth.service.ts +++ b/apps/api/src/modules/auth/services/oauth.service.ts @@ -1,9 +1,12 @@ import { Injectable, Logger } from "@nestjs/common"; import { TypedConfigService } from "@/common/config/services/config.service"; import { addMilliseconds, now } from "@/common/date"; -import { BusinessExceptions } from "@/common/exception/services/business-exception.service"; +import { + BusinessException, + BusinessExceptions, +} from "@/common/exception/services/business-exception.service"; import { DatabaseService } from "@/database"; -import type { AccountProvider } from "@/generated/prisma/client"; +import { type AccountProvider, Prisma } from "@/generated/prisma/client"; import { TodoCategoryRepository } from "@/modules/todo-category/todo-category.repository"; import { DEFAULT_CATEGORIES } from "@/modules/todo-category/types/todo-category.types"; @@ -690,7 +693,10 @@ export class OAuthService { ); if (existingAccount && existingAccount.userId !== userId) { - throw BusinessExceptions.appleAccountAlreadyLinked(providerAccountId); + throw this.getAlreadyLinkedExceptionForProvider( + provider, + providerAccountId, + ); } // 이미 연결된 경우 @@ -699,12 +705,25 @@ export class OAuthService { } // 계정 연결 - await this._accountRepository.createOAuthAccount({ - userId, - provider, - providerAccountId, - refreshToken, - }); + try { + await this._accountRepository.createOAuthAccount({ + userId, + provider, + providerAccountId, + refreshToken, + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw this.getAlreadyLinkedExceptionForProvider( + provider, + providerAccountId, + ); + } + throw error; + } this._logger.log(`Account linked: ${provider} for user ${userId}`); @@ -1099,6 +1118,25 @@ export class OAuthService { } } + private getAlreadyLinkedExceptionForProvider( + provider: AccountProvider, + providerAccountId: string, + ): BusinessException { + const exceptionMap: Record BusinessException> = { + KAKAO: BusinessExceptions.kakaoAccountAlreadyLinked, + APPLE: BusinessExceptions.appleAccountAlreadyLinked, + GOOGLE: BusinessExceptions.googleAccountAlreadyLinked, + NAVER: BusinessExceptions.naverAccountAlreadyLinked, + }; + const factory = exceptionMap[provider]; + return factory + ? factory(providerAccountId) + : BusinessExceptions.accountAlreadyExists({ + provider, + providerAccountId, + }); + } + // Google, Apple은 이메일 검증 보장. Kakao, Naver는 선택적. private _isTrustedProvider(provider: AccountProvider): boolean { return TRUSTED_EMAIL_PROVIDERS.includes(provider); diff --git a/apps/api/src/modules/cheer/cheer.controller.ts b/apps/api/src/modules/cheer/cheer.controller.ts index d58ddf6d..c3e72a7e 100644 --- a/apps/api/src/modules/cheer/cheer.controller.ts +++ b/apps/api/src/modules/cheer/cheer.controller.ts @@ -36,6 +36,7 @@ import { CheerCooldownResponseDto, CheerLimitInfoDto, CreateCheerResponseDto, + GetCheersQueryDto, MarkCheerReadResponseDto, MarkCheersReadDto, ReceivedCheersResponseDto, @@ -139,15 +140,14 @@ export class CheerController { @ApiUnauthorizedError(ErrorCode.AUTH_0107) async getReceivedCheers( @CurrentUser() user: CurrentUserPayload, - @Query("limit") limit?: string, - @Query("cursor") cursor?: string, + @Query() query: GetCheersQueryDto, ): Promise { this.logger.debug(`받은 응원 목록 조회: userId=${user.userId}`); const result = await this.cheerService.getReceivedCheers({ userId: user.userId, - cursor: cursor ? Number(cursor) : undefined, - size: limit ? Number(limit) : undefined, + cursor: query.cursor, + size: query.limit, }); return { @@ -172,15 +172,14 @@ export class CheerController { @ApiUnauthorizedError(ErrorCode.AUTH_0107) async getSentCheers( @CurrentUser() user: CurrentUserPayload, - @Query("limit") limit?: string, - @Query("cursor") cursor?: string, + @Query() query: GetCheersQueryDto, ): Promise { this.logger.debug(`보낸 응원 목록 조회: userId=${user.userId}`); const result = await this.cheerService.getSentCheers({ userId: user.userId, - cursor: cursor ? Number(cursor) : undefined, - size: limit ? Number(limit) : undefined, + cursor: query.cursor, + size: query.limit, }); return { diff --git a/apps/api/src/modules/cheer/dtos/request/get-cheers-query.dto.ts b/apps/api/src/modules/cheer/dtos/request/get-cheers-query.dto.ts new file mode 100644 index 00000000..ba521bc0 --- /dev/null +++ b/apps/api/src/modules/cheer/dtos/request/get-cheers-query.dto.ts @@ -0,0 +1,4 @@ +import { getCheersQuerySchema } from "@aido/validators"; +import { createZodDto } from "nestjs-zod"; + +export class GetCheersQueryDto extends createZodDto(getCheersQuerySchema) {} diff --git a/apps/api/src/modules/cheer/dtos/request/index.ts b/apps/api/src/modules/cheer/dtos/request/index.ts index 9077c40d..4f0a00af 100644 --- a/apps/api/src/modules/cheer/dtos/request/index.ts +++ b/apps/api/src/modules/cheer/dtos/request/index.ts @@ -1 +1,2 @@ +export * from "./get-cheers-query.dto"; export * from "./send-cheer.dto"; diff --git a/apps/api/src/modules/follow/follow.service.spec.ts b/apps/api/src/modules/follow/follow.service.spec.ts index 8a969c20..a7070af7 100644 --- a/apps/api/src/modules/follow/follow.service.spec.ts +++ b/apps/api/src/modules/follow/follow.service.spec.ts @@ -13,10 +13,13 @@ import type { Mocked } from "@suites/doubles.jest"; import { TestBed } from "@suites/unit"; import { FollowBuilder } from "@test/builders"; import { CacheService } from "@/common/cache/cache.service"; -import { BusinessException } from "@/common/exception/services/business-exception.service"; +import { + BusinessException, + BusinessExceptions, +} from "@/common/exception/services/business-exception.service"; import { PaginationService } from "@/common/pagination/services/pagination.service"; import { DatabaseService } from "@/database/database.service"; -import type { Follow } from "@/generated/prisma/client"; +import { type Follow, Prisma } from "@/generated/prisma/client"; import { FollowRepository } from "./follow.repository"; import { FollowService } from "./follow.service"; @@ -288,6 +291,26 @@ describe("FollowService", () => { ); }); + it("P2002 unique constraint(중복 요청) 시 followRequestAlreadySent를 던져야 한다", async () => { + // Given - create에서 P2002 발생 (동시 요청 race condition) + followRepo.userExists.mockResolvedValue(true); + followRepo.findByFollowerAndFollowing.mockResolvedValue(null); + followRepo.create.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + meta: { target: ["followerId", "followingId"] }, + clientVersion: "7.0.0", + }), + ); + + // When & Then + await expect( + service.sendRequest(mockUserId, mockTargetUserId), + ).rejects.toThrow( + BusinessExceptions.followRequestAlreadySent(mockTargetUserId), + ); + }); + it("상대방이 이미 ACCEPTED 상태면 FOLLOW_0902 에러를 던진다", async () => { // Given const acceptedReverseFollow = FollowBuilder.create( diff --git a/apps/api/src/modules/follow/follow.service.ts b/apps/api/src/modules/follow/follow.service.ts index 2df808ce..0dc88cd0 100644 --- a/apps/api/src/modules/follow/follow.service.ts +++ b/apps/api/src/modules/follow/follow.service.ts @@ -5,7 +5,7 @@ import { BusinessExceptions } from "@/common/exception/services/business-excepti import type { CursorPaginatedResponse } from "@/common/pagination/interfaces/pagination.interface"; import { PaginationService } from "@/common/pagination/services/pagination.service"; import { DatabaseService } from "@/database/database.service"; -import type { Follow } from "@/generated/prisma/client"; +import { type Follow, Prisma } from "@/generated/prisma/client"; import { type FollowMutualEventPayload, type FollowNewEventPayload, @@ -111,24 +111,35 @@ export class FollowService { if (reverseFollow) { if (reverseFollow.status === "PENDING") { // 상대방이 보낸 요청이 PENDING 상태면 자동 수락 (트랜잭션으로 처리) - const follow = await this.database.$transaction(async (tx) => { - await this.followRepository.updateByFollowerAndFollowing( - targetUserId, - userId, - { status: "ACCEPTED" }, - tx, - ); - - // 내 쪽도 ACCEPTED로 생성 - return this.followRepository.create( - { - follower: { connect: { id: userId } }, - following: { connect: { id: targetUserId } }, - status: "ACCEPTED", - }, - tx, - ); - }); + let follow: Follow; + try { + follow = await this.database.$transaction(async (tx) => { + await this.followRepository.updateByFollowerAndFollowing( + targetUserId, + userId, + { status: "ACCEPTED" }, + tx, + ); + + // 내 쪽도 ACCEPTED로 생성 + return this.followRepository.create( + { + follower: { connect: { id: userId } }, + following: { connect: { id: targetUserId } }, + status: "ACCEPTED", + }, + tx, + ); + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw BusinessExceptions.followRequestAlreadySent(targetUserId); + } + throw error; + } this.logger.log( `Friend request auto-accepted: ${userId} <-> ${targetUserId}`, @@ -164,11 +175,22 @@ export class FollowService { } // 5. 새 친구 요청 생성 - const follow = await this.followRepository.create({ - follower: { connect: { id: userId } }, - following: { connect: { id: targetUserId } }, - status: "PENDING", - }); + let follow: Follow; + try { + follow = await this.followRepository.create({ + follower: { connect: { id: userId } }, + following: { connect: { id: targetUserId } }, + status: "PENDING", + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw BusinessExceptions.followRequestAlreadySent(targetUserId); + } + throw error; + } this.logger.log(`Friend request sent: ${userId} -> ${targetUserId}`); diff --git a/apps/api/src/modules/notification/notification.service.spec.ts b/apps/api/src/modules/notification/notification.service.spec.ts index 44afad97..28455327 100644 --- a/apps/api/src/modules/notification/notification.service.spec.ts +++ b/apps/api/src/modules/notification/notification.service.spec.ts @@ -19,6 +19,7 @@ import { } from "@test/builders"; import { BusinessException } from "@/common/exception/services/business-exception.service"; import { PaginationService } from "@/common/pagination/services/pagination.service"; +import { Prisma } from "@/generated/prisma/client"; import { UserConsentRepository } from "@/modules/auth/repositories/user-consent.repository"; import { UserPreferenceRepository } from "@/modules/auth/repositories/user-preference.repository"; import { NotificationRepository } from "./notification.repository"; @@ -185,10 +186,14 @@ describe("NotificationService", () => { ); }); - it("토큰이 없어도 예외를 던지지 않아야 한다", async () => { - // Given - 토큰이 존재하지 않는 상황 + it("P2025 에러(토큰 미존재)는 무시하고 정상 반환해야 한다", async () => { + // Given - 토큰이 존재하지 않아 Prisma P2025 발생 notificationRepo.deletePushToken.mockRejectedValue( - new Error("Not found"), + new Prisma.PrismaClientKnownRequestError("Record not found", { + code: "P2025", + meta: { cause: "Record to delete does not exist." }, + clientVersion: "7.0.0", + }), ); // When & Then - 예외 없이 정상 처리 @@ -196,6 +201,35 @@ describe("NotificationService", () => { service.unregisterPushToken(mockUserId, "device-1"), ).resolves.not.toThrow(); }); + + it("P2025가 아닌 Prisma 에러는 re-throw해야 한다", async () => { + // Given - P2002 등 다른 Prisma 에러 + const prismaError = new Prisma.PrismaClientKnownRequestError( + "Connection error", + { + code: "P2010", + meta: {}, + clientVersion: "7.0.0", + }, + ); + notificationRepo.deletePushToken.mockRejectedValue(prismaError); + + // When & Then - 에러가 re-throw 되어야 함 + await expect( + service.unregisterPushToken(mockUserId, "device-1"), + ).rejects.toThrow(prismaError); + }); + + it("일반 에러(DB 연결 실패 등)는 re-throw해야 한다", async () => { + // Given - 일반 에러 (네트워크 장애 등) + const error = new Error("Connection refused"); + notificationRepo.deletePushToken.mockRejectedValue(error); + + // When & Then - 에러가 re-throw 되어야 함 + await expect( + service.unregisterPushToken(mockUserId, "device-1"), + ).rejects.toThrow(error); + }); }); describe("unregisterAllPushTokens", () => { diff --git a/apps/api/src/modules/notification/notification.service.ts b/apps/api/src/modules/notification/notification.service.ts index 9eccd969..5561ab5b 100644 --- a/apps/api/src/modules/notification/notification.service.ts +++ b/apps/api/src/modules/notification/notification.service.ts @@ -7,10 +7,11 @@ import { Inject, Injectable, Logger } from "@nestjs/common"; import { BusinessExceptions } from "@/common/exception/services/business-exception.service"; import type { CursorPaginatedResponse } from "@/common/pagination/interfaces/pagination.interface"; import { PaginationService } from "@/common/pagination/services/pagination.service"; -import type { - Notification, - NotificationType, - PushToken, +import { + type Notification, + type NotificationType, + Prisma, + type PushToken, } from "@/generated/prisma/client"; import { UserConsentRepository } from "@/modules/auth/repositories/user-consent.repository"; import { UserPreferenceRepository } from "@/modules/auth/repositories/user-preference.repository"; @@ -94,11 +95,18 @@ export class NotificationService { this.logger.log( `Push token unregistered: userId=${userId}, deviceId=${deviceId}`, ); - } catch (_error) { - // 토큰이 없는 경우 무시 - this.logger.warn( - `Push token not found for unregister: userId=${userId}, deviceId=${deviceId}`, - ); + } catch (error) { + // 토큰이 없는 경우(P2025)만 무시, 그 외는 re-throw + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ) { + this.logger.warn( + `Push token not found: userId=${userId}, deviceId=${deviceId}`, + ); + return; + } + throw error; } } diff --git a/apps/api/src/modules/nudge/dtos/request/get-nudges-query.dto.ts b/apps/api/src/modules/nudge/dtos/request/get-nudges-query.dto.ts new file mode 100644 index 00000000..2d124f1c --- /dev/null +++ b/apps/api/src/modules/nudge/dtos/request/get-nudges-query.dto.ts @@ -0,0 +1,4 @@ +import { getNudgesQuerySchema } from "@aido/validators"; +import { createZodDto } from "nestjs-zod"; + +export class GetNudgesQueryDto extends createZodDto(getNudgesQuerySchema) {} diff --git a/apps/api/src/modules/nudge/dtos/request/index.ts b/apps/api/src/modules/nudge/dtos/request/index.ts index 35361874..6a21a9f3 100644 --- a/apps/api/src/modules/nudge/dtos/request/index.ts +++ b/apps/api/src/modules/nudge/dtos/request/index.ts @@ -1 +1,2 @@ +export * from "./get-nudges-query.dto"; export * from "./send-nudge.dto"; diff --git a/apps/api/src/modules/nudge/nudge.controller.ts b/apps/api/src/modules/nudge/nudge.controller.ts index 6f8ad8e4..6cf0f9e0 100644 --- a/apps/api/src/modules/nudge/nudge.controller.ts +++ b/apps/api/src/modules/nudge/nudge.controller.ts @@ -32,6 +32,7 @@ import { JwtAuthGuard } from "../auth/guards"; import { CreateNudgeResponseDto, + GetNudgesQueryDto, MarkNudgeReadResponseDto, NudgeCooldownResponseDto, NudgeLimitInfoDto, @@ -140,15 +141,14 @@ export class NudgeController { @ApiUnauthorizedError(ErrorCode.AUTH_0107) async getReceivedNudges( @CurrentUser() user: CurrentUserPayload, - @Query("limit") limit?: string, - @Query("cursor") cursor?: string, + @Query() query: GetNudgesQueryDto, ): Promise { this.logger.debug(`받은 콕 찌름 목록 조회: userId=${user.userId}`); const result = await this.nudgeService.getReceivedNudges({ userId: user.userId, - cursor: cursor ? Number(cursor) : undefined, - size: limit ? Number(limit) : undefined, + cursor: query.cursor, + size: query.limit, }); return { @@ -173,15 +173,14 @@ export class NudgeController { @ApiUnauthorizedError(ErrorCode.AUTH_0107) async getSentNudges( @CurrentUser() user: CurrentUserPayload, - @Query("limit") limit?: string, - @Query("cursor") cursor?: string, + @Query() query: GetNudgesQueryDto, ): Promise { this.logger.debug(`보낸 콕 찌름 목록 조회: userId=${user.userId}`); const result = await this.nudgeService.getSentNudges({ userId: user.userId, - cursor: cursor ? Number(cursor) : undefined, - size: limit ? Number(limit) : undefined, + cursor: query.cursor, + size: query.limit, }); return { diff --git a/apps/api/src/modules/todo-category/todo-category.controller.ts b/apps/api/src/modules/todo-category/todo-category.controller.ts index 4b280dbe..6c24f0a3 100644 --- a/apps/api/src/modules/todo-category/todo-category.controller.ts +++ b/apps/api/src/modules/todo-category/todo-category.controller.ts @@ -326,14 +326,16 @@ DELETE /todo-categories/3?moveToCategoryId=1 | 상황 | 에러 코드 | 메시지 | |------|-----------|--------| | 마지막 카테고리 삭제 시도 | \`TODO_CATEGORY_0854\` | 최소 1개의 카테고리가 필요합니다 | -| 할 일이 있는데 moveToCategoryId 없음 | \`TODO_CATEGORY_0856\` | 이동할 카테고리를 지정해주세요 | +| 할 일이 있는데 moveToCategoryId 없음 | \`TODO_CATEGORY_0855\` | 카테고리에 할 일이 있습니다 (details에 todoCount 포함) | +| moveToCategoryId가 삭제 대상과 같음 | \`SYS_0002\` | 삭제할 카테고리와 이동 대상 카테고리가 같을 수 없습니다 | | moveToCategoryId 카테고리 없음 | \`TODO_CATEGORY_0851\` | 카테고리를 찾을 수 없습니다 |`, }) @ApiSuccessResponse({ type: DeleteTodoCategoryResponseDto }) @ApiUnauthorizedError() @ApiNotFoundError(ErrorCode.TODO_CATEGORY_0851) @ApiBadRequestError(ErrorCode.TODO_CATEGORY_0854) - @ApiBadRequestError(ErrorCode.TODO_CATEGORY_0856) + @ApiBadRequestError(ErrorCode.TODO_CATEGORY_0855) + @ApiBadRequestError(ErrorCode.SYS_0002) async delete( @CurrentUser() user: CurrentUserPayload, @Param() params: TodoCategoryIdParamDto, diff --git a/apps/api/src/modules/todo-category/todo-category.service.spec.ts b/apps/api/src/modules/todo-category/todo-category.service.spec.ts index bc064a54..2eeba223 100644 --- a/apps/api/src/modules/todo-category/todo-category.service.spec.ts +++ b/apps/api/src/modules/todo-category/todo-category.service.spec.ts @@ -15,6 +15,7 @@ import { TodoCategoryBuilder } from "@test/builders"; import { BusinessExceptions } from "@/common/exception/services/business-exception.service"; import { DatabaseService } from "@/database/database.service"; +import { Prisma } from "@/generated/prisma/client"; import { TodoCategoryRepository } from "./todo-category.repository"; import { TodoCategoryService } from "./todo-category.service"; @@ -97,6 +98,24 @@ describe("TodoCategoryService", () => { ); }); + it("P2002 unique constraint 위반 시 todoCategoryNameDuplicate를 던져야 한다", async () => { + // Given + todoCategoryRepo.existsByUserIdAndName.mockResolvedValue(false); + todoCategoryRepo.getMaxSortOrder.mockResolvedValue(0); + todoCategoryRepo.create.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + meta: { target: ["userId", "name"] }, + clientVersion: "7.0.0", + }), + ); + + // When & Then + await expect(service.create(createData)).rejects.toThrow( + BusinessExceptions.todoCategoryNameDuplicate("새 카테고리"), + ); + }); + it("sortOrder는 기존 최대값 + 1이어야 한다", async () => { // Given const expectedCategory = TodoCategoryBuilder.create(userId) @@ -298,6 +317,31 @@ describe("TodoCategoryService", () => { ); }); + it("P2002 unique constraint 위반 시 todoCategoryNameDuplicate를 던져야 한다", async () => { + // Given + const existingCategory = TodoCategoryBuilder.create(userId) + .withId(1) + .withName("중요한 일") + .build(); + + todoCategoryRepo.findByIdAndUserId.mockResolvedValue(existingCategory); + todoCategoryRepo.existsByUserIdAndName.mockResolvedValue(false); + todoCategoryRepo.update.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + meta: { target: ["userId", "name"] }, + clientVersion: "7.0.0", + }), + ); + + // When & Then + await expect( + service.update(1, userId, { name: "수정된 카테고리" }), + ).rejects.toThrow( + BusinessExceptions.todoCategoryNameDuplicate("수정된 카테고리"), + ); + }); + it("같은 이름으로 수정하면 중복 확인을 건너뛰어야 한다", async () => { // Given const existingCategory = TodoCategoryBuilder.create(userId) @@ -360,10 +404,24 @@ describe("TodoCategoryService", () => { // When & Then await expect(service.delete({ userId, categoryId: 1 })).rejects.toThrow( - BusinessExceptions.todoCategoryMoveTargetRequired(), + BusinessExceptions.todoCategoryHasTodos(1, 5), ); }); + it("moveToCategoryId가 삭제 대상과 같으면 invalidParameter를 던져야 한다", async () => { + // Given + const category = TodoCategoryBuilder.create(userId).withId(1).build(); + + todoCategoryRepo.findByIdAndUserId.mockResolvedValue(category); + todoCategoryRepo.countByUserId.mockResolvedValue(2); + todoCategoryRepo.getTodoCount.mockResolvedValue(3); + + // When & Then + await expect( + service.delete({ userId, categoryId: 1, moveToCategoryId: 1 }), + ).rejects.toThrow(BusinessExceptions.invalidParameter()); + }); + it("Todo가 있으면 이동 후 삭제해야 한다", async () => { // Given const sourceCategory = TodoCategoryBuilder.create(userId) diff --git a/apps/api/src/modules/todo-category/todo-category.service.ts b/apps/api/src/modules/todo-category/todo-category.service.ts index c3b671b3..d3e2b378 100644 --- a/apps/api/src/modules/todo-category/todo-category.service.ts +++ b/apps/api/src/modules/todo-category/todo-category.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { BusinessExceptions } from "@/common/exception/services/business-exception.service"; import { DatabaseService } from "@/database/database.service"; -import type { TodoCategory } from "@/generated/prisma/client"; +import { Prisma, type TodoCategory } from "@/generated/prisma/client"; import { TodoCategoryRepository } from "./todo-category.repository"; import type { @@ -41,12 +41,23 @@ export class TodoCategoryService { data.userId, ); - const category = await this.todoCategoryRepository.create({ - user: { connect: { id: data.userId } }, - name: data.name, - color: data.color, - sortOrder: maxSortOrder + 1, - }); + let category: TodoCategory; + try { + category = await this.todoCategoryRepository.create({ + user: { connect: { id: data.userId } }, + name: data.name, + color: data.color, + sortOrder: maxSortOrder + 1, + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw BusinessExceptions.todoCategoryNameDuplicate(data.name); + } + throw error; + } this.logger.log( `TodoCategory created: ${category.id} for user: ${data.userId}`, @@ -139,7 +150,18 @@ export class TodoCategoryService { } } - const updatedCategory = await this.todoCategoryRepository.update(id, data); + let updatedCategory: TodoCategory; + try { + updatedCategory = await this.todoCategoryRepository.update(id, data); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw BusinessExceptions.todoCategoryNameDuplicate(data.name ?? ""); + } + throw error; + } this.logger.log(`TodoCategory updated: ${id} for user: ${userId}`); @@ -187,7 +209,16 @@ export class TodoCategoryService { if (todoCount > 0) { // Todo가 있으면 이동 대상 카테고리 필수 if (!moveToCategoryId) { - throw BusinessExceptions.todoCategoryMoveTargetRequired(); + throw BusinessExceptions.todoCategoryHasTodos(categoryId, todoCount); + } + + // 삭제 대상과 이동 대상이 같으면 에러 + if (moveToCategoryId === categoryId) { + throw BusinessExceptions.invalidParameter({ + message: "삭제할 카테고리와 이동 대상 카테고리가 같을 수 없습니다", + categoryId, + moveToCategoryId, + }); } // 이동 대상 카테고리 확인 diff --git a/apps/api/test/e2e/todo-category.e2e-spec.ts b/apps/api/test/e2e/todo-category.e2e-spec.ts index 4d13ddb9..da12a083 100644 --- a/apps/api/test/e2e/todo-category.e2e-spec.ts +++ b/apps/api/test/e2e/todo-category.e2e-spec.ts @@ -510,7 +510,37 @@ describe("TodoCategory (e2e)", () => { .set("Authorization", `Bearer ${accessToken}`) .expect(400); - expect(response.body.error.code).toBe("TODO_CATEGORY_0856"); + expect(response.body.error.code).toBe("TODO_CATEGORY_0855"); + }); + + it("moveToCategoryId가 삭제 대상과 같으면 400을 반환해야 한다", async () => { + // 카테고리 생성 + const catRes = await request(app.getHttpServer()) + .post("/todo-categories") + .set("Authorization", `Bearer ${accessToken}`) + .send({ name: "셀프 이동 테스트", color: "#FF0000" }) + .expect(201); + const categoryId = catRes.body.data.category.id; + + // Todo 생성 + await request(app.getHttpServer()) + .post("/todos") + .set("Authorization", `Bearer ${accessToken}`) + .send({ + title: "셀프 이동 할 일", + categoryId, + startDate: "2024-01-15", + }) + .expect(201); + + // 자기 자신으로 이동 시도 + const response = await request(app.getHttpServer()) + .delete(`/todo-categories/${categoryId}`) + .set("Authorization", `Bearer ${accessToken}`) + .query({ moveToCategoryId: categoryId }) + .expect(400); + + expect(response.body.error.code).toBe("SYS_0002"); }); it("Todo가 있는 카테고리를 이동 대상과 함께 삭제 성공", async () => { diff --git a/apps/mobile/src/shared/infra/http/error-handler.ts b/apps/mobile/src/shared/infra/http/error-handler.ts index f605319a..515966bd 100644 --- a/apps/mobile/src/shared/infra/http/error-handler.ts +++ b/apps/mobile/src/shared/infra/http/error-handler.ts @@ -101,6 +101,7 @@ const MOBILE_ERROR_MESSAGES: Partial> = { USER_0608: '이메일 인증이 필요해요. 이메일을 확인해주세요.', USER_0609: '로그인 시도가 너무 많아요. 잠시 후 다시 시도해주세요.', USER_0610: '마지막 로그인 수단은 해제할 수 없어요', + USER_0611: '계정 설정에 문제가 있어요. 다시 시도해주세요.', // ========================================================================= // 세션 (SESSION) @@ -122,6 +123,17 @@ const MOBILE_ERROR_MESSAGES: Partial> = { // Todo (TODO) // ========================================================================= TODO_0801: '할 일을 찾을 수 없어요', + TODO_0810: '이동할 위치의 할 일을 찾을 수 없어요', + + // ========================================================================= + // Todo 카테고리 (TODO_CATEGORY) + // ========================================================================= + TODO_CATEGORY_0851: '카테고리를 찾을 수 없어요', + TODO_CATEGORY_0852: '다른 사용자의 카테고리예요', + TODO_CATEGORY_0853: '같은 이름의 카테고리가 이미 있어요', + TODO_CATEGORY_0854: '최소 1개의 카테고리가 필요해요', + TODO_CATEGORY_0855: '카테고리에 할 일이 있어요. 이동할 카테고리를 선택해주세요.', + TODO_CATEGORY_0856: '이동할 카테고리를 선택해주세요', // ========================================================================= // 친구/팔로우 (FOLLOW) diff --git a/packages/validators/src/domains/cheer/cheer.request.ts b/packages/validators/src/domains/cheer/cheer.request.ts index 9e195cc3..c32cc6f4 100644 --- a/packages/validators/src/domains/cheer/cheer.request.ts +++ b/packages/validators/src/domains/cheer/cheer.request.ts @@ -32,3 +32,21 @@ export const markCheersReadSchema = z.object({ }); export type MarkCheersReadInput = z.infer; + +export const getCheersQuerySchema = z.object({ + limit: z.coerce + .number() + .int() + .min(1, '최소 1개 이상 조회해야 합니다') + .max(50, '최대 50개까지 조회 가능합니다') + .default(20) + .describe('조회할 개수 (1-50, 기본값: 20)'), + cursor: z.coerce + .number() + .int() + .positive('유효하지 않은 커서입니다') + .optional() + .describe('페이지네이션 커서 (양의 정수)'), +}); + +export type GetCheersQuery = z.infer; diff --git a/packages/validators/src/domains/nudge/nudge.request.ts b/packages/validators/src/domains/nudge/nudge.request.ts index 5451e1d2..e7471539 100644 --- a/packages/validators/src/domains/nudge/nudge.request.ts +++ b/packages/validators/src/domains/nudge/nudge.request.ts @@ -37,3 +37,21 @@ export const markNudgesReadSchema = z.object({ }); export type MarkNudgesReadInput = z.infer; + +export const getNudgesQuerySchema = z.object({ + limit: z.coerce + .number() + .int() + .min(1, '최소 1개 이상 조회해야 합니다') + .max(50, '최대 50개까지 조회 가능합니다') + .default(20) + .describe('조회할 개수 (1-50, 기본값: 20)'), + cursor: z.coerce + .number() + .int() + .positive('유효하지 않은 커서입니다') + .optional() + .describe('페이지네이션 커서 (양의 정수)'), +}); + +export type GetNudgesQuery = z.infer;