refactor(api): replace new Logger() with DI LoggerService and split large files

- Migrate 30 files from `new Logger(ClassName.name)` to injected LoggerService
  for consistent PII masking and centralized logging config
- Split prisma-admin-query.repository.ts (313→121 lines) into admin-stats.queries.ts
  and admin-user.queries.ts
- Split admin.controller.ts (285→154 lines) into admin-moderation.controller.ts
- Split prisma-listing.repository.ts (274→111 lines) into listing-read.queries.ts
- Update 28 test files with mock LoggerService
- All 831 tests passing, zero direct new Logger() calls remaining

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 05:35:04 +07:00
parent 4e71036ddd
commit 34202f2527
67 changed files with 851 additions and 653 deletions

View File

@@ -22,6 +22,7 @@ import { GetUsersHandler } from './application/queries/get-users/get-users.handl
import { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository';
import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository';
import { AdminController } from './presentation/controllers/admin.controller';
import { AdminModerationController } from './presentation/controllers/admin-moderation.controller';
const CommandHandlers = [
ApproveListingHandler,
@@ -45,7 +46,7 @@ const QueryHandlers = [
@Module({
imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule],
controllers: [AdminController],
controllers: [AdminController, AdminModerationController],
providers: [
// Repositories
{ provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository },

View File

@@ -0,0 +1,109 @@
import { type PrismaService } from '@modules/shared';
import {
type DashboardStats,
type RevenueStatsItem,
} from '../../domain/repositories/admin-query.repository';
export async function getDashboardStats(prisma: PrismaService): Promise<DashboardStats> {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [
totalUsers,
totalListings,
activeListings,
pendingModerationCount,
totalAgents,
verifiedAgents,
totalTransactions,
newUsersLast30Days,
newListingsLast30Days,
] = await Promise.all([
prisma.user.count(),
prisma.listing.count(),
prisma.listing.count({ where: { status: 'ACTIVE' } }),
prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
prisma.agent.count(),
prisma.agent.count({ where: { isVerified: true } }),
prisma.transaction.count(),
prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
prisma.listing.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
]);
return {
totalUsers,
totalListings,
activeListings,
pendingModerationCount,
totalAgents,
verifiedAgents,
totalTransactions,
newUsersLast30Days,
newListingsLast30Days,
};
}
export async function getRevenueStats(
prisma: PrismaService,
startDate: Date,
endDate: Date,
groupBy: 'day' | 'month',
): Promise<RevenueStatsItem[]> {
const payments = await 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,
}));
}

View File

@@ -0,0 +1,128 @@
import { type Prisma, type UserRole } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import {
type UserListResult,
type UserDetail,
} from '../../domain/repositories/admin-query.repository';
export async function getUsers(
prisma: PrismaService,
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: Prisma.UserWhereInput = {};
if (role) where.role = role as UserRole;
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([
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,
}),
prisma.user.count({ where }),
]);
return {
data: users,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
export async function getUserDetail(
prisma: PrismaService,
userId: string,
): Promise<UserDetail | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
subscription: {
include: { plan: { select: { tier: true } } },
},
listings: {
select: { id: true, status: true },
take: 100,
orderBy: { createdAt: 'desc' },
},
},
});
if (!user) return null;
const [transactionsCount, recentListings] = await Promise.all([
prisma.transaction.count({
where: { buyerId: userId },
}),
prisma.listing.findMany({
where: { sellerId: userId },
select: {
id: true,
status: true,
createdAt: true,
property: { select: { title: true } },
},
orderBy: { createdAt: 'desc' },
take: 10,
}),
]);
const recentActivity = recentListings.map((l) => ({
type: 'listing',
description: `${l.property.title}${l.status}`,
createdAt: l.createdAt,
}));
return {
id: user.id,
email: user.email,
phone: user.phone,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
role: user.role,
kycStatus: user.kycStatus,
kycData: user.kycData,
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
listingsCount: user.listings.length,
activeListingsCount: user.listings.filter((l) => l.status === 'ACTIVE').length,
transactionsCount,
subscription: user.subscription
? {
planTier: user.subscription.plan.tier,
status: user.subscription.status,
currentPeriodEnd: user.subscription.currentPeriodEnd,
}
: null,
recentActivity,
};
}

View File

