feat(admin): add Admin module with moderation, user mgmt, and dashboard
- Commands: ApproveListing, RejectListing, BanUser, AdjustSubscription
- Queries: GetModerationQueue, GetDashboardStats, GetRevenueStats
- Admin-only guards via @Roles('ADMIN') on all endpoints
- Prisma-based admin query repository for dashboard aggregations
- 14 unit tests covering all command handlers and query handlers
- Added activate() method to UserEntity for unban support
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -5,6 +5,7 @@ import { SearchModule } from '@modules/search';
|
||||
import { NotificationsModule } from '@modules/notifications';
|
||||
import { PaymentsModule } from '@modules/payments';
|
||||
import { SubscriptionsModule } from '@modules/subscriptions';
|
||||
import { AdminModule } from '@modules/admin';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
@@ -22,6 +23,7 @@ import { AppController } from './app.controller';
|
||||
NotificationsModule,
|
||||
PaymentsModule,
|
||||
SubscriptionsModule,
|
||||
AdminModule,
|
||||
|
||||
// ── Rate Limiting ──
|
||||
// Default: 60 requests per 60 seconds per IP
|
||||
|
||||
52
apps/api/src/modules/admin/admin.module.ts
Normal file
52
apps/api/src/modules/admin/admin.module.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AuthModule } from '@modules/auth';
|
||||
import { ListingsModule } from '@modules/listings';
|
||||
import { SubscriptionsModule } from '@modules/subscriptions';
|
||||
|
||||
// Domain
|
||||
import { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository';
|
||||
|
||||
// Infrastructure
|
||||
import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository';
|
||||
|
||||
// Application — Commands
|
||||
import { ApproveListingHandler } from './application/commands/approve-listing/approve-listing.handler';
|
||||
import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler';
|
||||
import { BanUserHandler } from './application/commands/ban-user/ban-user.handler';
|
||||
import { AdjustSubscriptionHandler } from './application/commands/adjust-subscription/adjust-subscription.handler';
|
||||
|
||||
// Application — Queries
|
||||
import { GetModerationQueueHandler } from './application/queries/get-moderation-queue/get-moderation-queue.handler';
|
||||
import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler';
|
||||
import { GetRevenueStatsHandler } from './application/queries/get-revenue-stats/get-revenue-stats.handler';
|
||||
|
||||
// Presentation
|
||||
import { AdminController } from './presentation/controllers/admin.controller';
|
||||
|
||||
const CommandHandlers = [
|
||||
ApproveListingHandler,
|
||||
RejectListingHandler,
|
||||
BanUserHandler,
|
||||
AdjustSubscriptionHandler,
|
||||
];
|
||||
|
||||
const QueryHandlers = [
|
||||
GetModerationQueueHandler,
|
||||
GetDashboardStatsHandler,
|
||||
GetRevenueStatsHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule],
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
// Repositories
|
||||
{ provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },
|
||||
|
||||
// CQRS
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
})
|
||||
export class AdminModule {}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AdjustSubscriptionHandler } from '../commands/adjust-subscription/adjust-subscription.handler';
|
||||
import { AdjustSubscriptionCommand } from '../commands/adjust-subscription/adjust-subscription.command';
|
||||
import { type ISubscriptionRepository } from '@modules/subscriptions/domain/repositories/subscription.repository';
|
||||
import { SubscriptionEntity } from '@modules/subscriptions/domain/entities/subscription.entity';
|
||||
|
||||
describe('AdjustSubscriptionHandler', () => {
|
||||
let handler: AdjustSubscriptionHandler;
|
||||
let mockSubRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: any;
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockSubRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
plan: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new AdjustSubscriptionHandler(
|
||||
mockSubRepo as any,
|
||||
mockPrisma,
|
||||
mockEventBus as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('adjusts subscription to a new plan successfully', async () => {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-free', 'FREE',
|
||||
new Date(), new Date(Date.now() + 30 * 86400000),
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
|
||||
mockSubRepo.findByUserId.mockResolvedValue(sub);
|
||||
mockPrisma.plan.findUnique.mockResolvedValue({
|
||||
id: 'plan-pro',
|
||||
tier: 'AGENT_PRO',
|
||||
});
|
||||
mockSubRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'AGENT_PRO', 'Courtesy upgrade');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.newPlanTier).toBe('AGENT_PRO');
|
||||
expect(mockSubRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when user has no subscription', async () => {
|
||||
mockSubRepo.findByUserId.mockResolvedValue(null);
|
||||
|
||||
const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'AGENT_PRO', 'test');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Người dùng chưa có subscription');
|
||||
});
|
||||
|
||||
it('throws ValidationException for invalid plan tier', async () => {
|
||||
const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'INVALID_TIER', 'test');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/không hợp lệ/);
|
||||
});
|
||||
|
||||
it('throws ValidationException when user is already on the same plan', async () => {
|
||||
const sub = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-pro', 'AGENT_PRO',
|
||||
new Date(), new Date(Date.now() + 30 * 86400000),
|
||||
);
|
||||
sub.clearDomainEvents();
|
||||
|
||||
mockSubRepo.findByUserId.mockResolvedValue(sub);
|
||||
mockPrisma.plan.findUnique.mockResolvedValue({
|
||||
id: 'plan-pro',
|
||||
tier: 'AGENT_PRO',
|
||||
});
|
||||
|
||||
const command = new AdjustSubscriptionCommand('user-1', 'admin-1', 'AGENT_PRO', 'test');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/đã đang sử dụng/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ApproveListingHandler } from '../commands/approve-listing/approve-listing.handler';
|
||||
import { ApproveListingCommand } from '../commands/approve-listing/approve-listing.command';
|
||||
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
|
||||
import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
|
||||
import { Price } from '@modules/listings/domain/value-objects/price.vo';
|
||||
|
||||
function createPendingListing(id = 'listing-1'): ListingEntity {
|
||||
const price = Price.create(1_000_000_000n).unwrap();
|
||||
const listing = ListingEntity.createNew(
|
||||
id, 'prop-1', 'seller-1', 'SALE', price, 100, 'agent-1',
|
||||
);
|
||||
listing.submitForReview();
|
||||
listing.clearDomainEvents();
|
||||
return listing;
|
||||
}
|
||||
|
||||
describe('ApproveListingHandler', () => {
|
||||
let handler: ApproveListingHandler;
|
||||
let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockListingRepo = {
|
||||
findById: vi.fn(),
|
||||
findByIdWithProperty: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
search: vi.fn(),
|
||||
findByStatus: vi.fn(),
|
||||
findBySellerId: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new ApproveListingHandler(
|
||||
mockListingRepo as any,
|
||||
mockEventBus as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('approves a pending listing successfully', async () => {
|
||||
const listing = createPendingListing();
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
mockListingRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const command = new ApproveListingCommand('listing-1', 'admin-1', 'Looks good');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
expect(result.listingId).toBe('listing-1');
|
||||
expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when listing does not exist', async () => {
|
||||
mockListingRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new ApproveListingCommand('nonexistent', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Listing không tồn tại');
|
||||
});
|
||||
|
||||
it('throws ValidationException when listing is not pending review', async () => {
|
||||
const price = Price.create(500_000_000n).unwrap();
|
||||
const listing = ListingEntity.createNew(
|
||||
'listing-1', 'prop-1', 'seller-1', 'SALE', price, 80,
|
||||
);
|
||||
listing.clearDomainEvents();
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new ApproveListingCommand('listing-1', 'admin-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/trạng thái/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BanUserHandler } from '../commands/ban-user/ban-user.handler';
|
||||
import { BanUserCommand } from '../commands/ban-user/ban-user.command';
|
||||
import { type IUserRepository } from '@modules/auth/domain/repositories/user.repository';
|
||||
import { UserEntity } from '@modules/auth/domain/entities/user.entity';
|
||||
import { Phone } from '@modules/auth/domain/value-objects/phone.vo';
|
||||
import { HashedPassword } from '@modules/auth/domain/value-objects/hashed-password.vo';
|
||||
|
||||
async function createUser(role = 'BUYER' as any, isActive = true): Promise<UserEntity> {
|
||||
const phone = Phone.create('0901234567').unwrap();
|
||||
const hash = await HashedPassword.fromPlain('Password123');
|
||||
const passwordHash = hash.isOk ? hash.unwrap() : null;
|
||||
const user = new UserEntity('user-1', {
|
||||
email: null,
|
||||
phone,
|
||||
passwordHash,
|
||||
fullName: 'Test User',
|
||||
avatarUrl: null,
|
||||
role,
|
||||
kycStatus: 'NONE',
|
||||
kycData: null,
|
||||
isActive,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
describe('BanUserHandler', () => {
|
||||
let handler: BanUserHandler;
|
||||
let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserRepo = {
|
||||
findById: vi.fn(),
|
||||
findByPhone: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new BanUserHandler(
|
||||
mockUserRepo as any,
|
||||
mockEventBus as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('bans an active user successfully', async () => {
|
||||
const user = await createUser('BUYER', true);
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const command = new BanUserCommand('user-1', 'admin-1', 'Spam activity');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.isActive).toBe(false);
|
||||
expect(result.message).toContain('bị ban');
|
||||
expect(mockUserRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unbans a banned user successfully', async () => {
|
||||
const user = await createUser('BUYER', false);
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
mockUserRepo.update.mockResolvedValue(undefined);
|
||||
|
||||
const command = new BanUserCommand('user-1', 'admin-1', 'Resolved', true);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(result.message).toContain('gỡ ban');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when user does not exist', async () => {
|
||||
mockUserRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new BanUserCommand('nonexistent', 'admin-1', 'test');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Người dùng không tồn tại');
|
||||
});
|
||||
|
||||
it('prevents banning an admin user', async () => {
|
||||
const admin = await createUser('ADMIN', true);
|
||||
mockUserRepo.findById.mockResolvedValue(admin);
|
||||
|
||||
const command = new BanUserCommand('user-1', 'admin-1', 'test');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/admin/i);
|
||||
});
|
||||
|
||||
it('throws when trying to ban already banned user', async () => {
|
||||
const user = await createUser('BUYER', false);
|
||||
mockUserRepo.findById.mockResolvedValue(user);
|
||||
|
||||
const command = new BanUserCommand('user-1', 'admin-1', 'test');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Người dùng đã bị ban');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { GetDashboardStatsHandler } from '../queries/get-dashboard-stats/get-dashboard-stats.handler';
|
||||
import { GetDashboardStatsQuery } from '../queries/get-dashboard-stats/get-dashboard-stats.query';
|
||||
import { type IAdminQueryRepository } from '../../domain/repositories/admin-query.repository';
|
||||
|
||||
describe('GetDashboardStatsHandler', () => {
|
||||
let handler: GetDashboardStatsHandler;
|
||||
let mockAdminQueryRepo: { [K in keyof IAdminQueryRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockAdminQueryRepo = {
|
||||
getModerationQueue: vi.fn(),
|
||||
getDashboardStats: vi.fn(),
|
||||
getRevenueStats: vi.fn(),
|
||||
getUsers: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetDashboardStatsHandler(mockAdminQueryRepo as any);
|
||||
});
|
||||
|
||||
it('returns dashboard stats', async () => {
|
||||
const stats = {
|
||||
totalUsers: 150,
|
||||
totalListings: 500,
|
||||
activeListings: 200,
|
||||
pendingModerationCount: 15,
|
||||
totalAgents: 30,
|
||||
verifiedAgents: 20,
|
||||
totalTransactions: 50,
|
||||
newUsersLast30Days: 25,
|
||||
newListingsLast30Days: 40,
|
||||
};
|
||||
|
||||
mockAdminQueryRepo.getDashboardStats.mockResolvedValue(stats);
|
||||
|
||||
const result = await handler.execute(new GetDashboardStatsQuery());
|
||||
|
||||
expect(result).toEqual(stats);
|
||||
expect(mockAdminQueryRepo.getDashboardStats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { GetModerationQueueHandler } from '../queries/get-moderation-queue/get-moderation-queue.handler';
|
||||
import { GetModerationQueueQuery } from '../queries/get-moderation-queue/get-moderation-queue.query';
|
||||
import { type IAdminQueryRepository } from '../../domain/repositories/admin-query.repository';
|
||||
|
||||
describe('GetModerationQueueHandler', () => {
|
||||
let handler: GetModerationQueueHandler;
|
||||
let mockAdminQueryRepo: { [K in keyof IAdminQueryRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockAdminQueryRepo = {
|
||||
getModerationQueue: vi.fn(),
|
||||
getDashboardStats: vi.fn(),
|
||||
getRevenueStats: vi.fn(),
|
||||
getUsers: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetModerationQueueHandler(mockAdminQueryRepo as any);
|
||||
});
|
||||
|
||||
it('returns moderation queue with pagination', async () => {
|
||||
const queueResult = {
|
||||
data: [
|
||||
{
|
||||
listingId: 'listing-1',
|
||||
propertyTitle: 'Căn hộ 2PN quận 7',
|
||||
propertyType: 'APARTMENT',
|
||||
transactionType: 'SALE',
|
||||
priceVND: 3_000_000_000n,
|
||||
sellerName: 'Nguyễn Văn A',
|
||||
sellerId: 'seller-1',
|
||||
moderationScore: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
mockAdminQueryRepo.getModerationQueue.mockResolvedValue(queueResult);
|
||||
|
||||
const result = await handler.execute(new GetModerationQueueQuery(1, 20));
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(mockAdminQueryRepo.getModerationQueue).toHaveBeenCalledWith(1, 20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export class AdjustSubscriptionCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly newPlanTier: string,
|
||||
public readonly reason: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions/domain/repositories/subscription.repository';
|
||||
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { type PlanTier } from '@prisma/client';
|
||||
import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event';
|
||||
import { AdjustSubscriptionCommand } from './adjust-subscription.command';
|
||||
|
||||
const VALID_TIERS: PlanTier[] = ['FREE', 'AGENT_PRO', 'INVESTOR', 'ENTERPRISE'];
|
||||
|
||||
export interface AdjustSubscriptionResult {
|
||||
subscriptionId: string;
|
||||
newPlanTier: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@CommandHandler(AdjustSubscriptionCommand)
|
||||
export class AdjustSubscriptionHandler implements ICommandHandler<AdjustSubscriptionCommand> {
|
||||
constructor(
|
||||
@Inject(SUBSCRIPTION_REPOSITORY) private readonly subscriptionRepo: ISubscriptionRepository,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly eventBus: EventBus,
|
||||
) {}
|
||||
|
||||
async execute(command: AdjustSubscriptionCommand): Promise<AdjustSubscriptionResult> {
|
||||
const tier = command.newPlanTier as PlanTier;
|
||||
if (!VALID_TIERS.includes(tier)) {
|
||||
throw new ValidationException(`Gói không hợp lệ: ${command.newPlanTier}`, {
|
||||
validTiers: VALID_TIERS,
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await this.subscriptionRepo.findByUserId(command.userId);
|
||||
if (!subscription) {
|
||||
throw new NotFoundException('Người dùng chưa có subscription');
|
||||
}
|
||||
|
||||
const newPlan = await this.prisma.plan.findUnique({
|
||||
where: { tier },
|
||||
});
|
||||
if (!newPlan) {
|
||||
throw new NotFoundException(`Gói ${tier} không tồn tại trong hệ thống`);
|
||||
}
|
||||
|
||||
if (subscription.planId === newPlan.id) {
|
||||
throw new ValidationException('Người dùng đã đang sử dụng gói này', {
|
||||
currentPlanId: subscription.planId,
|
||||
});
|
||||
}
|
||||
|
||||
subscription.upgrade(newPlan.id, tier);
|
||||
await this.subscriptionRepo.update(subscription);
|
||||
|
||||
this.eventBus.publish(
|
||||
new SubscriptionAdjustedEvent(subscription.id, command.adminId, newPlan.id, command.reason),
|
||||
);
|
||||
|
||||
return {
|
||||
subscriptionId: subscription.id,
|
||||
newPlanTier: tier,
|
||||
message: `Subscription đã được chuyển sang gói ${tier}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class ApproveListingCommand {
|
||||
constructor(
|
||||
public readonly listingId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly moderationNotes?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
|
||||
import { ListingApprovedEvent } from '../../../domain/events/listing-approved.event';
|
||||
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
||||
import { ApproveListingCommand } from './approve-listing.command';
|
||||
|
||||
export interface ApproveListingResult {
|
||||
listingId: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@CommandHandler(ApproveListingCommand)
|
||||
export class ApproveListingHandler implements ICommandHandler<ApproveListingCommand> {
|
||||
constructor(
|
||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
) {}
|
||||
|
||||
async execute(command: ApproveListingCommand): Promise<ApproveListingResult> {
|
||||
const listing = await this.listingRepo.findById(command.listingId);
|
||||
if (!listing) {
|
||||
throw new NotFoundException('Listing không tồn tại');
|
||||
}
|
||||
|
||||
if (listing.status !== 'PENDING_REVIEW') {
|
||||
throw new ValidationException(
|
||||
`Listing đang ở trạng thái ${listing.status}, chỉ có thể duyệt listing đang chờ duyệt`,
|
||||
{ currentStatus: listing.status },
|
||||
);
|
||||
}
|
||||
|
||||
listing.approve();
|
||||
|
||||
if (command.moderationNotes) {
|
||||
listing.setModerationScore(1.0, command.moderationNotes);
|
||||
}
|
||||
|
||||
await this.listingRepo.update(listing);
|
||||
|
||||
this.eventBus.publish(
|
||||
new ListingApprovedEvent(listing.id, command.adminId, command.moderationNotes),
|
||||
);
|
||||
|
||||
return {
|
||||
listingId: listing.id,
|
||||
status: 'ACTIVE',
|
||||
message: 'Listing đã được duyệt thành công',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class BanUserCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly reason: string,
|
||||
public readonly unban: boolean = false,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { USER_REPOSITORY, type IUserRepository } from '@modules/auth/domain/repositories/user.repository';
|
||||
import { UserBannedEvent } from '../../../domain/events/user-banned.event';
|
||||
import { UserUnbannedEvent } from '../../../domain/events/user-unbanned.event';
|
||||
import { BanUserCommand } from './ban-user.command';
|
||||
|
||||
export interface BanUserResult {
|
||||
userId: string;
|
||||
isActive: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@CommandHandler(BanUserCommand)
|
||||
export class BanUserHandler implements ICommandHandler<BanUserCommand> {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
) {}
|
||||
|
||||
async execute(command: BanUserCommand): Promise<BanUserResult> {
|
||||
const user = await this.userRepo.findById(command.userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Người dùng không tồn tại');
|
||||
}
|
||||
|
||||
if (user.role === 'ADMIN') {
|
||||
throw new ValidationException('Không thể ban/unban tài khoản admin', {
|
||||
userId: command.userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (command.unban) {
|
||||
if (user.isActive) {
|
||||
throw new ValidationException('Người dùng chưa bị ban', {
|
||||
userId: command.userId,
|
||||
});
|
||||
}
|
||||
|
||||
user.activate();
|
||||
await this.userRepo.update(user);
|
||||
|
||||
this.eventBus.publish(new UserUnbannedEvent(user.id, command.adminId));
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
isActive: true,
|
||||
message: 'Người dùng đã được gỡ ban',
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new ValidationException('Người dùng đã bị ban', {
|
||||
userId: command.userId,
|
||||
});
|
||||
}
|
||||
|
||||
user.deactivate();
|
||||
await this.userRepo.update(user);
|
||||
|
||||
this.eventBus.publish(new UserBannedEvent(user.id, command.adminId, command.reason));
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
isActive: false,
|
||||
message: 'Người dùng đã bị ban',
|
||||
};
|
||||
}
|
||||
}
|
||||
8
apps/api/src/modules/admin/application/commands/index.ts
Normal file
8
apps/api/src/modules/admin/application/commands/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { ApproveListingCommand } from './approve-listing/approve-listing.command';
|
||||
export { ApproveListingHandler } from './approve-listing/approve-listing.handler';
|
||||
export { RejectListingCommand } from './reject-listing/reject-listing.command';
|
||||
export { RejectListingHandler } from './reject-listing/reject-listing.handler';
|
||||
export { BanUserCommand } from './ban-user/ban-user.command';
|
||||
export { BanUserHandler } from './ban-user/ban-user.handler';
|
||||
export { AdjustSubscriptionCommand } from './adjust-subscription/adjust-subscription.command';
|
||||
export { AdjustSubscriptionHandler } from './adjust-subscription/adjust-subscription.handler';
|
||||
@@ -0,0 +1,7 @@
|
||||
export class RejectListingCommand {
|
||||
constructor(
|
||||
public readonly listingId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly reason: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
|
||||
import { ListingRejectedEvent } from '../../../domain/events/listing-rejected.event';
|
||||
import { RejectListingCommand } from './reject-listing.command';
|
||||
|
||||
export interface RejectListingResult {
|
||||
listingId: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@CommandHandler(RejectListingCommand)
|
||||
export class RejectListingHandler implements ICommandHandler<RejectListingCommand> {
|
||||
constructor(
|
||||
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
) {}
|
||||
|
||||
async execute(command: RejectListingCommand): Promise<RejectListingResult> {
|
||||
const listing = await this.listingRepo.findById(command.listingId);
|
||||
if (!listing) {
|
||||
throw new NotFoundException('Listing không tồn tại');
|
||||
}
|
||||
|
||||
if (listing.status !== 'PENDING_REVIEW') {
|
||||
throw new ValidationException(
|
||||
`Listing đang ở trạng thái ${listing.status}, chỉ có thể từ chối listing đang chờ duyệt`,
|
||||
{ currentStatus: listing.status },
|
||||
);
|
||||
}
|
||||
|
||||
listing.reject(command.reason);
|
||||
await this.listingRepo.update(listing);
|
||||
|
||||
this.eventBus.publish(
|
||||
new ListingRejectedEvent(listing.id, command.adminId, command.reason),
|
||||
);
|
||||
|
||||
return {
|
||||
listingId: listing.id,
|
||||
status: 'REJECTED',
|
||||
message: 'Listing đã bị từ chối',
|
||||
};
|
||||
}
|
||||
}
|
||||
2
apps/api/src/modules/admin/application/index.ts
Normal file
2
apps/api/src/modules/admin/application/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './commands';
|
||||
export * from './queries';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type DashboardStats } from '../../../domain/repositories/admin-query.repository';
|
||||
import { GetDashboardStatsQuery } from './get-dashboard-stats.query';
|
||||
|
||||
@QueryHandler(GetDashboardStatsQuery)
|
||||
export class GetDashboardStatsHandler implements IQueryHandler<GetDashboardStatsQuery> {
|
||||
constructor(
|
||||
@Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
|
||||
) {}
|
||||
|
||||
async execute(_query: GetDashboardStatsQuery): Promise<DashboardStats> {
|
||||
return this.adminQueryRepo.getDashboardStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class GetDashboardStatsQuery {
|
||||
constructor() {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type ModerationQueueResult } from '../../../domain/repositories/admin-query.repository';
|
||||
import { GetModerationQueueQuery } from './get-moderation-queue.query';
|
||||
|
||||
@QueryHandler(GetModerationQueueQuery)
|
||||
export class GetModerationQueueHandler implements IQueryHandler<GetModerationQueueQuery> {
|
||||
constructor(
|
||||
@Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetModerationQueueQuery): Promise<ModerationQueueResult> {
|
||||
return this.adminQueryRepo.getModerationQueue(query.page, query.limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetModerationQueueQuery {
|
||||
constructor(
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository, type RevenueStatsItem } from '../../../domain/repositories/admin-query.repository';
|
||||
import { GetRevenueStatsQuery } from './get-revenue-stats.query';
|
||||
|
||||
@QueryHandler(GetRevenueStatsQuery)
|
||||
export class GetRevenueStatsHandler implements IQueryHandler<GetRevenueStatsQuery> {
|
||||
constructor(
|
||||
@Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetRevenueStatsQuery): Promise<RevenueStatsItem[]> {
|
||||
return this.adminQueryRepo.getRevenueStats(query.startDate, query.endDate, query.groupBy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class GetRevenueStatsQuery {
|
||||
constructor(
|
||||
public readonly startDate: Date,
|
||||
public readonly endDate: Date,
|
||||
public readonly groupBy: 'day' | 'month' = 'month',
|
||||
) {}
|
||||
}
|
||||
6
apps/api/src/modules/admin/application/queries/index.ts
Normal file
6
apps/api/src/modules/admin/application/queries/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { GetModerationQueueQuery } from './get-moderation-queue/get-moderation-queue.query';
|
||||
export { GetModerationQueueHandler } from './get-moderation-queue/get-moderation-queue.handler';
|
||||
export { GetDashboardStatsQuery } from './get-dashboard-stats/get-dashboard-stats.query';
|
||||
export { GetDashboardStatsHandler } from './get-dashboard-stats/get-dashboard-stats.handler';
|
||||
export { GetRevenueStatsQuery } from './get-revenue-stats/get-revenue-stats.query';
|
||||
export { GetRevenueStatsHandler } from './get-revenue-stats/get-revenue-stats.handler';
|
||||
5
apps/api/src/modules/admin/domain/events/index.ts
Normal file
5
apps/api/src/modules/admin/domain/events/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ListingApprovedEvent } from './listing-approved.event';
|
||||
export { ListingRejectedEvent } from './listing-rejected.event';
|
||||
export { UserBannedEvent } from './user-banned.event';
|
||||
export { UserUnbannedEvent } from './user-unbanned.event';
|
||||
export { SubscriptionAdjustedEvent } from './subscription-adjusted.event';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class ListingApprovedEvent implements DomainEvent {
|
||||
readonly eventName = 'listing.approved_by_admin';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly moderationNotes?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class ListingRejectedEvent implements DomainEvent {
|
||||
readonly eventName = 'listing.rejected_by_admin';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly reason: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class SubscriptionAdjustedEvent implements DomainEvent {
|
||||
readonly eventName = 'subscription.adjusted_by_admin';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly newPlanId: string,
|
||||
public readonly reason: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class UserBannedEvent implements DomainEvent {
|
||||
readonly eventName = 'user.banned';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly adminId: string,
|
||||
public readonly reason: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type DomainEvent } from '@modules/shared';
|
||||
|
||||
export class UserUnbannedEvent implements DomainEvent {
|
||||
readonly eventName = 'user.unbanned';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly adminId: string,
|
||||
) {}
|
||||
}
|
||||
2
apps/api/src/modules/admin/domain/index.ts
Normal file
2
apps/api/src/modules/admin/domain/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './events';
|
||||
export * from './repositories';
|
||||
@@ -0,0 +1,68 @@
|
||||
export const ADMIN_QUERY_REPOSITORY = Symbol('ADMIN_QUERY_REPOSITORY');
|
||||
|
||||
export interface ModerationQueueItem {
|
||||
listingId: string;
|
||||
propertyTitle: string;
|
||||
propertyType: string;
|
||||
transactionType: string;
|
||||
priceVND: bigint;
|
||||
sellerName: string;
|
||||
sellerId: string;
|
||||
moderationScore: number | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ModerationQueueResult {
|
||||
data: ModerationQueueItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
totalUsers: number;
|
||||
totalListings: number;
|
||||
activeListings: number;
|
||||
pendingModerationCount: number;
|
||||
totalAgents: number;
|
||||
verifiedAgents: number;
|
||||
totalTransactions: number;
|
||||
newUsersLast30Days: number;
|
||||
newListingsLast30Days: number;
|
||||
}
|
||||
|
||||
export interface RevenueStatsItem {
|
||||
period: string;
|
||||
totalRevenue: bigint;
|
||||
subscriptionRevenue: bigint;
|
||||
listingFeeRevenue: bigint;
|
||||
featuredListingRevenue: bigint;
|
||||
transactionCount: number;
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
email: string | null;
|
||||
phone: string;
|
||||
fullName: string;
|
||||
role: string;
|
||||
kycStatus: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface UserListResult {
|
||||
data: UserListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface IAdminQueryRepository {
|
||||
getModerationQueue(page: number, limit: number): Promise<ModerationQueueResult>;
|
||||
getDashboardStats(): Promise<DashboardStats>;
|
||||
getRevenueStats(startDate: Date, endDate: Date, groupBy: 'day' | 'month'): Promise<RevenueStatsItem[]>;
|
||||
getUsers(params: { page: number; limit: number; role?: string; isActive?: boolean; search?: string }): Promise<UserListResult>;
|
||||
}
|
||||
9
apps/api/src/modules/admin/domain/repositories/index.ts
Normal file
9
apps/api/src/modules/admin/domain/repositories/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { ADMIN_QUERY_REPOSITORY, type IAdminQueryRepository } from './admin-query.repository';
|
||||
export type {
|
||||
ModerationQueueItem,
|
||||
ModerationQueueResult,
|
||||
DashboardStats,
|
||||
RevenueStatsItem,
|
||||
UserListItem,
|
||||
UserListResult,
|
||||
} from './admin-query.repository';
|
||||
1
apps/api/src/modules/admin/index.ts
Normal file
1
apps/api/src/modules/admin/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AdminModule } from './admin.module';
|
||||
1
apps/api/src/modules/admin/infrastructure/index.ts
Normal file
1
apps/api/src/modules/admin/infrastructure/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './repositories';
|
||||
@@ -0,0 +1 @@
|
||||
export { PrismaAdminQueryRepository } from './prisma-admin-query.repository';
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import {
|
||||
type IAdminQueryRepository,
|
||||
type ModerationQueueResult,
|
||||
type DashboardStats,
|
||||
type RevenueStatsItem,
|
||||
type UserListResult,
|
||||
} from '../../domain/repositories/admin-query.repository';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaAdminQueryRepository implements IAdminQueryRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getModerationQueue(page: number, limit: number): Promise<ModerationQueueResult> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [listings, total] = await Promise.all([
|
||||
this.prisma.listing.findMany({
|
||||
where: { status: 'PENDING_REVIEW' },
|
||||
include: {
|
||||
property: { select: { title: true, propertyType: true } },
|
||||
seller: { select: { fullName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: listings.map((l) => ({
|
||||
listingId: l.id,
|
||||
propertyTitle: l.property.title,
|
||||
propertyType: l.property.propertyType,
|
||||
transactionType: l.transactionType,
|
||||
priceVND: l.priceVND,
|
||||
sellerName: l.seller.fullName,
|
||||
sellerId: l.sellerId,
|
||||
moderationScore: l.moderationScore,
|
||||
createdAt: l.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async getDashboardStats(): Promise<DashboardStats> {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const [
|
||||
totalUsers,
|
||||
totalListings,
|
||||
activeListings,
|
||||
pendingModerationCount,
|
||||
totalAgents,
|
||||
verifiedAgents,
|
||||
totalTransactions,
|
||||
newUsersLast30Days,
|
||||
newListingsLast30Days,
|
||||
] = await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
this.prisma.listing.count(),
|
||||
this.prisma.listing.count({ where: { status: 'ACTIVE' } }),
|
||||
this.prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
|
||||
this.prisma.agent.count(),
|
||||
this.prisma.agent.count({ where: { isVerified: true } }),
|
||||
this.prisma.transaction.count(),
|
||||
this.prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
this.prisma.listing.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
totalListings,
|
||||
activeListings,
|
||||
pendingModerationCount,
|
||||
totalAgents,
|
||||
verifiedAgents,
|
||||
totalTransactions,
|
||||
newUsersLast30Days,
|
||||
newListingsLast30Days,
|
||||
};
|
||||
}
|
||||
|
||||
async getRevenueStats(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
groupBy: 'day' | 'month',
|
||||
): Promise<RevenueStatsItem[]> {
|
||||
const payments = await this.prisma.payment.findMany({
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
createdAt: { gte: startDate, lte: endDate },
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
amountVND: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const grouped = new Map<string, {
|
||||
totalRevenue: bigint;
|
||||
subscriptionRevenue: bigint;
|
||||
listingFeeRevenue: bigint;
|
||||
featuredListingRevenue: bigint;
|
||||
transactionCount: number;
|
||||
}>();
|
||||
|
||||
for (const payment of payments) {
|
||||
const period = groupBy === 'day'
|
||||
? payment.createdAt.toISOString().slice(0, 10)
|
||||
: payment.createdAt.toISOString().slice(0, 7);
|
||||
|
||||
if (!grouped.has(period)) {
|
||||
grouped.set(period, {
|
||||
totalRevenue: 0n,
|
||||
subscriptionRevenue: 0n,
|
||||
listingFeeRevenue: 0n,
|
||||
featuredListingRevenue: 0n,
|
||||
transactionCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const stats = grouped.get(period)!;
|
||||
stats.totalRevenue += payment.amountVND;
|
||||
stats.transactionCount++;
|
||||
|
||||
switch (payment.type) {
|
||||
case 'SUBSCRIPTION':
|
||||
stats.subscriptionRevenue += payment.amountVND;
|
||||
break;
|
||||
case 'LISTING_FEE':
|
||||
stats.listingFeeRevenue += payment.amountVND;
|
||||
break;
|
||||
case 'FEATURED_LISTING':
|
||||
stats.featuredListingRevenue += payment.amountVND;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries()).map(([period, stats]) => ({
|
||||
period,
|
||||
...stats,
|
||||
}));
|
||||
}
|
||||
|
||||
async getUsers(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
role?: string;
|
||||
isActive?: boolean;
|
||||
search?: string;
|
||||
}): Promise<UserListResult> {
|
||||
const { page, limit, role, isActive, search } = params;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (role) where.role = role;
|
||||
if (isActive !== undefined) where.isActive = isActive;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search } },
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
fullName: true,
|
||||
role: true,
|
||||
kycStatus: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: users,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
|
||||
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
|
||||
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
|
||||
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service';
|
||||
|
||||
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
|
||||
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
|
||||
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
|
||||
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
|
||||
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
|
||||
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
|
||||
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
|
||||
|
||||
import { ApproveListingDto } from '../dto/approve-listing.dto';
|
||||
import { RejectListingDto } from '../dto/reject-listing.dto';
|
||||
import { BanUserDto } from '../dto/ban-user.dto';
|
||||
import { AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
|
||||
import { RevenueStatsDto } from '../dto/revenue-stats.dto';
|
||||
|
||||
import { type ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler';
|
||||
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
|
||||
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
|
||||
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
|
||||
import { type ModerationQueueResult } from '../../domain/repositories/admin-query.repository';
|
||||
import { type DashboardStats, type RevenueStatsItem } from '../../domain/repositories/admin-query.repository';
|
||||
|
||||
@Controller('admin')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
export class AdminController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
// ── Moderation ──
|
||||
|
||||
@Get('moderation')
|
||||
async getModerationQueue(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<ModerationQueueResult> {
|
||||
return this.queryBus.execute(
|
||||
new GetModerationQueueQuery(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('moderation/approve')
|
||||
async approveListing(
|
||||
@Body() dto: ApproveListingDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<ApproveListingResult> {
|
||||
return this.commandBus.execute(
|
||||
new ApproveListingCommand(dto.listingId, user.sub, dto.moderationNotes),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('moderation/reject')
|
||||
async rejectListing(
|
||||
@Body() dto: RejectListingDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<RejectListingResult> {
|
||||
return this.commandBus.execute(
|
||||
new RejectListingCommand(dto.listingId, user.sub, dto.reason),
|
||||
);
|
||||
}
|
||||
|
||||
// ── User Management ──
|
||||
|
||||
@Post('users/ban')
|
||||
async banUser(
|
||||
@Body() dto: BanUserDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<BanUserResult> {
|
||||
return this.commandBus.execute(
|
||||
new BanUserCommand(dto.userId, user.sub, dto.reason, dto.unban ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Subscription Management ──
|
||||
|
||||
@Post('subscriptions/adjust')
|
||||
async adjustSubscription(
|
||||
@Body() dto: AdjustSubscriptionDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<AdjustSubscriptionResult> {
|
||||
return this.commandBus.execute(
|
||||
new AdjustSubscriptionCommand(dto.userId, user.sub, dto.newPlanTier, dto.reason),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Dashboard ──
|
||||
|
||||
@Get('dashboard')
|
||||
async getDashboardStats(): Promise<DashboardStats> {
|
||||
return this.queryBus.execute(new GetDashboardStatsQuery());
|
||||
}
|
||||
|
||||
@Get('revenue')
|
||||
async getRevenueStats(
|
||||
@Query() dto: RevenueStatsDto,
|
||||
): Promise<RevenueStatsItem[]> {
|
||||
return this.queryBus.execute(
|
||||
new GetRevenueStatsQuery(
|
||||
new Date(dto.startDate),
|
||||
new Date(dto.endDate),
|
||||
dto.groupBy ?? 'month',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AdminController } from './admin.controller';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class AdjustSubscriptionDto {
|
||||
@IsString()
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
newPlanTier!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class ApproveListingDto {
|
||||
@IsString()
|
||||
listingId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
moderationNotes?: string;
|
||||
}
|
||||
14
apps/api/src/modules/admin/presentation/dto/ban-user.dto.ts
Normal file
14
apps/api/src/modules/admin/presentation/dto/ban-user.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class BanUserDto {
|
||||
@IsString()
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
reason!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
unban?: boolean;
|
||||
}
|
||||
5
apps/api/src/modules/admin/presentation/dto/index.ts
Normal file
5
apps/api/src/modules/admin/presentation/dto/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ApproveListingDto } from './approve-listing.dto';
|
||||
export { RejectListingDto } from './reject-listing.dto';
|
||||
export { BanUserDto } from './ban-user.dto';
|
||||
export { AdjustSubscriptionDto } from './adjust-subscription.dto';
|
||||
export { RevenueStatsDto } from './revenue-stats.dto';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RejectListingDto {
|
||||
@IsString()
|
||||
listingId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsDateString, IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
export class RevenueStatsDto {
|
||||
@IsDateString()
|
||||
startDate!: string;
|
||||
|
||||
@IsDateString()
|
||||
endDate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['day', 'month'])
|
||||
groupBy?: 'day' | 'month';
|
||||
}
|
||||
2
apps/api/src/modules/admin/presentation/index.ts
Normal file
2
apps/api/src/modules/admin/presentation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './controllers';
|
||||
export * from './dto';
|
||||
@@ -85,4 +85,9 @@ export class UserEntity extends AggregateRoot<string> {
|
||||
this._isActive = false;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
activate(): void {
|
||||
this._isActive = true;
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { EventBus } from '@nestjs/cqrs';
|
||||
import { CreateSubscriptionHandler } from '../commands/create-subscription/create-subscription.handler';
|
||||
import { CreateSubscriptionCommand } from '../commands/create-subscription/create-subscription.command';
|
||||
import { type ISubscriptionRepository } from '../../domain/repositories/subscription.repository';
|
||||
import { SubscriptionEntity } from '../../domain/entities/subscription.entity';
|
||||
|
||||
describe('CreateSubscriptionHandler', () => {
|
||||
let handler: CreateSubscriptionHandler;
|
||||
let mockRepo: { [K in keyof ISubscriptionRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: any;
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
mockPrisma = {
|
||||
plan: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
mockEventBus = {
|
||||
publish: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new CreateSubscriptionHandler(
|
||||
mockRepo as any,
|
||||
mockPrisma,
|
||||
mockEventBus as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates subscription successfully', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
mockPrisma.plan.findFirst.mockResolvedValue({
|
||||
id: 'plan-1',
|
||||
tier: 'AGENT_PRO',
|
||||
isActive: true,
|
||||
});
|
||||
mockRepo.save.mockResolvedValue(undefined);
|
||||
|
||||
const command = new CreateSubscriptionCommand('user-1', 'AGENT_PRO', 'monthly');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.planTier).toBe('AGENT_PRO');
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
expect(result.subscriptionId).toBeDefined();
|
||||
expect(mockRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictException when user already has active subscription', async () => {
|
||||
const existing = SubscriptionEntity.createNew(
|
||||
'sub-1', 'user-1', 'plan-1', 'FREE',
|
||||
new Date(), new Date(),
|
||||
);
|
||||
mockRepo.findByUserId.mockResolvedValue(existing);
|
||||
|
||||
const command = new CreateSubscriptionCommand('user-1', 'AGENT_PRO', 'monthly');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when plan does not exist', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
mockPrisma.plan.findFirst.mockResolvedValue(null);
|
||||
|
||||
const command = new CreateSubscriptionCommand('user-1', 'AGENT_PRO', 'monthly');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('creates yearly subscription with correct period', async () => {
|
||||
mockRepo.findByUserId.mockResolvedValue(null);
|
||||
mockPrisma.plan.findFirst.mockResolvedValue({
|
||||
id: 'plan-1',
|
||||
tier: 'INVESTOR',
|
||||
isActive: true,
|
||||
});
|
||||
mockRepo.save.mockResolvedValue(undefined);
|
||||
|
||||
const command = new CreateSubscriptionCommand('user-1', 'INVESTOR', 'yearly');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
const startYear = result.currentPeriodStart.getFullYear();
|
||||
const endYear = result.currentPeriodEnd.getFullYear();
|
||||
expect(endYear - startYear).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { SubscriptionEntity } from '../entities/subscription.entity';
|
||||
|
||||
describe('SubscriptionEntity', () => {
|
||||
const makeSub = (overrides?: Partial<Parameters<typeof SubscriptionEntity.createNew>[0]>) => {
|
||||
return SubscriptionEntity.createNew(
|
||||
'sub-1',
|
||||
'user-1',
|
||||
'plan-1',
|
||||
'AGENT_PRO',
|
||||
new Date('2026-01-01'),
|
||||
new Date('2026-02-01'),
|
||||
);
|
||||
};
|
||||
|
||||
it('creates a new subscription with ACTIVE status', () => {
|
||||
const sub = makeSub();
|
||||
expect(sub.id).toBe('sub-1');
|
||||
expect(sub.userId).toBe('user-1');
|
||||
expect(sub.planTier).toBe('AGENT_PRO');
|
||||
expect(sub.status).toBe('ACTIVE');
|
||||
expect(sub.isActive()).toBe(true);
|
||||
expect(sub.cancelledAt).toBeNull();
|
||||
});
|
||||
|
||||
it('emits SubscriptionCreatedEvent on creation', () => {
|
||||
const sub = makeSub();
|
||||
const events = sub.domainEvents;
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].eventName).toBe('subscription.created');
|
||||
});
|
||||
|
||||
it('upgrades plan tier', () => {
|
||||
const sub = makeSub();
|
||||
sub.clearDomainEvents();
|
||||
|
||||
sub.upgrade('plan-2', 'ENTERPRISE');
|
||||
|
||||
expect(sub.planId).toBe('plan-2');
|
||||
expect(sub.planTier).toBe('ENTERPRISE');
|
||||
const events = sub.domainEvents;
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].eventName).toBe('subscription.upgraded');
|
||||
});
|
||||
|
||||
it('throws when upgrading non-active subscription', () => {
|
||||
const sub = makeSub();
|
||||
sub.cancel();
|
||||
|
||||
expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow();
|
||||
});
|
||||
|
||||
it('cancels subscription', () => {
|
||||
const sub = makeSub();
|
||||
sub.clearDomainEvents();
|
||||
|
||||
sub.cancel();
|
||||
|
||||
expect(sub.status).toBe('CANCELLED');
|
||||
expect(sub.cancelledAt).not.toBeNull();
|
||||
const events = sub.domainEvents;
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].eventName).toBe('subscription.cancelled');
|
||||
});
|
||||
|
||||
it('throws when cancelling already cancelled subscription', () => {
|
||||
const sub = makeSub();
|
||||
sub.cancel();
|
||||
|
||||
expect(() => sub.cancel()).toThrow('Subscription đã bị hủy');
|
||||
});
|
||||
|
||||
it('marks past due', () => {
|
||||
const sub = makeSub();
|
||||
sub.markPastDue();
|
||||
expect(sub.status).toBe('PAST_DUE');
|
||||
});
|
||||
|
||||
it('marks expired', () => {
|
||||
const sub = makeSub();
|
||||
sub.markExpired();
|
||||
expect(sub.status).toBe('EXPIRED');
|
||||
});
|
||||
|
||||
it('renews period', () => {
|
||||
const sub = makeSub();
|
||||
sub.markPastDue();
|
||||
|
||||
const newStart = new Date('2026-02-01');
|
||||
const newEnd = new Date('2026-03-01');
|
||||
sub.renewPeriod(newStart, newEnd);
|
||||
|
||||
expect(sub.status).toBe('ACTIVE');
|
||||
expect(sub.currentPeriodStart).toEqual(newStart);
|
||||
expect(sub.currentPeriodEnd).toEqual(newEnd);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user