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:
@@ -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>
|
||||
<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>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>`,
|
||||
},
|
||||
'password.reset': {
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user