@@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { type Prisma, type UserRole } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import {
type IAdminQueryRepository,
@@ -10,6 +9,8 @@ import {
type UserDetail,
type KycQueueResult,
} from '../../domain/repositories/admin-query.repository';
import { getDashboardStats, getRevenueStats } from './admin-stats.queries';
import { getUsers, getUserDetail } from './admin-user.queries';
@Injectable()
export class PrismaAdminQueryRepository implements IAdminQueryRepository {
@@ -52,42 +53,7 @@ export class PrismaAdminQueryRepository implements IAdminQueryRepository {
}
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,
};
return getDashboardStats(this.prisma);
}
async getRevenueStats(
@@ -95,63 +61,7 @@ export class PrismaAdminQueryRepository implements IAdminQueryRepository {
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,
}));
return getRevenueStats(this.prisma, startDate, endDate, groupBy);
}
async getUsers(params: {
@@ -161,113 +71,11 @@ export class PrismaAdminQueryRepository implements IAdminQueryRepository {
isActive?: boolean;
search?: string;
}): Promise<UserListResult> {
const { page, limit, role, isActive, search } = params;
const skip = (page - 1) * limit;
const where: Prisma.UserWhereInput = {};
if (role) where.role = role as UserRole;
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),
};
return getUsers(this.prisma, params);
}
async getUserDetail(userId: string): Promise<UserDetail | null> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
subscription: {
include: { plan: { select: { tier: true } } },
},
listings: {
select: { id: true, status: true },
take: 100,
orderBy: { createdAt: 'desc' },
},
},
});
if (!user) return null;
const [transactionsCount, recentListings] = await Promise.all([
this.prisma.transaction.count({
where: { buyerId: userId },
}),
this.prisma.listing.findMany({
where: { sellerId: userId },
select: {
id: true,
status: true,
createdAt: true,
property: { select: { title: true } },
},
orderBy: { createdAt: 'desc' },
take: 10,
}),
]);
const recentActivity = recentListings.map((l) => ({
type: 'listing',
description: `${l.property.title}${l.status}`,
createdAt: l.createdAt,
}));
return {
id: user.id,
email: user.email,
phone: user.phone,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
role: user.role,
kycStatus: user.kycStatus,
kycData: user.kycData,
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
listingsCount: user.listings.length,
activeListingsCount: user.listings.filter((l) => l.status === 'ACTIVE').length,
transactionsCount,
subscription: user.subscription
? {
planTier: user.subscription.plan.tier,
status: user.subscription.status,
currentPeriodEnd: user.subscription.currentPeriodEnd,
}
: null,
recentActivity,
};
return getUserDetail(this.prisma, userId);
}
async getKycQueue(page: number, limit: number): Promise<KycQueueResult> {

View File

@@ -0,0 +1,156 @@
import {
Body,
Controller,
Get,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
import { type ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler';
import { BulkModerateListingsCommand } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.command';
import { type BulkModerateResult } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.handler';
import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-kyc.command';
import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
import {
type ModerationQueueResult,
type KycQueueResult,
} from '../../domain/repositories/admin-query.repository';
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
import { type ApproveListingDto } from '../dto/approve-listing.dto';
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
import { type RejectKycDto } from '../dto/reject-kyc.dto';
import { type RejectListingDto } from '../dto/reject-listing.dto';
@ApiTags('admin')
@ApiBearerAuth('JWT')
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
export class AdminModerationController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
// ── Moderation ──
@Get('moderation')
@ApiOperation({ summary: 'Get listing moderation queue' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' })
@ApiResponse({ status: 200, description: 'Moderation queue retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
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')
@ApiOperation({ summary: 'Approve a listing' })
@ApiResponse({ status: 201, description: 'Listing approved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
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')
@ApiOperation({ summary: 'Reject a listing' })
@ApiResponse({ status: 201, description: 'Listing rejected successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async rejectListing(
@Body() dto: RejectListingDto,
@CurrentUser() user: JwtPayload,
): Promise<RejectListingResult> {
return this.commandBus.execute(
new RejectListingCommand(dto.listingId, user.sub, dto.reason),
);
}
@Post('moderation/bulk')
@ApiOperation({ summary: 'Bulk approve or reject listings' })
@ApiResponse({ status: 201, description: 'Bulk moderation completed successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async bulkModerate(
@Body() dto: BulkModerateDto,
@CurrentUser() user: JwtPayload,
): Promise<BulkModerateResult> {
return this.commandBus.execute(
new BulkModerateListingsCommand(dto.listingIds, user.sub, dto.action, dto.reason),
);
}
// ── KYC ──
@Get('kyc')
@ApiOperation({ summary: 'Get KYC verification queue' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' })
@ApiResponse({ status: 200, description: 'KYC queue retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getKycQueue(
@Query('page') page?: string,
@Query('limit') limit?: string,
): Promise<KycQueueResult> {
return this.queryBus.execute(
new GetKycQueueQuery(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
),
);
}
@Post('kyc/approve')
@ApiOperation({ summary: 'Approve KYC verification' })
@ApiResponse({ status: 201, description: 'KYC approved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async approveKyc(
@Body() dto: ApproveKycDto,
@CurrentUser() user: JwtPayload,
): Promise<ApproveKycResult> {
return this.commandBus.execute(
new ApproveKycCommand(dto.userId, user.sub, dto.comments),
);
}
@Post('kyc/reject')
@ApiOperation({ summary: 'Reject KYC verification' })
@ApiResponse({ status: 201, description: 'KYC rejected successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async rejectKyc(
@Body() dto: RejectKycDto,
@CurrentUser() user: JwtPayload,
): Promise<RejectKycResult> {
return this.commandBus.execute(
new RejectKycCommand(dto.userId, user.sub, dto.reason),
);
}
}

View File

@@ -13,42 +13,23 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam }
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { AdjustSubscriptionCommand } from '../../application/commands/adjust-subscription/adjust-subscription.command';
import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler';
import { ApproveKycCommand } from '../../application/commands/approve-kyc/approve-kyc.command';
import { type ApproveKycResult } from '../../application/commands/approve-kyc/approve-kyc.handler';
import { ApproveListingCommand } from '../../application/commands/approve-listing/approve-listing.command';
import { type ApproveListingResult } from '../../application/commands/approve-listing/approve-listing.handler';
import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command';
import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler';
import { BulkModerateListingsCommand } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.command';
import { type BulkModerateResult } from '../../application/commands/bulk-moderate-listings/bulk-moderate-listings.handler';
import { RejectKycCommand } from '../../application/commands/reject-kyc/reject-kyc.command';
import { type RejectKycResult } from '../../application/commands/reject-kyc/reject-kyc.handler';
import { RejectListingCommand } from '../../application/commands/reject-listing/reject-listing.command';
import { type RejectListingResult } from '../../application/commands/reject-listing/reject-listing.handler';
import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command';
import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler';
import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query';
import { GetKycQueueQuery } from '../../application/queries/get-kyc-queue/get-kyc-queue.query';
import { GetModerationQueueQuery } from '../../application/queries/get-moderation-queue/get-moderation-queue.query';
import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query';
import { GetUserDetailQuery } from '../../application/queries/get-user-detail/get-user-detail.query';
import { GetUsersQuery } from '../../application/queries/get-users/get-users.query';
import {
type ModerationQueueResult,
type DashboardStats,
type RevenueStatsItem,
type UserListResult,
type UserDetail,
type KycQueueResult,
} from '../../domain/repositories/admin-query.repository';
import { type AdjustSubscriptionDto } from '../dto/adjust-subscription.dto';
import { type ApproveKycDto } from '../dto/approve-kyc.dto';
import { type ApproveListingDto } from '../dto/approve-listing.dto';
import { type BanUserDto } from '../dto/ban-user.dto';
import { type BulkModerateDto } from '../dto/bulk-moderate.dto';
import { type GetUsersQueryDto } from '../dto/get-users-query.dto';
import { type RejectKycDto } from '../dto/reject-kyc.dto';
import { type RejectListingDto } from '../dto/reject-listing.dto';
import { type RevenueStatsDto } from '../dto/revenue-stats.dto';
import { type UpdateUserStatusDto } from '../dto/update-user-status.dto';
@@ -63,69 +44,6 @@ export class AdminController {
private readonly queryBus: QueryBus,
) {}
// ── Moderation ──
@Get('moderation')
@ApiOperation({ summary: 'Get listing moderation queue' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' })
@ApiResponse({ status: 200, description: 'Moderation queue retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
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')
@ApiOperation({ summary: 'Approve a listing' })
@ApiResponse({ status: 201, description: 'Listing approved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
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')
@ApiOperation({ summary: 'Reject a listing' })
@ApiResponse({ status: 201, description: 'Listing rejected successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async rejectListing(
@Body() dto: RejectListingDto,
@CurrentUser() user: JwtPayload,
): Promise<RejectListingResult> {
return this.commandBus.execute(
new RejectListingCommand(dto.listingId, user.sub, dto.reason),
);
}
@Post('moderation/bulk')
@ApiOperation({ summary: 'Bulk approve or reject listings' })
@ApiResponse({ status: 201, description: 'Bulk moderation completed successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async bulkModerate(
@Body() dto: BulkModerateDto,
@CurrentUser() user: JwtPayload,
): Promise<BulkModerateResult> {
return this.commandBus.execute(
new BulkModerateListingsCommand(dto.listingIds, user.sub, dto.action, dto.reason),
);
}
// ── User Management ──
@Get('users')
@@ -187,55 +105,6 @@ export class AdminController {
);
}
// ── KYC ──
@Get('kyc')
@ApiOperation({ summary: 'Get KYC verification queue' })
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default 1)' })
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default 20)' })
@ApiResponse({ status: 200, description: 'KYC queue retrieved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async getKycQueue(
@Query('page') page?: string,
@Query('limit') limit?: string,
): Promise<KycQueueResult> {
return this.queryBus.execute(
new GetKycQueueQuery(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
),
);
}
@Post('kyc/approve')
@ApiOperation({ summary: 'Approve KYC verification' })
@ApiResponse({ status: 201, description: 'KYC approved successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async approveKyc(
@Body() dto: ApproveKycDto,
@CurrentUser() user: JwtPayload,
): Promise<ApproveKycResult> {
return this.commandBus.execute(
new ApproveKycCommand(dto.userId, user.sub, dto.comments),
);
}
@Post('kyc/reject')
@ApiOperation({ summary: 'Reject KYC verification' })
@ApiResponse({ status: 201, description: 'KYC rejected successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized missing or invalid JWT' })
@ApiResponse({ status: 403, description: 'Forbidden requires ADMIN role' })
async rejectKyc(
@Body() dto: RejectKycDto,
@CurrentUser() user: JwtPayload,
): Promise<RejectKycResult> {
return this.commandBus.execute(
new RejectKycCommand(dto.userId, user.sub, dto.reason),
);
}
// ── Subscription Management ──
@Post('subscriptions/adjust')

View File

@@ -1 +1,2 @@
export { AdminController } from './admin.controller';
export { AdminModerationController } from './admin-moderation.controller';

View File

@@ -27,9 +27,12 @@ describe('RecalculateQualityScoreHandler', () => {
agent: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new RecalculateQualityScoreHandler(
mockAgentRepo as any,
mockPrisma as any,
mockLogger as any,
);
});

View File

@@ -8,7 +8,8 @@ describe('ReviewEventsListener', () => {
beforeEach(() => {
mockCommandBus = { execute: vi.fn() };
listener = new ReviewEventsListener(mockCommandBus as unknown as CommandBus);
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
listener = new ReviewEventsListener(mockCommandBus as unknown as CommandBus, mockLogger as any);
});
describe('onReviewCreated', () => {

View File

@@ -1,6 +1,6 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { type PrismaService } from '@modules/shared';
import { type PrismaService, type LoggerService } from '@modules/shared';
import {
AGENT_REPOSITORY,
type IAgentRepository,
@@ -12,18 +12,17 @@ import { RecalculateQualityScoreCommand } from './recalculate-quality-score.comm
export class RecalculateQualityScoreHandler
implements ICommandHandler<RecalculateQualityScoreCommand>
{
private readonly logger = new Logger(RecalculateQualityScoreHandler.name);
constructor(
@Inject(AGENT_REPOSITORY)
private readonly agentRepo: IAgentRepository,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: RecalculateQualityScoreCommand): Promise<void> {
const agent = await this.agentRepo.findById(command.agentId);
if (!agent) {
this.logger.warn(`Agent ${command.agentId} not found, skipping recalculation`);
this.logger.warn(`Agent ${command.agentId} not found, skipping recalculation`, 'RecalculateQualityScoreHandler');
return;
}
@@ -79,6 +78,7 @@ export class RecalculateQualityScoreHandler
`(rating=${avgRating.toFixed(2)}, reviews=${totalReviews}, ` +
`conversion=${(conversionRate * 100).toFixed(1)}%, ` +
`activeListings=${activeListings}/${totalListings})`,
'RecalculateQualityScoreHandler',
);
}
}

View File

@@ -1,13 +1,15 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared';
import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command';
@Injectable()
export class ReviewEventsListener {
private readonly logger = new Logger(ReviewEventsListener.name);
constructor(private readonly commandBus: CommandBus) {}
constructor(
private readonly commandBus: CommandBus,
private readonly logger: LoggerService,
) {}
@OnEvent('review.created', { async: true })
async onReviewCreated(event: {
@@ -17,6 +19,7 @@ export class ReviewEventsListener {
if (event.targetType === 'AGENT') {
this.logger.log(
`Recalculating quality score for agent ${event.targetId}`,
'ReviewEventsListener',
);
await this.commandBus.execute(
new RecalculateQualityScoreCommand(event.targetId),
@@ -32,6 +35,7 @@ export class ReviewEventsListener {
if (event.targetType === 'AGENT') {
this.logger.log(
`Recalculating quality score for agent ${event.targetId} after review deletion`,
'ReviewEventsListener',
);
await this.commandBus.execute(
new RecalculateQualityScoreCommand(event.targetId),

View File

@@ -32,10 +32,13 @@ describe('ListingCreatedModerationHandler', () => {
},
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new ListingCreatedModerationHandler(
mockAiClient,
mockCommandBus,
mockPrisma as never,
mockLogger as any,
);
});

View File

@@ -5,7 +5,8 @@ describe('TrackEventHandler', () => {
let handler: TrackEventHandler;
beforeEach(() => {
handler = new TrackEventHandler();
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new TrackEventHandler(mockLogger as any);
});
it('tracks an event and returns result', async () => {

View File

@@ -1,5 +1,5 @@
import { Logger } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { type LoggerService } from '@modules/shared';
import { TrackEventCommand } from './track-event.command';
export interface TrackEventResult {
@@ -9,11 +9,12 @@ export interface TrackEventResult {
@CommandHandler(TrackEventCommand)
export class TrackEventHandler implements ICommandHandler<TrackEventCommand> {
private readonly logger = new Logger(TrackEventHandler.name);
constructor(private readonly logger: LoggerService) {}
async execute(command: TrackEventCommand): Promise<TrackEventResult> {
this.logger.log(
`Analytics event: ${command.eventType} on ${command.entityType}:${command.entityId}`,
'TrackEventHandler',
);
return {

View File

@@ -1,7 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { EventsHandler, type IEventHandler, type CommandBus } from '@nestjs/cqrs';
import { ListingCreatedEvent, ModerateListingCommand } from '@modules/listings';
import { type PrismaService } from '@modules/shared';
import { type PrismaService, type LoggerService } from '@modules/shared';
import {
AI_SERVICE_CLIENT,
type IAiServiceClient,
@@ -12,12 +12,11 @@ const AI_MODERATOR_ID = 'system:ai-moderation';
@EventsHandler(ListingCreatedEvent)
export class ListingCreatedModerationHandler implements IEventHandler<ListingCreatedEvent> {
private readonly logger = new Logger(ListingCreatedModerationHandler.name);
constructor(
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
private readonly commandBus: CommandBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async handle(event: ListingCreatedEvent): Promise<void> {
@@ -26,6 +25,7 @@ export class ListingCreatedModerationHandler implements IEventHandler<ListingCre
} catch (err) {
this.logger.warn(
`AI moderation skipped for listing ${event.aggregateId}: ${(err as Error).message}`,
'ListingCreatedModerationHandler',
);
}
}
@@ -48,6 +48,7 @@ export class ListingCreatedModerationHandler implements IEventHandler<ListingCre
if (!result.is_flagged) {
this.logger.debug(
`Listing ${event.aggregateId} passed AI moderation (score: ${result.score})`,
'ListingCreatedModerationHandler',
);
return;
}
@@ -55,6 +56,7 @@ export class ListingCreatedModerationHandler implements IEventHandler<ListingCre
this.logger.log(
`Listing ${event.aggregateId} flagged by AI moderation (score: ${result.score}, ` +
`flags: ${result.flags.map((f) => f.category).join(', ')})`,
'ListingCreatedModerationHandler',
);
const flagNotes = result.flags

View File

@@ -24,10 +24,13 @@ describe('HttpAVMService', () => {
property: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
service = new HttpAVMService(
mockAiClient,
mockFallback,
mockPrisma as never,
mockLogger as any,
);
});

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type LoggerService } from '@modules/shared';
export interface AiPredictRequest {
area: number;
@@ -51,12 +52,11 @@ export interface IAiServiceClient {
@Injectable()
export class AiServiceClient implements IAiServiceClient {
private readonly logger = new Logger(AiServiceClient.name);
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly timeoutMs: number;
constructor() {
constructor(private readonly logger: LoggerService) {
this.baseUrl = process.env['AI_SERVICE_URL'] ?? 'http://localhost:8000';
this.apiKey = process.env['AI_SERVICE_API_KEY'] ?? '';
this.timeoutMs = Number(process.env['AI_SERVICE_TIMEOUT_MS']) || 5000;

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type PrismaService } from '@modules/shared';
import { Inject, Injectable } from '@nestjs/common';
import { type PrismaService, type LoggerService } from '@modules/shared';
import {
type IAVMService,
type AVMParams,
@@ -15,12 +15,11 @@ import { type PrismaAVMService } from './prisma-avm.service';
@Injectable()
export class HttpAVMService implements IAVMService {
private readonly logger = new Logger(HttpAVMService.name);
constructor(
@Inject(AI_SERVICE_CLIENT) private readonly aiClient: IAiServiceClient,
private readonly fallback: PrismaAVMService,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async estimateValue(params: AVMParams): Promise<ValuationResult> {
@@ -29,6 +28,7 @@ export class HttpAVMService implements IAVMService {
} catch (err) {
this.logger.warn(
`AI AVM service unavailable, falling back to comparables-based estimation: ${(err as Error).message}`,
'HttpAVMService',
);
return this.fallback.estimateValue(params);
}

View File

@@ -1,8 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { type PrismaService, type LoggerService } from '@modules/shared';
import { UpdateMarketIndexCommand } from '../../application/commands/update-market-index/update-market-index.command';
interface MarketStats {
@@ -18,16 +18,15 @@ interface MarketStats {
@Injectable()
export class MarketIndexCronService {
private readonly logger = new Logger(MarketIndexCronService.name);
constructor(
private readonly prisma: PrismaService,
private readonly commandBus: CommandBus,
private readonly logger: LoggerService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'market-index-calculation' })
async calculateMarketIndices(): Promise<void> {
this.logger.log('Starting market index calculation...');
this.logger.log('Starting market index calculation...', 'MarketIndexCronService');
const period = this.getCurrentPeriod();
@@ -54,15 +53,18 @@ export class MarketIndexCronService {
} catch (err) {
this.logger.error(
`Failed to update market index for ${stat.district}/${stat.city}/${stat.propertyType}: ${(err as Error).message}`,
undefined,
'MarketIndexCronService',
);
}
}
this.logger.log(
`Market index calculation completed: ${updatedCount}/${stats.length} indices updated for period ${period}`,
'MarketIndexCronService',
);
} catch (err) {
this.logger.error(`Market index calculation failed: ${(err as Error).message}`);
this.logger.error(`Market index calculation failed: ${(err as Error).message}`, undefined, 'MarketIndexCronService');
}
}

View File

@@ -73,11 +73,14 @@ describe('OAuthService', () => {
mockEventBus = { publish: vi.fn() };
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
service = new OAuthService(
mockUserRepo as any,
mockTokenService as any,
mockPrisma as any,
mockEventBus as any,
mockLogger as any,
);
});

View File

@@ -21,7 +21,8 @@ describe('ZaloOAuthStrategy', () => {
authenticateOAuth: vi.fn().mockResolvedValue(mockTokenPair),
};
strategy = new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService);
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
strategy = new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any);
});
afterEach(() => {
@@ -31,13 +32,15 @@ describe('ZaloOAuthStrategy', () => {
it('throws if ZALO_APP_ID is missing', () => {
vi.stubEnv('ZALO_APP_ID', '');
expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService))
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any))
.toThrow('ZALO_APP_ID');
});
it('throws if ZALO_APP_SECRET is missing', () => {
vi.stubEnv('ZALO_APP_SECRET', '');
expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService))
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
expect(() => new ZaloOAuthStrategy(mockOAuthService as unknown as OAuthService, mockLogger as any))
.toThrow('ZALO_APP_SECRET');
});

View File

@@ -1,8 +1,8 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { type EventBus } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { type OAuthProvider, type Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { type PrismaService, type LoggerService } from '@modules/shared';
import { UserEntity } from '../../domain/entities/user.entity';
import { UserRegisteredEvent } from '../../domain/events/user-registered.event';
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository';
@@ -25,13 +25,12 @@ export interface OAuthUserProfile {
@Injectable()
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
constructor(
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
private readonly tokenService: TokenService,
private readonly prisma: PrismaService,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
/**
@@ -66,7 +65,7 @@ export class OAuthService {
throw new Error('Tài khoản đã bị vô hiệu hóa');
}
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`);
this.logger.log(`OAuth login: existing account for ${profile.provider}/${profile.providerUserId}`, 'OAuthService');
return this.generateTokensForUser(existingOAuth.user);
}
@@ -78,7 +77,7 @@ export class OAuthService {
throw new Error('Tài khoản đã bị vô hiệu hóa');
}
await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by email`, 'OAuthService');
return this.generateTokensForUser({
id: existingUser.id,
phone: existingUser.phone.value,
@@ -97,7 +96,7 @@ export class OAuthService {
throw new Error('Tài khoản đã bị vô hiệu hóa');
}
await this.createOAuthAccount(existingUser.id, profile);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`);
this.logger.log(`OAuth link: linked ${profile.provider} to existing user by phone`, 'OAuthService');
return this.generateTokensForUser({
id: existingUser.id,
phone: existingUser.phone.value,
@@ -130,7 +129,7 @@ export class OAuthService {
// Publish domain event
this.eventBus.publish(new UserRegisteredEvent(userId, phone.value, 'BUYER'));
this.logger.log(`OAuth register: new user created via ${profile.provider}`);
this.logger.log(`OAuth register: new user created via ${profile.provider}`, 'OAuthService');
return this.generateTokensForUser({
id: userId,
phone: phone.value,

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type LoggerService } from '@modules/shared';
import { type OAuthService, type OAuthUserProfile } from '../services/oauth.service';
import { type TokenPair } from '../services/token.service';
@@ -36,13 +37,14 @@ interface ZaloUserInfo {
@Injectable()
export class ZaloOAuthStrategy {
private readonly logger = new Logger(ZaloOAuthStrategy.name);
private readonly appId: string;
private readonly appSecret: string;
private readonly callbackUrl: string;
constructor(private readonly oauthService: OAuthService) {
constructor(
private readonly oauthService: OAuthService,
private readonly logger: LoggerService,
) {
const appId = process.env['ZALO_APP_ID'];
const appSecret = process.env['ZALO_APP_SECRET'];
@@ -119,7 +121,7 @@ export class ZaloOAuthStrategy {
const data = (await response.json()) as ZaloTokenResponse;
if (data.error) {
this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`);
this.logger.error(`Zalo token exchange failed: ${data.error_description ?? data.error}`, undefined, 'ZaloOAuthStrategy');
throw new Error(`Zalo OAuth error: ${data.error_description ?? 'Token exchange failed'}`);
}
@@ -143,7 +145,7 @@ export class ZaloOAuthStrategy {
const data = (await response.json()) as ZaloUserInfo;
if (data.error) {
this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`);
this.logger.error(`Zalo user info fetch failed: ${data.message ?? data.error}`, undefined, 'ZaloOAuthStrategy');
throw new Error(`Zalo OAuth error: ${data.message ?? 'Failed to fetch user info'}`);
}

View File

@@ -1,13 +1,15 @@
import { Injectable, Logger, type CanActivate, type ExecutionContext } from '@nestjs/common';
import { Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common';
import { type Reflector } from '@nestjs/core';
import { type UserRole } from '@prisma/client';
import { type LoggerService } from '@modules/shared';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
private readonly logger = new Logger(RolesGuard.name);
constructor(private readonly reflector: Reflector) {}
constructor(
private readonly reflector: Reflector,
private readonly logger: LoggerService,
) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
@@ -30,6 +32,7 @@ export class RolesGuard implements CanActivate {
this.logger.warn(
`Access denied: userId=${user?.sub ?? 'unknown'}, role=${user?.role ?? 'none'}, ` +
`required=${requiredRoles.join(',')}, action=${controller}.${handler}, ip=${ip}`,
'RolesGuard',
);
}

View File

@@ -27,10 +27,13 @@ describe('CreateInquiryHandler', () => {
listing: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreateInquiryHandler(
mockInquiryRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
mockLogger as any,
);
});

View File

@@ -30,10 +30,13 @@ describe('MarkInquiryReadHandler', () => {
agent: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new MarkInquiryReadHandler(
mockInquiryRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
mockLogger as any,
);
});

View File

@@ -1,7 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { NotFoundException, type PrismaService } from '@modules/shared';
import { NotFoundException, type PrismaService, type LoggerService } from '@modules/shared';
import { InquiryEntity } from '../../../domain/entities/inquiry.entity';
import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository';
import { CreateInquiryCommand } from './create-inquiry.command';
@@ -14,12 +14,11 @@ export interface CreateInquiryResult {
@CommandHandler(CreateInquiryCommand)
export class CreateInquiryHandler implements ICommandHandler<CreateInquiryCommand> {
private readonly logger = new Logger(CreateInquiryHandler.name);
constructor(
@Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository,
private readonly eventBus: EventBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: CreateInquiryCommand): Promise<CreateInquiryResult> {
@@ -49,7 +48,7 @@ export class CreateInquiryHandler implements ICommandHandler<CreateInquiryComman
this.eventBus.publish(event);
}
this.logger.log(`Inquiry ${id} created by user ${command.userId} for listing ${command.listingId}`);
this.logger.log(`Inquiry ${id} created by user ${command.userId} for listing ${command.listingId}`, 'CreateInquiryHandler');
return {
id,

View File

@@ -1,17 +1,16 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared';
import { ForbiddenException, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared';
import { INQUIRY_REPOSITORY, type IInquiryRepository } from '../../../domain/repositories/inquiry.repository';
import { MarkInquiryReadCommand } from './mark-inquiry-read.command';
@CommandHandler(MarkInquiryReadCommand)
export class MarkInquiryReadHandler implements ICommandHandler<MarkInquiryReadCommand> {
private readonly logger = new Logger(MarkInquiryReadHandler.name);
constructor(
@Inject(INQUIRY_REPOSITORY) private readonly inquiryRepo: IInquiryRepository,
private readonly eventBus: EventBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: MarkInquiryReadCommand): Promise<void> {
@@ -46,6 +45,6 @@ export class MarkInquiryReadHandler implements ICommandHandler<MarkInquiryReadCo
this.eventBus.publish(event);
}
this.logger.log(`Inquiry ${command.inquiryId} marked as read by agent ${command.agentUserId}`);
this.logger.log(`Inquiry ${command.inquiryId} marked as read by agent ${command.agentUserId}`, 'MarkInquiryReadHandler');
}
}

View File

@@ -27,10 +27,13 @@ describe('CreateLeadHandler', () => {
agent: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreateLeadHandler(
mockLeadRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
mockLogger as any,
);
});

View File

@@ -28,10 +28,13 @@ describe('DeleteLeadHandler', () => {
agent: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new DeleteLeadHandler(
mockLeadRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
mockLogger as any,
);
});

View File

@@ -28,10 +28,13 @@ describe('UpdateLeadStatusHandler', () => {
agent: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new UpdateLeadStatusHandler(
mockLeadRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
mockLogger as any,
);
});

View File

@@ -1,7 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { NotFoundException, ValidationException, type PrismaService } from '@modules/shared';
import { NotFoundException, ValidationException, type PrismaService, type LoggerService } from '@modules/shared';
import { LeadEntity } from '../../../domain/entities/lead.entity';
import { LEAD_REPOSITORY, type ILeadRepository } from '../../../domain/repositories/lead.repository';
import { LeadScore } from '../../../domain/value-objects/lead-score.vo';
@@ -15,12 +15,11 @@ export interface CreateLeadResult {
@CommandHandler(CreateLeadCommand)
export class CreateLeadHandler implements ICommandHandler<CreateLeadCommand> {
private readonly logger = new Logger(CreateLeadHandler.name);
constructor(
@Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository,
private readonly eventBus: EventBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: CreateLeadCommand): Promise<CreateLeadResult> {
@@ -62,7 +61,7 @@ export class CreateLeadHandler implements ICommandHandler<CreateLeadCommand> {
this.eventBus.publish(event);
}
this.logger.log(`Lead ${id} created by agent ${agent.id}`);
this.logger.log(`Lead ${id} created by agent ${agent.id}`, 'CreateLeadHandler');
return {
id,

View File

@@ -1,17 +1,16 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared';
import { ForbiddenException, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared';
import { LEAD_REPOSITORY, type ILeadRepository } from '../../../domain/repositories/lead.repository';
import { DeleteLeadCommand } from './delete-lead.command';
@CommandHandler(DeleteLeadCommand)
export class DeleteLeadHandler implements ICommandHandler<DeleteLeadCommand> {
private readonly logger = new Logger(DeleteLeadHandler.name);
constructor(
@Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository,
private readonly eventBus: EventBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: DeleteLeadCommand): Promise<void> {
@@ -35,6 +34,6 @@ export class DeleteLeadHandler implements ICommandHandler<DeleteLeadCommand> {
await this.leadRepo.delete(command.leadId);
this.logger.log(`Lead ${command.leadId} deleted by agent ${agent.id}`);
this.logger.log(`Lead ${command.leadId} deleted by agent ${agent.id}`, 'DeleteLeadHandler');
}
}

View File

@@ -1,18 +1,17 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException, type PrismaService } from '@modules/shared';
import { ForbiddenException, NotFoundException, type PrismaService, type LoggerService } from '@modules/shared';
import { type LeadStatus } from '../../../domain/entities/lead.entity';
import { LEAD_REPOSITORY, type ILeadRepository } from '../../../domain/repositories/lead.repository';
import { UpdateLeadStatusCommand } from './update-lead-status.command';
@CommandHandler(UpdateLeadStatusCommand)
export class UpdateLeadStatusHandler implements ICommandHandler<UpdateLeadStatusCommand> {
private readonly logger = new Logger(UpdateLeadStatusHandler.name);
constructor(
@Inject(LEAD_REPOSITORY) private readonly leadRepo: ILeadRepository,
private readonly eventBus: EventBus,
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async execute(command: UpdateLeadStatusCommand): Promise<void> {
@@ -43,6 +42,6 @@ export class UpdateLeadStatusHandler implements ICommandHandler<UpdateLeadStatus
this.eventBus.publish(event);
}
this.logger.log(`Lead ${command.leadId} status updated to ${command.newStatus} by agent ${agent.id}`);
this.logger.log(`Lead ${command.leadId} status updated to ${command.newStatus} by agent ${agent.id}`, 'UpdateLeadStatusHandler');
}
}

View File

@@ -50,6 +50,8 @@ describe('CreateListingHandler', () => {
getOrSet: vi.fn(),
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreateListingHandler(
mockPropertyRepo as any,
mockListingRepo as any,
@@ -57,6 +59,7 @@ describe('CreateListingHandler', () => {
mockPriceValidator as any,
mockEventBus as any,
mockCache as any,
mockLogger as any,
);
});

View File

@@ -6,7 +6,8 @@ describe('PrismaPriceValidator', () => {
beforeEach(() => {
mockPrisma = { $queryRaw: vi.fn() };
validator = new PrismaPriceValidator(mockPrisma as any);
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
validator = new PrismaPriceValidator(mockPrisma as any, mockLogger as any);
});
it('returns valid + not suspicious for normal price within market range', async () => {

View File

@@ -1,7 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { ValidationException, type CacheService, CachePrefix } from '@modules/shared';
import { ValidationException, type CacheService, CachePrefix, type LoggerService } from '@modules/shared';
import { ListingEntity } from '../../../domain/entities/listing.entity';
import { PropertyEntity } from '../../../domain/entities/property.entity';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
@@ -40,8 +40,6 @@ export interface CreateListingResult {
@CommandHandler(CreateListingCommand)
export class CreateListingHandler implements ICommandHandler<CreateListingCommand> {
private readonly logger = new Logger(CreateListingHandler.name);
constructor(
@Inject(PROPERTY_REPOSITORY) private readonly propertyRepo: IPropertyRepository,
@Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
@@ -49,6 +47,7 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
@Inject(PRICE_VALIDATOR) private readonly priceValidator: IPriceValidator,
private readonly eventBus: EventBus,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(command: CreateListingCommand): Promise<CreateListingResult> {
@@ -141,7 +140,7 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
titleSimilarity: c.titleSimilarity,
}));
} catch (err) {
this.logger.warn('Duplicate detection failed — listing created without warnings', err);
this.logger.warn('Duplicate detection failed — listing created without warnings', 'CreateListingHandler');
}
// Price validation — flag but never block creation
@@ -163,7 +162,7 @@ export class CreateListingHandler implements ICommandHandler<CreateListingComman
};
}
} catch (err) {
this.logger.warn('Price validation failed — listing created without price warning', err);
this.logger.warn('Price validation failed — listing created without price warning', 'CreateListingHandler');
}
return {

View File

@@ -0,0 +1,192 @@
import { type Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
import { type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
export async function findByIdWithProperty(
prisma: PrismaService,
id: string,
): Promise<ListingDetailData | null> {
const listing = await prisma.listing.findUnique({
where: { id },
include: {
property: {
include: {
media: { orderBy: { order: 'asc' }, take: 10 },
},
},
seller: { select: { id: true, fullName: true, phone: true } },
agent: { select: { id: true, userId: true, agency: true } },
},
});
if (!listing) return null;
return {
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
pricePerM2: listing.pricePerM2,
rentPriceMonthly: listing.rentPriceMonthly?.toString() ?? null,
commissionPct: listing.commissionPct,
viewCount: listing.viewCount,
saveCount: listing.saveCount,
inquiryCount: listing.inquiryCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
createdAt: listing.createdAt.toISOString(),
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
title: listing.property.title,
description: listing.property.description,
address: listing.property.address,
ward: listing.property.ward,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
floors: listing.property.floors,
direction: listing.property.direction,
yearBuilt: listing.property.yearBuilt,
legalStatus: listing.property.legalStatus,
amenities: listing.property.amenities,
projectName: listing.property.projectName,
media: listing.property.media.map((m) => ({
id: m.id,
url: m.url,
type: m.type,
order: m.order,
caption: m.caption,
})),
},
seller: listing.seller,
agent: listing.agent,
};
}
export async function searchListings(
prisma: PrismaService,
params: ListingSearchParams,
): Promise<PaginatedResult<ListingSearchItem>> {
const page = params.page ?? 1;
const limit = Math.min(params.limit ?? 20, 100);
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = {};
if (params.status) where.status = params.status;
if (params.transactionType) where.transactionType = params.transactionType;
if (params.minPrice || params.maxPrice) {
where.priceVND = {};
if (params.minPrice) where.priceVND.gte = params.minPrice;
if (params.maxPrice) where.priceVND.lte = params.maxPrice;
}
if (params.propertyType || params.city || params.district || params.minArea || params.maxArea || params.bedrooms) {
where.property = {};
if (params.propertyType) where.property.propertyType = params.propertyType;
if (params.city) where.property.city = params.city;
if (params.district) where.property.district = params.district;
if (params.minArea || params.maxArea) {
where.property.areaM2 = {};
if (params.minArea) where.property.areaM2.gte = params.minArea;
if (params.maxArea) where.property.areaM2.lte = params.maxArea;
}
if (params.bedrooms) where.property.bedrooms = { gte: params.bedrooms };
}
const [data, total] = await Promise.all([
prisma.listing.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
property: {
include: {
media: { orderBy: { order: 'asc' }, take: 1 },
},
},
seller: { select: { id: true, fullName: true } },
},
}),
prisma.listing.count({ where }),
]);
return {
data: data.map((listing) => ({
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
pricePerM2: listing.pricePerM2,
viewCount: listing.viewCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
title: listing.property.title,
address: listing.property.address,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
thumbnail: listing.property.media[0]?.url ?? null,
},
seller: listing.seller,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
export async function findBySellerIdQuery(
prisma: PrismaService,
sellerId: string,
page: number,
limit: number,
): Promise<PaginatedResult<ListingSellerItem>> {
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = { sellerId };
const [data, total] = await Promise.all([
prisma.listing.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
property: {
include: { media: { orderBy: { order: 'asc' }, take: 1 } },
},
},
}),
prisma.listing.count({ where }),
]);
return {
data: data.map((listing) => ({
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
property: {
id: listing.property.id,
title: listing.property.title,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
thumbnail: listing.property.media[0]?.url ?? null,
},
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}

View File

@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import { type Listing as PrismaListing, type Prisma, type ListingStatus } from '@prisma/client';
import { type Listing as PrismaListing, type ListingStatus } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
import { type ListingDetailData } from '../../domain/repositories/listing-read.dto';
import { type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';
import { Price } from '../../domain/value-objects/price.vo';
import { findByIdWithProperty, searchListings, findBySellerIdQuery } from './listing-read.queries';
@Injectable()
export class PrismaListingRepository implements IListingRepository {
@@ -16,63 +18,7 @@ export class PrismaListingRepository implements IListingRepository {
}
async findByIdWithProperty(id: string): Promise<ListingDetailData | null> {
const listing = await this.prisma.listing.findUnique({
where: { id },
include: {
property: {
include: {
media: { orderBy: { order: 'asc' }, take: 10 },
},
},
seller: { select: { id: true, fullName: true, phone: true } },
agent: { select: { id: true, userId: true, agency: true } },
},
});
if (!listing) return null;
return {
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
pricePerM2: listing.pricePerM2,
rentPriceMonthly: listing.rentPriceMonthly?.toString() ?? null,
commissionPct: listing.commissionPct,
viewCount: listing.viewCount,
saveCount: listing.saveCount,
inquiryCount: listing.inquiryCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
createdAt: listing.createdAt.toISOString(),
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
title: listing.property.title,
description: listing.property.description,
address: listing.property.address,
ward: listing.property.ward,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
floors: listing.property.floors,
direction: listing.property.direction,
yearBuilt: listing.property.yearBuilt,
legalStatus: listing.property.legalStatus,
amenities: listing.property.amenities,
projectName: listing.property.projectName,
media: listing.property.media.map((m) => ({
id: m.id,
url: m.url,
type: m.type,
order: m.order,
caption: m.caption,
})),
},
seller: listing.seller,
agent: listing.agent,
};
return findByIdWithProperty(this.prisma, id);
}
async save(entity: ListingEntity): Promise<void> {
@@ -124,79 +70,7 @@ export class PrismaListingRepository implements IListingRepository {
}
async search(params: ListingSearchParams): Promise<PaginatedResult<ListingSearchItem>> {
const page = params.page ?? 1;
const limit = Math.min(params.limit ?? 20, 100);
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = {};
if (params.status) where.status = params.status;
if (params.transactionType) where.transactionType = params.transactionType;
if (params.minPrice || params.maxPrice) {
where.priceVND = {};
if (params.minPrice) where.priceVND.gte = params.minPrice;
if (params.maxPrice) where.priceVND.lte = params.maxPrice;
}
if (params.propertyType || params.city || params.district || params.minArea || params.maxArea || params.bedrooms) {
where.property = {};
if (params.propertyType) where.property.propertyType = params.propertyType;
if (params.city) where.property.city = params.city;
if (params.district) where.property.district = params.district;
if (params.minArea || params.maxArea) {
where.property.areaM2 = {};
if (params.minArea) where.property.areaM2.gte = params.minArea;
if (params.maxArea) where.property.areaM2.lte = params.maxArea;
}
if (params.bedrooms) where.property.bedrooms = { gte: params.bedrooms };
}
const [data, total] = await Promise.all([
this.prisma.listing.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
property: {
include: {
media: { orderBy: { order: 'asc' }, take: 1 },
},
},
seller: { select: { id: true, fullName: true } },
},
}),
this.prisma.listing.count({ where }),
]);
return {
data: data.map((listing) => ({
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
pricePerM2: listing.pricePerM2,
viewCount: listing.viewCount,
publishedAt: listing.publishedAt?.toISOString() ?? null,
property: {
id: listing.property.id,
propertyType: listing.property.propertyType,
title: listing.property.title,
address: listing.property.address,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
thumbnail: listing.property.media[0]?.url ?? null,
},
seller: listing.seller,
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
return searchListings(this.prisma, params);
}
async findByStatus(status: ListingStatus, page: number, limit: number): Promise<PaginatedResult<ListingSearchItem>> {
@@ -204,44 +78,7 @@ export class PrismaListingRepository implements IListingRepository {
}
async findBySellerId(sellerId: string, page: number, limit: number): Promise<PaginatedResult<ListingSellerItem>> {
const skip = (page - 1) * limit;
const where: Prisma.ListingWhereInput = { sellerId };
const [data, total] = await Promise.all([
this.prisma.listing.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
property: {
include: { media: { orderBy: { order: 'asc' }, take: 1 } },
},
},
}),
this.prisma.listing.count({ where }),
]);
return {
data: data.map((listing) => ({
id: listing.id,
status: listing.status,
transactionType: listing.transactionType,
priceVND: listing.priceVND.toString(),
property: {
id: listing.property.id,
title: listing.property.title,
district: listing.property.district,
city: listing.property.city,
areaM2: listing.property.areaM2,
thumbnail: listing.property.media[0]?.url ?? null,
},
})),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
return findBySellerIdQuery(this.prisma, sellerId, page, limit);
}
private toDomain(raw: PrismaListing): ListingEntity {

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { type PrismaService, type LoggerService } from '@modules/shared';
import {
type IPriceValidator,
type PriceValidationParams,
@@ -25,9 +25,10 @@ const SUSPICIOUS_MULTIPLIER = 0.5;
@Injectable()
export class PrismaPriceValidator implements IPriceValidator {
private readonly logger = new Logger(PrismaPriceValidator.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
async validate(params: PriceValidationParams): Promise<PriceValidationResult> {
const { priceVND, areaM2, propertyType, district } = params;
@@ -113,7 +114,7 @@ export class PrismaPriceValidator implements IPriceValidator {
}
return null;
} catch (err) {
this.logger.warn('Failed to fetch market range, using defaults', err);
this.logger.warn('Failed to fetch market range, using defaults', 'PrismaPriceValidator');
return null;
}
}

View File

@@ -35,10 +35,13 @@ describe('CreatePaymentHandler', () => {
mockEventBus = { publish: vi.fn() };
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreatePaymentHandler(
mockPaymentRepo as any,
mockGatewayFactory as any,
mockEventBus as any,
mockLogger as any,
);
});

View File

@@ -48,10 +48,13 @@ describe('HandleCallbackHandler — edge cases', () => {
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
mockEventBus = { publish: vi.fn() };
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new HandleCallbackHandler(
mockPaymentRepo as any,
mockGatewayFactory as any,
mockEventBus as any,
mockLogger as any,
);
});

View File

@@ -43,10 +43,13 @@ describe('HandleCallbackHandler', () => {
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
mockEventBus = { publish: vi.fn() };
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new HandleCallbackHandler(
mockPaymentRepo as any,
mockGatewayFactory as any,
mockEventBus as any,
mockLogger as any,
);
});

View File

@@ -38,9 +38,12 @@ describe('RefundPaymentHandler', () => {
mockGatewayFactory = { getGateway: vi.fn().mockReturnValue(mockGateway) };
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new RefundPaymentHandler(
mockPaymentRepo as any,
mockGatewayFactory as any,
mockLogger as any,
);
});

View File

@@ -1,7 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { ConflictException, ValidationException } from '@modules/shared';
import { ConflictException, ValidationException, type LoggerService } from '@modules/shared';
import { PaymentEntity } from '../../../domain/entities/payment.entity';
import {
PAYMENT_REPOSITORY,
@@ -22,14 +22,13 @@ export interface CreatePaymentResult {
@CommandHandler(CreatePaymentCommand)
export class CreatePaymentHandler implements ICommandHandler<CreatePaymentCommand> {
private readonly logger = new Logger(CreatePaymentHandler.name);
constructor(
@Inject(PAYMENT_REPOSITORY)
private readonly paymentRepo: IPaymentRepository,
@Inject(PAYMENT_GATEWAY_FACTORY)
private readonly gatewayFactory: IPaymentGatewayFactory,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: CreatePaymentCommand): Promise<CreatePaymentResult> {
@@ -86,6 +85,7 @@ export class CreatePaymentHandler implements ICommandHandler<CreatePaymentComman
this.logger.log(
`Payment created: id=${paymentId}, provider=${command.provider}, amount=${command.amountVND}`,
'CreatePaymentHandler',
);
return { paymentId, paymentUrl, providerTxId };

View File

@@ -1,7 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { type PaymentStatus } from '@prisma/client';
import { NotFoundException, ValidationException } from '@modules/shared';
import { NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
import {
PAYMENT_REPOSITORY,
type IPaymentRepository,
@@ -20,14 +20,13 @@ export interface HandleCallbackResult {
@CommandHandler(HandleCallbackCommand)
export class HandleCallbackHandler implements ICommandHandler<HandleCallbackCommand> {
private readonly logger = new Logger(HandleCallbackHandler.name);
constructor(
@Inject(PAYMENT_REPOSITORY)
private readonly paymentRepo: IPaymentRepository,
@Inject(PAYMENT_GATEWAY_FACTORY)
private readonly gatewayFactory: IPaymentGatewayFactory,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: HandleCallbackCommand): Promise<HandleCallbackResult> {
@@ -37,6 +36,7 @@ export class HandleCallbackHandler implements ICommandHandler<HandleCallbackComm
if (!result.isValid) {
this.logger.warn(
`Invalid callback signature for provider=${command.provider}`,
'HandleCallbackHandler',
);
throw new ValidationException('Chữ ký callback không hợp lệ');
}
@@ -57,13 +57,14 @@ export class HandleCallbackHandler implements ICommandHandler<HandleCallbackComm
// Either payment doesn't exist or is already in a terminal state
const existing = await this.paymentRepo.findById(result.orderId);
if (!existing) {
this.logger.warn(`Payment not found for orderId=${result.orderId}`);
this.logger.warn(`Payment not found for orderId=${result.orderId}`, 'HandleCallbackHandler');
throw new NotFoundException('Payment', result.orderId);
}
// Already processed — return idempotent response
this.logger.log(
`Payment ${existing.id} already in terminal state: ${existing.status}`,
'HandleCallbackHandler',
);
return {
paymentId: existing.id,
@@ -86,6 +87,7 @@ export class HandleCallbackHandler implements ICommandHandler<HandleCallbackComm
this.logger.log(
`Payment ${updated.id} callback processed: status=${updated.status}`,
'HandleCallbackHandler',
);
return {

View File

@@ -1,6 +1,6 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { NotFoundException, ValidationException } from '@modules/shared';
import { NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
import {
PAYMENT_REPOSITORY,
type IPaymentRepository,
@@ -19,13 +19,12 @@ export interface RefundPaymentResult {
@CommandHandler(RefundPaymentCommand)
export class RefundPaymentHandler implements ICommandHandler<RefundPaymentCommand> {
private readonly logger = new Logger(RefundPaymentHandler.name);
constructor(
@Inject(PAYMENT_REPOSITORY)
private readonly paymentRepo: IPaymentRepository,
@Inject(PAYMENT_GATEWAY_FACTORY)
private readonly gatewayFactory: IPaymentGatewayFactory,
private readonly logger: LoggerService,
) {}
async execute(command: RefundPaymentCommand): Promise<RefundPaymentResult> {
@@ -59,6 +58,7 @@ export class RefundPaymentHandler implements ICommandHandler<RefundPaymentComman
this.logger.log(
`Refund ${result.success ? 'successful' : 'failed'} for payment ${command.paymentId}`,
'RefundPaymentHandler',
);
return {

View File

@@ -29,7 +29,8 @@ describe('MomoService', () => {
return env[key];
}),
} as unknown as ConfigService;
service = new MomoService(mockConfig);
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
service = new MomoService(mockConfig, mockLogger as any);
});
function buildCallbackData(overrides: Record<string, string> = {}): Record<string, string> {

View File

@@ -24,7 +24,8 @@ describe('VnpayService', () => {
return env[key];
}),
} as unknown as ConfigService;
service = new VnpayService(mockConfig);
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
service = new VnpayService(mockConfig, mockLogger as any);
});
it('should create a payment URL', async () => {

View File

@@ -27,7 +27,8 @@ describe('ZalopayService', () => {
return env[key];
}),
} as unknown as ConfigService;
service = new ZalopayService(mockConfig);
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
service = new ZalopayService(mockConfig, mockLogger as any);
});
function buildCallbackData(

View File

@@ -1,7 +1,8 @@
import * as crypto from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type ConfigService } from '@nestjs/config';
import { type PaymentProvider } from '@prisma/client';
import { type LoggerService } from '@modules/shared';
import {
type IPaymentGateway,
type CreatePaymentUrlParams,
@@ -13,7 +14,6 @@ import {
@Injectable()
export class MomoService implements IPaymentGateway {
private readonly logger = new Logger(MomoService.name);
readonly provider: PaymentProvider = 'MOMO';
private readonly partnerCode: string;
@@ -21,7 +21,10 @@ export class MomoService implements IPaymentGateway {
private readonly secretKey: string;
private readonly endpoint: string;
constructor(private readonly config: ConfigService) {
constructor(
private readonly config: ConfigService,
private readonly logger: LoggerService,
) {
this.partnerCode = this.config.getOrThrow<string>('MOMO_PARTNER_CODE');
this.accessKey = this.config.getOrThrow<string>('MOMO_ACCESS_KEY');
this.secretKey = this.config.getOrThrow<string>('MOMO_SECRET_KEY');
@@ -84,14 +87,14 @@ export class MomoService implements IPaymentGateway {
throw new Error(`MoMo create payment failed: resultCode=${result.resultCode}`);
}
this.logger.log(`MoMo payment URL created for order ${params.orderId}`);
this.logger.log(`MoMo payment URL created for order ${params.orderId}`, 'MomoService');
return {
paymentUrl: result.payUrl,
providerTxId: params.orderId,
};
} catch (error) {
this.logger.error(`MoMo createPaymentUrl error: ${error}`);
this.logger.error(`MoMo createPaymentUrl error: ${error}`, undefined, 'MomoService');
throw error;
}
}
@@ -131,6 +134,7 @@ export class MomoService implements IPaymentGateway {
this.logger.log(
`MoMo callback verified: orderId=${orderId}, valid=${isValid}, success=${isSuccess}`,
'MomoService',
);
return {
@@ -184,6 +188,7 @@ export class MomoService implements IPaymentGateway {
this.logger.log(
`MoMo refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`,
'MomoService',
);
return {
@@ -191,7 +196,7 @@ export class MomoService implements IPaymentGateway {
refundTxId: success ? requestId : null,
};
} catch (error) {
this.logger.error(`MoMo refund error: ${error}`);
this.logger.error(`MoMo refund error: ${error}`, undefined, 'MomoService');
return { success: false, refundTxId: null };
}
}

View File

@@ -1,7 +1,8 @@
import * as crypto from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type ConfigService } from '@nestjs/config';
import { type PaymentProvider } from '@prisma/client';
import { type LoggerService } from '@modules/shared';
import {
type IPaymentGateway,
type CreatePaymentUrlParams,
@@ -13,7 +14,6 @@ import {
@Injectable()
export class VnpayService implements IPaymentGateway {
private readonly logger = new Logger(VnpayService.name);
readonly provider: PaymentProvider = 'VNPAY';
private readonly tmnCode: string;
@@ -21,7 +21,10 @@ export class VnpayService implements IPaymentGateway {
private readonly baseUrl: string;
private readonly apiUrl: string;
constructor(private readonly config: ConfigService) {
constructor(
private readonly config: ConfigService,
private readonly logger: LoggerService,
) {
this.tmnCode = this.config.getOrThrow<string>('VNPAY_TMN_CODE');
this.hashSecret = this.config.getOrThrow<string>('VNPAY_HASH_SECRET');
this.baseUrl = this.config.get<string>('VNPAY_BASE_URL', 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html');
@@ -58,7 +61,7 @@ export class VnpayService implements IPaymentGateway {
const paymentUrl = `${this.baseUrl}?${new URLSearchParams(sortedParams).toString()}`;
this.logger.log(`VNPay payment URL created for order ${params.orderId}`);
this.logger.log(`VNPay payment URL created for order ${params.orderId}`, 'VnpayService');
return {
paymentUrl,
@@ -89,6 +92,7 @@ export class VnpayService implements IPaymentGateway {
this.logger.log(
`VNPay callback verified: orderId=${orderId}, valid=${isValid}, success=${isSuccess}`,
'VnpayService',
);
return {
@@ -151,6 +155,7 @@ export class VnpayService implements IPaymentGateway {
this.logger.log(
`VNPay refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`,
'VnpayService',
);
return {
@@ -158,7 +163,7 @@ export class VnpayService implements IPaymentGateway {
refundTxId: success ? requestId : null,
};
} catch (error) {
this.logger.error(`VNPay refund error: ${error}`);
this.logger.error(`VNPay refund error: ${error}`, undefined, 'VnpayService');
return { success: false, refundTxId: null };
}
}

View File

@@ -1,7 +1,8 @@
import * as crypto from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type ConfigService } from '@nestjs/config';
import { type PaymentProvider } from '@prisma/client';
import { type LoggerService } from '@modules/shared';
import {
type IPaymentGateway,
type CreatePaymentUrlParams,
@@ -13,7 +14,6 @@ import {
@Injectable()
export class ZalopayService implements IPaymentGateway {
private readonly logger = new Logger(ZalopayService.name);
readonly provider: PaymentProvider = 'ZALOPAY';
private readonly appId: string;
@@ -21,7 +21,10 @@ export class ZalopayService implements IPaymentGateway {
private readonly key2: string;
private readonly endpoint: string;
constructor(private readonly config: ConfigService) {
constructor(
private readonly config: ConfigService,
private readonly logger: LoggerService,
) {
this.appId = this.config.getOrThrow<string>('ZALOPAY_APP_ID');
this.key1 = this.config.getOrThrow<string>('ZALOPAY_KEY1');
this.key2 = this.config.getOrThrow<string>('ZALOPAY_KEY2');
@@ -80,14 +83,14 @@ export class ZalopayService implements IPaymentGateway {
throw new Error(`ZaloPay create payment failed: return_code=${result.return_code}`);
}
this.logger.log(`ZaloPay payment URL created for order ${params.orderId}`);
this.logger.log(`ZaloPay payment URL created for order ${params.orderId}`, 'ZalopayService');
return {
paymentUrl: result.order_url,
providerTxId: appTransId,
};
} catch (error) {
this.logger.error(`ZaloPay createPaymentUrl error: ${error}`);
this.logger.error(`ZaloPay createPaymentUrl error: ${error}`, undefined, 'ZalopayService');
throw error;
}
}
@@ -128,6 +131,7 @@ export class ZalopayService implements IPaymentGateway {
this.logger.log(
`ZaloPay callback verified: orderId=${orderId}, valid=${isValid}`,
'ZalopayService',
);
return {
@@ -179,6 +183,7 @@ export class ZalopayService implements IPaymentGateway {
this.logger.log(
`ZaloPay refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`,
'ZalopayService',
);
return {
@@ -186,7 +191,7 @@ export class ZalopayService implements IPaymentGateway {
refundTxId: success ? mRefundId : null,
};
} catch (error) {
this.logger.error(`ZaloPay refund error: ${error}`);
this.logger.error(`ZaloPay refund error: ${error}`, undefined, 'ZalopayService');
return { success: false, refundTxId: null };
}
}

View File

@@ -23,9 +23,12 @@ describe('CreateReviewHandler', () => {
mockEventBus = { publish: vi.fn() };
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreateReviewHandler(
mockReviewRepo as any,
mockEventBus as unknown as EventBus,
mockLogger as any,
);
});

View File

@@ -31,9 +31,12 @@ describe('DeleteReviewHandler', () => {
mockEventBus = { publish: vi.fn() };
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new DeleteReviewHandler(
mockReviewRepo as any,
mockEventBus as unknown as EventBus,
mockLogger as any,
);
});

View File

@@ -1,7 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { ConflictException, ValidationException } from '@modules/shared';
import { ConflictException, ValidationException, type LoggerService } from '@modules/shared';
import { ReviewEntity } from '../../../domain/entities/review.entity';
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
import { Rating } from '../../../domain/value-objects/rating.vo';
@@ -17,11 +17,10 @@ export interface CreateReviewResult {
@CommandHandler(CreateReviewCommand)
export class CreateReviewHandler implements ICommandHandler<CreateReviewCommand> {
private readonly logger = new Logger(CreateReviewHandler.name);
constructor(
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: CreateReviewCommand): Promise<CreateReviewResult> {
@@ -65,7 +64,7 @@ export class CreateReviewHandler implements ICommandHandler<CreateReviewCommand>
this.eventBus.publish(event);
}
this.logger.log(`Review ${id} created by user ${command.userId} for ${command.targetType}:${command.targetId}`);
this.logger.log(`Review ${id} created by user ${command.userId} for ${command.targetType}:${command.targetId}`, 'CreateReviewHandler');
return {
id,

View File

@@ -1,16 +1,15 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenException, NotFoundException } from '@modules/shared';
import { ForbiddenException, NotFoundException, type LoggerService } from '@modules/shared';
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
import { DeleteReviewCommand } from './delete-review.command';
@CommandHandler(DeleteReviewCommand)
export class DeleteReviewHandler implements ICommandHandler<DeleteReviewCommand> {
private readonly logger = new Logger(DeleteReviewHandler.name);
constructor(
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: DeleteReviewCommand): Promise<void> {
@@ -32,6 +31,6 @@ export class DeleteReviewHandler implements ICommandHandler<DeleteReviewCommand>
this.eventBus.publish(event);
}
this.logger.log(`Review ${command.reviewId} deleted by user ${command.userId}`);
this.logger.log(`Review ${command.reviewId} deleted by user ${command.userId}`, 'DeleteReviewHandler');
}
}

View File

@@ -27,9 +27,12 @@ describe('CancelSubscriptionHandler', () => {
mockEventBus = { publish: vi.fn() };
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CancelSubscriptionHandler(
mockRepo as any,
mockEventBus as any,
mockLogger as any,
);
});

View File

@@ -28,10 +28,13 @@ describe('CreateSubscriptionHandler', () => {
publish: vi.fn(),
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreateSubscriptionHandler(
mockRepo as any,
mockPrisma,
mockEventBus as any,
mockLogger as any,
);
});

View File

@@ -40,10 +40,13 @@ describe('MeterUsageHandler', () => {
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new MeterUsageHandler(
mockRepo as any,
mockPrisma,
mockCache as any,
mockLogger as any,
);
});

View File

@@ -41,11 +41,14 @@ describe('UpgradeSubscriptionHandler', () => {
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new UpgradeSubscriptionHandler(
mockRepo as any,
mockPrisma,
mockEventBus as any,
mockCache as any,
mockLogger as any,
);
});

View File

@@ -1,6 +1,6 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { NotFoundException, ValidationException } from '@modules/shared';
import { NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
import {
SUBSCRIPTION_REPOSITORY,
type ISubscriptionRepository,
@@ -15,12 +15,11 @@ export interface CancelSubscriptionResult {
@CommandHandler(CancelSubscriptionCommand)
export class CancelSubscriptionHandler implements ICommandHandler<CancelSubscriptionCommand> {
private readonly logger = new Logger(CancelSubscriptionHandler.name);
constructor(
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepo: ISubscriptionRepository,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: CancelSubscriptionCommand): Promise<CancelSubscriptionResult> {
@@ -47,6 +46,7 @@ export class CancelSubscriptionHandler implements ICommandHandler<CancelSubscrip
this.logger.log(
`Subscription cancelled: id=${subscription.id}, user=${command.userId}, reason=${command.reason ?? 'N/A'}`,
'CancelSubscriptionHandler',
);
return {

View File

@@ -1,7 +1,7 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { NotFoundException, ConflictException, type PrismaService } from '@modules/shared';
import { NotFoundException, ConflictException, type PrismaService, type LoggerService } from '@modules/shared';
import { SubscriptionEntity } from '../../../domain/entities/subscription.entity';
import {
SUBSCRIPTION_REPOSITORY,
@@ -19,13 +19,12 @@ export interface CreateSubscriptionResult {
@CommandHandler(CreateSubscriptionCommand)
export class CreateSubscriptionHandler implements ICommandHandler<CreateSubscriptionCommand> {
private readonly logger = new Logger(CreateSubscriptionHandler.name);
constructor(
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepo: ISubscriptionRepository,
private readonly prisma: PrismaService,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(command: CreateSubscriptionCommand): Promise<CreateSubscriptionResult> {
@@ -72,6 +71,7 @@ export class CreateSubscriptionHandler implements ICommandHandler<CreateSubscrip
this.logger.log(
`Subscription created: id=${subscriptionId}, user=${command.userId}, tier=${command.planTier}`,
'CreateSubscriptionHandler',
);
return {

View File

@@ -1,6 +1,6 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService } from '@modules/shared';
import { NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService, type LoggerService } from '@modules/shared';
import {
SUBSCRIPTION_REPOSITORY,
type ISubscriptionRepository,
@@ -17,13 +17,12 @@ export interface MeterUsageResult {
@CommandHandler(MeterUsageCommand)
export class MeterUsageHandler implements ICommandHandler<MeterUsageCommand> {
private readonly logger = new Logger(MeterUsageHandler.name);
constructor(
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepo: ISubscriptionRepository,
private readonly prisma: PrismaService,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(command: MeterUsageCommand): Promise<MeterUsageResult> {
@@ -75,6 +74,7 @@ export class MeterUsageHandler implements ICommandHandler<MeterUsageCommand> {
this.logger.log(
`Usage metered: subscription=${subscription.id}, metric=${command.metric}, count=${command.count}`,
'MeterUsageHandler',
);
return {

View File

@@ -1,6 +1,6 @@
import { Inject, Logger } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService } from '@modules/shared';
import { NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService, type LoggerService } from '@modules/shared';
import {
SUBSCRIPTION_REPOSITORY,
type ISubscriptionRepository,
@@ -18,14 +18,13 @@ export interface UpgradeSubscriptionResult {
@CommandHandler(UpgradeSubscriptionCommand)
export class UpgradeSubscriptionHandler implements ICommandHandler<UpgradeSubscriptionCommand> {
private readonly logger = new Logger(UpgradeSubscriptionHandler.name);
constructor(
@Inject(SUBSCRIPTION_REPOSITORY)
private readonly subscriptionRepo: ISubscriptionRepository,
private readonly prisma: PrismaService,
private readonly eventBus: EventBus,
private readonly cache: CacheService,
private readonly logger: LoggerService,
) {}
async execute(command: UpgradeSubscriptionCommand): Promise<UpgradeSubscriptionResult> {
@@ -80,6 +79,7 @@ export class UpgradeSubscriptionHandler implements ICommandHandler<UpgradeSubscr
this.logger.log(
`Subscription upgraded: id=${subscription.id}, ${previousTier}${command.newPlanTier}`,
'UpgradeSubscriptionHandler',
);
return {

View File

@@ -8,7 +8,8 @@ describe('ListingCreatedUsageHandler', () => {
beforeEach(() => {
mockCommandBus = { execute: vi.fn() };
handler = new ListingCreatedUsageHandler(mockCommandBus as unknown as CommandBus);
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new ListingCreatedUsageHandler(mockCommandBus as unknown as CommandBus, mockLogger as any);
});
it('meters listings_created usage for the seller', async () => {

View File

@@ -1,19 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingCreatedEvent } from '@modules/listings';
import { type LoggerService } from '@modules/shared';
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) {}
constructor(
private readonly commandBus: CommandBus,
private readonly logger: LoggerService,
) {}
@OnEvent('listing.created', { async: true })
async handle(event: ListingCreatedEvent): Promise<void> {
this.logger.log(
`Metering listings_created usage for seller=${event.sellerId}`,
'ListingCreatedUsageHandler',
);
try {
@@ -25,6 +28,7 @@ export class ListingCreatedUsageHandler {
// 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}`,
'ListingCreatedUsageHandler',
);
}
}