diff --git a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts index adb56c6..d9df7af 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -2,9 +2,13 @@ import { Controller, Get, Query, + UseGuards, } from '@nestjs/common'; import { type QueryBus } from '@nestjs/cqrs'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard'; +import { QuotaGuard } from '@modules/subscriptions/presentation/guards/quota.guard'; +import { RequireQuota } from '@modules/subscriptions/presentation/decorators/require-quota.decorator'; import { type DistrictStatsDto } from '../../application/queries/get-district-stats/get-district-stats.handler'; import { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query'; import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler'; @@ -25,36 +29,52 @@ export class AnalyticsController { private readonly queryBus: QueryBus, ) {} + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') @Get('market-report') @ApiOperation({ summary: 'Get market report for a city' }) @ApiResponse({ status: 200, description: 'Market report retrieved' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) async getMarketReport(@Query() dto: GetMarketReportDto): Promise { return this.queryBus.execute( new GetMarketReportQuery(dto.city, dto.period, dto.propertyType), ); } + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') @Get('price-trend') @ApiOperation({ summary: 'Get price trend for a district' }) @ApiResponse({ status: 200, description: 'Price trend data retrieved' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) async getPriceTrend(@Query() dto: GetPriceTrendDto): Promise { return this.queryBus.execute( new GetPriceTrendQuery(dto.district, dto.city, dto.propertyType, dto.periods), ); } + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') @Get('heatmap') @ApiOperation({ summary: 'Get price heatmap for a city' }) @ApiResponse({ status: 200, description: 'Heatmap data retrieved' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) async getHeatmap(@Query() dto: GetHeatmapDto): Promise { return this.queryBus.execute( new GetHeatmapQuery(dto.city, dto.period), ); } + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('analytics_queries') @Get('district-stats') @ApiOperation({ summary: 'Get statistics by district' }) @ApiResponse({ status: 200, description: 'District statistics retrieved' }) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) async getDistrictStats(@Query() dto: GetDistrictStatsDto): Promise { return this.queryBus.execute( new GetDistrictStatsQuery(dto.city, dto.period), diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 5edec72..9c0ad9c 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -26,6 +26,8 @@ import { CurrentUser } from '@modules/auth/presentation/decorators/current-user. import { Roles } from '@modules/auth/presentation/decorators/roles.decorator'; import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard'; import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard'; +import { QuotaGuard } from '@modules/subscriptions/presentation/guards/quota.guard'; +import { RequireQuota } from '@modules/subscriptions/presentation/decorators/require-quota.decorator'; import { FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared/infrastructure/pipes/file-validation.pipe'; import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command'; import { type CreateListingResult } from '../../application/commands/create-listing/create-listing.handler'; @@ -55,7 +57,9 @@ export class ListingsController { @ApiResponse({ status: 201, description: 'Listing created successfully' }) @ApiResponse({ status: 400, description: 'Validation error' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) - @UseGuards(JwtAuthGuard) + @ApiResponse({ status: 403, description: 'Quota exceeded' }) + @UseGuards(JwtAuthGuard, QuotaGuard) + @RequireQuota('listings_created') @Post() async createListing( @Body() dto: CreateListingDto, diff --git a/apps/api/src/modules/notifications/application/__tests__/quota-exceeded.listener.spec.ts b/apps/api/src/modules/notifications/application/__tests__/quota-exceeded.listener.spec.ts new file mode 100644 index 0000000..01b2613 --- /dev/null +++ b/apps/api/src/modules/notifications/application/__tests__/quota-exceeded.listener.spec.ts @@ -0,0 +1,89 @@ +import { type CommandBus } from '@nestjs/cqrs'; +import { type LoggerService } from '@modules/shared/infrastructure/logger.service'; +import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { QuotaExceededEvent } from '@modules/subscriptions/domain/events/quota-exceeded.event'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; +import { QuotaExceededListener } from '../listeners/quota-exceeded.listener'; + +describe('QuotaExceededListener', () => { + let listener: QuotaExceededListener; + let mockCommandBus: { execute: ReturnType }; + let mockPrisma: any; + let mockLogger: { log: ReturnType; warn: ReturnType }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn() }; + mockPrisma = { + user: { + findUnique: vi.fn(), + }, + }; + mockLogger = { log: vi.fn(), warn: vi.fn() }; + + listener = new QuotaExceededListener( + mockCommandBus as unknown as CommandBus, + mockPrisma as unknown as PrismaService, + mockLogger as unknown as LoggerService, + ); + }); + + it('sends email notification when user has email', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'user@example.com', + }); + mockCommandBus.execute.mockResolvedValue({}); + + const event = new QuotaExceededEvent('user-1', 'listings_created', 3, 3); + await listener.handle(event); + + expect(mockCommandBus.execute).toHaveBeenCalledWith( + new SendNotificationCommand( + 'user-1', + 'EMAIL', + 'quota.exceeded', + { metric: 'listings_created', limit: 3, used: 3 }, + 'user@example.com', + ), + ); + }); + + it('skips notification when user has no email', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: null, + }); + + const event = new QuotaExceededEvent('user-1', 'listings_created', 3, 3); + await listener.handle(event); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); + + it('skips notification when user not found', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + const event = new QuotaExceededEvent('user-99', 'analytics_queries', 0, 0); + await listener.handle(event); + + expect(mockCommandBus.execute).not.toHaveBeenCalled(); + }); + + it('handles analytics_queries metric', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 'user-2', + email: 'investor@example.com', + }); + mockCommandBus.execute.mockResolvedValue({}); + + const event = new QuotaExceededEvent('user-2', 'analytics_queries', 100, 100); + await listener.handle(event); + + expect(mockCommandBus.execute).toHaveBeenCalledWith( + expect.objectContaining({ + templateKey: 'quota.exceeded', + templateData: { metric: 'analytics_queries', limit: 100, used: 100 }, + }), + ); + }); +}); diff --git a/apps/api/src/modules/notifications/application/listeners/quota-exceeded.listener.ts b/apps/api/src/modules/notifications/application/listeners/quota-exceeded.listener.ts new file mode 100644 index 0000000..a3848aa --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/quota-exceeded.listener.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { type CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { type LoggerService } from '@modules/shared/infrastructure/logger.service'; +import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type QuotaExceededEvent } from '@modules/subscriptions/domain/events/quota-exceeded.event'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +@Injectable() +export class QuotaExceededListener { + constructor( + private readonly commandBus: CommandBus, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('quota.exceeded', { async: true }) + async handle(event: QuotaExceededEvent): Promise { + this.logger.log( + `Handling quota.exceeded for user=${event.aggregateId}, metric=${event.metric}`, + 'QuotaExceededListener', + ); + + const user = await this.prisma.user.findUnique({ + where: { id: event.aggregateId }, + select: { id: true, email: true }, + }); + + if (!user?.email) return; + + await this.commandBus.execute( + new SendNotificationCommand( + user.id, + 'EMAIL', + 'quota.exceeded', + { + metric: event.metric, + limit: event.limit, + used: event.used, + }, + user.email, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts index 08ea5f1..b25b13d 100644 --- a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts +++ b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts @@ -37,6 +37,13 @@ const TEMPLATES: Record = { body: `

Yêu cầu tư vấn mới

Bạn nhận được yêu cầu tư vấn từ {{senderName}} cho tin đăng {{listingTitle}}.

Nội dung: {{message}}

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'quota.exceeded': { + subject: 'Bạn đã đạt giới hạn sử dụng', + body: `

Giới hạn đã đạt

+

Bạn đã sử dụng hết giới hạn {{metric}} ({{used}}/{{limit}}).

+

Vui lòng nâng cấp gói để tiếp tục sử dụng dịch vụ.

Trân trọng,
Đội ngũ GoodGo

`, }, 'password.reset': { diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index 43a4a7c..e3d7c3d 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler'; import { AgentVerifiedListener } from './application/listeners/agent-verified.listener'; +import { QuotaExceededListener } from './application/listeners/quota-exceeded.listener'; import { UserRegisteredListener } from './application/listeners/user-registered.listener'; import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository'; import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository'; @@ -14,7 +15,7 @@ import { NotificationsController } from './presentation/controllers/notification const CommandHandlers = [SendNotificationHandler]; -const EventListeners = [UserRegisteredListener, AgentVerifiedListener]; +const EventListeners = [UserRegisteredListener, AgentVerifiedListener, QuotaExceededListener]; @Module({ imports: [CqrsModule], diff --git a/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts index 3cc6550..9c3f22f 100644 --- a/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts +++ b/apps/api/src/modules/subscriptions/application/queries/check-quota/check-quota.handler.ts @@ -20,6 +20,8 @@ export interface QuotaCheckResult { const METRIC_TO_PLAN_FIELD: Record = { listings_created: 'maxListings', searches_saved: 'maxSavedSearches', + analytics_queries: 'maxAnalyticsQueries', + media_uploads: 'maxMediaUploads', }; @QueryHandler(CheckQuotaQuery) diff --git a/apps/api/src/modules/subscriptions/domain/__tests__/quota-exceeded.event.spec.ts b/apps/api/src/modules/subscriptions/domain/__tests__/quota-exceeded.event.spec.ts new file mode 100644 index 0000000..273d6e4 --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/__tests__/quota-exceeded.event.spec.ts @@ -0,0 +1,21 @@ +import { QuotaExceededEvent } from '../events/quota-exceeded.event'; + +describe('QuotaExceededEvent', () => { + it('creates event with correct properties', () => { + const event = new QuotaExceededEvent('user-1', 'listings_created', 3, 3); + + expect(event.eventName).toBe('quota.exceeded'); + expect(event.aggregateId).toBe('user-1'); + expect(event.metric).toBe('listings_created'); + expect(event.limit).toBe(3); + expect(event.used).toBe(3); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + + it('creates event for analytics metric', () => { + const event = new QuotaExceededEvent('user-2', 'analytics_queries', 0, 0); + + expect(event.metric).toBe('analytics_queries'); + expect(event.limit).toBe(0); + }); +}); diff --git a/apps/api/src/modules/subscriptions/domain/events/quota-exceeded.event.ts b/apps/api/src/modules/subscriptions/domain/events/quota-exceeded.event.ts new file mode 100644 index 0000000..a2239d7 --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/events/quota-exceeded.event.ts @@ -0,0 +1,13 @@ +import { type DomainEvent } from '@modules/shared/domain/domain-event'; + +export class QuotaExceededEvent implements DomainEvent { + readonly eventName = 'quota.exceeded'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, // userId + public readonly metric: string, + public readonly limit: number, + public readonly used: number, + ) {} +} diff --git a/apps/api/src/modules/subscriptions/infrastructure/__tests__/listing-created-usage.handler.spec.ts b/apps/api/src/modules/subscriptions/infrastructure/__tests__/listing-created-usage.handler.spec.ts new file mode 100644 index 0000000..68b223c --- /dev/null +++ b/apps/api/src/modules/subscriptions/infrastructure/__tests__/listing-created-usage.handler.spec.ts @@ -0,0 +1,67 @@ +import { type CommandBus } from '@nestjs/cqrs'; +import { MeterUsageCommand } from '../../application/commands/meter-usage/meter-usage.command'; +import { ListingCreatedUsageHandler } from '../event-handlers/listing-created-usage.handler'; + +describe('ListingCreatedUsageHandler', () => { + let handler: ListingCreatedUsageHandler; + let mockCommandBus: { execute: ReturnType }; + + beforeEach(() => { + mockCommandBus = { execute: vi.fn() }; + handler = new ListingCreatedUsageHandler(mockCommandBus as unknown as CommandBus); + }); + + it('meters listings_created usage for the seller', async () => { + mockCommandBus.execute.mockResolvedValue({ + usageRecordId: 'usage-1', + metric: 'listings_created', + count: 1, + }); + + await handler.handle({ + eventName: 'listing.created', + occurredAt: new Date(), + aggregateId: 'listing-1', + propertyId: 'prop-1', + sellerId: 'user-1', + transactionType: 'SALE', + } as any); + + expect(mockCommandBus.execute).toHaveBeenCalledWith( + new MeterUsageCommand('user-1', 'listings_created', 1), + ); + }); + + it('does not throw when metering fails (best-effort)', async () => { + mockCommandBus.execute.mockRejectedValue(new Error('Subscription not found')); + + await expect( + handler.handle({ + eventName: 'listing.created', + occurredAt: new Date(), + aggregateId: 'listing-1', + propertyId: 'prop-1', + sellerId: 'user-99', + transactionType: 'SALE', + } as any), + ).resolves.not.toThrow(); + }); + + it('calls MeterUsageCommand with count of 1', async () => { + mockCommandBus.execute.mockResolvedValue({}); + + await handler.handle({ + eventName: 'listing.created', + occurredAt: new Date(), + aggregateId: 'listing-2', + propertyId: 'prop-2', + sellerId: 'user-2', + transactionType: 'RENT', + } as any); + + const command = mockCommandBus.execute.mock.calls[0]![0] as MeterUsageCommand; + expect(command.userId).toBe('user-2'); + expect(command.metric).toBe('listings_created'); + expect(command.count).toBe(1); + }); +}); diff --git a/apps/api/src/modules/subscriptions/infrastructure/event-handlers/listing-created-usage.handler.ts b/apps/api/src/modules/subscriptions/infrastructure/event-handlers/listing-created-usage.handler.ts new file mode 100644 index 0000000..7413376 --- /dev/null +++ b/apps/api/src/modules/subscriptions/infrastructure/event-handlers/listing-created-usage.handler.ts @@ -0,0 +1,31 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { type CommandBus } from '@nestjs/cqrs'; +import { OnEvent } from '@nestjs/event-emitter'; +import { type ListingCreatedEvent } from '@modules/listings/domain/events/listing-created.event'; +import { MeterUsageCommand } from '../../application/commands/meter-usage/meter-usage.command'; + +@Injectable() +export class ListingCreatedUsageHandler { + private readonly logger = new Logger(ListingCreatedUsageHandler.name); + + constructor(private readonly commandBus: CommandBus) {} + + @OnEvent('listing.created', { async: true }) + async handle(event: ListingCreatedEvent): Promise { + this.logger.log( + `Metering listings_created usage for seller=${event.sellerId}`, + ); + + try { + await this.commandBus.execute( + new MeterUsageCommand(event.sellerId, 'listings_created', 1), + ); + } catch (error) { + // Log but don't fail — usage metering is best-effort + // User without subscription still creates listing (quota check already passed in guard) + this.logger.warn( + `Failed to meter usage for seller=${event.sellerId}: ${(error as Error).message}`, + ); + } + } +} diff --git a/apps/api/src/modules/subscriptions/presentation/__tests__/quota.guard.spec.ts b/apps/api/src/modules/subscriptions/presentation/__tests__/quota.guard.spec.ts new file mode 100644 index 0000000..844c24d --- /dev/null +++ b/apps/api/src/modules/subscriptions/presentation/__tests__/quota.guard.spec.ts @@ -0,0 +1,138 @@ +import { type ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { type Reflector } from '@nestjs/core'; +import { type QueryBus } from '@nestjs/cqrs'; +import { type EventEmitter2 } from '@nestjs/event-emitter'; +import { type QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler'; +import { QuotaGuard } from '../guards/quota.guard'; + +function createMockContext(user?: { sub: string }): ExecutionContext { + return { + getHandler: vi.fn(), + getClass: vi.fn(), + switchToHttp: () => ({ + getRequest: () => ({ user }), + }), + } as unknown as ExecutionContext; +} + +describe('QuotaGuard', () => { + let guard: QuotaGuard; + let mockReflector: { getAllAndOverride: ReturnType }; + let mockQueryBus: { execute: ReturnType }; + let mockEventEmitter: { emit: ReturnType }; + + beforeEach(() => { + mockReflector = { getAllAndOverride: vi.fn() }; + mockQueryBus = { execute: vi.fn() }; + mockEventEmitter = { emit: vi.fn() }; + + guard = new QuotaGuard( + mockReflector as unknown as Reflector, + mockQueryBus as unknown as QueryBus, + mockEventEmitter as unknown as EventEmitter2, + ); + }); + + it('allows when no metric is set', async () => { + mockReflector.getAllAndOverride.mockReturnValue(undefined); + const context = createMockContext({ sub: 'user-1' }); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + expect(mockQueryBus.execute).not.toHaveBeenCalled(); + }); + + it('allows when user is not authenticated', async () => { + mockReflector.getAllAndOverride.mockReturnValue('listings_created'); + const context = createMockContext(undefined); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + }); + + it('allows when quota is not exceeded', async () => { + mockReflector.getAllAndOverride.mockReturnValue('listings_created'); + const quotaResult: QuotaCheckResult = { + metric: 'listings_created', + limit: 50, + used: 10, + remaining: 40, + allowed: true, + }; + mockQueryBus.execute.mockResolvedValue(quotaResult); + const context = createMockContext({ sub: 'user-1' }); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + expect(mockEventEmitter.emit).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenException when quota exceeded', async () => { + mockReflector.getAllAndOverride.mockReturnValue('listings_created'); + const quotaResult: QuotaCheckResult = { + metric: 'listings_created', + limit: 3, + used: 3, + remaining: 0, + allowed: false, + }; + mockQueryBus.execute.mockResolvedValue(quotaResult); + const context = createMockContext({ sub: 'user-1' }); + + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + }); + + it('emits QuotaExceededEvent when quota exceeded', async () => { + mockReflector.getAllAndOverride.mockReturnValue('listings_created'); + const quotaResult: QuotaCheckResult = { + metric: 'listings_created', + limit: 3, + used: 3, + remaining: 0, + allowed: false, + }; + mockQueryBus.execute.mockResolvedValue(quotaResult); + const context = createMockContext({ sub: 'user-1' }); + + try { + await guard.canActivate(context); + } catch { + // expected + } + + expect(mockEventEmitter.emit).toHaveBeenCalledWith( + 'quota.exceeded', + expect.objectContaining({ + aggregateId: 'user-1', + metric: 'listings_created', + limit: 3, + used: 3, + }), + ); + }); + + it('includes QUOTA_EXCEEDED code in error response', async () => { + mockReflector.getAllAndOverride.mockReturnValue('analytics_queries'); + const quotaResult: QuotaCheckResult = { + metric: 'analytics_queries', + limit: 0, + used: 0, + remaining: 0, + allowed: false, + }; + mockQueryBus.execute.mockResolvedValue(quotaResult); + const context = createMockContext({ sub: 'user-1' }); + + try { + await guard.canActivate(context); + expect.unreachable('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ForbiddenException); + const response = (error as ForbiddenException).getResponse(); + expect(response).toMatchObject({ + code: 'QUOTA_EXCEEDED', + quota: { metric: 'analytics_queries', limit: 0, used: 0 }, + }); + } + }); +}); diff --git a/apps/api/src/modules/subscriptions/presentation/guards/quota.guard.ts b/apps/api/src/modules/subscriptions/presentation/guards/quota.guard.ts index 319b8f9..c2f3385 100644 --- a/apps/api/src/modules/subscriptions/presentation/guards/quota.guard.ts +++ b/apps/api/src/modules/subscriptions/presentation/guards/quota.guard.ts @@ -6,8 +6,10 @@ import { } from '@nestjs/common'; import { type Reflector } from '@nestjs/core'; import { type QueryBus } from '@nestjs/cqrs'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { type QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler'; import { CheckQuotaQuery } from '../../application/queries/check-quota/check-quota.query'; +import { QuotaExceededEvent } from '../../domain/events/quota-exceeded.event'; import { QUOTA_METRIC_KEY } from '../decorators/require-quota.decorator'; @Injectable() @@ -15,6 +17,7 @@ export class QuotaGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly queryBus: QueryBus, + private readonly eventEmitter: EventEmitter2, ) {} async canActivate(context: ExecutionContext): Promise { @@ -39,6 +42,14 @@ export class QuotaGuard implements CanActivate { ); if (!result.allowed) { + const event = new QuotaExceededEvent( + user.sub, + result.metric, + result.limit!, + result.used, + ); + this.eventEmitter.emit(event.eventName, event); + throw new ForbiddenException({ code: 'QUOTA_EXCEEDED', message: `Bạn đã đạt giới hạn ${metric}. Vui lòng nâng cấp gói để tiếp tục.`, diff --git a/apps/api/src/modules/subscriptions/subscriptions.module.ts b/apps/api/src/modules/subscriptions/subscriptions.module.ts index abe90f8..97685b4 100644 --- a/apps/api/src/modules/subscriptions/subscriptions.module.ts +++ b/apps/api/src/modules/subscriptions/subscriptions.module.ts @@ -9,6 +9,7 @@ import { GetBillingHistoryHandler } from './application/queries/get-billing-hist import { GetPlanHandler } from './application/queries/get-plan/get-plan.handler'; import { SUBSCRIPTION_REPOSITORY } from './domain/repositories/subscription.repository'; import { PrismaSubscriptionRepository } from './infrastructure/repositories/prisma-subscription.repository'; +import { ListingCreatedUsageHandler } from './infrastructure/event-handlers/listing-created-usage.handler'; import { SubscriptionsController } from './presentation/controllers/subscriptions.controller'; import { QuotaGuard } from './presentation/guards/quota.guard'; @@ -35,6 +36,9 @@ const QueryHandlers = [ // Guards QuotaGuard, + // Event Listeners + ListingCreatedUsageHandler, + // CQRS ...CommandHandlers, ...QueryHandlers, diff --git a/prisma/migrations/20260408080000_add_analytics_media_quota_fields/migration.sql b/prisma/migrations/20260408080000_add_analytics_media_quota_fields/migration.sql new file mode 100644 index 0000000..d25fdf2 --- /dev/null +++ b/prisma/migrations/20260408080000_add_analytics_media_quota_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Plan" ADD COLUMN "maxAnalyticsQueries" INTEGER; +ALTER TABLE "Plan" ADD COLUMN "maxMediaUploads" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dac8070..0cb5acf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -423,9 +423,11 @@ model Plan { name String priceMonthlyVND BigInt priceYearlyVND BigInt - maxListings Int? - maxSavedSearches Int? - features Json + maxListings Int? + maxSavedSearches Int? + maxAnalyticsQueries Int? + maxMediaUploads Int? + features Json isActive Boolean @default(true) subscriptions Subscription[] diff --git a/scripts/seed-plans.ts b/scripts/seed-plans.ts index 48b5da0..e9e5edb 100644 --- a/scripts/seed-plans.ts +++ b/scripts/seed-plans.ts @@ -21,6 +21,8 @@ export const PLANS = [ priceYearlyVND: BigInt(0), maxListings: 3, maxSavedSearches: 5, + maxAnalyticsQueries: 0, + maxMediaUploads: 5, features: { basicSearch: true, listingPost: true, @@ -38,6 +40,8 @@ export const PLANS = [ priceYearlyVND: BigInt(4_990_000), maxListings: 50, maxSavedSearches: 30, + maxAnalyticsQueries: 100, + maxMediaUploads: 150, features: { basicSearch: true, listingPost: true, @@ -57,6 +61,8 @@ export const PLANS = [ priceYearlyVND: BigInt(9_990_000), maxListings: 20, maxSavedSearches: 100, + maxAnalyticsQueries: 500, + maxMediaUploads: 60, features: { basicSearch: true, listingPost: true, @@ -77,6 +83,8 @@ export const PLANS = [ priceYearlyVND: BigInt(49_900_000), maxListings: null, maxSavedSearches: null, + maxAnalyticsQueries: null, + maxMediaUploads: null, features: { basicSearch: true, listingPost: true, @@ -109,6 +117,8 @@ async function seedPlans() { priceYearlyVND: plan.priceYearlyVND, maxListings: plan.maxListings, maxSavedSearches: plan.maxSavedSearches, + maxAnalyticsQueries: plan.maxAnalyticsQueries, + maxMediaUploads: plan.maxMediaUploads, features: plan.features, }, create: plan,