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:
@@ -2,9 +2,13 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Query,
|
Query,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { type QueryBus } from '@nestjs/cqrs';
|
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 { 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 { GetDistrictStatsQuery } from '../../application/queries/get-district-stats/get-district-stats.query';
|
||||||
import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
|
import { type HeatmapDto } from '../../application/queries/get-heatmap/get-heatmap.handler';
|
||||||
@@ -25,36 +29,52 @@ export class AnalyticsController {
|
|||||||
private readonly queryBus: QueryBus,
|
private readonly queryBus: QueryBus,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
@Get('market-report')
|
@Get('market-report')
|
||||||
@ApiOperation({ summary: 'Get market report for a city' })
|
@ApiOperation({ summary: 'Get market report for a city' })
|
||||||
@ApiResponse({ status: 200, description: 'Market report retrieved' })
|
@ApiResponse({ status: 200, description: 'Market report retrieved' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
async getMarketReport(@Query() dto: GetMarketReportDto): Promise<MarketReportDto> {
|
async getMarketReport(@Query() dto: GetMarketReportDto): Promise<MarketReportDto> {
|
||||||
return this.queryBus.execute(
|
return this.queryBus.execute(
|
||||||
new GetMarketReportQuery(dto.city, dto.period, dto.propertyType),
|
new GetMarketReportQuery(dto.city, dto.period, dto.propertyType),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
@Get('price-trend')
|
@Get('price-trend')
|
||||||
@ApiOperation({ summary: 'Get price trend for a district' })
|
@ApiOperation({ summary: 'Get price trend for a district' })
|
||||||
@ApiResponse({ status: 200, description: 'Price trend data retrieved' })
|
@ApiResponse({ status: 200, description: 'Price trend data retrieved' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
async getPriceTrend(@Query() dto: GetPriceTrendDto): Promise<PriceTrendDto> {
|
async getPriceTrend(@Query() dto: GetPriceTrendDto): Promise<PriceTrendDto> {
|
||||||
return this.queryBus.execute(
|
return this.queryBus.execute(
|
||||||
new GetPriceTrendQuery(dto.district, dto.city, dto.propertyType, dto.periods),
|
new GetPriceTrendQuery(dto.district, dto.city, dto.propertyType, dto.periods),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
@Get('heatmap')
|
@Get('heatmap')
|
||||||
@ApiOperation({ summary: 'Get price heatmap for a city' })
|
@ApiOperation({ summary: 'Get price heatmap for a city' })
|
||||||
@ApiResponse({ status: 200, description: 'Heatmap data retrieved' })
|
@ApiResponse({ status: 200, description: 'Heatmap data retrieved' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
async getHeatmap(@Query() dto: GetHeatmapDto): Promise<HeatmapDto> {
|
async getHeatmap(@Query() dto: GetHeatmapDto): Promise<HeatmapDto> {
|
||||||
return this.queryBus.execute(
|
return this.queryBus.execute(
|
||||||
new GetHeatmapQuery(dto.city, dto.period),
|
new GetHeatmapQuery(dto.city, dto.period),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth('JWT')
|
||||||
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('analytics_queries')
|
||||||
@Get('district-stats')
|
@Get('district-stats')
|
||||||
@ApiOperation({ summary: 'Get statistics by district' })
|
@ApiOperation({ summary: 'Get statistics by district' })
|
||||||
@ApiResponse({ status: 200, description: 'District statistics retrieved' })
|
@ApiResponse({ status: 200, description: 'District statistics retrieved' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
async getDistrictStats(@Query() dto: GetDistrictStatsDto): Promise<DistrictStatsDto> {
|
async getDistrictStats(@Query() dto: GetDistrictStatsDto): Promise<DistrictStatsDto> {
|
||||||
return this.queryBus.execute(
|
return this.queryBus.execute(
|
||||||
new GetDistrictStatsQuery(dto.city, dto.period),
|
new GetDistrictStatsQuery(dto.city, dto.period),
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.
|
|||||||
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
|
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '@modules/auth/presentation/guards/roles.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 { FileValidationPipe, type UploadedFile as ValidatedFile } from '@modules/shared/infrastructure/pipes/file-validation.pipe';
|
||||||
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
|
import { CreateListingCommand } from '../../application/commands/create-listing/create-listing.command';
|
||||||
import { type CreateListingResult } from '../../application/commands/create-listing/create-listing.handler';
|
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: 201, description: 'Listing created successfully' })
|
||||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@UseGuards(JwtAuthGuard)
|
@ApiResponse({ status: 403, description: 'Quota exceeded' })
|
||||||
|
@UseGuards(JwtAuthGuard, QuotaGuard)
|
||||||
|
@RequireQuota('listings_created')
|
||||||
@Post()
|
@Post()
|
||||||
async createListing(
|
async createListing(
|
||||||
@Body() dto: CreateListingDto,
|
@Body() dto: CreateListingDto,
|
||||||
|
|||||||
@@ -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<typeof vi.fn> };
|
||||||
|
let mockPrisma: any;
|
||||||
|
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
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 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,13 @@ const TEMPLATES: Record<string, TemplateDefinition> = {
|
|||||||
body: `<h1>Yêu cầu tư vấn mới</h1>
|
body: `<h1>Yêu cầu tư vấn mới</h1>
|
||||||
<p>Bạn nhận được yêu cầu tư vấn từ <strong>{{senderName}}</strong> cho tin đăng <strong>{{listingTitle}}</strong>.</p>
|
<p>Bạn nhận được yêu cầu tư vấn từ <strong>{{senderName}}</strong> cho tin đăng <strong>{{listingTitle}}</strong>.</p>
|
||||||
<p>Nội dung: {{message}}</p>
|
<p>Nội dung: {{message}}</p>
|
||||||
|
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||||
|
},
|
||||||
|
'quota.exceeded': {
|
||||||
|
subject: 'Bạn đã đạt giới hạn sử dụng',
|
||||||
|
body: `<h1>Giới hạn đã đạt</h1>
|
||||||
|
<p>Bạn đã sử dụng hết giới hạn <strong>{{metric}}</strong> ({{used}}/{{limit}}).</p>
|
||||||
|
<p>Vui lòng nâng cấp gói để tiếp tục sử dụng dịch vụ.</p>
|
||||||
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
|
||||||
},
|
},
|
||||||
'password.reset': {
|
'password.reset': {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler';
|
import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler';
|
||||||
import { AgentVerifiedListener } from './application/listeners/agent-verified.listener';
|
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 { UserRegisteredListener } from './application/listeners/user-registered.listener';
|
||||||
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository';
|
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository';
|
||||||
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
|
import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository';
|
||||||
@@ -14,7 +15,7 @@ import { NotificationsController } from './presentation/controllers/notification
|
|||||||
|
|
||||||
const CommandHandlers = [SendNotificationHandler];
|
const CommandHandlers = [SendNotificationHandler];
|
||||||
|
|
||||||
const EventListeners = [UserRegisteredListener, AgentVerifiedListener];
|
const EventListeners = [UserRegisteredListener, AgentVerifiedListener, QuotaExceededListener];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CqrsModule],
|
imports: [CqrsModule],
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface QuotaCheckResult {
|
|||||||
const METRIC_TO_PLAN_FIELD: Record<string, keyof Plan> = {
|
const METRIC_TO_PLAN_FIELD: Record<string, keyof Plan> = {
|
||||||
listings_created: 'maxListings',
|
listings_created: 'maxListings',
|
||||||
searches_saved: 'maxSavedSearches',
|
searches_saved: 'maxSavedSearches',
|
||||||
|
analytics_queries: 'maxAnalyticsQueries',
|
||||||
|
media_uploads: 'maxMediaUploads',
|
||||||
};
|
};
|
||||||
|
|
||||||
@QueryHandler(CheckQuotaQuery)
|
@QueryHandler(CheckQuotaQuery)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<typeof vi.fn> };
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof vi.fn> };
|
||||||
|
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||||
|
let mockEventEmitter: { emit: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { type Reflector } from '@nestjs/core';
|
import { type Reflector } from '@nestjs/core';
|
||||||
import { type QueryBus } from '@nestjs/cqrs';
|
import { type QueryBus } from '@nestjs/cqrs';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { type QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler';
|
import { type QuotaCheckResult } from '../../application/queries/check-quota/check-quota.handler';
|
||||||
import { CheckQuotaQuery } from '../../application/queries/check-quota/check-quota.query';
|
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';
|
import { QUOTA_METRIC_KEY } from '../decorators/require-quota.decorator';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -15,6 +17,7 @@ export class QuotaGuard implements CanActivate {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly reflector: Reflector,
|
private readonly reflector: Reflector,
|
||||||
private readonly queryBus: QueryBus,
|
private readonly queryBus: QueryBus,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
@@ -39,6 +42,14 @@ export class QuotaGuard implements CanActivate {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!result.allowed) {
|
if (!result.allowed) {
|
||||||
|
const event = new QuotaExceededEvent(
|
||||||
|
user.sub,
|
||||||
|
result.metric,
|
||||||
|
result.limit!,
|
||||||
|
result.used,
|
||||||
|
);
|
||||||
|
this.eventEmitter.emit(event.eventName, event);
|
||||||
|
|
||||||
throw new ForbiddenException({
|
throw new ForbiddenException({
|
||||||
code: 'QUOTA_EXCEEDED',
|
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.`,
|
message: `Bạn đã đạt giới hạn ${metric}. Vui lòng nâng cấp gói để tiếp tục.`,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { GetBillingHistoryHandler } from './application/queries/get-billing-hist
|
|||||||
import { GetPlanHandler } from './application/queries/get-plan/get-plan.handler';
|
import { GetPlanHandler } from './application/queries/get-plan/get-plan.handler';
|
||||||
import { SUBSCRIPTION_REPOSITORY } from './domain/repositories/subscription.repository';
|
import { SUBSCRIPTION_REPOSITORY } from './domain/repositories/subscription.repository';
|
||||||
import { PrismaSubscriptionRepository } from './infrastructure/repositories/prisma-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 { SubscriptionsController } from './presentation/controllers/subscriptions.controller';
|
||||||
import { QuotaGuard } from './presentation/guards/quota.guard';
|
import { QuotaGuard } from './presentation/guards/quota.guard';
|
||||||
|
|
||||||
@@ -35,6 +36,9 @@ const QueryHandlers = [
|
|||||||
// Guards
|
// Guards
|
||||||
QuotaGuard,
|
QuotaGuard,
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
ListingCreatedUsageHandler,
|
||||||
|
|
||||||
// CQRS
|
// CQRS
|
||||||
...CommandHandlers,
|
...CommandHandlers,
|
||||||
...QueryHandlers,
|
...QueryHandlers,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Plan" ADD COLUMN "maxAnalyticsQueries" INTEGER;
|
||||||
|
ALTER TABLE "Plan" ADD COLUMN "maxMediaUploads" INTEGER;
|
||||||
@@ -425,6 +425,8 @@ model Plan {
|
|||||||
priceYearlyVND BigInt
|
priceYearlyVND BigInt
|
||||||
maxListings Int?
|
maxListings Int?
|
||||||
maxSavedSearches Int?
|
maxSavedSearches Int?
|
||||||
|
maxAnalyticsQueries Int?
|
||||||
|
maxMediaUploads Int?
|
||||||
features Json
|
features Json
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const PLANS = [
|
|||||||
priceYearlyVND: BigInt(0),
|
priceYearlyVND: BigInt(0),
|
||||||
maxListings: 3,
|
maxListings: 3,
|
||||||
maxSavedSearches: 5,
|
maxSavedSearches: 5,
|
||||||
|
maxAnalyticsQueries: 0,
|
||||||
|
maxMediaUploads: 5,
|
||||||
features: {
|
features: {
|
||||||
basicSearch: true,
|
basicSearch: true,
|
||||||
listingPost: true,
|
listingPost: true,
|
||||||
@@ -38,6 +40,8 @@ export const PLANS = [
|
|||||||
priceYearlyVND: BigInt(4_990_000),
|
priceYearlyVND: BigInt(4_990_000),
|
||||||
maxListings: 50,
|
maxListings: 50,
|
||||||
maxSavedSearches: 30,
|
maxSavedSearches: 30,
|
||||||
|
maxAnalyticsQueries: 100,
|
||||||
|
maxMediaUploads: 150,
|
||||||
features: {
|
features: {
|
||||||
basicSearch: true,
|
basicSearch: true,
|
||||||
listingPost: true,
|
listingPost: true,
|
||||||
@@ -57,6 +61,8 @@ export const PLANS = [
|
|||||||
priceYearlyVND: BigInt(9_990_000),
|
priceYearlyVND: BigInt(9_990_000),
|
||||||
maxListings: 20,
|
maxListings: 20,
|
||||||
maxSavedSearches: 100,
|
maxSavedSearches: 100,
|
||||||
|
maxAnalyticsQueries: 500,
|
||||||
|
maxMediaUploads: 60,
|
||||||
features: {
|
features: {
|
||||||
basicSearch: true,
|
basicSearch: true,
|
||||||
listingPost: true,
|
listingPost: true,
|
||||||
@@ -77,6 +83,8 @@ export const PLANS = [
|
|||||||
priceYearlyVND: BigInt(49_900_000),
|
priceYearlyVND: BigInt(49_900_000),
|
||||||
maxListings: null,
|
maxListings: null,
|
||||||
maxSavedSearches: null,
|
maxSavedSearches: null,
|
||||||
|
maxAnalyticsQueries: null,
|
||||||
|
maxMediaUploads: null,
|
||||||
features: {
|
features: {
|
||||||
basicSearch: true,
|
basicSearch: true,
|
||||||
listingPost: true,
|
listingPost: true,
|
||||||
@@ -109,6 +117,8 @@ async function seedPlans() {
|
|||||||
priceYearlyVND: plan.priceYearlyVND,
|
priceYearlyVND: plan.priceYearlyVND,
|
||||||
maxListings: plan.maxListings,
|
maxListings: plan.maxListings,
|
||||||
maxSavedSearches: plan.maxSavedSearches,
|
maxSavedSearches: plan.maxSavedSearches,
|
||||||
|
maxAnalyticsQueries: plan.maxAnalyticsQueries,
|
||||||
|
maxMediaUploads: plan.maxMediaUploads,
|
||||||
features: plan.features,
|
features: plan.features,
|
||||||
},
|
},
|
||||||
create: plan,
|
create: plan,
|
||||||
|
|||||||
Reference in New Issue
Block a user