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:
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { AdminController } from './admin.controller';
|
||||
export { AdminModerationController } from './admin-moderation.controller';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user