feat(subscriptions): implement subscription quota enforcement

- Apply QuotaGuard + @RequireQuota to listing creation and analytics endpoints
- Add QuotaExceeded domain event emitted when quota is exceeded
- Create ListingCreatedUsageHandler to auto-meter usage on listing creation
- Create QuotaExceededListener to send email notifications on quota exceeded
- Add maxAnalyticsQueries and maxMediaUploads fields to Plan model
- Add quota.exceeded email notification template
- Define quota limits per plan tier in seed data
- Add 15 unit tests covering guard, event handler, listener, and event

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 14:16:32 +07:00
parent 23bb380d34
commit 3864f78405
17 changed files with 474 additions and 6 deletions

View File

@@ -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<MarketReportDto> {
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<PriceTrendDto> {
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<HeatmapDto> {
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<DistrictStatsDto> {
return this.queryBus.execute(
new GetDistrictStatsQuery(dto.city, dto.period),