From 04787492c3aab52c609e777510c316c6b5736e24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8B=E1=85=AD=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Wed, 25 Feb 2026 00:38:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(api,mobile):=20ADMIN=20=EB=AC=B4?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EC=9A=B0=ED=9A=8C,=20Sentry=20=EA=B3=A0?= =?UTF-8?q?=EB=8F=84=ED=99=94,=20=EC=95=8C=EB=A6=BC=20Lock=20dedup=20(#217?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.docker.prod.example | 1 + apps/api/src/common/cache/cache.service.ts | 1 + .../common/config/schemas/external.schema.ts | 5 + .../common/config/services/config.service.ts | 6 + .../filters/global-exception.filter.spec.ts | 12 +- .../filters/global-exception.filter.ts | 23 +++- apps/api/src/instrument.ts | 24 +++- apps/api/src/main.ts | 9 ++ .../api/src/modules/cheer/cheer.repository.ts | 14 +++ .../src/modules/cheer/cheer.service.spec.ts | 107 +++++++++++++++--- apps/api/src/modules/cheer/cheer.service.ts | 24 ++-- .../notification/notification.service.spec.ts | 104 ++++++++++++++++- .../notification/notification.service.ts | 104 ++++++++++++----- .../api/src/modules/nudge/nudge.repository.ts | 14 +++ .../src/modules/nudge/nudge.service.spec.ts | 76 ++++++++++++- apps/api/src/modules/nudge/nudge.service.ts | 23 ++-- .../notification/models/notification.model.ts | 3 +- .../presentations/utils/get-internal-route.ts | 3 +- .../features/user/models/user.model.test.ts | 6 + .../src/features/user/models/user.model.ts | 7 +- .../components/ProfileInfoCard.tsx | 4 +- .../src/features/user/services/user.mapper.ts | 1 + 22 files changed, 490 insertions(+), 81 deletions(-) diff --git a/.env.docker.prod.example b/.env.docker.prod.example index d8a496b3..793e4795 100644 --- a/.env.docker.prod.example +++ b/.env.docker.prod.example @@ -75,4 +75,5 @@ CACHE_CLEANUP_INTERVAL_MS=30000 # --- Monitoring --- # SENTRY_DSN= +# SENTRY_TRACES_SAMPLE_RATE=0.2 # LOG_LEVEL=info diff --git a/apps/api/src/common/cache/cache.service.ts b/apps/api/src/common/cache/cache.service.ts index 84405bba..84a17957 100644 --- a/apps/api/src/common/cache/cache.service.ts +++ b/apps/api/src/common/cache/cache.service.ts @@ -38,6 +38,7 @@ export interface CachedUserProfile { export interface CachedSubscription { status: SubscriptionStatus | null; + isAdmin?: boolean; } /** diff --git a/apps/api/src/common/config/schemas/external.schema.ts b/apps/api/src/common/config/schemas/external.schema.ts index b74b48b7..bbbc565c 100644 --- a/apps/api/src/common/config/schemas/external.schema.ts +++ b/apps/api/src/common/config/schemas/external.schema.ts @@ -25,6 +25,11 @@ export const externalSchema = z.object({ message: "SENTRY_DSN must be a valid HTTPS URL", }), + // Sentry 트레이스 샘플링 비율 (0.0 ~ 1.0, 기본값: production=0.2, 나머지=1.0) + SENTRY_TRACES_SAMPLE_RATE: z.coerce.number().min(0).max(1).optional(), + + // TODO: 서비스 스케일업 시 릴리스 버저닝 추가 (SENTRY_RELEASE: z.string().optional()) + // Google Generative AI (Gemini) 서비스 (선택) GOOGLE_GENERATIVE_AI_API_KEY: z .string() diff --git a/apps/api/src/common/config/services/config.service.ts b/apps/api/src/common/config/services/config.service.ts index 17b5d954..d33b56cb 100644 --- a/apps/api/src/common/config/services/config.service.ts +++ b/apps/api/src/common/config/services/config.service.ts @@ -197,6 +197,12 @@ export class TypedConfigService { return this.get("SENTRY_DSN"); } + get sentryTracesSampleRate(): number | undefined { + return this.get("SENTRY_TRACES_SAMPLE_RATE"); + } + + // TODO: 서비스 스케일업 시 릴리스 버저닝 추가 (sentryRelease getter) + // ============================================ // AI Config Helpers // ============================================ 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 index dde163b2..50aff2c4 100644 --- a/apps/api/src/common/exception/filters/global-exception.filter.spec.ts +++ b/apps/api/src/common/exception/filters/global-exception.filter.spec.ts @@ -15,6 +15,13 @@ import { GlobalExceptionFilter } from "./global-exception.filter"; jest.mock("@sentry/nestjs", () => ({ captureException: jest.fn(), + withScope: jest.fn((callback: (scope: unknown) => void) => { + callback({ + setUser: jest.fn(), + setTags: jest.fn(), + setExtra: jest.fn(), + }); + }), })); describe("GlobalExceptionFilter", () => { @@ -237,7 +244,7 @@ describe("GlobalExceptionFilter", () => { jest.clearAllMocks(); }); - it("5xx 서버 에러는 Sentry에 캡처해야 한다", () => { + it("5xx 서버 에러는 Sentry에 scope 컨텍스트와 함께 캡처해야 한다", () => { // Given const exception = new Error("unexpected server error"); @@ -245,6 +252,7 @@ describe("GlobalExceptionFilter", () => { filter.catch(exception, mockHost as never); // Then + expect(Sentry.withScope).toHaveBeenCalledTimes(1); expect(Sentry.captureException).toHaveBeenCalledWith(exception); }); @@ -259,6 +267,7 @@ describe("GlobalExceptionFilter", () => { filter.catch(exception, mockHost as never); // Then + expect(Sentry.withScope).not.toHaveBeenCalled(); expect(Sentry.captureException).not.toHaveBeenCalled(); }); @@ -270,6 +279,7 @@ describe("GlobalExceptionFilter", () => { filter.catch(exception, mockHost as never); // Then + expect(Sentry.withScope).not.toHaveBeenCalled(); expect(Sentry.captureException).not.toHaveBeenCalled(); }); }); 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 55176e09..7ff09137 100644 --- a/apps/api/src/common/exception/filters/global-exception.filter.ts +++ b/apps/api/src/common/exception/filters/global-exception.filter.ts @@ -114,15 +114,30 @@ export class GlobalExceptionFilter implements ExceptionFilter { }; } + // 사용자 ID 추출 (Sentry 컨텍스트 + 로깅 공용) + const userId = + (request as Request & { user?: { userId?: string } }).user?.userId ?? + "anonymous"; + // 서버 에러(5xx)만 Sentry에 캡처 (4xx 클라이언트 에러는 노이즈 방지) if (statusCode >= 500) { - Sentry.captureException(exception); + Sentry.withScope((scope) => { + scope.setUser({ + id: userId !== "anonymous" ? userId : undefined, + ip_address: "{{auto}}", + }); + scope.setTags({ + "http.method": request.method, + "http.url": request.url, + "http.status_code": String(statusCode), + "error.code": errorResponse.error.code, + }); + scope.setExtra("errorResponse", errorResponse); + Sentry.captureException(exception); + }); } // 에러 로깅 (pinoHttp가 요청/응답은 자동 로깅하므로 에러 정보만 간결하게) - const userId = - (request as Request & { user?: { userId?: string } }).user?.userId ?? - "anonymous"; if (statusCode >= 500) { // 서버 에러: 스택 트레이스 포함 const stack = exception instanceof Error ? exception.stack : undefined; diff --git a/apps/api/src/instrument.ts b/apps/api/src/instrument.ts index 3b71f3ee..d8e8564a 100644 --- a/apps/api/src/instrument.ts +++ b/apps/api/src/instrument.ts @@ -3,5 +3,27 @@ import * as Sentry from "@sentry/nestjs"; Sentry.init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV || "development", - tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0, + // TODO: 서비스 스케일업 시 릴리스 버저닝 추가 (e.g., release: process.env.SENTRY_RELEASE) + tracesSampleRate: + Number(process.env.SENTRY_TRACES_SAMPLE_RATE) || + (process.env.NODE_ENV === "production" ? 0.2 : 1.0), + + beforeSend(event) { + // 요청 헤더에서 민감 정보 제거 + if (event.request?.headers) { + delete event.request.headers.authorization; + delete event.request.headers.cookie; + delete event.request.headers["x-forwarded-for"]; + } + + // 쿼리스트링에서 token 파라미터 마스킹 + if (event.request?.query_string) { + event.request.query_string = String(event.request.query_string).replace( + /token=[^&]+/gi, + "token=[FILTERED]", + ); + } + + return event; + }, }); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 5eabf578..59114074 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -3,6 +3,7 @@ import "./instrument"; import { ConfigService } from "@nestjs/config"; import { NestFactory } from "@nestjs/core"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import * as Sentry from "@sentry/nestjs"; import helmet from "helmet"; import { Logger } from "nestjs-pino"; import { cleanupOpenApiDoc, ZodValidationPipe } from "nestjs-zod"; @@ -252,6 +253,14 @@ async function bootstrap() { logger.log(`📚 API Docs: http://localhost:${port}/api/docs`); logger.log(`📚 Admin API Docs: http://localhost:${port}/api/admin/docs`); logger.log(`💊 Health Check: http://localhost:${port}/health`); + + // Graceful shutdown 시 Sentry 버퍼 flush + const shutdown = async (signal: string) => { + logger.log(`Received ${signal}, flushing Sentry events...`); + await Sentry.close(2000); + }; + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); } bootstrap(); diff --git a/apps/api/src/modules/cheer/cheer.repository.ts b/apps/api/src/modules/cheer/cheer.repository.ts index 1dbc3cb3..d67113b6 100644 --- a/apps/api/src/modules/cheer/cheer.repository.ts +++ b/apps/api/src/modules/cheer/cheer.repository.ts @@ -287,4 +287,18 @@ export class CheerRepository { }); return user?.subscriptionStatus ?? null; } + + /** + * 사용자 구독 상태 및 역할 조회 (ADMIN 우회 판단용) + */ + async getUserSubscriptionInfo(userId: string): Promise<{ + subscriptionStatus: "FREE" | "ACTIVE" | "EXPIRED" | "CANCELLED"; + role: string; + } | null> { + const user = await this.database.user.findUnique({ + where: { id: userId }, + select: { subscriptionStatus: true, role: true }, + }); + return user ?? null; + } } diff --git a/apps/api/src/modules/cheer/cheer.service.spec.ts b/apps/api/src/modules/cheer/cheer.service.spec.ts index 3ae55fc5..83da0d0b 100644 --- a/apps/api/src/modules/cheer/cheer.service.spec.ts +++ b/apps/api/src/modules/cheer/cheer.service.spec.ts @@ -237,9 +237,43 @@ describe("CheerService", () => { async (callback: (tx: unknown) => Promise) => { const txProxy = { user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "ACTIVE" }), + findUnique: jest.fn().mockResolvedValue({ + subscriptionStatus: "ACTIVE", + role: "USER", + }), + }, + cheer: { + count: jest.fn().mockResolvedValue(100), + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue(expectedCheer), + }, + }; + return callback(txProxy); + }, + ); + + // When + const result = await service.sendCheer(validParams); + + // Then + expect(result).toEqual(expectedCheer); + }); + + it("ADMIN은 구독 상태와 무관하게 일일 제한이 없다", async () => { + // Given + const expectedCheer = CheerBuilder.create(senderId, receiverId) + .withMessage(validParams.message) + .withSenderProfile({ name: "테스트유저", profileImage: null }) + .buildWithRelations(); + + (database.$transaction as jest.Mock).mockImplementation( + async (callback: (tx: unknown) => Promise) => { + const txProxy = { + user: { + findUnique: jest.fn().mockResolvedValue({ + subscriptionStatus: "FREE", + role: "ADMIN", + }), }, cheer: { count: jest.fn().mockResolvedValue(100), @@ -650,9 +684,10 @@ describe("CheerService", () => { it("FREE 구독자의 일일 제한 정보를 반환한다", async () => { // Given const userId = "user-1"; - (cheerRepo.getUserSubscriptionStatus as jest.Mock).mockResolvedValue( - "FREE", - ); + (cheerRepo.getUserSubscriptionInfo as jest.Mock).mockResolvedValue({ + subscriptionStatus: "FREE", + role: "USER", + }); (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(1); // When @@ -669,9 +704,10 @@ describe("CheerService", () => { it("ACTIVE 구독자는 무제한 제한 정보를 반환한다", async () => { // Given const userId = "user-1"; - (cheerRepo.getUserSubscriptionStatus as jest.Mock).mockResolvedValue( - "ACTIVE", - ); + (cheerRepo.getUserSubscriptionInfo as jest.Mock).mockResolvedValue({ + subscriptionStatus: "ACTIVE", + role: "USER", + }); (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(50); // When @@ -685,12 +721,52 @@ describe("CheerService", () => { }); }); + it("ADMIN은 구독 상태와 무관하게 무제한이다", async () => { + // Given + const userId = "user-1"; + (cacheService.getSubscription as jest.Mock).mockResolvedValue({ + status: "FREE", + isAdmin: true, + }); + (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(100); + + // When + const result = await service.getLimitInfo(userId); + + // Then + expect(result).toEqual({ + dailyLimit: null, + used: 100, + remaining: null, + }); + }); + + it("캐시 미스 시 ADMIN 정보를 DB에서 조회하여 캐싱한다", async () => { + // Given + const userId = "user-1"; + (cacheService.getSubscription as jest.Mock).mockResolvedValue(undefined); + (cheerRepo.getUserSubscriptionInfo as jest.Mock).mockResolvedValue({ + subscriptionStatus: "FREE", + role: "ADMIN", + }); + (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(100); + + // When + const result = await service.getLimitInfo(userId); + + // Then + expect(result.dailyLimit).toBeNull(); + expect(result.remaining).toBeNull(); + expect(cacheService.setSubscription).toHaveBeenCalledWith(userId, { + status: "FREE", + isAdmin: true, + }); + }); + it("구독 상태가 없으면 FREE로 처리한다", async () => { // Given const userId = "user-1"; - (cheerRepo.getUserSubscriptionStatus as jest.Mock).mockResolvedValue( - null, - ); + (cheerRepo.getUserSubscriptionInfo as jest.Mock).mockResolvedValue(null); (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(0); // When @@ -703,9 +779,10 @@ describe("CheerService", () => { it("남은 횟수가 음수가 되지 않는다", async () => { // Given const userId = "user-1"; - (cheerRepo.getUserSubscriptionStatus as jest.Mock).mockResolvedValue( - "FREE", - ); + (cheerRepo.getUserSubscriptionInfo as jest.Mock).mockResolvedValue({ + subscriptionStatus: "FREE", + role: "USER", + }); (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(100); // 제한보다 많음 // When diff --git a/apps/api/src/modules/cheer/cheer.service.ts b/apps/api/src/modules/cheer/cheer.service.ts index ae784ad1..8deb5909 100644 --- a/apps/api/src/modules/cheer/cheer.service.ts +++ b/apps/api/src/modules/cheer/cheer.service.ts @@ -88,13 +88,15 @@ export class CheerService { const subscriptionStatus = await tx.user.findUnique({ where: { id: senderId }, - select: { subscriptionStatus: true }, + select: { subscriptionStatus: true, role: true }, }); + const isAdmin = subscriptionStatus?.role === "ADMIN"; const status = subscriptionStatus?.subscriptionStatus ?? "FREE"; const limitKey = status as keyof typeof SUBSCRIPTION_CHEER_LIMITS; - const dailyLimit = - limitKey in SUBSCRIPTION_CHEER_LIMITS + const dailyLimit = isAdmin + ? null + : limitKey in SUBSCRIPTION_CHEER_LIMITS ? SUBSCRIPTION_CHEER_LIMITS[limitKey] : CHEER_LIMITS.FREE_DAILY_LIMIT; @@ -275,24 +277,30 @@ export class CheerService { ): Promise { // 구독 상태 조회 (캐시 우선) let subscriptionStatus: "FREE" | "ACTIVE" | "EXPIRED" | "CANCELLED" | null; + let isAdmin = false; const cachedSubscription = await this.cacheService.getSubscription(userId); if (cachedSubscription !== undefined) { subscriptionStatus = cachedSubscription.status; + isAdmin = cachedSubscription.isAdmin ?? false; } else { - subscriptionStatus = - await this.cheerRepository.getUserSubscriptionStatus(userId); + const userInfo = + await this.cheerRepository.getUserSubscriptionInfo(userId); + subscriptionStatus = userInfo?.subscriptionStatus ?? null; + isAdmin = userInfo?.role === "ADMIN"; await this.cacheService.setSubscription(userId, { status: subscriptionStatus, + isAdmin, }); } // 구독 상태에 따른 제한 const status = subscriptionStatus ?? "FREE"; const limitKey = status as keyof typeof SUBSCRIPTION_CHEER_LIMITS; - // ACTIVE 구독자는 null(무제한)이므로 undefined만 체크 - const dailyLimit = - limitKey in SUBSCRIPTION_CHEER_LIMITS + // ADMIN은 무제한, ACTIVE 구독자도 null(무제한) + const dailyLimit = isAdmin + ? null + : limitKey in SUBSCRIPTION_CHEER_LIMITS ? SUBSCRIPTION_CHEER_LIMITS[limitKey] : CHEER_LIMITS.FREE_DAILY_LIMIT; diff --git a/apps/api/src/modules/notification/notification.service.spec.ts b/apps/api/src/modules/notification/notification.service.spec.ts index bfdc44fe..6cf4092b 100644 --- a/apps/api/src/modules/notification/notification.service.spec.ts +++ b/apps/api/src/modules/notification/notification.service.spec.ts @@ -18,6 +18,8 @@ import { UserPreferenceBuilder, } from "@test/builders"; import { BusinessException } from "@/common/exception/services/business-exception.service"; +import type { ILockProvider } from "@/common/lock"; +import { LOCK_PROVIDER } from "@/common/lock"; import { PaginationService } from "@/common/pagination/services/pagination.service"; import { NotificationRepository } from "./notification.repository"; import { NotificationService } from "./notification.service"; @@ -29,6 +31,7 @@ describe("NotificationService", () => { let notificationRepo: Mocked; let paginationService: Mocked; let pushDeliveryService: Mocked; + let lockProvider: Mocked; // 테스트 데이터 const mockUserId = "user-1"; @@ -39,9 +42,16 @@ describe("NotificationService", () => { PushTokenBuilder.resetIdCounter(); UserPreferenceBuilder.resetIdCounter(); + const mockLockProvider: ILockProvider = { + acquire: jest.fn(), + isLocked: jest.fn(), + }; + // Suites가 모든 의존성을 자동으로 mock - const { unit, unitRef } = - await TestBed.solitary(NotificationService).compile(); + const { unit, unitRef } = await TestBed.solitary(NotificationService) + .mock(LOCK_PROVIDER) + .impl(() => mockLockProvider) + .compile(); service = unit; notificationRepo = unitRef.get( @@ -53,6 +63,12 @@ describe("NotificationService", () => { pushDeliveryService = unitRef.get( PushDeliveryService, ) as unknown as Mocked; + lockProvider = unitRef.get( + LOCK_PROVIDER, + ) as unknown as Mocked; + + // 기본: Lock 획득 성공 (release 함수 반환) + lockProvider.acquire.mockResolvedValue(jest.fn()); // PaginationService 기본 동작 설정 paginationService.normalizeCursorPagination.mockReturnValue({ @@ -763,5 +779,89 @@ describe("NotificationService", () => { jest.spyOn(Date, "now").mockRestore(); }); + + // ====================================================================== + // 잠금(Lock) 관련 테스트 + // ====================================================================== + + it("dedup 처리 시 잠금을 획득하고 해제해야 한다", async () => { + // Given + const mockRelease = jest.fn().mockResolvedValue(undefined); + lockProvider.acquire.mockResolvedValue(mockRelease); + notificationRepo.existsRecentNotification.mockResolvedValue(false); + baseSetup(); + + // When + await service.createAndSendWithDedup({ + userId: mockUserId, + type: "NUDGE_RECEIVED", + title: "콕!", + body: "찔러요", + friendId: "friend-1", + }); + + // Then + expect(lockProvider.acquire).toHaveBeenCalledWith( + expect.stringContaining("dedup:user-1:NUDGE_RECEIVED"), + 5000, + ); + expect(mockRelease).toHaveBeenCalled(); + }); + + it("잠금 획득 실패 시 null을 반환하고 DB 조회를 하지 않아야 한다", async () => { + // Given + lockProvider.acquire.mockResolvedValue(null); + + // When + const result = await service.createAndSendWithDedup({ + userId: mockUserId, + type: "NUDGE_RECEIVED", + title: "콕!", + body: "찔러요", + friendId: "friend-1", + }); + + // Then + expect(result).toBeNull(); + expect(notificationRepo.existsRecentNotification).not.toHaveBeenCalled(); + expect(notificationRepo.createNotification).not.toHaveBeenCalled(); + }); + + it("DB 조회 실패 시에도 잠금이 해제되어야 한다", async () => { + // Given + const mockRelease = jest.fn().mockResolvedValue(undefined); + lockProvider.acquire.mockResolvedValue(mockRelease); + notificationRepo.existsRecentNotification.mockRejectedValue( + new Error("DB error"), + ); + + // When & Then + await expect( + service.createAndSendWithDedup({ + userId: mockUserId, + type: "NUDGE_RECEIVED", + title: "콕!", + body: "찔러요", + friendId: "friend-1", + }), + ).rejects.toThrow("DB error"); + expect(mockRelease).toHaveBeenCalled(); + }); + + it("전략이 없는 타입은 잠금을 획득하지 않아야 한다", async () => { + // Given + baseSetup(); + + // When + await service.createAndSendWithDedup({ + userId: mockUserId, + type: "SYSTEM_NOTICE", + title: "공지", + body: "점검", + }); + + // Then + expect(lockProvider.acquire).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/api/src/modules/notification/notification.service.ts b/apps/api/src/modules/notification/notification.service.ts index 2f57590e..b0e9d82a 100644 --- a/apps/api/src/modules/notification/notification.service.ts +++ b/apps/api/src/modules/notification/notification.service.ts @@ -3,8 +3,9 @@ import { type NotificationCategory, type Notification as NotificationDto, } from "@aido/validators"; -import { Injectable, Logger } from "@nestjs/common"; +import { Inject, Injectable, Logger } from "@nestjs/common"; import { BusinessExceptions } from "@/common/exception/services/business-exception.service"; +import { type ILockProvider, LOCK_PROVIDER } from "@/common/lock"; import type { CursorPaginatedResponse } from "@/common/pagination/interfaces/pagination.interface"; import { PaginationService } from "@/common/pagination/services/pagination.service"; import { @@ -42,6 +43,7 @@ export class NotificationService { private readonly notificationRepository: NotificationRepository, private readonly paginationService: PaginationService, private readonly pushDeliveryService: PushDeliveryService, + @Inject(LOCK_PROVIDER) private readonly lockProvider: ILockProvider, ) {} // ========================================================================= @@ -88,10 +90,16 @@ export class NotificationService { }, }; + /** dedup 잠금 TTL (밀리초) — DB 조회 + 생성에 충분한 시간 */ + private static readonly DEDUP_LOCK_TTL = 5_000; + /** * 중복 방지가 적용된 알림 생성 및 푸시 발송 * - * @returns 생성된 Notification 또는 null (중복 스킵) + * Race Condition 방지: ILockProvider 기반 잠금으로 + * 같은 (userId, type, contextKeys) 조합의 동시 요청을 직렬화합니다. + * + * @returns 생성된 Notification 또는 null (중복 스킵 / 잠금 대기 스킵) */ async createAndSendWithDedup( data: CreateNotificationData, @@ -104,40 +112,76 @@ export class NotificationService { return this.createAndSend(data, tx); } - const since = new Date(Date.now() - strategy.windowMs); - const params: { - userId: string; - type: NotificationType; - since: Date; - friendId?: string; - todoId?: number; - nudgeId?: number; - cheerId?: number; - } = { - userId: data.userId, - type: data.type as NotificationType, - since, - }; - - for (const key of strategy.keys) { - const value = data[key]; - if (value != null) { - (params as Record)[key] = value; - } - } - - const exists = await this.notificationRepository.existsRecentNotification( - params, - tx, + const dedupKey = this.#buildDedupKey(data, strategy); + const release = await this.lockProvider.acquire( + dedupKey, + NotificationService.DEDUP_LOCK_TTL, ); - if (exists) { + + if (!release) { this.#logger.debug( - `Notification dedup: skipped ${data.type} for userId=${data.userId}`, + `Notification dedup: lock busy for ${data.type}, userId=${data.userId}`, ); return null; } - return this.createAndSend(data, tx); + try { + const since = new Date(Date.now() - strategy.windowMs); + const params: { + userId: string; + type: NotificationType; + since: Date; + friendId?: string; + todoId?: number; + nudgeId?: number; + cheerId?: number; + } = { + userId: data.userId, + type: data.type as NotificationType, + since, + }; + + for (const key of strategy.keys) { + const value = data[key]; + if (value != null) { + (params as Record)[key] = value; + } + } + + const exists = await this.notificationRepository.existsRecentNotification( + params, + tx, + ); + if (exists) { + this.#logger.debug( + `Notification dedup: skipped ${data.type} for userId=${data.userId}`, + ); + return null; + } + + return await this.createAndSend(data, tx); + } finally { + await release(); + } + } + + /** + * 중복 방지 잠금 키 생성 + */ + #buildDedupKey( + data: CreateNotificationData, + strategy: { + keys: Array<"friendId" | "todoId" | "nudgeId" | "cheerId">; + }, + ): string { + const parts = ["dedup", data.userId, data.type as string]; + for (const key of strategy.keys) { + const value = data[key]; + if (value != null) { + parts.push(`${key}:${String(value)}`); + } + } + return parts.join(":"); } /** diff --git a/apps/api/src/modules/nudge/nudge.repository.ts b/apps/api/src/modules/nudge/nudge.repository.ts index 33b644f3..7c77b922 100644 --- a/apps/api/src/modules/nudge/nudge.repository.ts +++ b/apps/api/src/modules/nudge/nudge.repository.ts @@ -263,6 +263,20 @@ export class NudgeRepository { return user?.subscriptionStatus ?? null; } + /** + * 사용자 구독 상태 및 역할 조회 (ADMIN 우회 판단용) + */ + async getUserSubscriptionInfo(userId: string): Promise<{ + subscriptionStatus: "FREE" | "ACTIVE" | "EXPIRED" | "CANCELLED"; + role: string; + } | null> { + const user = await this.database.user.findUnique({ + where: { id: userId }, + select: { subscriptionStatus: true, role: true }, + }); + return user ?? null; + } + /** * Todo 존재 및 소유자 확인 */ diff --git a/apps/api/src/modules/nudge/nudge.service.spec.ts b/apps/api/src/modules/nudge/nudge.service.spec.ts index ddf4550e..7aee10a1 100644 --- a/apps/api/src/modules/nudge/nudge.service.spec.ts +++ b/apps/api/src/modules/nudge/nudge.service.spec.ts @@ -321,6 +321,48 @@ describe("NudgeService", () => { }); mockDatabase.user.findUnique.mockResolvedValue({ subscriptionStatus: "ACTIVE", + role: "USER", + }); + mockDatabase.nudge.count.mockResolvedValue(100); + mockDatabase.nudge.findFirst.mockResolvedValue(null); + + const nudgeWithRelations = NudgeBuilder.create( + "sender-id", + "receiver-id", + 100, + ) + .withSenderInfo({ + id: "sender-id", + userTag: "sender_tag", + profile: { name: "보내는 사람", profileImage: null }, + }) + .withTodoInfo({ id: 100, title: "테스트 할일", completed: false }) + .buildWithRelations(); + + mockDatabase.nudge.create.mockResolvedValue(nudgeWithRelations); + nudgeRepository.getUserName.mockResolvedValue("보내는 사람"); + + // When + const result = await service.sendNudge(defaultParams); + + // Then + expect(result).toBeDefined(); + }); + + it("ADMIN은 구독 상태와 무관하게 무제한 Nudge를 보낼 수 있다", async () => { + // Given + followService.isMutualFriend.mockResolvedValue(true); + mockDatabase.todo.findUnique.mockResolvedValue({ + id: 100, + userId: "receiver-id", + title: "테스트 할일", + startDate: todayMidnight, + endDate: null, + visibility: "PUBLIC", + }); + mockDatabase.user.findUnique.mockResolvedValue({ + subscriptionStatus: "FREE", + role: "ADMIN", }); mockDatabase.nudge.count.mockResolvedValue(100); mockDatabase.nudge.findFirst.mockResolvedValue(null); @@ -618,7 +660,10 @@ describe("NudgeService", () => { describe("getLimitInfo", () => { it("FREE 사용자의 제한 정보를 조회한다", async () => { // Given - nudgeRepository.getUserSubscriptionStatus.mockResolvedValue("FREE"); + nudgeRepository.getUserSubscriptionInfo.mockResolvedValue({ + subscriptionStatus: "FREE", + role: "USER", + }); nudgeRepository.countTodayNudges.mockResolvedValue(1); // When @@ -632,7 +677,27 @@ describe("NudgeService", () => { it("ACTIVE 사용자는 무제한이다", async () => { // Given - nudgeRepository.getUserSubscriptionStatus.mockResolvedValue("ACTIVE"); + nudgeRepository.getUserSubscriptionInfo.mockResolvedValue({ + subscriptionStatus: "ACTIVE", + role: "USER", + }); + nudgeRepository.countTodayNudges.mockResolvedValue(100); + + // When + const result = await service.getLimitInfo("user-id"); + + // Then + expect(result.dailyLimit).toBeNull(); + expect(result.used).toBe(100); + expect(result.remaining).toBeNull(); + }); + + it("ADMIN은 구독 상태와 무관하게 무제한이다", async () => { + // Given + nudgeRepository.getUserSubscriptionInfo.mockResolvedValue({ + subscriptionStatus: "FREE", + role: "ADMIN", + }); nudgeRepository.countTodayNudges.mockResolvedValue(100); // When @@ -646,7 +711,10 @@ describe("NudgeService", () => { it("EXPIRED 사용자는 FREE 제한이 적용된다", async () => { // Given - nudgeRepository.getUserSubscriptionStatus.mockResolvedValue("EXPIRED"); + nudgeRepository.getUserSubscriptionInfo.mockResolvedValue({ + subscriptionStatus: "EXPIRED", + role: "USER", + }); nudgeRepository.countTodayNudges.mockResolvedValue(2); // When @@ -659,7 +727,7 @@ describe("NudgeService", () => { it("사용자가 없으면 FREE 제한이 적용된다", async () => { // Given - nudgeRepository.getUserSubscriptionStatus.mockResolvedValue(null); + nudgeRepository.getUserSubscriptionInfo.mockResolvedValue(null); nudgeRepository.countTodayNudges.mockResolvedValue(0); // When diff --git a/apps/api/src/modules/nudge/nudge.service.ts b/apps/api/src/modules/nudge/nudge.service.ts index 9979d7ee..ee69a11e 100644 --- a/apps/api/src/modules/nudge/nudge.service.ts +++ b/apps/api/src/modules/nudge/nudge.service.ts @@ -126,13 +126,15 @@ export class NudgeService { // 4. 일일 제한 체크 (트랜잭션 내에서 실시간 조회) const subscriptionStatus = await tx.user.findUnique({ where: { id: senderId }, - select: { subscriptionStatus: true }, + select: { subscriptionStatus: true, role: true }, }); + const isAdmin = subscriptionStatus?.role === "ADMIN"; const status = subscriptionStatus?.subscriptionStatus ?? "FREE"; const limitKey = status as keyof typeof SUBSCRIPTION_NUDGE_LIMITS; - const dailyLimit = - limitKey in SUBSCRIPTION_NUDGE_LIMITS + const dailyLimit = isAdmin + ? null + : limitKey in SUBSCRIPTION_NUDGE_LIMITS ? SUBSCRIPTION_NUDGE_LIMITS[limitKey] : NUDGE_LIMITS.FREE_DAILY_LIMIT; @@ -320,16 +322,17 @@ export class NudgeService { userId: string, tz: string = "UTC", ): Promise { - // 구독 상태 조회 - const subscriptionStatus = - await this.nudgeRepository.getUserSubscriptionStatus(userId); + // 구독 상태 및 역할 조회 + const userInfo = await this.nudgeRepository.getUserSubscriptionInfo(userId); + const isAdmin = userInfo?.role === "ADMIN"; // 구독 상태에 따른 제한 - const status = subscriptionStatus ?? "FREE"; + const status = userInfo?.subscriptionStatus ?? "FREE"; const limitKey = status as keyof typeof SUBSCRIPTION_NUDGE_LIMITS; - // ACTIVE 구독자는 null(무제한)이므로 undefined만 체크 - const dailyLimit = - limitKey in SUBSCRIPTION_NUDGE_LIMITS + // ADMIN은 무제한, ACTIVE 구독자도 null(무제한) + const dailyLimit = isAdmin + ? null + : limitKey in SUBSCRIPTION_NUDGE_LIMITS ? SUBSCRIPTION_NUDGE_LIMITS[limitKey] : NUDGE_LIMITS.FREE_DAILY_LIMIT; diff --git a/apps/mobile/src/features/notification/models/notification.model.ts b/apps/mobile/src/features/notification/models/notification.model.ts index e1d291b6..fe5a2ab1 100644 --- a/apps/mobile/src/features/notification/models/notification.model.ts +++ b/apps/mobile/src/features/notification/models/notification.model.ts @@ -137,7 +137,8 @@ const getCategoryLabel = (type: NotificationType): string => const getInternalRoute = (type: NotificationType, context?: NotificationContext): string | null => match(type) .with('FOLLOW_NEW', () => '/friends') - .with('FOLLOW_ACCEPTED', 'CHEER_RECEIVED', 'FRIEND_COMPLETED', () => + .with('FOLLOW_ACCEPTED', () => '/feed') + .with('CHEER_RECEIVED', 'FRIEND_COMPLETED', () => context?.friendId ? `/friends/${context.friendId}` : null, ) .with('NUDGE_RECEIVED', () => (context?.friendId ? `/friends/${context.friendId}` : null)) diff --git a/apps/mobile/src/features/notification/presentations/utils/get-internal-route.ts b/apps/mobile/src/features/notification/presentations/utils/get-internal-route.ts index 7711307e..b33c05cb 100644 --- a/apps/mobile/src/features/notification/presentations/utils/get-internal-route.ts +++ b/apps/mobile/src/features/notification/presentations/utils/get-internal-route.ts @@ -28,7 +28,8 @@ export const getInternalRoute = ( ): string | null => { return match(type) .with('FOLLOW_NEW', () => '/friends') - .with('FOLLOW_ACCEPTED', 'CHEER_RECEIVED', 'FRIEND_COMPLETED', () => + .with('FOLLOW_ACCEPTED', () => '/feed') + .with('CHEER_RECEIVED', 'FRIEND_COMPLETED', () => context?.friendId ? `/friends/${context.friendId}` : null, ) .with('NUDGE_RECEIVED', () => (context?.friendId ? `/friends/${context.friendId}` : null)) diff --git a/apps/mobile/src/features/user/models/user.model.test.ts b/apps/mobile/src/features/user/models/user.model.test.ts index 7b3c7ddf..3d2dd4ca 100644 --- a/apps/mobile/src/features/user/models/user.model.test.ts +++ b/apps/mobile/src/features/user/models/user.model.test.ts @@ -7,6 +7,7 @@ const createUser = (overrides?: Partial): User => ({ name: '테스트', profileImage: null, userTag: 'TEST2025', + role: 'USER', subscriptionStatus: 'FREE', providers: ['CREDENTIAL'], createdAt: new Date('2026-01-01T09:00:00.000Z'), @@ -20,6 +21,11 @@ describe('UserPolicy', () => { expect(UserPolicy.isPremiumUser(user)).toBe(true); }); + test('ADMIN role이면 구독 상태와 무관하게 true를 반환한다', () => { + const user = createUser({ role: 'ADMIN', subscriptionStatus: 'FREE' }); + expect(UserPolicy.isPremiumUser(user)).toBe(true); + }); + test.each([ 'FREE', 'EXPIRED', diff --git a/apps/mobile/src/features/user/models/user.model.ts b/apps/mobile/src/features/user/models/user.model.ts index a1dff8cd..0b68e4df 100644 --- a/apps/mobile/src/features/user/models/user.model.ts +++ b/apps/mobile/src/features/user/models/user.model.ts @@ -1,4 +1,4 @@ -import { ACCOUNT_PROVIDERS, SUBSCRIPTION_STATUS } from '@aido/validators'; +import { ACCOUNT_PROVIDERS, SUBSCRIPTION_STATUS, USER_ROLE } from '@aido/validators'; import { z } from 'zod'; const accountProviderSchema = z.enum(ACCOUNT_PROVIDERS); @@ -6,12 +6,15 @@ const accountProviderSchema = z.enum(ACCOUNT_PROVIDERS); export const subscriptionStatusSchema = z.enum(SUBSCRIPTION_STATUS); export type SubscriptionStatus = z.infer; +const userRoleSchema = z.enum([USER_ROLE.USER, USER_ROLE.ADMIN]); + export const userSchema = z.object({ id: z.string(), email: z.string(), name: z.string().nullable(), profileImage: z.string().nullable(), userTag: z.string(), + role: userRoleSchema, subscriptionStatus: subscriptionStatusSchema, providers: z.array(accountProviderSchema), createdAt: z.coerce.date(), @@ -27,7 +30,7 @@ export const updateNameInputSchema = z.object({ export type UpdateNameInput = z.infer; const isPremiumUser = (user: User) => { - return user.subscriptionStatus === 'ACTIVE'; + return user.role === 'ADMIN' || user.subscriptionStatus === 'ACTIVE'; }; const hasCredential = (user: User) => { diff --git a/apps/mobile/src/features/user/presentations/components/ProfileInfoCard.tsx b/apps/mobile/src/features/user/presentations/components/ProfileInfoCard.tsx index ea5bb473..a79aac4b 100644 --- a/apps/mobile/src/features/user/presentations/components/ProfileInfoCard.tsx +++ b/apps/mobile/src/features/user/presentations/components/ProfileInfoCard.tsx @@ -33,8 +33,8 @@ export function ProfileInfoCard({ - - + + diff --git a/apps/mobile/src/features/user/services/user.mapper.ts b/apps/mobile/src/features/user/services/user.mapper.ts index 8c674090..c691a0de 100644 --- a/apps/mobile/src/features/user/services/user.mapper.ts +++ b/apps/mobile/src/features/user/services/user.mapper.ts @@ -7,6 +7,7 @@ export const toUser = (dto: CurrentUser): User => ({ name: dto.name, profileImage: dto.profileImage, userTag: dto.userTag, + role: dto.role, subscriptionStatus: dto.subscriptionStatus, providers: dto.providers, createdAt: new Date(dto.createdAt), From 52e52678ede856435dce4792e3e1692110c8f6b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8B=E1=85=AD=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Wed, 25 Feb 2026 01:47:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor(api,mobile,validators):=20Entitlem?= =?UTF-8?q?entService=20=EC=A4=91=EC=95=99=EC=A7=91=EC=A4=91=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=BC=EA=B4=80=EC=84=B1=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EntitlementService(@Global) 생성: CHEER/NUDGE/AI_PARSE 기능별 제한을 역할/구독 상태 기반으로 통합 관리 - AI Parse: 고정 dailyLimit → EntitlementService 위임 (ADMIN/ACTIVE 무제한, FREE/EXPIRED/CANCELLED 5회) - Cheer/Nudge: 리포지토리 구독 조회 제거, EntitlementService로 위임 - AiService/EmailService spec: Test.createTestingModule → Suites TestBed.solitary 패턴 통일 - validators: AI_PARSE_LIMITS 상수 추가, UsageInfo.limit nullable 지원 --- apps/api/src/app.module.ts | 2 + .../common/entitlement/entitlement.module.ts | 9 + .../entitlement/entitlement.service.spec.ts | 398 ++++++++++++++++++ .../common/entitlement/entitlement.service.ts | 128 ++++++ apps/api/src/common/entitlement/index.ts | 3 + apps/api/src/common/index.ts | 2 + apps/api/src/instrument.ts | 7 +- apps/api/src/modules/ai/ai.service.spec.ts | 293 +++++++++---- apps/api/src/modules/ai/ai.service.ts | 46 +- .../modules/ai/guards/ai-usage.guard.spec.ts | 18 +- .../src/modules/ai/guards/ai-usage.guard.ts | 4 +- .../modules/cheer/cheer.repository.spec.ts | 53 --- .../api/src/modules/cheer/cheer.repository.ts | 27 -- .../src/modules/cheer/cheer.service.spec.ts | 195 ++++----- apps/api/src/modules/cheer/cheer.service.ts | 59 +-- .../src/modules/email/email.service.spec.ts | 105 +++-- .../modules/nudge/nudge.repository.spec.ts | 30 -- .../api/src/modules/nudge/nudge.repository.ts | 27 -- .../src/modules/nudge/nudge.service.spec.ts | 103 +++-- apps/api/src/modules/nudge/nudge.service.ts | 42 +- .../src/features/todo/models/todo.model.ts | 2 +- packages/validators/jest.config.cjs | 17 + packages/validators/package.json | 1 + .../src/domains/ai/ai-usage.response.ts | 2 +- .../validators/src/domains/ai/ai.constants.ts | 10 + packages/validators/src/domains/ai/index.ts | 2 + 26 files changed, 1092 insertions(+), 493 deletions(-) create mode 100644 apps/api/src/common/entitlement/entitlement.module.ts create mode 100644 apps/api/src/common/entitlement/entitlement.service.spec.ts create mode 100644 apps/api/src/common/entitlement/entitlement.service.ts create mode 100644 apps/api/src/common/entitlement/index.ts create mode 100644 packages/validators/jest.config.cjs create mode 100644 packages/validators/src/domains/ai/ai.constants.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 15cd1c39..8891bf6d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -8,6 +8,7 @@ import { AppConfigModule, CacheModule, EncryptionModule, + EntitlementModule, ExceptionModule, LockModule, LoggerModule, @@ -57,6 +58,7 @@ import { AppService } from "./app.service"; }), // 4. Global Modules + EntitlementModule, LoggerModule.forRootAsync(), ExceptionModule, ResponseModule, diff --git a/apps/api/src/common/entitlement/entitlement.module.ts b/apps/api/src/common/entitlement/entitlement.module.ts new file mode 100644 index 00000000..190353b1 --- /dev/null +++ b/apps/api/src/common/entitlement/entitlement.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { EntitlementService } from "./entitlement.service"; + +@Global() +@Module({ + providers: [EntitlementService], + exports: [EntitlementService], +}) +export class EntitlementModule {} diff --git a/apps/api/src/common/entitlement/entitlement.service.spec.ts b/apps/api/src/common/entitlement/entitlement.service.spec.ts new file mode 100644 index 00000000..5f210bf3 --- /dev/null +++ b/apps/api/src/common/entitlement/entitlement.service.spec.ts @@ -0,0 +1,398 @@ +/** + * EntitlementService 단위 테스트 + * + * Suites + GWT 패턴 적용 + * - Suites: 자동 Mock 생성 (CacheService, DatabaseService) + * - GWT: Given/When/Then 주석 + * + * 테스트 범위: + * - getFeatureLimit: 캐시 우선 조회 경로 (ADMIN/USER, 구독 상태별, 캐시 히트/미스) + * - getFeatureLimitInTx: 트랜잭션 내 실시간 조회 경로 + */ +import { AI_PARSE_LIMITS, CHEER_LIMITS, NUDGE_LIMITS } from "@aido/validators"; +import type { Mocked } from "@suites/doubles.jest"; +import { TestBed } from "@suites/unit"; +import { CacheService } from "@/common/cache/cache.service"; +import { DatabaseService } from "@/database/database.service"; + +import { + EntitlementService, + Feature, + type FeatureEntitlement, +} from "./entitlement.service"; + +// ============================================================================= +// Test Suite +// ============================================================================= + +describe("EntitlementService", () => { + let service: EntitlementService; + let cacheService: Mocked; + let database: Mocked; + + const userId = "user-test-123"; + + beforeEach(async () => { + const { unit, unitRef } = + await TestBed.solitary(EntitlementService).compile(); + + service = unit; + cacheService = unitRef.get(CacheService) as unknown as Mocked; + database = unitRef.get( + DatabaseService, + ) as unknown as Mocked; + }); + + // ========================================================================= + // getFeatureLimit (캐시 우선 조회) + // ========================================================================= + + describe("getFeatureLimit", () => { + describe("ADMIN 역할", () => { + it.each([ + ["CHEER", Feature.CHEER], + ["NUDGE", Feature.NUDGE], + ["AI_PARSE", Feature.AI_PARSE], + ] as const)("ADMIN은 %s 기능이 무제한이다", async (_name, feature) => { + // Given - 캐시에 ADMIN 사용자 정보 존재 + (cacheService.getSubscription as jest.Mock).mockResolvedValue({ + status: "FREE", + isAdmin: true, + }); + + // When + const result = await service.getFeatureLimit(userId, feature); + + // Then - ADMIN은 항상 무제한 + expect(result).toEqual({ + dailyLimit: null, + isAdmin: true, + subscriptionStatus: "FREE", + }); + }); + }); + + describe("USER 역할 + ACTIVE 구독 (무제한)", () => { + it.each([ + ["CHEER", Feature.CHEER], + ["NUDGE", Feature.NUDGE], + ["AI_PARSE", Feature.AI_PARSE], + ] as const)("ACTIVE 구독 + %s 기능은 무제한이다", async (_name, feature) => { + // Given - 캐시에 ACTIVE 구독 사용자 정보 존재 + (cacheService.getSubscription as jest.Mock).mockResolvedValue({ + status: "ACTIVE", + isAdmin: false, + }); + + // When + const result = await service.getFeatureLimit(userId, feature); + + // Then - ACTIVE 구독은 무제한 + expect(result).toEqual({ + dailyLimit: null, + isAdmin: false, + subscriptionStatus: "ACTIVE", + }); + }); + }); + + describe("USER 역할 + 비프리미엄 구독 (제한 적용)", () => { + it.each([ + ["FREE", "CHEER", Feature.CHEER, CHEER_LIMITS.FREE_DAILY_LIMIT], + ["FREE", "NUDGE", Feature.NUDGE, NUDGE_LIMITS.FREE_DAILY_LIMIT], + [ + "FREE", + "AI_PARSE", + Feature.AI_PARSE, + AI_PARSE_LIMITS.FREE_DAILY_LIMIT, + ], + ["EXPIRED", "CHEER", Feature.CHEER, CHEER_LIMITS.FREE_DAILY_LIMIT], + ["EXPIRED", "NUDGE", Feature.NUDGE, NUDGE_LIMITS.FREE_DAILY_LIMIT], + [ + "EXPIRED", + "AI_PARSE", + Feature.AI_PARSE, + AI_PARSE_LIMITS.FREE_DAILY_LIMIT, + ], + ["CANCELLED", "CHEER", Feature.CHEER, CHEER_LIMITS.FREE_DAILY_LIMIT], + ["CANCELLED", "NUDGE", Feature.NUDGE, NUDGE_LIMITS.FREE_DAILY_LIMIT], + [ + "CANCELLED", + "AI_PARSE", + Feature.AI_PARSE, + AI_PARSE_LIMITS.FREE_DAILY_LIMIT, + ], + ] as const)("%s 구독 + %s 기능은 일일 %d회 제한이다", async (status, _featureName, feature, expectedLimit) => { + // Given + (cacheService.getSubscription as jest.Mock).mockResolvedValue({ + status, + isAdmin: false, + }); + + // When + const result = await service.getFeatureLimit(userId, feature); + + // Then + expect(result).toEqual({ + dailyLimit: expectedLimit, + isAdmin: false, + subscriptionStatus: status, + }); + }); + }); + + describe("캐시 동작", () => { + it("캐시 히트 시 DB 조회를 하지 않는다", async () => { + // Given - 캐시에 데이터 존재 (히트) + (cacheService.getSubscription as jest.Mock).mockResolvedValue({ + status: "FREE", + isAdmin: false, + }); + + // When - 기능 제한 조회 + await service.getFeatureLimit(userId, Feature.CHEER); + + // Then - DB 조회 미호출 + expect(database.user.findUnique).not.toHaveBeenCalled(); + }); + + it("캐시 미스 시 DB 조회 후 캐싱한다", async () => { + // Given - 캐시 미스 (undefined 반환), DB에 사용자 존재 + (cacheService.getSubscription as jest.Mock).mockResolvedValue( + undefined, + ); + (database.user.findUnique as jest.Mock).mockResolvedValue({ + role: "USER", + subscriptionStatus: "FREE", + }); + + // When - 기능 제한 조회 + const result = await service.getFeatureLimit(userId, Feature.CHEER); + + // Then - DB 조회 확인 + expect(database.user.findUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { role: true, subscriptionStatus: true }, + }); + + // Then - 캐시에 저장 확인 + expect(cacheService.setSubscription).toHaveBeenCalledWith(userId, { + status: "FREE", + isAdmin: false, + }); + + // Then - 올바른 결과 반환 + expect(result).toEqual({ + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }); + }); + + it("캐시 미스 + DB에 사용자 없으면 기본값(USER/FREE)을 사용한다", async () => { + // Given - 캐시 미스, DB에도 사용자 없음 + (cacheService.getSubscription as jest.Mock).mockResolvedValue( + undefined, + ); + (database.user.findUnique as jest.Mock).mockResolvedValue(null); + + // When - CHEER 기능 제한 조회 + const result = await service.getFeatureLimit(userId, Feature.CHEER); + + // Then - 기본값(FREE) 적용으로 일일 제한 + expect(result).toEqual({ + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }); + + // Then - 기본값으로 캐싱 + expect(cacheService.setSubscription).toHaveBeenCalledWith(userId, { + status: null, + isAdmin: false, + }); + }); + + it("캐시 미스 + DB에 ADMIN 사용자면 isAdmin: true로 캐싱한다", async () => { + // Given - 캐시 미스, DB에 ADMIN 사용자 존재 + (cacheService.getSubscription as jest.Mock).mockResolvedValue( + undefined, + ); + (database.user.findUnique as jest.Mock).mockResolvedValue({ + role: "ADMIN", + subscriptionStatus: "FREE", + }); + + // When - 기능 제한 조회 + const result = await service.getFeatureLimit(userId, Feature.CHEER); + + // Then - ADMIN 무제한 + expect(result).toEqual({ + dailyLimit: null, + isAdmin: true, + subscriptionStatus: "FREE", + }); + + // Then - isAdmin: true로 캐싱 + expect(cacheService.setSubscription).toHaveBeenCalledWith(userId, { + status: "FREE", + isAdmin: true, + }); + }); + }); + }); + + // ========================================================================= + // getFeatureLimitInTx (트랜잭션 내 조회) + // ========================================================================= + + describe("getFeatureLimitInTx", () => { + let txMock: { + user: { + findUnique: jest.Mock; + }; + }; + + beforeEach(() => { + txMock = { + user: { + findUnique: jest.fn(), + }, + }; + }); + + it("ADMIN 사용자 + NUDGE 기능은 무제한이다", async () => { + // Given - 트랜잭션 내 ADMIN 사용자 + txMock.user.findUnique.mockResolvedValue({ + role: "ADMIN", + subscriptionStatus: "FREE", + }); + + // When - 트랜잭션 내 NUDGE 기능 제한 조회 + const result = await service.getFeatureLimitInTx( + txMock as never, + userId, + Feature.NUDGE, + ); + + // Then - ADMIN은 무제한 + expect(result).toEqual({ + dailyLimit: null, + isAdmin: true, + subscriptionStatus: "FREE", + }); + }); + + it("ADMIN 사용자 + AI_PARSE 기능은 무제한이다", async () => { + // Given - 트랜잭션 내 ADMIN 사용자 + txMock.user.findUnique.mockResolvedValue({ + role: "ADMIN", + subscriptionStatus: "FREE", + }); + + // When - 트랜잭션 내 AI_PARSE 기능 제한 조회 + const result = await service.getFeatureLimitInTx( + txMock as never, + userId, + Feature.AI_PARSE, + ); + + // Then - ADMIN은 무제한 + expect(result).toEqual({ + dailyLimit: null, + isAdmin: true, + subscriptionStatus: "FREE", + }); + }); + + it("USER + FREE 구독 + CHEER 기능은 일일 제한이 적용된다", async () => { + // Given - 트랜잭션 내 FREE 구독 일반 사용자 + txMock.user.findUnique.mockResolvedValue({ + role: "USER", + subscriptionStatus: "FREE", + }); + + // When - 트랜잭션 내 CHEER 기능 제한 조회 + const result = await service.getFeatureLimitInTx( + txMock as never, + userId, + Feature.CHEER, + ); + + // Then - FREE는 일일 제한 적용 + expect(result).toEqual({ + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }); + + // Then - 올바른 쿼리 호출 확인 + expect(txMock.user.findUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { role: true, subscriptionStatus: true }, + }); + }); + + it("USER + FREE 구독 + AI_PARSE 기능은 일일 제한이 적용된다", async () => { + // Given - 트랜잭션 내 FREE 구독 일반 사용자 + txMock.user.findUnique.mockResolvedValue({ + role: "USER", + subscriptionStatus: "FREE", + }); + + // When - 트랜잭션 내 AI_PARSE 기능 제한 조회 + const result = await service.getFeatureLimitInTx( + txMock as never, + userId, + Feature.AI_PARSE, + ); + + // Then - FREE는 일일 제한 적용 + expect(result).toEqual({ + dailyLimit: AI_PARSE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }); + + // Then - 올바른 쿼리 호출 확인 + expect(txMock.user.findUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { role: true, subscriptionStatus: true }, + }); + }); + + it("사용자가 null인 경우 기본값(USER/FREE)을 사용한다", async () => { + // Given - 트랜잭션 내 사용자 없음 + txMock.user.findUnique.mockResolvedValue(null); + + // When - 트랜잭션 내 CHEER 기능 제한 조회 + const result = await service.getFeatureLimitInTx( + txMock as never, + userId, + Feature.CHEER, + ); + + // Then - 기본값(USER/FREE) 적용으로 일일 제한 + expect(result).toEqual({ + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }); + }); + + it("캐시를 사용하지 않고 트랜잭션으로 직접 조회한다", async () => { + // Given - 트랜잭션 내 사용자 존재 + txMock.user.findUnique.mockResolvedValue({ + role: "USER", + subscriptionStatus: "ACTIVE", + }); + + // When - 트랜잭션 내 조회 + await service.getFeatureLimitInTx(txMock as never, userId, Feature.CHEER); + + // Then - 캐시 서비스 미호출 (TOCTOU 방지) + expect(cacheService.getSubscription).not.toHaveBeenCalled(); + expect(cacheService.setSubscription).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/common/entitlement/entitlement.service.ts b/apps/api/src/common/entitlement/entitlement.service.ts new file mode 100644 index 00000000..3fb42eda --- /dev/null +++ b/apps/api/src/common/entitlement/entitlement.service.ts @@ -0,0 +1,128 @@ +import { + AI_PARSE_LIMITS, + CHEER_LIMITS, + NUDGE_LIMITS, + SUBSCRIPTION_AI_PARSE_LIMITS, + SUBSCRIPTION_CHEER_LIMITS, + SUBSCRIPTION_NUDGE_LIMITS, +} from "@aido/validators"; +import { Injectable } from "@nestjs/common"; +import { CacheService } from "@/common/cache/cache.service"; +import type { TransactionClient } from "@/common/database"; +import { DatabaseService } from "@/database/database.service"; + +export const Feature = { + CHEER: "CHEER", + NUDGE: "NUDGE", + AI_PARSE: "AI_PARSE", +} as const; +export type Feature = (typeof Feature)[keyof typeof Feature]; + +const FEATURE_LIMITS: Record> = { + CHEER: { ...SUBSCRIPTION_CHEER_LIMITS }, + NUDGE: { ...SUBSCRIPTION_NUDGE_LIMITS }, + AI_PARSE: { ...SUBSCRIPTION_AI_PARSE_LIMITS }, +}; + +const FEATURE_FREE_DEFAULTS: Record = { + CHEER: CHEER_LIMITS.FREE_DAILY_LIMIT, + NUDGE: NUDGE_LIMITS.FREE_DAILY_LIMIT, + AI_PARSE: AI_PARSE_LIMITS.FREE_DAILY_LIMIT, +}; + +function resolveFeatureLimit( + role: string, + subscriptionStatus: string, + feature: Feature, +): number | null { + if (role === "ADMIN") return null; + const limits = FEATURE_LIMITS[feature]; + if (subscriptionStatus in limits) { + return limits[subscriptionStatus] as number | null; + } + return FEATURE_FREE_DEFAULTS[feature]; +} + +export interface FeatureEntitlement { + dailyLimit: number | null; + isAdmin: boolean; + subscriptionStatus: string; +} + +/** + * 기능 접근 권한(Entitlement) 판단 서비스 + * + * - ADMIN 역할 → 항상 무제한 + * - ACTIVE 구독 → 무제한 + * - 그 외 → 구독 상태별 제한 적용 + * + * 캐시 우선 조회 (getLimitInfo 등 읽기 전용 경로) 와 + * 트랜잭션 내 실시간 조회 (TOCTOU 방지) 를 분리합니다. + */ +@Injectable() +export class EntitlementService { + constructor( + private readonly cacheService: CacheService, + private readonly database: DatabaseService, + ) {} + + /** + * 캐시 우선 조회 (읽기 전용 경로) + * + * getLimitInfo 등 조회 API에서 사용 + */ + async getFeatureLimit( + userId: string, + feature: Feature, + ): Promise { + const { role, subscriptionStatus } = await this.#resolveUserInfo(userId); + const dailyLimit = resolveFeatureLimit(role, subscriptionStatus, feature); + return { dailyLimit, isAdmin: role === "ADMIN", subscriptionStatus }; + } + + /** + * 트랜잭션 내 실시간 조회 (쓰기 경로) + * + * sendCheer/sendNudge 등 TOCTOU 방지가 필요한 곳에서 사용 + */ + async getFeatureLimitInTx( + tx: TransactionClient, + userId: string, + feature: Feature, + ): Promise { + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { role: true, subscriptionStatus: true }, + }); + const role = user?.role ?? "USER"; + const subscriptionStatus = user?.subscriptionStatus ?? "FREE"; + const dailyLimit = resolveFeatureLimit(role, subscriptionStatus, feature); + return { dailyLimit, isAdmin: role === "ADMIN", subscriptionStatus }; + } + + async #resolveUserInfo( + userId: string, + ): Promise<{ role: string; subscriptionStatus: string }> { + const cached = await this.cacheService.getSubscription(userId); + if (cached !== undefined) { + return { + role: cached.isAdmin ? "ADMIN" : "USER", + subscriptionStatus: cached.status ?? "FREE", + }; + } + + const user = await this.database.user.findUnique({ + where: { id: userId }, + select: { role: true, subscriptionStatus: true }, + }); + const role = user?.role ?? "USER"; + const subscriptionStatus = user?.subscriptionStatus ?? "FREE"; + + await this.cacheService.setSubscription(userId, { + status: user?.subscriptionStatus ?? null, + isAdmin: role === "ADMIN", + }); + + return { role, subscriptionStatus }; + } +} diff --git a/apps/api/src/common/entitlement/index.ts b/apps/api/src/common/entitlement/index.ts new file mode 100644 index 00000000..e222c6ad --- /dev/null +++ b/apps/api/src/common/entitlement/index.ts @@ -0,0 +1,3 @@ +export { EntitlementModule } from "./entitlement.module"; +export type { FeatureEntitlement } from "./entitlement.service"; +export { EntitlementService, Feature } from "./entitlement.service"; diff --git a/apps/api/src/common/index.ts b/apps/api/src/common/index.ts index 89a35f9d..1e037d98 100644 --- a/apps/api/src/common/index.ts +++ b/apps/api/src/common/index.ts @@ -12,6 +12,8 @@ export * from "./database"; export * from "./date"; // Encryption export * from "./encryption"; +// Entitlement +export * from "./entitlement"; // Exception export * from "./exception"; diff --git a/apps/api/src/instrument.ts b/apps/api/src/instrument.ts index d8e8564a..dc34b9bc 100644 --- a/apps/api/src/instrument.ts +++ b/apps/api/src/instrument.ts @@ -5,8 +5,11 @@ Sentry.init({ environment: process.env.NODE_ENV || "development", // TODO: 서비스 스케일업 시 릴리스 버저닝 추가 (e.g., release: process.env.SENTRY_RELEASE) tracesSampleRate: - Number(process.env.SENTRY_TRACES_SAMPLE_RATE) || - (process.env.NODE_ENV === "production" ? 0.2 : 1.0), + process.env.SENTRY_TRACES_SAMPLE_RATE !== undefined + ? Number(process.env.SENTRY_TRACES_SAMPLE_RATE) + : process.env.NODE_ENV === "production" + ? 0.2 + : 1.0, beforeSend(event) { // 요청 헤더에서 민감 정보 제거 diff --git a/apps/api/src/modules/ai/ai.service.spec.ts b/apps/api/src/modules/ai/ai.service.spec.ts index 28c94162..ac5e7135 100644 --- a/apps/api/src/modules/ai/ai.service.spec.ts +++ b/apps/api/src/modules/ai/ai.service.spec.ts @@ -1,37 +1,28 @@ -import { Test, type TestingModule } from "@nestjs/testing"; -import { TypedConfigService } from "@/common/config/services/config.service"; +/** + * AiService 단위 테스트 (Suites + GWT 패턴) + * + * 자연어 투두 파싱, 일일 사용량 관리, 권한별 제한 검증 + * + * - Suites: 자동 Mock 생성 (DatabaseService, EntitlementService) + * - FakeAiProvider: AI_PROVIDER Symbol 토큰용 테스트 더블 + * - GWT: Given/When/Then 주석 + */ +import type { Mocked } from "@suites/doubles.jest"; +import { TestBed } from "@suites/unit"; +import { EntitlementService } from "@/common/entitlement/entitlement.service"; import { BusinessException } from "@/common/exception/services/business-exception.service"; import { DatabaseService } from "@/database/database.service"; import { FakeAiProvider } from "../../../test/mocks/fake-ai.provider"; import { AiService } from "./ai.service"; import { AI_PROVIDER } from "./providers/ai.provider"; -interface MockUser { - id: string; - aiUsageCount: number; - aiUsageResetAt: Date; -} - -interface AiUsageInfo { - aiUsageCount: number; - aiUsageResetAt: Date; -} - -interface MockPrisma { - user: { - findUnique: jest.Mock>; - update: jest.Mock>; - }; - $transaction: jest.Mock; -} - describe("AiService", () => { let service: AiService; let fakeAiProvider: FakeAiProvider; - let mockPrisma: MockPrisma; - let mockConfigService: { aiDailyLimit: number }; + let database: Mocked; + let entitlementService: Mocked; - const mockUser: MockUser = { + const mockUser = { id: "user-1", aiUsageCount: 0, aiUsageResetAt: new Date(), @@ -39,29 +30,31 @@ describe("AiService", () => { beforeEach(async () => { fakeAiProvider = new FakeAiProvider(); - mockPrisma = { - user: { - findUnique: jest.fn(), - update: jest.fn(), - }, - $transaction: jest.fn((callback: (tx: unknown) => unknown) => - callback(mockPrisma), - ), - }; - mockConfigService = { - aiDailyLimit: 5, - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AiService, - { provide: AI_PROVIDER, useValue: fakeAiProvider }, - { provide: DatabaseService, useValue: mockPrisma }, - { provide: TypedConfigService, useValue: mockConfigService }, - ], - }).compile(); - - service = module.get(AiService); + + const { unit, unitRef } = await TestBed.solitary(AiService) + .mock(AI_PROVIDER) + .impl(() => fakeAiProvider) + .compile(); + + service = unit; + database = unitRef.get( + DatabaseService, + ) as unknown as Mocked; + entitlementService = unitRef.get( + EntitlementService, + ) as unknown as Mocked; + + // $transaction passthrough + (database.$transaction as jest.Mock).mockImplementation( + (callback: (tx: unknown) => unknown) => callback(database), + ); + + // 기본: FREE 사용자 (dailyLimit: 5) + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: 5, + isAdmin: false, + subscriptionStatus: "FREE", + }); }); afterEach(() => { @@ -69,6 +62,10 @@ describe("AiService", () => { jest.clearAllMocks(); }); + // ========================================================================= + // parseTodo + // ========================================================================= + describe("parseTodo", () => { it("자연어를 구조화된 투두로 파싱한다", async () => { // Given @@ -78,8 +75,8 @@ describe("AiService", () => { scheduledTime: "15:00", isAllDay: false, }); - mockPrisma.user.findUnique.mockResolvedValue(mockUser); - mockPrisma.user.update.mockResolvedValue(mockUser); + (database.user.findUnique as jest.Mock).mockResolvedValue(mockUser); + (database.user.update as jest.Mock).mockResolvedValue(mockUser); // When const result = await service.parseTodo( @@ -108,8 +105,8 @@ describe("AiService", () => { scheduledTime: null, isAllDay: true, }); - mockPrisma.user.findUnique.mockResolvedValue(mockUser); - mockPrisma.user.update.mockResolvedValue(mockUser); + (database.user.findUnique as jest.Mock).mockResolvedValue(mockUser); + (database.user.update as jest.Mock).mockResolvedValue(mockUser); // When const result = await service.parseTodo("다음주 월~금 출장", "user-1"); @@ -127,17 +124,17 @@ describe("AiService", () => { startDate: "2025-01-26", isAllDay: true, }); - mockPrisma.user.findUnique.mockResolvedValue({ + (database.user.findUnique as jest.Mock).mockResolvedValue({ ...mockUser, aiUsageCount: 3, }); - mockPrisma.user.update.mockResolvedValue(mockUser); + (database.user.update as jest.Mock).mockResolvedValue(mockUser); // When await service.parseTodo("테스트", "user-1"); // Then - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(database.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, data: { aiUsageCount: { increment: 1 } }, }); @@ -154,18 +151,18 @@ describe("AiService", () => { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); - mockPrisma.user.findUnique.mockResolvedValue({ + (database.user.findUnique as jest.Mock).mockResolvedValue({ ...mockUser, aiUsageCount: 5, aiUsageResetAt: yesterday, }); - mockPrisma.user.update.mockResolvedValue(mockUser); + (database.user.update as jest.Mock).mockResolvedValue(mockUser); // When await service.parseTodo("테스트", "user-1"); // Then - expect(mockPrisma.user.update).toHaveBeenCalledWith({ + expect(database.user.update).toHaveBeenCalledWith({ where: { id: "user-1" }, data: { aiUsageCount: 1, @@ -192,7 +189,7 @@ describe("AiService", () => { it("AI 파싱 실패시 AI_1302 에러를 던진다", async () => { // Given fakeAiProvider.setInvalidResponse(new Error("Parse error")); - mockPrisma.user.findUnique.mockResolvedValue(mockUser); + (database.user.findUnique as jest.Mock).mockResolvedValue(mockUser); // When & Then await expect(service.parseTodo("테스트", "user-1")).rejects.toThrow( @@ -212,8 +209,8 @@ describe("AiService", () => { startDate: "2025-01-26", isAllDay: true, }); - mockPrisma.user.findUnique.mockResolvedValue(mockUser); - mockPrisma.user.update.mockResolvedValue(mockUser); + (database.user.findUnique as jest.Mock).mockResolvedValue(mockUser); + (database.user.update as jest.Mock).mockResolvedValue(mockUser); // When await service.parseTodo("내일 회의", "user-1"); @@ -232,7 +229,7 @@ describe("AiService", () => { startDate: "2025-01-26", isAllDay: true, }); - mockPrisma.user.findUnique.mockResolvedValue(null); + (database.user.findUnique as jest.Mock).mockResolvedValue(null); // When & Then await expect(service.parseTodo("테스트", "unknown-user")).rejects.toThrow( @@ -247,7 +244,7 @@ describe("AiService", () => { startDate: "2025-01-26", isAllDay: true, }); - mockPrisma.user.findUnique.mockResolvedValue({ + (database.user.findUnique as jest.Mock).mockResolvedValue({ aiUsageCount: 5, aiUsageResetAt: new Date(), }); @@ -270,7 +267,7 @@ describe("AiService", () => { startDate: "2025-01-26", isAllDay: true, }); - mockPrisma.user.findUnique.mockResolvedValue({ + (database.user.findUnique as jest.Mock).mockResolvedValue({ aiUsageCount: 5, aiUsageResetAt: new Date(), }); @@ -281,12 +278,66 @@ describe("AiService", () => { // Then expect(fakeAiProvider.getCallCount()).toBe(0); }); + + it("ADMIN 사용자는 사용량 초과해도 파싱 가능하다", async () => { + // Given + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: true, + subscriptionStatus: "FREE", + }); + fakeAiProvider.setResponse({ + title: "테스트", + startDate: "2025-01-26", + isAllDay: true, + }); + (database.user.findUnique as jest.Mock).mockResolvedValue({ + ...mockUser, + aiUsageCount: 100, + }); + (database.user.update as jest.Mock).mockResolvedValue(mockUser); + + // When + const result = await service.parseTodo("테스트", "user-1"); + + // Then + expect(result.data.title).toBe("테스트"); + }); + + it("ACTIVE 구독자는 사용량 초과해도 파싱 가능하다", async () => { + // Given + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: false, + subscriptionStatus: "ACTIVE", + }); + fakeAiProvider.setResponse({ + title: "테스트", + startDate: "2025-01-26", + isAllDay: true, + }); + (database.user.findUnique as jest.Mock).mockResolvedValue({ + ...mockUser, + aiUsageCount: 100, + }); + (database.user.update as jest.Mock).mockResolvedValue(mockUser); + + // When + const result = await service.parseTodo("테스트", "user-1"); + + // Then + expect(result.data.title).toBe("테스트"); + }); }); + // ========================================================================= + // getUsage + // ========================================================================= + describe("getUsage", () => { it("현재 사용량을 반환한다", async () => { // Given - mockPrisma.user.findUnique.mockResolvedValue({ + (database.user.findUnique as jest.Mock).mockResolvedValue({ aiUsageCount: 3, aiUsageResetAt: new Date(), }); @@ -309,7 +360,7 @@ describe("AiService", () => { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); - mockPrisma.user.findUnique.mockResolvedValue({ + (database.user.findUnique as jest.Mock).mockResolvedValue({ aiUsageCount: 5, aiUsageResetAt: yesterday, }); @@ -324,19 +375,67 @@ describe("AiService", () => { it("사용자를 찾을 수 없으면 에러를 던진다", async () => { // Given - mockPrisma.user.findUnique.mockResolvedValue(null); + (database.user.findUnique as jest.Mock).mockResolvedValue(null); // When & Then await expect(service.getUsage("unknown-user")).rejects.toThrow( BusinessException, ); }); + + it("ADMIN 사용자는 limit이 null이다", async () => { + // Given + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: true, + subscriptionStatus: "FREE", + }); + (database.user.findUnique as jest.Mock).mockResolvedValue({ + aiUsageCount: 3, + aiUsageResetAt: new Date(), + }); + + // When + const result = await service.getUsage("user-1"); + + // Then + expect(result).toMatchObject({ + used: 3, + limit: null, + }); + }); + + it("ACTIVE 구독자는 limit이 null이다", async () => { + // Given + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: false, + subscriptionStatus: "ACTIVE", + }); + (database.user.findUnique as jest.Mock).mockResolvedValue({ + aiUsageCount: 10, + aiUsageResetAt: new Date(), + }); + + // When + const result = await service.getUsage("user-1"); + + // Then + expect(result).toMatchObject({ + used: 10, + limit: null, + }); + }); }); + // ========================================================================= + // checkUsageLimit + // ========================================================================= + describe("checkUsageLimit", () => { it("한도 내면 true를 반환한다", async () => { // Given - mockPrisma.user.findUnique.mockResolvedValue({ + (database.user.findUnique as jest.Mock).mockResolvedValue({ aiUsageCount: 4, aiUsageResetAt: new Date(), }); @@ -350,7 +449,7 @@ describe("AiService", () => { it("한도에 도달하면 false를 반환한다", async () => { // Given - mockPrisma.user.findUnique.mockResolvedValue({ + (database.user.findUnique as jest.Mock).mockResolvedValue({ aiUsageCount: 5, aiUsageResetAt: new Date(), }); @@ -364,7 +463,7 @@ describe("AiService", () => { it("한도 초과면 false를 반환한다", async () => { // Given - mockPrisma.user.findUnique.mockResolvedValue({ + (database.user.findUnique as jest.Mock).mockResolvedValue({ aiUsageCount: 10, aiUsageResetAt: new Date(), }); @@ -381,7 +480,7 @@ describe("AiService", () => { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); - mockPrisma.user.findUnique.mockResolvedValue({ + (database.user.findUnique as jest.Mock).mockResolvedValue({ aiUsageCount: 5, aiUsageResetAt: yesterday, }); @@ -392,8 +491,50 @@ describe("AiService", () => { // Then expect(result).toBe(true); }); + + it("ADMIN 사용자는 항상 true를 반환한다", async () => { + // Given + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: true, + subscriptionStatus: "FREE", + }); + (database.user.findUnique as jest.Mock).mockResolvedValue({ + aiUsageCount: 1000, + aiUsageResetAt: new Date(), + }); + + // When + const result = await service.checkUsageLimit("user-1"); + + // Then + expect(result).toBe(true); + }); + + it("ACTIVE 구독자는 항상 true를 반환한다", async () => { + // Given + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: false, + subscriptionStatus: "ACTIVE", + }); + (database.user.findUnique as jest.Mock).mockResolvedValue({ + aiUsageCount: 1000, + aiUsageResetAt: new Date(), + }); + + // When + const result = await service.checkUsageLimit("user-1"); + + // Then + expect(result).toBe(true); + }); }); + // ========================================================================= + // 토큰 사용량 추적 + // ========================================================================= + describe("토큰 사용량 추적", () => { it("결과에 토큰 사용량이 포함된다", async () => { // Given @@ -403,8 +544,8 @@ describe("AiService", () => { startDate: "2025-01-26", isAllDay: true, }); - mockPrisma.user.findUnique.mockResolvedValue(mockUser); - mockPrisma.user.update.mockResolvedValue(mockUser); + (database.user.findUnique as jest.Mock).mockResolvedValue(mockUser); + (database.user.update as jest.Mock).mockResolvedValue(mockUser); // When const result = await service.parseTodo("테스트", "user-1"); @@ -417,6 +558,10 @@ describe("AiService", () => { }); }); + // ========================================================================= + // 연속 요청 처리 + // ========================================================================= + describe("연속 요청 처리", () => { it("여러 응답을 순차적으로 반환한다", async () => { // Given @@ -425,8 +570,8 @@ describe("AiService", () => { { title: "두번째", startDate: "2025-01-27", isAllDay: true }, { title: "세번째", startDate: "2025-01-28", isAllDay: true }, ]); - mockPrisma.user.findUnique.mockResolvedValue(mockUser); - mockPrisma.user.update.mockResolvedValue(mockUser); + (database.user.findUnique as jest.Mock).mockResolvedValue(mockUser); + (database.user.update as jest.Mock).mockResolvedValue(mockUser); // When const result1 = await service.parseTodo("첫번째", "user-1"); diff --git a/apps/api/src/modules/ai/ai.service.ts b/apps/api/src/modules/ai/ai.service.ts index f30aa35d..b9c828cf 100644 --- a/apps/api/src/modules/ai/ai.service.ts +++ b/apps/api/src/modules/ai/ai.service.ts @@ -5,16 +5,20 @@ * * 핵심 기능: * - 자연어 → 구조화된 투두 변환 (Gemini 2.0 Flash) - * - 일일 사용량 추적 (무료 유저: 5회/일) + * - 일일 사용량 추적 (ADMIN/ACTIVE: 무제한, 그 외: 5회/일) * - KST 기준 자정 리셋 */ import type { ParsedTodoData } from "@aido/validators"; import { parsedTodoDataSchema } from "@aido/validators"; import { Inject, Injectable, Logger } from "@nestjs/common"; import { APICallError } from "ai"; -import { TypedConfigService } from "@/common/config/services/config.service"; +import { + EntitlementService, + Feature, +} from "@/common/entitlement/entitlement.service"; import { BusinessExceptions } from "@/common/exception/services/business-exception.service"; import { DatabaseService } from "@/database/database.service"; + import { buildParseTodoPrompt } from "./prompts/parse-todo.prompt"; import { AI_PROVIDER, @@ -31,8 +35,8 @@ const KST_OFFSET_MS = 9 * 60 * 60 * 1000; export interface UsageInfo { /** 오늘 사용한 횟수 */ used: number; - /** 일일 제한 횟수 */ - limit: number; + /** 일일 제한 횟수 (null = 무제한) */ + limit: number | null; /** 다음 리셋 시간 (ISO 8601) */ resetsAt: string; } @@ -67,14 +71,9 @@ export class AiService { @Inject(AI_PROVIDER) private readonly aiProvider: AiProvider, private readonly prisma: DatabaseService, - private readonly configService: TypedConfigService, + private readonly entitlementService: EntitlementService, ) {} - /** 환경변수에서 일일 사용 제한 가져오기 (기본값: 5) */ - get #dailyLimit(): number { - return this.configService.aiDailyLimit; - } - /** * 자연어 텍스트를 투두 데이터로 파싱 * @@ -165,11 +164,12 @@ export class AiService { throw BusinessExceptions.userNotFound(userId); } + const dailyLimit = await this.#getDailyLimit(userId); const isNewDay = this.#isNewDay(user.aiUsageResetAt); return { used: isNewDay ? 0 : user.aiUsageCount, - limit: this.#dailyLimit, + limit: dailyLimit, resetsAt: this.#getNextResetTime(), }; } @@ -182,6 +182,11 @@ export class AiService { */ async checkUsageLimit(userId: string): Promise { const usage = await this.getUsage(userId); + + if (usage.limit === null) { + return true; + } + return usage.used < usage.limit; } @@ -195,6 +200,8 @@ export class AiService { * @throws AI_1303 - 일일 사용량 초과 */ async #checkAndIncrementUsage(userId: string): Promise { + const dailyLimit = await this.#getDailyLimit(userId); + await this.prisma.$transaction(async (tx) => { const user = await tx.user.findUnique({ where: { id: userId }, @@ -208,11 +215,9 @@ export class AiService { const isNewDay = this.#isNewDay(user.aiUsageResetAt); const currentUsage = isNewDay ? 0 : user.aiUsageCount; - if (currentUsage >= this.#dailyLimit) { - throw BusinessExceptions.aiUsageLimitExceeded( - currentUsage, - this.#dailyLimit, - ); + // dailyLimit === null → 무제한 (ADMIN / ACTIVE 구독) + if (dailyLimit !== null && currentUsage >= dailyLimit) { + throw BusinessExceptions.aiUsageLimitExceeded(currentUsage, dailyLimit); } if (isNewDay) { @@ -256,6 +261,15 @@ export class AiService { } } + /** 사용자별 일일 제한 조회 (EntitlementService 위임) */ + async #getDailyLimit(userId: string): Promise { + const entitlement = await this.entitlementService.getFeatureLimit( + userId, + Feature.AI_PARSE, + ); + return entitlement.dailyLimit; + } + /** * 새로운 날인지 확인 (KST 기준) * diff --git a/apps/api/src/modules/ai/guards/ai-usage.guard.spec.ts b/apps/api/src/modules/ai/guards/ai-usage.guard.spec.ts index d1177988..af53772a 100644 --- a/apps/api/src/modules/ai/guards/ai-usage.guard.spec.ts +++ b/apps/api/src/modules/ai/guards/ai-usage.guard.spec.ts @@ -15,7 +15,7 @@ describe("AiUsageGuard", () => { // Mock Factory Functions // ========================================================================== - const createMockUsage = (used: number, limit: number): UsageInfo => ({ + const createMockUsage = (used: number, limit: number | null): UsageInfo => ({ used, limit, resetsAt: new Date().toISOString(), @@ -114,6 +114,22 @@ describe("AiUsageGuard", () => { } }); + it("무제한 사용자(limit: null)는 사용량에 관계없이 true를 반환해야 한다", async () => { + // Given + const { context, request } = createMockExecutionContext({ + user: mockUser, + }); + const usage = createMockUsage(1000, null); + aiService.getUsage.mockResolvedValue(usage); + + // When + const result = await guard.canActivate(context); + + // Then + expect(result).toBe(true); + expect(request.aiUsage).toEqual(usage); + }); + it("request에 aiUsage 정보를 첨부해야 한다", async () => { // Given const { context, request } = createMockExecutionContext({ diff --git a/apps/api/src/modules/ai/guards/ai-usage.guard.ts b/apps/api/src/modules/ai/guards/ai-usage.guard.ts index a27c3006..c6fffbe0 100644 --- a/apps/api/src/modules/ai/guards/ai-usage.guard.ts +++ b/apps/api/src/modules/ai/guards/ai-usage.guard.ts @@ -19,7 +19,7 @@ export interface AiUsageRequest extends Request { * * 사용자의 일일 AI 사용량을 체크하여 제한을 초과한 경우 요청을 차단합니다. * - 무료 사용자: 일일 5회 제한 - * - 프리미엄 사용자: 무제한 (향후 구현) + * - ADMIN/ACTIVE 구독자: 무제한 * * Guard에서 조회한 usage 정보를 request에 첨부하여 Service에서 재사용합니다. */ @@ -38,7 +38,7 @@ export class AiUsageGuard implements CanActivate { const usage = await this.aiService.getUsage(user.userId); - if (usage.used >= usage.limit) { + if (usage.limit !== null && usage.used >= usage.limit) { throw BusinessExceptions.aiUsageLimitExceeded(usage.used, usage.limit); } diff --git a/apps/api/src/modules/cheer/cheer.repository.spec.ts b/apps/api/src/modules/cheer/cheer.repository.spec.ts index 63f49473..3a0a3815 100644 --- a/apps/api/src/modules/cheer/cheer.repository.spec.ts +++ b/apps/api/src/modules/cheer/cheer.repository.spec.ts @@ -552,57 +552,4 @@ describe("CheerRepository", () => { expect(result).toBeNull(); }); }); - - describe("getUserSubscriptionStatus", () => { - it("사용자의 구독 상태를 반환한다", async () => { - // Given - const userId = "user-1"; - (db.user.findUnique as jest.Mock).mockResolvedValue({ - subscriptionStatus: "ACTIVE", - }); - - // When - const result = await repository.getUserSubscriptionStatus(userId); - - // Then - expect(result).toBe("ACTIVE"); - expect(db.user.findUnique).toHaveBeenCalledWith({ - where: { id: userId }, - select: { subscriptionStatus: true }, - }); - }); - - it("사용자가 없으면 null을 반환한다", async () => { - // Given - (db.user.findUnique as jest.Mock).mockResolvedValue(null); - - // When - const result = await repository.getUserSubscriptionStatus("non-existent"); - - // Then - expect(result).toBeNull(); - }); - - const subscriptionStatuses = [ - "FREE", - "ACTIVE", - "EXPIRED", - "CANCELLED", - ] as const; - - it.each( - subscriptionStatuses, - )("구독 상태가 %s일 때 해당 상태를 반환한다", async (status) => { - // Given - (db.user.findUnique as jest.Mock).mockResolvedValue({ - subscriptionStatus: status, - }); - - // When - const result = await repository.getUserSubscriptionStatus("user-1"); - - // Then - expect(result).toBe(status); - }); - }); }); diff --git a/apps/api/src/modules/cheer/cheer.repository.ts b/apps/api/src/modules/cheer/cheer.repository.ts index d67113b6..4b713431 100644 --- a/apps/api/src/modules/cheer/cheer.repository.ts +++ b/apps/api/src/modules/cheer/cheer.repository.ts @@ -274,31 +274,4 @@ export class CheerRepository { }); return user?.profile?.name ?? null; } - - /** - * 사용자 구독 상태 조회 - */ - async getUserSubscriptionStatus( - userId: string, - ): Promise<"FREE" | "ACTIVE" | "EXPIRED" | "CANCELLED" | null> { - const user = await this.database.user.findUnique({ - where: { id: userId }, - select: { subscriptionStatus: true }, - }); - return user?.subscriptionStatus ?? null; - } - - /** - * 사용자 구독 상태 및 역할 조회 (ADMIN 우회 판단용) - */ - async getUserSubscriptionInfo(userId: string): Promise<{ - subscriptionStatus: "FREE" | "ACTIVE" | "EXPIRED" | "CANCELLED"; - role: string; - } | null> { - const user = await this.database.user.findUnique({ - where: { id: userId }, - select: { subscriptionStatus: true, role: true }, - }); - return user ?? null; - } } diff --git a/apps/api/src/modules/cheer/cheer.service.spec.ts b/apps/api/src/modules/cheer/cheer.service.spec.ts index 83da0d0b..749e4e97 100644 --- a/apps/api/src/modules/cheer/cheer.service.spec.ts +++ b/apps/api/src/modules/cheer/cheer.service.spec.ts @@ -13,7 +13,10 @@ import { EventEmitter2 } from "@nestjs/event-emitter"; import type { Mocked } from "@suites/doubles.jest"; import { TestBed } from "@suites/unit"; import { CheerBuilder } from "@test/builders"; -import { CacheService } from "@/common/cache/cache.service"; +import { + EntitlementService, + Feature, +} from "@/common/entitlement/entitlement.service"; import { PaginationService } from "@/common/pagination/services/pagination.service"; import { DatabaseService } from "@/database/database.service"; import { FollowService } from "@/modules/follow/follow.service"; @@ -33,7 +36,7 @@ describe("CheerService", () => { let paginationService: Mocked; let eventEmitter: Mocked; let database: Mocked; - let cacheService: Mocked; + let entitlementService: Mocked; // 테스트 데이터 const senderId = "sender-123"; @@ -61,17 +64,14 @@ describe("CheerService", () => { database = unitRef.get( DatabaseService, ) as unknown as Mocked; - cacheService = unitRef.get(CacheService) as unknown as Mocked; + entitlementService = unitRef.get( + EntitlementService, + ) as unknown as Mocked; // DatabaseService.$transaction passthrough mock 설정 (database.$transaction as jest.Mock).mockImplementation( (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "FREE" }), - }, cheer: { count: jest.fn().mockResolvedValue(0), findFirst: jest.fn().mockResolvedValue(null), @@ -82,6 +82,19 @@ describe("CheerService", () => { }, ); + // EntitlementService 기본 mock 설정 (FREE 사용자) + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue({ + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }); + + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }); + // PaginationService 기본 mock 설정 (paginationService.normalizeCursorPagination as jest.Mock).mockReturnValue({ cursor: undefined, @@ -96,9 +109,6 @@ describe("CheerService", () => { nextCursor: items.length > size ? items[size - 1].id : null, }, })); - - // CacheService 기본 mock 설정 - (cacheService.getSubscription as jest.Mock).mockResolvedValue(undefined); }); // =========================================================================== @@ -125,11 +135,6 @@ describe("CheerService", () => { .buildWithRelations(); const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "FREE" }), - }, cheer: { count: jest.fn().mockResolvedValue(0), findFirst: jest.fn().mockResolvedValue(null), @@ -173,11 +178,6 @@ describe("CheerService", () => { (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "FREE" }), - }, cheer: { count: jest.fn().mockResolvedValue(0), findFirst: jest.fn().mockResolvedValue(null), @@ -202,14 +202,17 @@ describe("CheerService", () => { describe("일일 제한 체크", () => { it("FREE 구독자가 일일 제한에 도달하면 에러를 던진다", async () => { // Given + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue( + { + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }, + ); + (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "FREE" }), - }, cheer: { count: jest .fn() @@ -233,15 +236,17 @@ describe("CheerService", () => { .withSenderProfile({ name: "테스트유저", profileImage: null }) .buildWithRelations(); + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue( + { + dailyLimit: null, + isAdmin: false, + subscriptionStatus: "ACTIVE", + }, + ); + (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest.fn().mockResolvedValue({ - subscriptionStatus: "ACTIVE", - role: "USER", - }), - }, cheer: { count: jest.fn().mockResolvedValue(100), findFirst: jest.fn().mockResolvedValue(null), @@ -266,15 +271,17 @@ describe("CheerService", () => { .withSenderProfile({ name: "테스트유저", profileImage: null }) .buildWithRelations(); + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue( + { + dailyLimit: null, + isAdmin: true, + subscriptionStatus: "FREE", + }, + ); + (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest.fn().mockResolvedValue({ - subscriptionStatus: "FREE", - role: "ADMIN", - }), - }, cheer: { count: jest.fn().mockResolvedValue(100), findFirst: jest.fn().mockResolvedValue(null), @@ -294,14 +301,17 @@ describe("CheerService", () => { it("EXPIRED 구독자는 FREE와 동일한 제한을 받는다", async () => { // Given + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue( + { + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "EXPIRED", + }, + ); + (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "EXPIRED" }), - }, cheer: { count: jest .fn() @@ -329,11 +339,6 @@ describe("CheerService", () => { (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "FREE" }), - }, cheer: { count: jest.fn().mockResolvedValue(0), findFirst: jest.fn().mockResolvedValue(recentCheer), @@ -366,11 +371,6 @@ describe("CheerService", () => { (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "FREE" }), - }, cheer: { count: jest.fn().mockResolvedValue(0), findFirst: jest.fn().mockResolvedValue(oldCheer), @@ -402,11 +402,6 @@ describe("CheerService", () => { (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "FREE" }), - }, cheer: { count: jest.fn().mockResolvedValue(0), findFirst: jest.fn().mockResolvedValue(null), @@ -474,11 +469,6 @@ describe("CheerService", () => { (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "FREE" }), - }, cheer: { count: jest.fn().mockResolvedValue(0), findFirst: jest.fn().mockResolvedValue(null), @@ -509,11 +499,6 @@ describe("CheerService", () => { (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "FREE" }), - }, cheer: { count: jest.fn().mockResolvedValue(0), findFirst: jest.fn().mockResolvedValue(null), @@ -550,11 +535,6 @@ describe("CheerService", () => { (database.$transaction as jest.Mock).mockImplementation( async (callback: (tx: unknown) => Promise) => { const txProxy = { - user: { - findUnique: jest - .fn() - .mockResolvedValue({ subscriptionStatus: "FREE" }), - }, cheer: { count: jest.fn().mockResolvedValue(0), findFirst: jest.fn().mockResolvedValue(null), @@ -684,9 +664,10 @@ describe("CheerService", () => { it("FREE 구독자의 일일 제한 정보를 반환한다", async () => { // Given const userId = "user-1"; - (cheerRepo.getUserSubscriptionInfo as jest.Mock).mockResolvedValue({ + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, subscriptionStatus: "FREE", - role: "USER", }); (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(1); @@ -704,9 +685,10 @@ describe("CheerService", () => { it("ACTIVE 구독자는 무제한 제한 정보를 반환한다", async () => { // Given const userId = "user-1"; - (cheerRepo.getUserSubscriptionInfo as jest.Mock).mockResolvedValue({ + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: false, subscriptionStatus: "ACTIVE", - role: "USER", }); (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(50); @@ -724,9 +706,10 @@ describe("CheerService", () => { it("ADMIN은 구독 상태와 무관하게 무제한이다", async () => { // Given const userId = "user-1"; - (cacheService.getSubscription as jest.Mock).mockResolvedValue({ - status: "FREE", + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: null, isAdmin: true, + subscriptionStatus: "FREE", }); (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(100); @@ -741,47 +724,33 @@ describe("CheerService", () => { }); }); - it("캐시 미스 시 ADMIN 정보를 DB에서 조회하여 캐싱한다", async () => { + it("EntitlementService에 올바른 Feature를 전달한다", async () => { // Given const userId = "user-1"; - (cacheService.getSubscription as jest.Mock).mockResolvedValue(undefined); - (cheerRepo.getUserSubscriptionInfo as jest.Mock).mockResolvedValue({ + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, subscriptionStatus: "FREE", - role: "ADMIN", - }); - (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(100); - - // When - const result = await service.getLimitInfo(userId); - - // Then - expect(result.dailyLimit).toBeNull(); - expect(result.remaining).toBeNull(); - expect(cacheService.setSubscription).toHaveBeenCalledWith(userId, { - status: "FREE", - isAdmin: true, }); - }); - - it("구독 상태가 없으면 FREE로 처리한다", async () => { - // Given - const userId = "user-1"; - (cheerRepo.getUserSubscriptionInfo as jest.Mock).mockResolvedValue(null); (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(0); // When - const result = await service.getLimitInfo(userId); + await service.getLimitInfo(userId); // Then - expect(result.dailyLimit).toBe(CHEER_LIMITS.FREE_DAILY_LIMIT); + expect(entitlementService.getFeatureLimit).toHaveBeenCalledWith( + userId, + Feature.CHEER, + ); }); it("남은 횟수가 음수가 되지 않는다", async () => { // Given const userId = "user-1"; - (cheerRepo.getUserSubscriptionInfo as jest.Mock).mockResolvedValue({ + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: CHEER_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, subscriptionStatus: "FREE", - role: "USER", }); (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(100); // 제한보다 많음 @@ -791,22 +760,6 @@ describe("CheerService", () => { // Then expect(result.remaining).toBe(0); }); - - it("캐시된 구독 상태를 사용한다", async () => { - // Given - const userId = "user-1"; - (cacheService.getSubscription as jest.Mock).mockResolvedValue({ - status: "ACTIVE", - }); - (cheerRepo.countTodayCheers as jest.Mock).mockResolvedValue(10); - - // When - const result = await service.getLimitInfo(userId); - - // Then - expect(result.dailyLimit).toBeNull(); - expect(cheerRepo.getUserSubscriptionStatus).not.toHaveBeenCalled(); - }); }); describe("getCooldownInfoForUser", () => { diff --git a/apps/api/src/modules/cheer/cheer.service.ts b/apps/api/src/modules/cheer/cheer.service.ts index 8deb5909..52a948d3 100644 --- a/apps/api/src/modules/cheer/cheer.service.ts +++ b/apps/api/src/modules/cheer/cheer.service.ts @@ -1,8 +1,11 @@ -import { CHEER_LIMITS, SUBSCRIPTION_CHEER_LIMITS } from "@aido/validators"; +import { CHEER_LIMITS } from "@aido/validators"; import { Injectable, Logger } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; -import { CacheService } from "@/common/cache/cache.service"; import { addMilliseconds, now, startOfDayInTimezone } from "@/common/date"; +import { + EntitlementService, + Feature, +} from "@/common/entitlement/entitlement.service"; 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"; @@ -42,7 +45,7 @@ export class CheerService { private readonly paginationService: PaginationService, private readonly eventEmitter: EventEmitter2, private readonly database: DatabaseService, - private readonly cacheService: CacheService, + private readonly entitlementService: EntitlementService, ) {} // ========================================================================= @@ -86,19 +89,11 @@ export class CheerService { // 3. 일일 제한 체크 (트랜잭션 내에서 실시간 조회) const todayStart = startOfDayInTimezone(now(), tz); - const subscriptionStatus = await tx.user.findUnique({ - where: { id: senderId }, - select: { subscriptionStatus: true, role: true }, - }); - - const isAdmin = subscriptionStatus?.role === "ADMIN"; - const status = subscriptionStatus?.subscriptionStatus ?? "FREE"; - const limitKey = status as keyof typeof SUBSCRIPTION_CHEER_LIMITS; - const dailyLimit = isAdmin - ? null - : limitKey in SUBSCRIPTION_CHEER_LIMITS - ? SUBSCRIPTION_CHEER_LIMITS[limitKey] - : CHEER_LIMITS.FREE_DAILY_LIMIT; + const { dailyLimit } = await this.entitlementService.getFeatureLimitInTx( + tx, + senderId, + Feature.CHEER, + ); const used = await tx.cheer.count({ where: { @@ -275,34 +270,10 @@ export class CheerService { userId: string, tz: string = "UTC", ): Promise { - // 구독 상태 조회 (캐시 우선) - let subscriptionStatus: "FREE" | "ACTIVE" | "EXPIRED" | "CANCELLED" | null; - let isAdmin = false; - - const cachedSubscription = await this.cacheService.getSubscription(userId); - if (cachedSubscription !== undefined) { - subscriptionStatus = cachedSubscription.status; - isAdmin = cachedSubscription.isAdmin ?? false; - } else { - const userInfo = - await this.cheerRepository.getUserSubscriptionInfo(userId); - subscriptionStatus = userInfo?.subscriptionStatus ?? null; - isAdmin = userInfo?.role === "ADMIN"; - await this.cacheService.setSubscription(userId, { - status: subscriptionStatus, - isAdmin, - }); - } - - // 구독 상태에 따른 제한 - const status = subscriptionStatus ?? "FREE"; - const limitKey = status as keyof typeof SUBSCRIPTION_CHEER_LIMITS; - // ADMIN은 무제한, ACTIVE 구독자도 null(무제한) - const dailyLimit = isAdmin - ? null - : limitKey in SUBSCRIPTION_CHEER_LIMITS - ? SUBSCRIPTION_CHEER_LIMITS[limitKey] - : CHEER_LIMITS.FREE_DAILY_LIMIT; + const { dailyLimit } = await this.entitlementService.getFeatureLimit( + userId, + Feature.CHEER, + ); // 오늘 사용량 조회 const today = startOfDayInTimezone(now(), tz); diff --git a/apps/api/src/modules/email/email.service.spec.ts b/apps/api/src/modules/email/email.service.spec.ts index 6911f263..d1448faa 100644 --- a/apps/api/src/modules/email/email.service.spec.ts +++ b/apps/api/src/modules/email/email.service.spec.ts @@ -1,5 +1,14 @@ +/** + * EmailService 단위 테스트 (Suites + GWT 패턴) + * + * 이메일 발송, 재시도 로직, 지수 백오프, 태그 검증 + * + * - Suites: 자동 Mock 생성 (TypedConfigService) + * - jest.mock("resend"): 생성자 내부 new Resend() 인스턴스화 때문에 모듈 레벨 mock 필수 + * - GWT: Given/When/Then 주석 + */ import { Logger } from "@nestjs/common"; -import { Test, type TestingModule } from "@nestjs/testing"; +import { TestBed } from "@suites/unit"; import { Resend } from "resend"; import { TypedConfigService } from "../../common/config/services/config.service"; import { EMAIL_CONSTANTS } from "./constants/email.constants"; @@ -26,7 +35,6 @@ type ResendMock = { describe("EmailService", () => { let service: EmailService; let resendMock: ResendMock; - let configServiceMock: Partial; let setTimeoutCalls: number[]; // 테스트 데이터 @@ -47,27 +55,19 @@ describe("EmailService", () => { return 0 as unknown as ReturnType; }); - // ConfigService mock 생성 - configServiceMock = { - email: { - isConfigured: true, - apiKey: "test-api-key", - from: "noreply@test.com", - fromName: "Test App", - supportEmail: "support@aido.app", - }, - nodeEnv: "test", - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - EmailService, - { - provide: TypedConfigService, - useValue: configServiceMock, + const { unit } = await TestBed.solitary(EmailService) + .mock(TypedConfigService) + .impl(() => ({ + email: { + isConfigured: true, + apiKey: "test-api-key", + from: "noreply@test.com", + fromName: "Test App", + supportEmail: "support@aido.app", }, - ], - }).compile(); + nodeEnv: "test", + })) + .compile(); // Logger 출력 비활성화 jest.spyOn(Logger.prototype, "log").mockImplementation(); @@ -75,7 +75,7 @@ describe("EmailService", () => { jest.spyOn(Logger.prototype, "error").mockImplementation(); jest.spyOn(Logger.prototype, "debug").mockImplementation(); - service = module.get(EmailService); + service = unit; // Resend mock 인스턴스에서 resendMock 참조 획득 const resendInstance = @@ -87,6 +87,10 @@ describe("EmailService", () => { jest.restoreAllMocks(); }); + // ========================================================================= + // sendVerificationCode + // ========================================================================= + describe("sendVerificationCode", () => { it("성공적으로 인증 코드 이메일을 발송한다", async () => { // Given @@ -160,6 +164,10 @@ describe("EmailService", () => { }); }); + // ========================================================================= + // sendPasswordResetCode + // ========================================================================= + describe("sendPasswordResetCode", () => { it("성공적으로 비밀번호 재설정 이메일을 발송한다", async () => { // Given @@ -188,6 +196,10 @@ describe("EmailService", () => { }); }); + // ========================================================================= + // retry 로직 + // ========================================================================= + describe("retry 로직", () => { it("application_error 발생 시 재시도한다", async () => { // Given @@ -330,30 +342,29 @@ describe("EmailService", () => { }); }); + // ========================================================================= + // Resend가 설정되지 않은 경우 + // ========================================================================= + describe("Resend가 설정되지 않은 경우", () => { beforeEach(async () => { - configServiceMock = { - email: { - isConfigured: false, - apiKey: "", - from: "noreply@test.com", - fromName: "Test App", - supportEmail: "support@aido.app", - }, - nodeEnv: "test", - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - EmailService, - { - provide: TypedConfigService, - useValue: configServiceMock, + MockedResend.mockClear(); + + const { unit } = await TestBed.solitary(EmailService) + .mock(TypedConfigService) + .impl(() => ({ + email: { + isConfigured: false, + apiKey: "", + from: "noreply@test.com", + fromName: "Test App", + supportEmail: "support@aido.app", }, - ], - }).compile(); + nodeEnv: "test", + })) + .compile(); - service = module.get(EmailService); + service = unit; }); it("mock 결과를 반환한다", async () => { @@ -372,6 +383,10 @@ describe("EmailService", () => { }); }); + // ========================================================================= + // tags + // ========================================================================= + describe("tags", () => { it("verification 타입 태그가 포함된다", async () => { // Given @@ -436,6 +451,10 @@ describe("EmailService", () => { }); }); + // ========================================================================= + // sendInquiry + // ========================================================================= + describe("sendInquiry", () => { const inquiryData: Parameters[1] = { userEmail: "user@example.com", diff --git a/apps/api/src/modules/nudge/nudge.repository.spec.ts b/apps/api/src/modules/nudge/nudge.repository.spec.ts index 42310ce5..4696dfe0 100644 --- a/apps/api/src/modules/nudge/nudge.repository.spec.ts +++ b/apps/api/src/modules/nudge/nudge.repository.spec.ts @@ -474,36 +474,6 @@ describe("NudgeRepository", () => { }); }); - describe("getUserSubscriptionStatus", () => { - it("사용자 구독 상태를 조회한다", async () => { - // Given - (db.user.findUnique as jest.Mock).mockResolvedValue({ - subscriptionStatus: "ACTIVE", - }); - - // When - const result = await repository.getUserSubscriptionStatus("user-id"); - - // Then - expect(db.user.findUnique).toHaveBeenCalledWith({ - where: { id: "user-id" }, - select: { subscriptionStatus: true }, - }); - expect(result).toBe("ACTIVE"); - }); - - it("사용자가 없으면 null을 반환한다", async () => { - // Given - (db.user.findUnique as jest.Mock).mockResolvedValue(null); - - // When - const result = await repository.getUserSubscriptionStatus("non-existent"); - - // Then - expect(result).toBeNull(); - }); - }); - describe("findTodoWithOwner", () => { it("Todo와 소유자 정보를 조회한다", async () => { // Given diff --git a/apps/api/src/modules/nudge/nudge.repository.ts b/apps/api/src/modules/nudge/nudge.repository.ts index 7c77b922..c3435ea6 100644 --- a/apps/api/src/modules/nudge/nudge.repository.ts +++ b/apps/api/src/modules/nudge/nudge.repository.ts @@ -250,33 +250,6 @@ export class NudgeRepository { return user?.profile?.name ?? null; } - /** - * 사용자 구독 상태 조회 - */ - async getUserSubscriptionStatus( - userId: string, - ): Promise<"FREE" | "ACTIVE" | "EXPIRED" | "CANCELLED" | null> { - const user = await this.database.user.findUnique({ - where: { id: userId }, - select: { subscriptionStatus: true }, - }); - return user?.subscriptionStatus ?? null; - } - - /** - * 사용자 구독 상태 및 역할 조회 (ADMIN 우회 판단용) - */ - async getUserSubscriptionInfo(userId: string): Promise<{ - subscriptionStatus: "FREE" | "ACTIVE" | "EXPIRED" | "CANCELLED"; - role: string; - } | null> { - const user = await this.database.user.findUnique({ - where: { id: userId }, - select: { subscriptionStatus: true, role: true }, - }); - return user ?? null; - } - /** * Todo 존재 및 소유자 확인 */ diff --git a/apps/api/src/modules/nudge/nudge.service.spec.ts b/apps/api/src/modules/nudge/nudge.service.spec.ts index 7aee10a1..06ac84c1 100644 --- a/apps/api/src/modules/nudge/nudge.service.spec.ts +++ b/apps/api/src/modules/nudge/nudge.service.spec.ts @@ -14,6 +14,10 @@ import type { Mocked } from "@suites/doubles.jest"; import { TestBed } from "@suites/unit"; import { NudgeBuilder } from "@test/builders"; import { addDays, getUserToday, subtractDays } from "@/common/date"; +import { + EntitlementService, + Feature, +} from "@/common/entitlement/entitlement.service"; import { PaginationService } from "@/common/pagination/services/pagination.service"; import { DatabaseService } from "@/database/database.service"; import { FollowService } from "@/modules/follow/follow.service"; @@ -32,6 +36,7 @@ describe("NudgeService", () => { let paginationService: Mocked; let eventEmitter: Mocked; let database: Mocked; + let entitlementService: Mocked; // Mock database transaction passthrough // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -43,9 +48,6 @@ describe("NudgeService", () => { $transaction: jest.fn((callback: (tx: unknown) => unknown) => callback(mockDatabase), ), - user: { - findUnique: jest.fn(), - }, todo: { findUnique: jest.fn(), }, @@ -75,12 +77,28 @@ describe("NudgeService", () => { database = unitRef.get( DatabaseService, ) as unknown as Mocked; + entitlementService = unitRef.get( + EntitlementService, + ) as unknown as Mocked; // DatabaseService.$transaction passthrough mock 설정 database.$transaction.mockImplementation((callback) => callback(mockDatabase), ); + // EntitlementService 기본 mock 설정 (FREE 사용자) + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue({ + dailyLimit: NUDGE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }); + + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: NUDGE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }); + // 기본 paginationService mock 설정 paginationService.normalizeCursorPagination.mockReturnValue({ cursor: undefined, @@ -123,9 +141,13 @@ describe("NudgeService", () => { const setupSuccessfulSend = () => { followService.isMutualFriend.mockResolvedValue(true); nudgeRepository.getUserName.mockResolvedValue("보내는 사람"); - mockDatabase.user.findUnique.mockResolvedValue({ + + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue({ + dailyLimit: NUDGE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, subscriptionStatus: "FREE", }); + mockDatabase.todo.findUnique.mockResolvedValue({ id: 100, userId: "receiver-id", @@ -283,9 +305,13 @@ describe("NudgeService", () => { endDate: null, visibility: "PUBLIC", }); - mockDatabase.user.findUnique.mockResolvedValue({ + + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue({ + dailyLimit: NUDGE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, subscriptionStatus: "FREE", }); + mockDatabase.nudge.count.mockResolvedValue(NUDGE_LIMITS.FREE_DAILY_LIMIT); // When & Then @@ -319,10 +345,13 @@ describe("NudgeService", () => { endDate: null, visibility: "PUBLIC", }); - mockDatabase.user.findUnique.mockResolvedValue({ + + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: false, subscriptionStatus: "ACTIVE", - role: "USER", }); + mockDatabase.nudge.count.mockResolvedValue(100); mockDatabase.nudge.findFirst.mockResolvedValue(null); @@ -360,10 +389,13 @@ describe("NudgeService", () => { endDate: null, visibility: "PUBLIC", }); - mockDatabase.user.findUnique.mockResolvedValue({ + + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: true, subscriptionStatus: "FREE", - role: "ADMIN", }); + mockDatabase.nudge.count.mockResolvedValue(100); mockDatabase.nudge.findFirst.mockResolvedValue(null); @@ -401,9 +433,13 @@ describe("NudgeService", () => { endDate: null, visibility: "PUBLIC", }); - mockDatabase.user.findUnique.mockResolvedValue({ + + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue({ + dailyLimit: NUDGE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, subscriptionStatus: "FREE", }); + mockDatabase.nudge.count.mockResolvedValue(0); const recentNudge = NudgeBuilder.create("sender-id", "receiver-id", 100) @@ -455,9 +491,13 @@ describe("NudgeService", () => { const tomorrow = addDays(1, todayMidnight); followService.isMutualFriend.mockResolvedValue(true); nudgeRepository.getUserName.mockResolvedValue("보내는 사람"); - mockDatabase.user.findUnique.mockResolvedValue({ + + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue({ + dailyLimit: NUDGE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, subscriptionStatus: "FREE", }); + mockDatabase.todo.findUnique.mockResolvedValue({ id: 100, userId: "receiver-id", @@ -520,9 +560,13 @@ describe("NudgeService", () => { endDate: null, visibility: "PUBLIC", }); - mockDatabase.user.findUnique.mockResolvedValue({ + + (entitlementService.getFeatureLimitInTx as jest.Mock).mockResolvedValue({ + dailyLimit: NUDGE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, subscriptionStatus: "FREE", }); + mockDatabase.nudge.count.mockResolvedValue(0); const oldNudge = NudgeBuilder.create("sender-id", "receiver-id", 100) @@ -660,9 +704,10 @@ describe("NudgeService", () => { describe("getLimitInfo", () => { it("FREE 사용자의 제한 정보를 조회한다", async () => { // Given - nudgeRepository.getUserSubscriptionInfo.mockResolvedValue({ + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: NUDGE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, subscriptionStatus: "FREE", - role: "USER", }); nudgeRepository.countTodayNudges.mockResolvedValue(1); @@ -677,9 +722,10 @@ describe("NudgeService", () => { it("ACTIVE 사용자는 무제한이다", async () => { // Given - nudgeRepository.getUserSubscriptionInfo.mockResolvedValue({ + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: false, subscriptionStatus: "ACTIVE", - role: "USER", }); nudgeRepository.countTodayNudges.mockResolvedValue(100); @@ -694,9 +740,10 @@ describe("NudgeService", () => { it("ADMIN은 구독 상태와 무관하게 무제한이다", async () => { // Given - nudgeRepository.getUserSubscriptionInfo.mockResolvedValue({ + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: null, + isAdmin: true, subscriptionStatus: "FREE", - role: "ADMIN", }); nudgeRepository.countTodayNudges.mockResolvedValue(100); @@ -711,9 +758,10 @@ describe("NudgeService", () => { it("EXPIRED 사용자는 FREE 제한이 적용된다", async () => { // Given - nudgeRepository.getUserSubscriptionInfo.mockResolvedValue({ + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: NUDGE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, subscriptionStatus: "EXPIRED", - role: "USER", }); nudgeRepository.countTodayNudges.mockResolvedValue(2); @@ -725,16 +773,23 @@ describe("NudgeService", () => { expect(result.remaining).toBe(NUDGE_LIMITS.FREE_DAILY_LIMIT - 2); }); - it("사용자가 없으면 FREE 제한이 적용된다", async () => { + it("EntitlementService에 올바른 Feature를 전달한다", async () => { // Given - nudgeRepository.getUserSubscriptionInfo.mockResolvedValue(null); + (entitlementService.getFeatureLimit as jest.Mock).mockResolvedValue({ + dailyLimit: NUDGE_LIMITS.FREE_DAILY_LIMIT, + isAdmin: false, + subscriptionStatus: "FREE", + }); nudgeRepository.countTodayNudges.mockResolvedValue(0); // When - const result = await service.getLimitInfo("unknown-id"); + await service.getLimitInfo("user-id"); // Then - expect(result.dailyLimit).toBe(NUDGE_LIMITS.FREE_DAILY_LIMIT); + expect(entitlementService.getFeatureLimit).toHaveBeenCalledWith( + "user-id", + Feature.NUDGE, + ); }); }); diff --git a/apps/api/src/modules/nudge/nudge.service.ts b/apps/api/src/modules/nudge/nudge.service.ts index ee69a11e..60794158 100644 --- a/apps/api/src/modules/nudge/nudge.service.ts +++ b/apps/api/src/modules/nudge/nudge.service.ts @@ -1,4 +1,4 @@ -import { NUDGE_LIMITS, SUBSCRIPTION_NUDGE_LIMITS } from "@aido/validators"; +import { NUDGE_LIMITS } from "@aido/validators"; import { Injectable, Logger } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { @@ -7,6 +7,10 @@ import { now, startOfDayInTimezone, } from "@/common/date"; +import { + EntitlementService, + Feature, +} from "@/common/entitlement/entitlement.service"; 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"; @@ -46,6 +50,7 @@ export class NudgeService { private readonly paginationService: PaginationService, private readonly eventEmitter: EventEmitter2, private readonly database: DatabaseService, + private readonly entitlementService: EntitlementService, ) {} // ========================================================================= @@ -124,19 +129,11 @@ export class NudgeService { } // 4. 일일 제한 체크 (트랜잭션 내에서 실시간 조회) - const subscriptionStatus = await tx.user.findUnique({ - where: { id: senderId }, - select: { subscriptionStatus: true, role: true }, - }); - - const isAdmin = subscriptionStatus?.role === "ADMIN"; - const status = subscriptionStatus?.subscriptionStatus ?? "FREE"; - const limitKey = status as keyof typeof SUBSCRIPTION_NUDGE_LIMITS; - const dailyLimit = isAdmin - ? null - : limitKey in SUBSCRIPTION_NUDGE_LIMITS - ? SUBSCRIPTION_NUDGE_LIMITS[limitKey] - : NUDGE_LIMITS.FREE_DAILY_LIMIT; + const { dailyLimit } = await this.entitlementService.getFeatureLimitInTx( + tx, + senderId, + Feature.NUDGE, + ); const todayStart = startOfDayInTimezone(now(), tz); const used = await tx.nudge.count({ @@ -322,19 +319,10 @@ export class NudgeService { userId: string, tz: string = "UTC", ): Promise { - // 구독 상태 및 역할 조회 - const userInfo = await this.nudgeRepository.getUserSubscriptionInfo(userId); - const isAdmin = userInfo?.role === "ADMIN"; - - // 구독 상태에 따른 제한 - const status = userInfo?.subscriptionStatus ?? "FREE"; - const limitKey = status as keyof typeof SUBSCRIPTION_NUDGE_LIMITS; - // ADMIN은 무제한, ACTIVE 구독자도 null(무제한) - const dailyLimit = isAdmin - ? null - : limitKey in SUBSCRIPTION_NUDGE_LIMITS - ? SUBSCRIPTION_NUDGE_LIMITS[limitKey] - : NUDGE_LIMITS.FREE_DAILY_LIMIT; + const { dailyLimit } = await this.entitlementService.getFeatureLimit( + userId, + Feature.NUDGE, + ); // 오늘 사용량 조회 const today = startOfDayInTimezone(now(), tz); diff --git a/apps/mobile/src/features/todo/models/todo.model.ts b/apps/mobile/src/features/todo/models/todo.model.ts index 706ef2a3..2002907f 100644 --- a/apps/mobile/src/features/todo/models/todo.model.ts +++ b/apps/mobile/src/features/todo/models/todo.model.ts @@ -66,7 +66,7 @@ export type ParsedTodoResult = z.infer; export const aiUsageSchema = z.object({ used: z.number(), - limit: z.number(), + limit: z.number().nullable(), resetsAt: z.string(), }); export type AiUsage = z.infer; diff --git a/packages/validators/jest.config.cjs b/packages/validators/jest.config.cjs new file mode 100644 index 00000000..b7de5ac3 --- /dev/null +++ b/packages/validators/jest.config.cjs @@ -0,0 +1,17 @@ +/** + * @aido/validators Jest 설정 + * + * @aido/jest-config 프리셋을 확장하여 순수 함수 테스트에 최적화 + */ + +const preset = require('@aido/jest-config/jest.preset.cjs'); + +/** @type {import('jest').Config} */ +module.exports = { + ...preset, + + rootDir: '.', + roots: ['/src'], + + testMatch: ['**/__tests__/**/*.test.ts', '**/*.test.ts'], +}; diff --git a/packages/validators/package.json b/packages/validators/package.json index 5f93a18b..99736609 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -18,6 +18,7 @@ "format": "biome format --write .", "lint": "biome lint .", "lint:fix": "biome lint --write .", + "test": "jest", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/validators/src/domains/ai/ai-usage.response.ts b/packages/validators/src/domains/ai/ai-usage.response.ts index b3f54a1c..aaf401ed 100644 --- a/packages/validators/src/domains/ai/ai-usage.response.ts +++ b/packages/validators/src/domains/ai/ai-usage.response.ts @@ -4,7 +4,7 @@ import { datetimeSchema } from '../../common/datetime'; export const aiUsageDataSchema = z.object({ used: z.number().int().nonnegative().describe('현재까지 사용한 AI 요청 횟수 (0 이상)'), - limit: z.number().int().positive().describe('일일 최대 AI 요청 횟수 (양의 정수)'), + limit: z.number().int().positive().nullable().describe('일일 최대 AI 요청 횟수 (null = 무제한)'), resetsAt: datetimeSchema.describe( '사용량 리셋 시각 (ISO 8601 UTC, 예: 2026-01-18T00:00:00.000Z)', ), diff --git a/packages/validators/src/domains/ai/ai.constants.ts b/packages/validators/src/domains/ai/ai.constants.ts new file mode 100644 index 00000000..4c46054f --- /dev/null +++ b/packages/validators/src/domains/ai/ai.constants.ts @@ -0,0 +1,10 @@ +export const AI_PARSE_LIMITS = { + FREE_DAILY_LIMIT: 5, +} as const; + +export const SUBSCRIPTION_AI_PARSE_LIMITS = { + FREE: AI_PARSE_LIMITS.FREE_DAILY_LIMIT, + ACTIVE: null, // 무제한 + EXPIRED: AI_PARSE_LIMITS.FREE_DAILY_LIMIT, + CANCELLED: AI_PARSE_LIMITS.FREE_DAILY_LIMIT, +} as const; diff --git a/packages/validators/src/domains/ai/index.ts b/packages/validators/src/domains/ai/index.ts index 0905b67e..9a9bf21b 100644 --- a/packages/validators/src/domains/ai/index.ts +++ b/packages/validators/src/domains/ai/index.ts @@ -4,6 +4,8 @@ * AI 자연어 처리 및 사용량 관련 스키마 및 타입 */ +// 상수 (Constants) +export * from './ai.constants'; // 응답 스키마 (Response) export * from './ai-usage.response'; // 요청 스키마 (Request) From 471780d0240e826a7f9a58e6fef5960a03d1c1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=80=E1=85=B5=E1=86=B7=E1=84=8B=E1=85=AD=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Wed, 25 Feb 2026 01:52:33 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore(api):=20=EB=B0=B0=ED=8F=AC=EC=A0=84?= =?UTF-8?q?=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 10 - .../migration.sql | 19 -- .../migration.sql | 14 -- .../migration.sql | 97 --------- .../migration.sql | 8 - .../migration.sql | 32 --- .../migration.sql | 53 ----- .../migration.sql | 18 -- .../migration.sql | 3 - .../migration.sql | 5 - .../migration.sql | 54 ----- .../migration.sql | 20 -- .../migration.sql | 16 -- .../migration.sql | 28 --- .../migration.sql | 2 - .../migration.sql | 16 -- .../migration.sql | 2 - .../migration.sql | 4 - .../migration.sql | 36 ---- .../migration.sql | 2 - .../migration.sql | 4 - .../migration.sql | 10 - .../migration.sql | 2 - .../migration.sql | 5 - .../migration.sql | 8 - .../migration.sql | 13 -- .../migration.sql | 8 - .../migration.sql | 17 -- .../migration.sql | 12 -- .../migration.sql | 201 +++++++++++++++--- .../api/prisma/migrations/migration_lock.toml | 2 +- 31 files changed, 178 insertions(+), 543 deletions(-) delete mode 100644 apps/api/prisma/migrations/20260116042357_add_oauth_auto_link_events/migration.sql delete mode 100644 apps/api/prisma/migrations/20260116092900_oauth_state_exchange_code/migration.sql delete mode 100644 apps/api/prisma/migrations/20260117132632_change_todo_id_to_autoincrement/migration.sql delete mode 100644 apps/api/prisma/migrations/20260117200700_add_follow_nudge_dailycompletion/migration.sql delete mode 100644 apps/api/prisma/migrations/20260117210000_rename_friendcode_to_usertag/migration.sql delete mode 100644 apps/api/prisma/migrations/20260118023438_add_follow_status/migration.sql delete mode 100644 apps/api/prisma/migrations/20260118143626_add_cheer_and_notification_types/migration.sql delete mode 100644 apps/api/prisma/migrations/20260118144856_simplify_notification_links/migration.sql delete mode 100644 apps/api/prisma/migrations/20260124160724_add_ai_usage_tracking/migration.sql delete mode 100644 apps/api/prisma/migrations/20260125050214_add_provider_to_login_attempt/migration.sql delete mode 100644 apps/api/prisma/migrations/20260129100000_add_todo_category/migration.sql delete mode 100644 apps/api/prisma/migrations/20260129110000_fix_todo_sort_order/migration.sql delete mode 100644 apps/api/prisma/migrations/20260129120000_fix_todo_category_sort_order/migration.sql delete mode 100644 apps/api/prisma/migrations/20260129130000_add_default_categories_for_existing_users/migration.sql delete mode 100644 apps/api/prisma/migrations/20260201000000_remove_notification_route_field/migration.sql delete mode 100644 apps/api/prisma/migrations/20260203100220_add_user_role_and_admin_notifications/migration.sql delete mode 100644 apps/api/prisma/migrations/20260205233052_add_timestamptz_to_scheduled_time/migration.sql delete mode 100644 apps/api/prisma/migrations/20260207173657_add_timezone_and_reminder_hours/migration.sql delete mode 100644 apps/api/prisma/migrations/20260208100000_add_notification_dedup/migration.sql delete mode 100644 apps/api/prisma/migrations/20260210110159_add_oauth_state_mode/migration.sql delete mode 100644 apps/api/prisma/migrations/20260210233500_expand_oauth_state_user_id_length/migration.sql delete mode 100644 apps/api/prisma/migrations/20260212233911_add_account_deletion_security_events/migration.sql delete mode 100644 apps/api/prisma/migrations/20260213002058_add_oauth_state_initiating_user_id/migration.sql delete mode 100644 apps/api/prisma/migrations/20260214070152_add_password_setup_types/migration.sql delete mode 100644 apps/api/prisma/migrations/20260215034001_remove_redundant_indexes/migration.sql delete mode 100644 apps/api/prisma/migrations/20260215050000_notification_partial_unique_indexes/migration.sql delete mode 100644 apps/api/prisma/migrations/20260221120230_add_reminder_query_indexes/migration.sql delete mode 100644 apps/api/prisma/migrations/20260223010105_add_account_restored_event/migration.sql delete mode 100644 apps/api/prisma/migrations/20260224120000_restore_notification_unique_indexes/migration.sql rename apps/api/prisma/migrations/{20260115093106_init => 20260225000000_init}/migration.sql (66%) diff --git a/apps/api/prisma/migrations/20260116042357_add_oauth_auto_link_events/migration.sql b/apps/api/prisma/migrations/20260116042357_add_oauth_auto_link_events/migration.sql deleted file mode 100644 index f9c6dd42..00000000 --- a/apps/api/prisma/migrations/20260116042357_add_oauth_auto_link_events/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- AlterEnum --- This migration adds more than one value to an enum. --- With PostgreSQL versions 11 and earlier, this is not possible --- in a single migration. This can be worked around by creating --- multiple migrations, each migration adding only one value to --- the enum. - - -ALTER TYPE "SecurityEvent" ADD VALUE 'OAUTH_AUTO_LINKED'; -ALTER TYPE "SecurityEvent" ADD VALUE 'OAUTH_LINK_REQUIRED'; diff --git a/apps/api/prisma/migrations/20260116092900_oauth_state_exchange_code/migration.sql b/apps/api/prisma/migrations/20260116092900_oauth_state_exchange_code/migration.sql deleted file mode 100644 index d6eaac5a..00000000 --- a/apps/api/prisma/migrations/20260116092900_oauth_state_exchange_code/migration.sql +++ /dev/null @@ -1,19 +0,0 @@ --- DropTable (OAuthExchangeCode 제거) -DROP TABLE IF EXISTS "OAuthExchangeCode"; - --- AlterTable (OAuthState에 교환 코드 필드 추가) -ALTER TABLE "OAuthState" ADD COLUMN "exchangeCode" VARCHAR(64); -ALTER TABLE "OAuthState" ADD COLUMN "accessToken" VARCHAR(1000); -ALTER TABLE "OAuthState" ADD COLUMN "refreshToken" VARCHAR(1000); -ALTER TABLE "OAuthState" ADD COLUMN "userId" VARCHAR(30); -ALTER TABLE "OAuthState" ADD COLUMN "userName" VARCHAR(100); -ALTER TABLE "OAuthState" ADD COLUMN "profileImage" VARCHAR(500); -ALTER TABLE "OAuthState" ADD COLUMN "ipAddress" VARCHAR(45); -ALTER TABLE "OAuthState" ADD COLUMN "userAgent" VARCHAR(500); -ALTER TABLE "OAuthState" ADD COLUMN "exchangedAt" TIMESTAMP(3); - --- CreateIndex -CREATE UNIQUE INDEX "OAuthState_exchangeCode_key" ON "OAuthState"("exchangeCode"); - --- CreateIndex -CREATE INDEX "OAuthState_exchangeCode_idx" ON "OAuthState"("exchangeCode"); diff --git a/apps/api/prisma/migrations/20260117132632_change_todo_id_to_autoincrement/migration.sql b/apps/api/prisma/migrations/20260117132632_change_todo_id_to_autoincrement/migration.sql deleted file mode 100644 index 6c078d53..00000000 --- a/apps/api/prisma/migrations/20260117132632_change_todo_id_to_autoincrement/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ -/* - Warnings: - - - The `todoId` column on the `Notification` table would be dropped and recreated. This will lead to data loss if there is data in the column. - - The primary key for the `Todo` table will be changed. If it partially fails, the table could be left without primary key constraint. - - The `id` column on the `Todo` table would be dropped and recreated. This will lead to data loss if there is data in the column. - - Changed the type of `todoId` on the `Nudge` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. - -*/ --- AlterTable: Change Todo.id from CUID to auto-increment -ALTER TABLE "Todo" DROP CONSTRAINT "Todo_pkey", -DROP COLUMN "id", -ADD COLUMN "id" SERIAL NOT NULL, -ADD CONSTRAINT "Todo_pkey" PRIMARY KEY ("id"); diff --git a/apps/api/prisma/migrations/20260117200700_add_follow_nudge_dailycompletion/migration.sql b/apps/api/prisma/migrations/20260117200700_add_follow_nudge_dailycompletion/migration.sql deleted file mode 100644 index c956c906..00000000 --- a/apps/api/prisma/migrations/20260117200700_add_follow_nudge_dailycompletion/migration.sql +++ /dev/null @@ -1,97 +0,0 @@ --- DropForeignKey -ALTER TABLE "Friendship" DROP CONSTRAINT IF EXISTS "Friendship_friendId_fkey"; -ALTER TABLE "Friendship" DROP CONSTRAINT IF EXISTS "Friendship_userId_fkey"; - --- DropTable -DROP TABLE IF EXISTS "Friendship"; - --- DropEnum -DROP TYPE IF EXISTS "FriendshipStatus"; - --- AlterEnum: Remove old values from NotificationType -ALTER TYPE "NotificationType" RENAME TO "NotificationType_old"; -CREATE TYPE "NotificationType" AS ENUM ('TODO_REMINDER', 'TODO_SHARED', 'WEEKLY_ACHIEVEMENT', 'SYSTEM_NOTICE', 'FOLLOW_NEW', 'NUDGE_RECEIVED', 'DAILY_COMPLETE'); -ALTER TABLE "Notification" ALTER COLUMN "type" TYPE "NotificationType" USING ("type"::text::"NotificationType"); -DROP TYPE "NotificationType_old"; - --- AlterTable: Add friendCode to User -ALTER TABLE "User" ADD COLUMN "friendCode" VARCHAR(8); - --- Update existing users with random friendCode (if any) -UPDATE "User" SET "friendCode" = UPPER(SUBSTR(MD5(RANDOM()::TEXT), 1, 8)) WHERE "friendCode" IS NULL; - --- Make friendCode NOT NULL after populating -ALTER TABLE "User" ALTER COLUMN "friendCode" SET NOT NULL; - --- CreateIndex -CREATE UNIQUE INDEX "User_friendCode_key" ON "User"("friendCode"); - --- AlterTable: Add nudgeId to Notification -ALTER TABLE "Notification" ADD COLUMN "nudgeId" TEXT; - --- CreateTable: Follow -CREATE TABLE "Follow" ( - "id" TEXT NOT NULL, - "followerId" TEXT NOT NULL, - "followingId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "Follow_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "Follow_followerId_idx" ON "Follow"("followerId"); -CREATE INDEX "Follow_followingId_idx" ON "Follow"("followingId"); -CREATE UNIQUE INDEX "Follow_followerId_followingId_key" ON "Follow"("followerId", "followingId"); - --- CreateTable: Nudge -CREATE TABLE "Nudge" ( - "id" TEXT NOT NULL, - "senderId" TEXT NOT NULL, - "receiverId" TEXT NOT NULL, - "todoId" INTEGER NOT NULL, - "message" VARCHAR(200), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "readAt" TIMESTAMP(3), - - CONSTRAINT "Nudge_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "Nudge_receiverId_createdAt_idx" ON "Nudge"("receiverId", "createdAt"); -CREATE INDEX "Nudge_senderId_createdAt_idx" ON "Nudge"("senderId", "createdAt"); -CREATE INDEX "Nudge_todoId_idx" ON "Nudge"("todoId"); -CREATE INDEX "Nudge_senderId_todoId_createdAt_idx" ON "Nudge"("senderId", "todoId", "createdAt"); - --- CreateTable: DailyCompletion -CREATE TABLE "DailyCompletion" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "date" DATE NOT NULL, - "totalTodos" INTEGER NOT NULL, - "completedTodos" INTEGER NOT NULL, - "achievedAt" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "DailyCompletion_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "DailyCompletion_userId_date_idx" ON "DailyCompletion"("userId", "date"); -CREATE UNIQUE INDEX "DailyCompletion_userId_date_key" ON "DailyCompletion"("userId", "date"); - --- AddForeignKey -ALTER TABLE "Follow" ADD CONSTRAINT "Follow_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "Follow" ADD CONSTRAINT "Follow_followingId_fkey" FOREIGN KEY ("followingId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Nudge" ADD CONSTRAINT "Nudge_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "Nudge" ADD CONSTRAINT "Nudge_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "Nudge" ADD CONSTRAINT "Nudge_todoId_fkey" FOREIGN KEY ("todoId") REFERENCES "Todo"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "DailyCompletion" ADD CONSTRAINT "DailyCompletion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Notification" ADD CONSTRAINT "Notification_nudgeId_fkey" FOREIGN KEY ("nudgeId") REFERENCES "Nudge"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260117210000_rename_friendcode_to_usertag/migration.sql b/apps/api/prisma/migrations/20260117210000_rename_friendcode_to_usertag/migration.sql deleted file mode 100644 index 769b1918..00000000 --- a/apps/api/prisma/migrations/20260117210000_rename_friendcode_to_usertag/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ --- RenameColumn -ALTER TABLE "User" RENAME COLUMN "friendCode" TO "userTag"; - --- RenameIndex (unique constraint) -ALTER INDEX "User_friendCode_key" RENAME TO "User_userTag_key"; - --- CreateIndex (별도 인덱스 추가) -CREATE INDEX "User_userTag_idx" ON "User"("userTag"); diff --git a/apps/api/prisma/migrations/20260118023438_add_follow_status/migration.sql b/apps/api/prisma/migrations/20260118023438_add_follow_status/migration.sql deleted file mode 100644 index 56b02a18..00000000 --- a/apps/api/prisma/migrations/20260118023438_add_follow_status/migration.sql +++ /dev/null @@ -1,32 +0,0 @@ -/* - Warnings: - - - The `todoId` column on the `Notification` table would be dropped and recreated. This will lead to data loss if there is data in the column. - - Added the required column `updatedAt` to the `Follow` table without a default value. This is not possible if the table is not empty. - -*/ --- CreateEnum -CREATE TYPE "FollowStatus" AS ENUM ('PENDING', 'ACCEPTED'); - --- DropForeignKey -ALTER TABLE "Notification" DROP CONSTRAINT "Notification_nudgeId_fkey"; - --- DropIndex -DROP INDEX "Follow_followerId_idx"; - --- DropIndex -DROP INDEX "Follow_followingId_idx"; - --- AlterTable -ALTER TABLE "Follow" ADD COLUMN "status" "FollowStatus" NOT NULL DEFAULT 'PENDING', -ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; - --- AlterTable -ALTER TABLE "Notification" DROP COLUMN "todoId", -ADD COLUMN "todoId" INTEGER; - --- CreateIndex -CREATE INDEX "Follow_followerId_status_idx" ON "Follow"("followerId", "status"); - --- CreateIndex -CREATE INDEX "Follow_followingId_status_idx" ON "Follow"("followingId", "status"); diff --git a/apps/api/prisma/migrations/20260118143626_add_cheer_and_notification_types/migration.sql b/apps/api/prisma/migrations/20260118143626_add_cheer_and_notification_types/migration.sql deleted file mode 100644 index 798f6625..00000000 --- a/apps/api/prisma/migrations/20260118143626_add_cheer_and_notification_types/migration.sql +++ /dev/null @@ -1,53 +0,0 @@ -/* - Warnings: - - - The primary key for the `Nudge` table will be changed. If it partially fails, the table could be left without primary key constraint. - - The `id` column on the `Nudge` table would be dropped and recreated. This will lead to data loss if there is data in the column. - -*/ --- AlterEnum --- This migration adds more than one value to an enum. --- With PostgreSQL versions 11 and earlier, this is not possible --- in a single migration. This can be worked around by creating --- multiple migrations, each migration adding only one value to --- the enum. - - -ALTER TYPE "NotificationType" ADD VALUE 'FOLLOW_ACCEPTED'; -ALTER TYPE "NotificationType" ADD VALUE 'CHEER_RECEIVED'; -ALTER TYPE "NotificationType" ADD VALUE 'FRIEND_COMPLETED'; -ALTER TYPE "NotificationType" ADD VALUE 'MORNING_REMINDER'; -ALTER TYPE "NotificationType" ADD VALUE 'EVENING_REMINDER'; - --- AlterTable -ALTER TABLE "Nudge" DROP CONSTRAINT "Nudge_pkey", -DROP COLUMN "id", -ADD COLUMN "id" SERIAL NOT NULL, -ADD CONSTRAINT "Nudge_pkey" PRIMARY KEY ("id"); - --- CreateTable -CREATE TABLE "Cheer" ( - "id" SERIAL NOT NULL, - "senderId" TEXT NOT NULL, - "receiverId" TEXT NOT NULL, - "message" VARCHAR(200), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "readAt" TIMESTAMP(3), - - CONSTRAINT "Cheer_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "Cheer_receiverId_createdAt_idx" ON "Cheer"("receiverId", "createdAt"); - --- CreateIndex -CREATE INDEX "Cheer_senderId_createdAt_idx" ON "Cheer"("senderId", "createdAt"); - --- CreateIndex -CREATE INDEX "Cheer_senderId_receiverId_createdAt_idx" ON "Cheer"("senderId", "receiverId", "createdAt"); - --- AddForeignKey -ALTER TABLE "Cheer" ADD CONSTRAINT "Cheer_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Cheer" ADD CONSTRAINT "Cheer_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260118144856_simplify_notification_links/migration.sql b/apps/api/prisma/migrations/20260118144856_simplify_notification_links/migration.sql deleted file mode 100644 index 3db0ed8b..00000000 --- a/apps/api/prisma/migrations/20260118144856_simplify_notification_links/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `actionTarget` on the `Notification` table. All the data in the column will be lost. - - You are about to drop the column `actionType` on the `Notification` table. All the data in the column will be lost. - - The `nudgeId` column on the `Notification` table would be dropped and recreated. This will lead to data loss if there is data in the column. - -*/ --- AlterTable -ALTER TABLE "Notification" DROP COLUMN "actionTarget", -DROP COLUMN "actionType", -ADD COLUMN "cheerId" INTEGER, -ADD COLUMN "route" VARCHAR(200), -DROP COLUMN "nudgeId", -ADD COLUMN "nudgeId" INTEGER; - --- DropEnum -DROP TYPE "NotificationActionType"; diff --git a/apps/api/prisma/migrations/20260124160724_add_ai_usage_tracking/migration.sql b/apps/api/prisma/migrations/20260124160724_add_ai_usage_tracking/migration.sql deleted file mode 100644 index 98671179..00000000 --- a/apps/api/prisma/migrations/20260124160724_add_ai_usage_tracking/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "User" ADD COLUMN "aiUsageCount" INTEGER NOT NULL DEFAULT 0, -ADD COLUMN "aiUsageResetAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/api/prisma/migrations/20260125050214_add_provider_to_login_attempt/migration.sql b/apps/api/prisma/migrations/20260125050214_add_provider_to_login_attempt/migration.sql deleted file mode 100644 index a7b5a3e0..00000000 --- a/apps/api/prisma/migrations/20260125050214_add_provider_to_login_attempt/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "LoginAttempt" ADD COLUMN "provider" "AccountProvider"; - --- CreateIndex -CREATE INDEX "LoginAttempt_provider_createdAt_idx" ON "LoginAttempt"("provider", "createdAt"); diff --git a/apps/api/prisma/migrations/20260129100000_add_todo_category/migration.sql b/apps/api/prisma/migrations/20260129100000_add_todo_category/migration.sql deleted file mode 100644 index 58755b06..00000000 --- a/apps/api/prisma/migrations/20260129100000_add_todo_category/migration.sql +++ /dev/null @@ -1,54 +0,0 @@ --- ============================================================================= --- 마이그레이션: TodoCategory 추가 및 Todo 스키마 수정 --- ============================================================================= - --- 1. TodoCategory 테이블 생성 -CREATE TABLE "TodoCategory" ( - "id" SERIAL NOT NULL, - "userId" TEXT NOT NULL, - "name" VARCHAR(50) NOT NULL, - "color" VARCHAR(7) NOT NULL, - "sortOrder" INTEGER NOT NULL DEFAULT 0, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "TodoCategory_pkey" PRIMARY KEY ("id") -); - --- 2. TodoCategory 인덱스 생성 -CREATE UNIQUE INDEX "TodoCategory_userId_name_key" ON "TodoCategory"("userId", "name"); -CREATE INDEX "TodoCategory_userId_sortOrder_idx" ON "TodoCategory"("userId", "sortOrder"); - --- 3. TodoCategory FK 생성 -ALTER TABLE "TodoCategory" ADD CONSTRAINT "TodoCategory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- 4. 기존 사용자들에게 기본 카테고리 생성 (Todo가 있는 사용자) -INSERT INTO "TodoCategory" ("userId", "name", "color", "sortOrder", "updatedAt") -SELECT DISTINCT "userId", '할 일', '#FF6B43', 0, NOW() -FROM "Todo" -ON CONFLICT ("userId", "name") DO NOTHING; - --- 5. Todo 테이블에 categoryId 컬럼 추가 (nullable로 먼저) -ALTER TABLE "Todo" ADD COLUMN "categoryId" INTEGER; - --- 6. Todo 테이블에 sortOrder 컬럼 추가 -ALTER TABLE "Todo" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0; - --- 7. 기존 Todo에 기본 카테고리 연결 -UPDATE "Todo" t -SET "categoryId" = c.id -FROM "TodoCategory" c -WHERE t."userId" = c."userId" AND c."name" = '할 일'; - --- 8. categoryId NOT NULL 제약 추가 -ALTER TABLE "Todo" ALTER COLUMN "categoryId" SET NOT NULL; - --- 9. Todo FK 생성 (onDelete: Restrict) -ALTER TABLE "Todo" ADD CONSTRAINT "Todo_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TodoCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- 10. color 컬럼 삭제 -ALTER TABLE "Todo" DROP COLUMN "color"; - --- 11. 새 인덱스 추가 -CREATE INDEX "Todo_userId_categoryId_idx" ON "Todo"("userId", "categoryId"); -CREATE INDEX "Todo_userId_sortOrder_idx" ON "Todo"("userId", "sortOrder"); diff --git a/apps/api/prisma/migrations/20260129110000_fix_todo_sort_order/migration.sql b/apps/api/prisma/migrations/20260129110000_fix_todo_sort_order/migration.sql deleted file mode 100644 index 6937d7c5..00000000 --- a/apps/api/prisma/migrations/20260129110000_fix_todo_sort_order/migration.sql +++ /dev/null @@ -1,20 +0,0 @@ --- ============================================================================= --- 마이그레이션: 기존 Todo의 sortOrder 순차 설정 --- ============================================================================= - --- 기존 Todo들에 대해 사용자별로 createdAt 기준 순차적인 sortOrder 설정 --- 각 사용자의 Todo를 생성일 순서대로 0, 1, 2, ... 로 설정 - -WITH ranked_todos AS ( - SELECT - id, - ROW_NUMBER() OVER ( - PARTITION BY "userId" - ORDER BY "createdAt" ASC - ) - 1 AS new_sort_order - FROM "Todo" -) -UPDATE "Todo" t -SET "sortOrder" = rt.new_sort_order -FROM ranked_todos rt -WHERE t.id = rt.id; diff --git a/apps/api/prisma/migrations/20260129120000_fix_todo_category_sort_order/migration.sql b/apps/api/prisma/migrations/20260129120000_fix_todo_category_sort_order/migration.sql deleted file mode 100644 index acb7ed03..00000000 --- a/apps/api/prisma/migrations/20260129120000_fix_todo_category_sort_order/migration.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Fix TodoCategory sortOrder (all records have sortOrder = 0) --- Assigns sequential sortOrder per user based on createdAt - -WITH ranked_categories AS ( - SELECT - id, - ROW_NUMBER() OVER ( - PARTITION BY "userId" - ORDER BY "createdAt" ASC - ) - 1 AS new_sort_order - FROM "TodoCategory" -) -UPDATE "TodoCategory" tc -SET "sortOrder" = rc.new_sort_order -FROM ranked_categories rc -WHERE tc.id = rc.id; diff --git a/apps/api/prisma/migrations/20260129130000_add_default_categories_for_existing_users/migration.sql b/apps/api/prisma/migrations/20260129130000_add_default_categories_for_existing_users/migration.sql deleted file mode 100644 index 88b7d81a..00000000 --- a/apps/api/prisma/migrations/20260129130000_add_default_categories_for_existing_users/migration.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Add default categories for existing users who don't have any categories --- This fixes social login users who were created before the category creation logic was added - -INSERT INTO "TodoCategory" ("userId", "name", "color", "sortOrder", "createdAt", "updatedAt") -SELECT - u.id AS "userId", - '중요한 일' AS "name", - '#FFB3B3' AS "color", - 0 AS "sortOrder", - NOW() AS "createdAt", - NOW() AS "updatedAt" -FROM "User" u -WHERE NOT EXISTS ( - SELECT 1 FROM "TodoCategory" tc WHERE tc."userId" = u.id -); - -INSERT INTO "TodoCategory" ("userId", "name", "color", "sortOrder", "createdAt", "updatedAt") -SELECT - u.id AS "userId", - '할 일' AS "name", - '#FF6B43' AS "color", - 1 AS "sortOrder", - NOW() AS "createdAt", - NOW() AS "updatedAt" -FROM "User" u -WHERE NOT EXISTS ( - SELECT 1 FROM "TodoCategory" tc WHERE tc."userId" = u.id AND tc."name" = '할 일' -); diff --git a/apps/api/prisma/migrations/20260201000000_remove_notification_route_field/migration.sql b/apps/api/prisma/migrations/20260201000000_remove_notification_route_field/migration.sql deleted file mode 100644 index 041f73f8..00000000 --- a/apps/api/prisma/migrations/20260201000000_remove_notification_route_field/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Notification" DROP COLUMN "route"; diff --git a/apps/api/prisma/migrations/20260203100220_add_user_role_and_admin_notifications/migration.sql b/apps/api/prisma/migrations/20260203100220_add_user_role_and_admin_notifications/migration.sql deleted file mode 100644 index 44ff01c2..00000000 --- a/apps/api/prisma/migrations/20260203100220_add_user_role_and_admin_notifications/migration.sql +++ /dev/null @@ -1,16 +0,0 @@ --- CreateEnum -CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN'); - --- AlterEnum --- This migration adds more than one value to an enum. --- With PostgreSQL versions 11 and earlier, this is not possible --- in a single migration. This can be worked around by creating --- multiple migrations, each migration adding only one value to --- the enum. - - -ALTER TYPE "NotificationType" ADD VALUE 'ADMIN_BROADCAST'; -ALTER TYPE "NotificationType" ADD VALUE 'ADMIN_TARGETED'; - --- AlterTable -ALTER TABLE "User" ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER'; diff --git a/apps/api/prisma/migrations/20260205233052_add_timestamptz_to_scheduled_time/migration.sql b/apps/api/prisma/migrations/20260205233052_add_timestamptz_to_scheduled_time/migration.sql deleted file mode 100644 index e370d5df..00000000 --- a/apps/api/prisma/migrations/20260205233052_add_timestamptz_to_scheduled_time/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Todo" ALTER COLUMN "scheduledTime" SET DATA TYPE TIMESTAMPTZ(3); diff --git a/apps/api/prisma/migrations/20260207173657_add_timezone_and_reminder_hours/migration.sql b/apps/api/prisma/migrations/20260207173657_add_timezone_and_reminder_hours/migration.sql deleted file mode 100644 index 9052c2e7..00000000 --- a/apps/api/prisma/migrations/20260207173657_add_timezone_and_reminder_hours/migration.sql +++ /dev/null @@ -1,4 +0,0 @@ --- AlterTable -ALTER TABLE "UserPreference" ADD COLUMN "eveningReminderHour" INTEGER NOT NULL DEFAULT 18, -ADD COLUMN "morningReminderHour" INTEGER NOT NULL DEFAULT 8, -ADD COLUMN "timezone" VARCHAR(50) NOT NULL DEFAULT 'UTC'; diff --git a/apps/api/prisma/migrations/20260208100000_add_notification_dedup/migration.sql b/apps/api/prisma/migrations/20260208100000_add_notification_dedup/migration.sql deleted file mode 100644 index 241f0065..00000000 --- a/apps/api/prisma/migrations/20260208100000_add_notification_dedup/migration.sql +++ /dev/null @@ -1,36 +0,0 @@ --- AlterTable -ALTER TABLE "Notification" ADD COLUMN "notificationDate" DATE; - --- BackfillData -UPDATE "Notification" -SET "notificationDate" = DATE("createdAt") -WHERE type IN ('DAILY_COMPLETE', 'FRIEND_COMPLETED'); - --- Deduplicate DAILY_COMPLETE: keep the earliest row per (userId, type, notificationDate) -DELETE FROM "Notification" a -USING "Notification" b -WHERE a.type = 'DAILY_COMPLETE' - AND a."userId" = b."userId" - AND a.type = b.type - AND a."notificationDate" = b."notificationDate" - AND a."createdAt" > b."createdAt"; - --- Deduplicate FRIEND_COMPLETED: keep the earliest row per (userId, type, friendId, notificationDate) -DELETE FROM "Notification" a -USING "Notification" b -WHERE a.type = 'FRIEND_COMPLETED' - AND a."userId" = b."userId" - AND a.type = b.type - AND a."friendId" = b."friendId" - AND a."notificationDate" = b."notificationDate" - AND a."createdAt" > b."createdAt"; - --- CreateIndex (partial unique index for DAILY_COMPLETE) -CREATE UNIQUE INDEX "Notification_daily_complete_unique" -ON "Notification" ("userId", "type", "notificationDate") -WHERE "type" = 'DAILY_COMPLETE'; - --- CreateIndex (partial unique index for FRIEND_COMPLETED) -CREATE UNIQUE INDEX "Notification_friend_completed_unique" -ON "Notification" ("userId", "type", "friendId", "notificationDate") -WHERE "type" = 'FRIEND_COMPLETED'; diff --git a/apps/api/prisma/migrations/20260210110159_add_oauth_state_mode/migration.sql b/apps/api/prisma/migrations/20260210110159_add_oauth_state_mode/migration.sql deleted file mode 100644 index 6567eaa6..00000000 --- a/apps/api/prisma/migrations/20260210110159_add_oauth_state_mode/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "OAuthState" ADD COLUMN "mode" VARCHAR(10); diff --git a/apps/api/prisma/migrations/20260210233500_expand_oauth_state_user_id_length/migration.sql b/apps/api/prisma/migrations/20260210233500_expand_oauth_state_user_id_length/migration.sql deleted file mode 100644 index 8457ae9d..00000000 --- a/apps/api/prisma/migrations/20260210233500_expand_oauth_state_user_id_length/migration.sql +++ /dev/null @@ -1,4 +0,0 @@ --- OAuthState.userId is reused to temporarily store providerAccountId in link mode. --- Existing length (VARCHAR(30)) is too short for some providers. -ALTER TABLE "OAuthState" -ALTER COLUMN "userId" TYPE VARCHAR(255); diff --git a/apps/api/prisma/migrations/20260212233911_add_account_deletion_security_events/migration.sql b/apps/api/prisma/migrations/20260212233911_add_account_deletion_security_events/migration.sql deleted file mode 100644 index 6467c7d6..00000000 --- a/apps/api/prisma/migrations/20260212233911_add_account_deletion_security_events/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- AlterEnum --- This migration adds more than one value to an enum. --- With PostgreSQL versions 11 and earlier, this is not possible --- in a single migration. This can be worked around by creating --- multiple migrations, each migration adding only one value to --- the enum. - - -ALTER TYPE "SecurityEvent" ADD VALUE 'ACCOUNT_DELETION_REQUESTED'; -ALTER TYPE "SecurityEvent" ADD VALUE 'ACCOUNT_HARD_DELETED'; diff --git a/apps/api/prisma/migrations/20260213002058_add_oauth_state_initiating_user_id/migration.sql b/apps/api/prisma/migrations/20260213002058_add_oauth_state_initiating_user_id/migration.sql deleted file mode 100644 index 1a76c563..00000000 --- a/apps/api/prisma/migrations/20260213002058_add_oauth_state_initiating_user_id/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "OAuthState" ADD COLUMN "initiatingUserId" VARCHAR(36); diff --git a/apps/api/prisma/migrations/20260214070152_add_password_setup_types/migration.sql b/apps/api/prisma/migrations/20260214070152_add_password_setup_types/migration.sql deleted file mode 100644 index b0b42d49..00000000 --- a/apps/api/prisma/migrations/20260214070152_add_password_setup_types/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterEnum -ALTER TYPE "SecurityEvent" ADD VALUE 'PASSWORD_SETUP'; - --- AlterEnum -ALTER TYPE "VerificationType" ADD VALUE 'PASSWORD_SETUP'; diff --git a/apps/api/prisma/migrations/20260215034001_remove_redundant_indexes/migration.sql b/apps/api/prisma/migrations/20260215034001_remove_redundant_indexes/migration.sql deleted file mode 100644 index f85a5817..00000000 --- a/apps/api/prisma/migrations/20260215034001_remove_redundant_indexes/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ --- DropIndex: @unique on userTag already creates a B-tree index -DROP INDEX "User_userTag_idx"; - --- DropIndex: @unique on exchangeCode already creates a B-tree index -DROP INDEX "OAuthState_exchangeCode_idx"; - --- DropIndex: @@unique([userId, date]) already creates a B-tree index -DROP INDEX "DailyCompletion_userId_date_idx"; diff --git a/apps/api/prisma/migrations/20260215050000_notification_partial_unique_indexes/migration.sql b/apps/api/prisma/migrations/20260215050000_notification_partial_unique_indexes/migration.sql deleted file mode 100644 index a7004abf..00000000 --- a/apps/api/prisma/migrations/20260215050000_notification_partial_unique_indexes/migration.sql +++ /dev/null @@ -1,13 +0,0 @@ --- DropIndex -DROP INDEX IF EXISTS "Notification_daily_dedup"; -DROP INDEX IF EXISTS "Notification_friend_dedup"; - --- Partial unique index: friendId가 NULL인 타입용 (DAILY_COMPLETE, MORNING_REMINDER, EVENING_REMINDER) -CREATE UNIQUE INDEX "Notification_daily_dedup" - ON "Notification" ("userId", "type", "notificationDate") - WHERE "notificationDate" IS NOT NULL AND "friendId" IS NULL; - --- Partial unique index: friendId가 있는 타입용 (FRIEND_COMPLETED) -CREATE UNIQUE INDEX "Notification_friend_dedup" - ON "Notification" ("userId", "type", "friendId", "notificationDate") - WHERE "notificationDate" IS NOT NULL AND "friendId" IS NOT NULL; diff --git a/apps/api/prisma/migrations/20260221120230_add_reminder_query_indexes/migration.sql b/apps/api/prisma/migrations/20260221120230_add_reminder_query_indexes/migration.sql deleted file mode 100644 index 1bda44f9..00000000 --- a/apps/api/prisma/migrations/20260221120230_add_reminder_query_indexes/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ --- CreateIndex -CREATE INDEX "Notification_todoId_type_createdAt_idx" ON "Notification"("todoId", "type", "createdAt"); - --- CreateIndex -CREATE INDEX "Todo_completed_scheduledTime_idx" ON "Todo"("completed", "scheduledTime"); - --- CreateIndex -CREATE INDEX "UserPreference_pushEnabled_timezone_idx" ON "UserPreference"("pushEnabled", "timezone"); diff --git a/apps/api/prisma/migrations/20260223010105_add_account_restored_event/migration.sql b/apps/api/prisma/migrations/20260223010105_add_account_restored_event/migration.sql deleted file mode 100644 index 2fff4e83..00000000 --- a/apps/api/prisma/migrations/20260223010105_add_account_restored_event/migration.sql +++ /dev/null @@ -1,17 +0,0 @@ --- AlterEnum -ALTER TYPE "SecurityEvent" ADD VALUE 'ACCOUNT_RESTORED'; - --- DropIndex -DROP INDEX "Notification_daily_complete_unique"; - --- DropIndex -DROP INDEX "Notification_daily_dedup"; - --- DropIndex -DROP INDEX "Notification_friend_completed_unique"; - --- DropIndex -DROP INDEX "Notification_friend_dedup"; - --- AlterTable - OAuthState에 accountRestored 필드 추가 -ALTER TABLE "OAuthState" ADD COLUMN "accountRestored" BOOLEAN; diff --git a/apps/api/prisma/migrations/20260224120000_restore_notification_unique_indexes/migration.sql b/apps/api/prisma/migrations/20260224120000_restore_notification_unique_indexes/migration.sql deleted file mode 100644 index ff5f6c42..00000000 --- a/apps/api/prisma/migrations/20260224120000_restore_notification_unique_indexes/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Restore partial unique indexes that were accidentally dropped in 20260223010105 --- These indexes are critical for notification deduplication (P2002 catch + skipDuplicates) - --- Partial unique index: friendId가 NULL인 타입용 (DAILY_COMPLETE, MORNING_REMINDER, EVENING_REMINDER) -CREATE UNIQUE INDEX "Notification_daily_dedup" - ON "Notification" ("userId", "type", "notificationDate") - WHERE "notificationDate" IS NOT NULL AND "friendId" IS NULL; - --- Partial unique index: friendId가 있는 타입용 (FRIEND_COMPLETED) -CREATE UNIQUE INDEX "Notification_friend_dedup" - ON "Notification" ("userId", "type", "friendId", "notificationDate") - WHERE "notificationDate" IS NOT NULL AND "friendId" IS NOT NULL; diff --git a/apps/api/prisma/migrations/20260115093106_init/migration.sql b/apps/api/prisma/migrations/20260225000000_init/migration.sql similarity index 66% rename from apps/api/prisma/migrations/20260115093106_init/migration.sql rename to apps/api/prisma/migrations/20260225000000_init/migration.sql index ee516780..a545b276 100644 --- a/apps/api/prisma/migrations/20260115093106_init/migration.sql +++ b/apps/api/prisma/migrations/20260225000000_init/migration.sql @@ -1,3 +1,9 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN'); + -- CreateEnum CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'LOCKED', 'SUSPENDED', 'PENDING_VERIFY'); @@ -8,30 +14,29 @@ CREATE TYPE "SubscriptionStatus" AS ENUM ('FREE', 'ACTIVE', 'EXPIRED', 'CANCELLE CREATE TYPE "AccountProvider" AS ENUM ('CREDENTIAL', 'KAKAO', 'APPLE', 'GOOGLE', 'NAVER'); -- CreateEnum -CREATE TYPE "SecurityEvent" AS ENUM ('REGISTRATION', 'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'LOGOUT', 'TOKEN_REFRESH', 'TOKEN_REVOKED', 'PASSWORD_CHANGED', 'PASSWORD_RESET_REQUESTED', 'EMAIL_VERIFIED', 'TWO_FACTOR_ENABLED', 'TWO_FACTOR_DISABLED', 'SUSPICIOUS_ACTIVITY', 'ACCOUNT_LOCKED', 'ACCOUNT_UNLOCKED', 'SESSION_REVOKED', 'SESSION_REVOKED_ALL', 'OAUTH_LINKED', 'OAUTH_UNLINKED'); +CREATE TYPE "SecurityEvent" AS ENUM ('REGISTRATION', 'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'LOGOUT', 'TOKEN_REFRESH', 'TOKEN_REVOKED', 'PASSWORD_CHANGED', 'PASSWORD_RESET_REQUESTED', 'EMAIL_VERIFIED', 'TWO_FACTOR_ENABLED', 'TWO_FACTOR_DISABLED', 'SUSPICIOUS_ACTIVITY', 'ACCOUNT_LOCKED', 'ACCOUNT_UNLOCKED', 'SESSION_REVOKED', 'SESSION_REVOKED_ALL', 'OAUTH_LINKED', 'OAUTH_UNLINKED', 'OAUTH_AUTO_LINKED', 'OAUTH_LINK_REQUIRED', 'ACCOUNT_DELETION_REQUESTED', 'ACCOUNT_HARD_DELETED', 'ACCOUNT_RESTORED', 'PASSWORD_SETUP'); -- CreateEnum -CREATE TYPE "VerificationType" AS ENUM ('EMAIL_VERIFY', 'PASSWORD_RESET'); +CREATE TYPE "VerificationType" AS ENUM ('EMAIL_VERIFY', 'PASSWORD_RESET', 'PASSWORD_SETUP'); -- CreateEnum CREATE TYPE "TodoVisibility" AS ENUM ('PUBLIC', 'PRIVATE'); -- CreateEnum -CREATE TYPE "FriendshipStatus" AS ENUM ('PENDING', 'ACCEPTED', 'BLOCKED'); +CREATE TYPE "FollowStatus" AS ENUM ('PENDING', 'ACCEPTED'); -- CreateEnum CREATE TYPE "Platform" AS ENUM ('IOS', 'ANDROID'); -- CreateEnum -CREATE TYPE "NotificationType" AS ENUM ('FRIEND_REQUEST', 'FRIEND_ACCEPTED', 'TODO_REMINDER', 'TODO_SHARED', 'WEEKLY_ACHIEVEMENT', 'SYSTEM_NOTICE'); - --- CreateEnum -CREATE TYPE "NotificationActionType" AS ENUM ('NONE', 'VIEW_TODO', 'VIEW_FRIEND', 'VIEW_FRIENDS', 'VIEW_ACHIEVEMENT', 'OPEN_URL'); +CREATE TYPE "NotificationType" AS ENUM ('FOLLOW_NEW', 'FOLLOW_ACCEPTED', 'NUDGE_RECEIVED', 'CHEER_RECEIVED', 'DAILY_COMPLETE', 'FRIEND_COMPLETED', 'TODO_REMINDER', 'TODO_SHARED', 'MORNING_REMINDER', 'EVENING_REMINDER', 'WEEKLY_ACHIEVEMENT', 'SYSTEM_NOTICE', 'ADMIN_BROADCAST', 'ADMIN_TARGETED'); -- CreateTable CREATE TABLE "User" ( "id" TEXT NOT NULL, "email" VARCHAR(255) NOT NULL, + "userTag" VARCHAR(8) NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'USER', "status" "UserStatus" NOT NULL DEFAULT 'PENDING_VERIFY', "emailVerifiedAt" TIMESTAMP(3), "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false, @@ -39,6 +44,8 @@ CREATE TABLE "User" ( "subscriptionStatus" "SubscriptionStatus" NOT NULL DEFAULT 'FREE', "subscriptionExpiresAt" TIMESTAMP(3), "revenueCatUserId" VARCHAR(255), + "aiUsageCount" INTEGER NOT NULL DEFAULT 0, + "aiUsageResetAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, "lastLoginAt" TIMESTAMP(3), @@ -63,6 +70,9 @@ CREATE TABLE "UserPreference" ( "userId" TEXT NOT NULL, "pushEnabled" BOOLEAN NOT NULL DEFAULT false, "nightPushEnabled" BOOLEAN NOT NULL DEFAULT false, + "timezone" VARCHAR(50) NOT NULL DEFAULT 'UTC', + "morningReminderHour" INTEGER NOT NULL DEFAULT 8, + "eveningReminderHour" INTEGER NOT NULL DEFAULT 18, CONSTRAINT "UserPreference_pkey" PRIMARY KEY ("id") ); @@ -121,6 +131,7 @@ CREATE TABLE "Session" ( CREATE TABLE "LoginAttempt" ( "id" SERIAL NOT NULL, "email" VARCHAR(255) NOT NULL, + "provider" "AccountProvider", "ipAddress" VARCHAR(45) NOT NULL, "userAgent" VARCHAR(500) NOT NULL, "success" BOOLEAN NOT NULL, @@ -164,24 +175,50 @@ CREATE TABLE "OAuthState" ( "codeVerifier" VARCHAR(128), "provider" "AccountProvider" NOT NULL, "redirectUri" VARCHAR(500) NOT NULL, + "mode" VARCHAR(10), + "initiatingUserId" VARCHAR(36), + "exchangeCode" VARCHAR(64), + "accessToken" VARCHAR(1000), + "refreshToken" VARCHAR(1000), + "userId" VARCHAR(255), + "userName" VARCHAR(100), + "profileImage" VARCHAR(500), + "accountRestored" BOOLEAN, + "ipAddress" VARCHAR(45), + "userAgent" VARCHAR(500), "expiresAt" TIMESTAMP(3) NOT NULL, + "exchangedAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "OAuthState_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "TodoCategory" ( + "id" SERIAL NOT NULL, + "userId" TEXT NOT NULL, + "name" VARCHAR(50) NOT NULL, + "color" VARCHAR(7) NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TodoCategory_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "Todo" ( - "id" TEXT NOT NULL, + "id" SERIAL NOT NULL, "userId" TEXT NOT NULL, "title" VARCHAR(200) NOT NULL, "content" VARCHAR(5000), - "color" VARCHAR(7), + "categoryId" INTEGER NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, "completed" BOOLEAN NOT NULL DEFAULT false, "completedAt" TIMESTAMP(3), "startDate" DATE NOT NULL, "endDate" DATE, - "scheduledTime" TIMESTAMP(3), + "scheduledTime" TIMESTAMPTZ(3), "isAllDay" BOOLEAN NOT NULL DEFAULT true, "visibility" "TodoVisibility" NOT NULL DEFAULT 'PUBLIC', "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -191,17 +228,54 @@ CREATE TABLE "Todo" ( ); -- CreateTable -CREATE TABLE "Friendship" ( +CREATE TABLE "Follow" ( + "id" TEXT NOT NULL, + "followerId" TEXT NOT NULL, + "followingId" TEXT NOT NULL, + "status" "FollowStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Follow_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Nudge" ( + "id" SERIAL NOT NULL, + "senderId" TEXT NOT NULL, + "receiverId" TEXT NOT NULL, + "todoId" INTEGER NOT NULL, + "message" VARCHAR(200), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "readAt" TIMESTAMP(3), + + CONSTRAINT "Nudge_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Cheer" ( + "id" SERIAL NOT NULL, + "senderId" TEXT NOT NULL, + "receiverId" TEXT NOT NULL, + "message" VARCHAR(200), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "readAt" TIMESTAMP(3), + + CONSTRAINT "Cheer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DailyCompletion" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, - "friendId" TEXT NOT NULL, - "status" "FriendshipStatus" NOT NULL DEFAULT 'PENDING', - "isRequester" BOOLEAN NOT NULL, + "date" DATE NOT NULL, + "totalTodos" INTEGER NOT NULL, + "completedTodos" INTEGER NOT NULL, + "achievedAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, - "acceptedAt" TIMESTAMP(3), - CONSTRAINT "Friendship_pkey" PRIMARY KEY ("id") + CONSTRAINT "DailyCompletion_pkey" PRIMARY KEY ("id") ); -- CreateTable @@ -227,10 +301,11 @@ CREATE TABLE "Notification" ( "title" VARCHAR(200) NOT NULL, "body" VARCHAR(500) NOT NULL, "isRead" BOOLEAN NOT NULL DEFAULT false, - "actionType" "NotificationActionType" NOT NULL DEFAULT 'NONE', - "actionTarget" VARCHAR(500), - "todoId" TEXT, + "todoId" INTEGER, "friendId" TEXT, + "nudgeId" INTEGER, + "cheerId" INTEGER, + "notificationDate" DATE, "metadata" JSONB, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "readAt" TIMESTAMP(3), @@ -273,6 +348,9 @@ CREATE TABLE "Subscription" ( -- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +-- CreateIndex +CREATE UNIQUE INDEX "User_userTag_key" ON "User"("userTag"); + -- CreateIndex CREATE UNIQUE INDEX "User_revenueCatUserId_key" ON "User"("revenueCatUserId"); @@ -291,6 +369,9 @@ CREATE UNIQUE INDEX "UserProfile_userId_key" ON "UserProfile"("userId"); -- CreateIndex CREATE UNIQUE INDEX "UserPreference_userId_key" ON "UserPreference"("userId"); +-- CreateIndex +CREATE INDEX "UserPreference_pushEnabled_timezone_idx" ON "UserPreference"("pushEnabled", "timezone"); + -- CreateIndex CREATE UNIQUE INDEX "UserConsent_userId_key" ON "UserConsent"("userId"); @@ -318,6 +399,9 @@ CREATE INDEX "LoginAttempt_email_createdAt_idx" ON "LoginAttempt"("email", "crea -- CreateIndex CREATE INDEX "LoginAttempt_ipAddress_createdAt_idx" ON "LoginAttempt"("ipAddress", "createdAt"); +-- CreateIndex +CREATE INDEX "LoginAttempt_provider_createdAt_idx" ON "LoginAttempt"("provider", "createdAt"); + -- CreateIndex CREATE INDEX "LoginAttempt_createdAt_idx" ON "LoginAttempt"("createdAt"); @@ -339,9 +423,18 @@ CREATE INDEX "Verification_userId_type_idx" ON "Verification"("userId", "type"); -- CreateIndex CREATE UNIQUE INDEX "OAuthState_state_key" ON "OAuthState"("state"); +-- CreateIndex +CREATE UNIQUE INDEX "OAuthState_exchangeCode_key" ON "OAuthState"("exchangeCode"); + -- CreateIndex CREATE INDEX "OAuthState_expiresAt_idx" ON "OAuthState"("expiresAt"); +-- CreateIndex +CREATE INDEX "TodoCategory_userId_sortOrder_idx" ON "TodoCategory"("userId", "sortOrder"); + +-- CreateIndex +CREATE UNIQUE INDEX "TodoCategory_userId_name_key" ON "TodoCategory"("userId", "name"); + -- CreateIndex CREATE INDEX "Todo_userId_startDate_endDate_idx" ON "Todo"("userId", "startDate", "endDate"); @@ -349,13 +442,46 @@ CREATE INDEX "Todo_userId_startDate_endDate_idx" ON "Todo"("userId", "startDate" CREATE INDEX "Todo_userId_completed_startDate_idx" ON "Todo"("userId", "completed", "startDate"); -- CreateIndex -CREATE INDEX "Friendship_userId_status_idx" ON "Friendship"("userId", "status"); +CREATE INDEX "Todo_userId_categoryId_idx" ON "Todo"("userId", "categoryId"); + +-- CreateIndex +CREATE INDEX "Todo_userId_sortOrder_idx" ON "Todo"("userId", "sortOrder"); + +-- CreateIndex +CREATE INDEX "Todo_completed_scheduledTime_idx" ON "Todo"("completed", "scheduledTime"); + +-- CreateIndex +CREATE INDEX "Follow_followerId_status_idx" ON "Follow"("followerId", "status"); + +-- CreateIndex +CREATE INDEX "Follow_followingId_status_idx" ON "Follow"("followingId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "Follow_followerId_followingId_key" ON "Follow"("followerId", "followingId"); + +-- CreateIndex +CREATE INDEX "Nudge_receiverId_createdAt_idx" ON "Nudge"("receiverId", "createdAt"); + +-- CreateIndex +CREATE INDEX "Nudge_senderId_createdAt_idx" ON "Nudge"("senderId", "createdAt"); + +-- CreateIndex +CREATE INDEX "Nudge_todoId_idx" ON "Nudge"("todoId"); + +-- CreateIndex +CREATE INDEX "Nudge_senderId_todoId_createdAt_idx" ON "Nudge"("senderId", "todoId", "createdAt"); + +-- CreateIndex +CREATE INDEX "Cheer_receiverId_createdAt_idx" ON "Cheer"("receiverId", "createdAt"); -- CreateIndex -CREATE INDEX "Friendship_friendId_idx" ON "Friendship"("friendId"); +CREATE INDEX "Cheer_senderId_createdAt_idx" ON "Cheer"("senderId", "createdAt"); -- CreateIndex -CREATE UNIQUE INDEX "Friendship_userId_friendId_key" ON "Friendship"("userId", "friendId"); +CREATE INDEX "Cheer_senderId_receiverId_createdAt_idx" ON "Cheer"("senderId", "receiverId", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DailyCompletion_userId_date_key" ON "DailyCompletion"("userId", "date"); -- CreateIndex CREATE UNIQUE INDEX "PushToken_token_key" ON "PushToken"("token"); @@ -375,6 +501,9 @@ CREATE INDEX "Notification_userId_type_idx" ON "Notification"("userId", "type"); -- CreateIndex CREATE INDEX "Notification_createdAt_idx" ON "Notification"("createdAt"); +-- CreateIndex +CREATE INDEX "Notification_todoId_type_createdAt_idx" ON "Notification"("todoId", "type", "createdAt"); + -- CreateIndex CREATE INDEX "WeeklyAchievement_userId_year_idx" ON "WeeklyAchievement"("userId", "year"); @@ -417,14 +546,38 @@ ALTER TABLE "SecurityLog" ADD CONSTRAINT "SecurityLog_userId_fkey" FOREIGN KEY ( -- AddForeignKey ALTER TABLE "Verification" ADD CONSTRAINT "Verification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "TodoCategory" ADD CONSTRAINT "TodoCategory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Todo" ADD CONSTRAINT "Todo_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TodoCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "Todo" ADD CONSTRAINT "Todo_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Friendship" ADD CONSTRAINT "Friendship_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Follow" ADD CONSTRAINT "Follow_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Follow" ADD CONSTRAINT "Follow_followingId_fkey" FOREIGN KEY ("followingId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Nudge" ADD CONSTRAINT "Nudge_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Nudge" ADD CONSTRAINT "Nudge_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Nudge" ADD CONSTRAINT "Nudge_todoId_fkey" FOREIGN KEY ("todoId") REFERENCES "Todo"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Cheer" ADD CONSTRAINT "Cheer_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Cheer" ADD CONSTRAINT "Cheer_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Friendship" ADD CONSTRAINT "Friendship_friendId_fkey" FOREIGN KEY ("friendId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "DailyCompletion" ADD CONSTRAINT "DailyCompletion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "PushToken" ADD CONSTRAINT "PushToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml index 044d57cd..99e4f200 100644 --- a/apps/api/prisma/migrations/migration_lock.toml +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) +# It should be added in your version-control system (i.e. Git) provider = "postgresql"